こんにちは。iOS エンジニアの @wadash です。
今回は iOSDC Japan 2021 のセッション「スタディサプリ」がFull SwiftUIを選択した先に見えてきたもの。で紹介した、 SwiftUI におけるアニメーションの実装方法方法について詳しく解説します。
SwiftUI のアニメーション実装
SwiftUI の View にアニメーションを適用する方法として、対応する View Modifier を組み合わせることで簡単に実装することが出来ます。
なお、SwiftUI の基本的なアニメーション実装は Apple の SwiftUI Tutorials の一つとしても公開されています。
今回は実際に考え得る要件を題材に、手順建てて実装を進める形式でその方法を紹介します。
実現したい機能要件
次のようなアニメーションを表示する要件があると仮定します。
- ボタンを押してリストに要素を追加・削除し、その際に任意のアニメーションを適用
- 追加時: 画面左側から右側へスライドイン
- 削除時: 拡大・縮小

ボタンの実装
まずは画面上部のボタンの実装から行います。
Button の action クロージャー内で、withAnimation 関数をコールし、その body クロージャー内でリストに表示する要素の配列を操作(追加・削除)します。
このようなブロックを作ることで、特定の処理が呼ばれるタイミングでアニメーションを適用することが出来ます。 今回は配列の要素の追加と削除に合わせて、後述のトランジションアニメーションを適用していきます。
HStack {
Button("+1") {
withAnimation {
appendItem()
}
}
Button("-1") {
withAnimation {
removeItem()
}
}
}

また、今回は詳しくは紹介しませんが、アニメーションの実装には withAnimation 関数を利用する方法の他にも、animation modifier) を利用した方法もあります。
animation modifier を利用すると、View に付与されている他の animatable な modifier に紐付いた flag の変化に伴って指定したアニメーションを適用することが出来ます。
例えば、矩形を0°から45°に回転するアニメーションをリピートさせる方法は以下のようにそれぞれ記述出来ます。
withAnimation 関数を利用
struct SampleAnimationView: View { @State var isAnimating = false var body: some View { Rectangle() .fill(Color.blue) .frame(width: 100, height: 100) .rotationEffect(isAnimating ? .degrees(45) : .zero) .onAppear { withAnimation(.default.repeatForever()) { isAnimating = true } } } }
animation modifier を利用
struct SampleAnimationView: View { @State var isAnimating = false var body: some View { Rectangle() .fill(Color.blue) .frame(width: 100, height: 100) .rotationEffect(isAnimating ? .degrees(45) : .zero) .animation(.default.repeatForever()) .onAppear { isAnimating = true } } }

今回は animation modifier を利用した方法ではなく、配列の要素の操作に対応したアニメーションを対象とするため、withAnimation を利用していきます。
追加時アニメーション
次に、画面の中でメインとなるリスト形式の View と要素追加時のアニメーションを実装します。
任意に用意するリストの Row に transition modifier を適用し、その AnyTransition 型の引数に asymmetric を指定します。
transition modifier を適用することにより、View が画面上に追加・削除されたタイミングのアニメーションを指定出来ます。
asymmetric は AnyTransition 型の引数 insertion と removal を取り、AnyTransition 型の値を返す関数です。
すなわち、View の追加時と削除時で異なるトランジションを指定し、それを一つのトランジションとして返すものになります。
ここでは、insertion 引数に標準で提供されているトランジションの種類である slide と opacity を .combined で組み合わせて指定します。
この実装により、Row が追加されるタイミングで画面左側から右側へフェードインするアニメーションが行われます。
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(item: item)
.transition(
AnyTransition.asymmetric(
insertion: AnyTransition.slide.combined(with: AnyTransition.opacity),
removal: AnyTransition.identity
)
)
}
}
}

