【iOSDC2024 #LT】4. SwiftUIのアニメーションでスライド演出を作ってみた
この記事はiOSDC2024で発表したLTのセッション、我が家に電子ピアノがやってきたので、ピアノと連携するアプリを作ってみた!の発表のうち、発表スライドで実装したアニメーションの実装についてまとめたものです
前回の記事は、「3. Layout Protocolを使って、ピアノのUIを作ってみた」でした
今回の発表スライドは、SwiftUIでスライドを実装できるSlideKitを利用しています
そのためSlideKitが用意しているスライドのテンプレートのView以外にも、自作のViewを組み込むことができ、一般的なスライドツールでは実現できない見た目や効果のある資料を作ることができます
例えば一番最初は以下のような色が変わるト音記号🎼が映っている楽譜風の本が開くようなアニメーションから始まるようになっています
なおスライド下部のAutoScrollTextViewについては、次の記事で紹介します
また以下のようなアニメーションもスライドの途中で挟んでいます
これらのアニメーションはすべてSwiftUIを使って、実装しています
このようなアニメーションがどのように実装しているかを一部紹介します
細かい詳細は実際のソースコードを確認してください
オープニングの色が変わるト音記号+本を開くアニメーション
ここではオープニングのアニメーションについて紹介します
本が開くアニメーションについては、以前記事にしているので、そちらが参考になります
今回はそのアニメーションに追加して、offsetによる移動と拡大のscaleのアニメーションを追加しています
色が変わるト音記号のアニメーションはどのような構造になっているのでしょうか?
以下が実際のコードになります
鍵となるのは、AngularGradientとmaskのmodifierの使い方です
struct MusicNoteMaskView: View {
let colorfulGradient = Gradient(
colors: [.red, .orange, .yellow, .green, .blue, .purple, .pink]
.map({ (color: Color) in color.opacity(0.8) })
)
@State private var gradientAngle: Angle = .degrees(0)
var body: some View {
AngularGradient(
gradient: colorfulGradient,
center: .leading,
angle: gradientAngle
)
.mask({
Image(.trebleClef)
.resizable()
.scaledToFit()
})
.onAppear {
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
gradientAngle = .degrees(360)
}
}
}
}
まず背景の色が変わるグラデーションの部分は、AngularGradientを利用しています
このAngularGradientに指定した色の配列を適用したGradientを渡すと以下のような形のグラデーションのViewを実装できます
これのanchorをleading or trailingに指定すると、その中心点が左右にずれます
その状態で以下のようにアニメーションを実行します
元々0°を指定しているangleを360°、つまり1周するように回転させるアニメーションを指定時間(今回は4秒)で実行します
そして重要なのはrepeatForever(autoreverses: false)を指定しているので、360°まで回転した後もそのアニメーションが維持され、かつアニメーションが逆再生されないようにすることで、このアニメーションをループさせることができます
withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) {
gradientAngle = .degrees(360)
}
そして最後に用意したト音記号の画像でマスクすることでこのグラデーションのViewをト音記号の形でくり抜きます
.mask({
Image(.trebleClef)
.resizable()
.scaledToFit()
})
これで色がグラデーションで変化するト音記号の完成です!🎉
文字が上にスライドして切り替わるアニメーション
次に実装方法を確認するのはこのアニメーションです
これも重要なのはグラデーションとマスクの使い方です
var body: some View {
VStack(alignment: .center, spacing: 40) {
Text("I developed App of")
.foregroundStyle(.white)
.font(.system(size: 120, weight: .heavy))
.offset(y: osNameHeight)
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 40) {
LinearGradient(
colors: [
Color(hex: "73BBC5"),
Color(hex: "4072D8"),
Color(hex: "436BDD"),
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: 400)
.mask {
Text("iPad")
.font(.system(size: 150, weight: .heavy))
}
Image(systemName: "ipad.gen1.landscape")
.resizable()
.foregroundStyle(.gray)
.aspectRatio(contentMode: .fit)
.frame(width: osNameHeight)
}
.frame(height: osNameHeight)
Text("And...")
.font(.system(size: 150, weight: .heavy))
.foregroundStyle(.white)
.frame(height: osNameHeight)
HStack(alignment: .center, spacing: 40) {
LinearGradient(
colors: [
Color(hex: "393EE2"),
Color(hex: "E86780"),
Color(hex: "C8BBE2"),
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: 780, height: osNameHeight)
.mask {
Text("Vision Pro")
.font(.system(size: 150, weight: .heavy))
.frame(height: osNameHeight)
.shadow(radius: 10)
}
Image(systemName: "visionpro")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.gray)
.frame(height: osNameHeight * 0.6)
}
}
.offset(y: osNameHeight)
.offset(y: yOffset)
.mask { Rectangle().frame(height: osNameHeight + 10) }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black)
.task {
if !shouldAnimated { return }
try? await Task.sleep(for: .milliseconds(800))
withAnimation {
yOffset = -osNameHeight
} completion: {
Task {
try? await Task.sleep(for: .milliseconds(600))
withAnimation(.linear(duration: 0.6)) {
yOffset += -osNameHeight
}
}
}
}
}
基本の構造は、アニメーションがない「I developed App of」のTextとそれ以外の部分をVStackに入れます
そしてそれ以外のアニメーションをしている3つのText/HStackも1つのVStackに入れます
iPadやVisionProの箇所は記述のト音記号のアニメーションでも利用したGradientとmaskを利用した実装です
アニメーション要素のViewが入っているVStackは実は、下記のように.maskを利用して、表示したい以外の領域を無理やり隠しています
.mask { Rectangle().frame(height: osNameHeight + 10) }
あとは2段階でoffsetのアニメーションを実行すればOKです!
なお移動距離やmaskの位置は、手動で調整したものです笑
ト音記号のstrokeのアニメーション
最後に紹介するのは以下のようにト音記号が手書きで描かれたようなアニメーションです
これの背景が動くグラデーションになっているのは、先ほどのAngularGradientを使った実装と同じです
このようなアニメーションを実装するには、この図形をPathで書く必要があります
しかしこのような複雑な図形を自身でPathを計算して書くのは非常に難しいです
今回利用したのは、SVG to SwiftUIというツールです
このツールを使うことで、SVGをSwiftUIのパスに変換できます
今回はまず元々利用していたト音記号のSVG画像をこのツールに設定し、SwiftUIのPathを出力します
そしてそのPathがアニメーションで動くような修正を加えます
struct MusicIconShape: Shape {
var animatableData: CGFloat {
get { animationProgress }
set { animationProgress = newValue }
}
var animationProgress: CGFloat = 0
func path(in rect: CGRect) -> Path {
var path = Path()
.....(Omitting path code)
return path.trimmedPath(from: 0, to: animationProgress)
}
}
struct MusicStrokeAnimationView: View {
@State private var animationProgress: CGFloat = 0
let lineWidth: CGFloat
let duration: CGFloat
var body: some View {
MusicIconShape(animationProgress: animationProgress)
.stroke(Color.blue, lineWidth: lineWidth)
.onAppear {
withAnimation(.linear(duration: duration) {
animationProgress = 1.0
}
}
}
}
ポイントは、animatableDataの定義です
このanimatableDataはShapeやカスタムのViewがアニメーションの進捗具体を把握するために使用するもので、これはAnimatable Protocolに定義されたもので、このProtocolはSwiftUIのアニメーションの仕組みをサポートしています
そしてViewやShapeは標準でこのAnimatableに準拠しているので、そのプロパティのanimatableDataも定義することができます
このanimatableDataにアニメーションの進捗の管理に使用しているanimationProgressを渡してあげると、それに合わせてアニメーションが動くようになります
また最後のこのpathがanimationProgressに合わせて、アニメーションが実行されるようにするにはPathの返却処理の最後で以下のようにPathのtrim処理を書く必要があります
path.trimmedPath(from: 0, to: animationProgress)
まとめ
今回はスライドの中でお見せしたSwiftUIのアニメーションのうち、一部の実装について紹介しました
なお音符が回転しているUIは、ソースコードを確認いただけると実装の方法や構造がわかるかと思います
基本的には前の記事で紹介したLayout ProtocolとTimelineViewを組み合わせたものです
次の記事は「5. AutoScrollViewの実装について」です。