こんにちは。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
メソッドで変数の変化に伴ってアニメーションを適用transition
modifier で View の追加・削除時のトランジションを適用GeometryEffect
プロトコルを実装して View にアフィン変換を適用
このような要素を組み合わせることで、宣言的にアニメーションを実装することが SwiftUI では可能になっており、ある程度複雑なアニメーションでも簡潔に記述出来ることが分かるかと思います。
様々な表現方法を手元で確認しながら試してみるといいでしょう。
採用のお知らせ
Quipper ではスタディサプリの開発に関わる iOS エンジニア および シニア iOS エンジニア を積極的に募集しています。
カジュアル面談も行っているので、ぜひお気軽にご連絡ください!