削除時アニメーション
続いて、削除時のアニメーションを実装します。
削除時の要件は「拡大・縮小」のアニメーションだったので、View を拡大・縮小させる効果をカスタマイズで実装します。
具体的には、GeometryEffect プロトコルに準拠した struct を実装していきます。
GeometryEffect は ViewModifier プロトコル及び Animatable プロトコルを継承しています。
slide や opacity といった 標準で提供されている種類以外のトランジションをカスタムで用意するためには、各種 View Modifier からトランジションを作成出来る AnyTransition.modifier を利用します。
まずは、 AnyTransition.modifier に渡すカスタム View Modifier として、GeometryEffect を利用した複雑な図形変換を実装します。
GeometryEffect に準拠するこの struct を ScaleTransitionEffect と命名して以降は進めていきます。
Animatable プロトコルの required プロパティである animationData プロパティを実装して、struct 内で定義した animationFactor 変数を取得・更新出来るようにします。
このようにすることで、animationFactor 変数の値の変化に基づいて View をアフィン変換させることが可能となります。
アフィン変換の処理は GeometryEffect の required メソッドである、effectValue メソッド内に実装していきます。
struct ScaleTransitionEffect: GeometryEffect { var animationFactor: CGFloat var animatableData: CGFloat { get { animationFactor } set { animationFactor = newValue } } func effectValue(size: CGSize) -> ProjectionTransform { // 実装は後述 } }
effectValue メソッド内の詳細実装をしていきます。
このメソッドは ProjectionTransform 型を返し、Viewに対して 2D, 3D のアフィン変換を行うことが出来ます。
今回は2次元の変換を行います。
先ほど定義した animationFactor の値を基にスケールを計算し、CGAffineTransform に適用しています。
ここでは2次関数を用いて拡大・縮小アニメーションのイージングタイミングをカスタム実装しています。
animationFactor の変化に応じて導出される f(x) の値を scale として定義し、effectValue メソッドが返す ProjectionTransform に反映させることで動的な図形変換を実現しています。
func effectValue(size: CGSize) -> ProjectionTransform { let anchorPoint = CGPoint(x: size.width / 2, y: size.height / 2) // 2次関数を利用 `f(x) = ax^2 + bx + c`. let scale = quadraticFunction(x: animationFactor, a: -6.8, b: 5.8, c: 1) return ProjectionTransform( CGAffineTransform.identity .translatedBy(x: anchorPoint.x, y: anchorPoint.y) .scaledBy(x: scale, y: scale) .translatedBy(x: -anchorPoint.x, y: -anchorPoint.y) ) } private func quadraticFunction(x: CGFloat, a: CGFloat, b: CGFloat, c: CGFloat) -> CGFloat { a * pow(x, 2) + b * x + c }
最後に、先ほどの asymmetric トランジションの removal 引数に削除時のトランジションを指定します。
指定するトランジションは .modifier の active と identity 引数にそれぞれ、先ほど実装した ScaleTransitionEffect の animationFactor に 1 と 0 を指定して初期化したものを指定します。
これで、削除時に animationFactor が 0 から 1 に変化し、アフィン変換がアニメーションを伴って適用されるようになります。
ScrollView {
VStack {
ForEach(items, id: \.self) { item in
Row(item: item)
.transition(
AnyTransition.asymmetric(
insertion: AnyTransition.slide.combined(with: AnyTransition.opacity),
removal: AnyTransition.modifier(
active: ScaleTransitionEffect(animationFactor: 1),
identity: ScaleTransitionEffect(animationFactor: 0)
)
)
)
}
}
}

以上で、今回の要件のアニメーションの実装は完了です。
このように、GeometryEffect を利用することで任意のアニメーション効果を用意することが可能となります。
まとめ
今回は以下の方法を紹介しました。
withAnimationメソッドで変数の変化に伴ってアニメーションを適用transitionmodifier で View の追加・削除時のトランジションを適用GeometryEffectプロトコルを実装して View にアフィン変換を適用

このような要素を組み合わせることで、宣言的にアニメーションを実装することが SwiftUI では可能になっており、ある程度複雑なアニメーションでも簡潔に記述出来ることが分かるかと思います。
様々な表現方法を手元で確認しながら試してみるといいでしょう。
採用のお知らせ
Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。
カジュアル面談も行っているので、ぜひお気軽にご連絡ください!