3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIのAnimationについてふわっと学ぶ(keyframe編)

Posted at

はじめに

SwiftUIで高度なアニメーションを実装するためのAPIで、keyframeAnimatorというmodifierが提供されているようで、今回はこちらを用いてアニメーションを実装する方法を記事にしてみようと思います。

こちらは、WWDC23の動画で紹介されており、私はこちらでキャッチアップしました。
動画内のコードも公開されているので、こちらも参考になるかと思います。

keyframeAnimatorとは

nonisolated
func keyframeAnimator<Value>(
   initialValue: Value,
   trigger: some Equatable,
   @ViewBuilder content: @escaping (PlaceholderContentView<Self>, Value) -> some View,
   @KeyframesBuilder<Value> keyframes: @escaping (Value) -> some Keyframes
) -> some View

任意のアニメーションをタイミングを指定して、複雑なアニメーションを実装することができます。
タイミングを指定するというのは、一つのアニメーションが細かいアニメーションの集合と考えた時に、細かい方のアニメーションの発火タイミングを指定できるという意味です。
例えば、上下するアニメーションであれば、上に動くアニメーションと下に動くアニメーションの集合と考えられます。
その中で上に動くアニメーションはn秒後に、下に動くアニメーションは上に動くアニメーションが動いた後にといったタイミングを指定することができます。
この考え方は、CSSのkeyframesとほぼ同じと思うので、知っている方はイメージしやすいかもです。

APIの使用例

実際に上下するアニメーションをkeyframeAnimatorを用いて実装する例を見て、使い方を説明していきます。

import SwiftUI

struct SingleKeyframeAnimationView: View {
    
    @State private var animationTrigger = false
    
    var body: some View {
        ZStack {
            Divider()
            Text("😇")
                .font(.largeTitle)
                .keyframeAnimator(initialValue: 0.0, trigger: animationTrigger) { content, value in
                    content
                        .offset(y: value)
                } keyframes: { _ in
                    KeyframeTrack {
                        LinearKeyframe(0.0, duration: 1.0)
                        SpringKeyframe(90.0, duration: 4.0)
                        SpringKeyframe(-90.0, duration: 4.0, spring: .smooth(duration: 4.0))
                        MoveKeyframe(90.0)
                        CubicKeyframe(-90.0, duration: 1.0)
                        CubicKeyframe(90.0, duration: 4.0)
                        MoveKeyframe(-90.0)
                        LinearKeyframe(0, duration: 1.0)
                    }
                }
                .onTapGesture {
                    animationTrigger.toggle()
                }
        }
    }
}

singleKeyframeAnimationGif.gif

initialValueには、0.0を指定していますが、これはアニメーション対象のoffsetのy軸を指定しています。
初期は上下には移動していないので、0.0としています。

続いて、triggerには@StateBool型のプロパティを設定しています。
こちらは名前でも推測できますが、指定した値の変更を検知することでアニメーションのトリガーとなります。
例ではBool型を指定していますが、Equatableであればなんでも大丈夫です。

.keyframeAnimator(initialValue: 0.0, trigger: animationTrigger) { content, value in
        content
            .offset(y: value)
}

contentのクロージャでは、アニメーション対象のViewに対してどんなアニメーションを行うかを指定します。
指定の仕方としては、クロージャの第一引数にkeyframeAnimatormodifierを付与したViewが渡されるので、そいつに対して任意のmodifierを付与してアニメーションを推定します。
例は、上下移動のアニメーションなので、offset(y:)のmodifierを付与しています。
offsetでは第二引数のvalueを指定しており、初期の値はinitialValueの設定した値で渡されます。
第二引数の値(value)は後述するkeyframesで設定するクロージャ内で変更する値を決定し、変更するたびにcontentのクロージャが呼び出されてアニメーションが起きます。

一点注意点として、以下ドキュメントの注意喚起もされていますがcontentのクロージャはアニメーション中頻繁に呼び出されることになるので、高負荷な処理は実行するとパフォーマンスに影響が出る可能性があります。

Note that the content closure will be updated on every frame while animating, so avoid performing any expensive operations directly within content.
日本語訳
contentアニメーション中はフレームごとにクロージャが更新されるので、 内で直接コストのかかる操作を実行しないように注意してくださいcontent。

} keyframes: { _ in
                    KeyframeTrack {
                        LinearKeyframe(0.0, duration: 1.0)
                        SpringKeyframe(90.0, duration: 4.0)
                        SpringKeyframe(-90.0, duration: 4.0, spring: .smooth(duration: 4.0))
                        MoveKeyframe(90.0)
                        CubicKeyframe(-90.0, duration: 1.0)
                        CubicKeyframe(90.0, duration: 4.0)
                        MoveKeyframe(-90.0)
                        LinearKeyframe(0, duration: 1.0)
                    }
                }

最後にkeyframesクロージャでは、アニメーションを行うための値をどの様なタイミングでどの様な値に変更するかといったことを指定します。
このクロージャ内で返すものとしては、KeyframeTrackのインスタンスを返すだけです。
KeyframeTrackのイニシャライザのクロージャ内で、KeyframeTrackContentに準拠したインスタンスを複数指定することで、具体的なアニメーションの各動作の発火タイミングや値を指定しています。

KeyframeTrackContentに準拠した型は標準では以下が存在し、それぞれ使い分けることでさまざまなアニメーションを表現できます。

型名 概要
LinearKeyframe 線形のアニメーションを表現する
SpringKeyframe スプリング関数を使用したアニメーションを表現する
MoveKeyframe アニメーションをせずに指定した値をUIに反映させる
CubicKeyframe 3字曲線を使用したアニメーションを表現する

ただこれを見てもどの様な動きになるか想像しづらいので、サンプルのコードとgifを見てどういう動きになっているか見ていただければと思います。
またお手元でも、いろいろ値を変えてどの様に動作が変わるか見ると、理解が進むかと思います。

補足すると、以下コードでは

  1. 1秒間offsetを変化させず待機: セグメント時間1秒
  2. Springアニメーションで90.0pxの位置に移動する(アニメーションする速度はSpringのデフォルト): セグメント時間4秒
  3. Springアニメーションで-90.0pxの位置に移動する(アニメーションする時間は4秒): セグメント時間4秒
  4. 90.0pxの位置にアニメーション無しで移動する: セグメントの時間無し
  5. -90.0pxの位置に1秒かけてスムーズにアニメーションする様に自動計算された曲線で移動する: セグメント時間1秒
  6. 90.0pxの位置に4秒かけてスムーズにアニメーションする様に自動計算された曲線で移動する: セグメント時間4秒
  7. 1秒かけて線形で元の位置に移動する: セグメント時間1秒
KeyframeTrack {
    LinearKeyframe(0.0, duration: 1.0) // - 1
    SpringKeyframe(90.0, duration: 4.0) // - 2
    SpringKeyframe(-90.0, duration: 4.0, spring: .smooth(duration: 4.0)) // - 3
    MoveKeyframe(90.0) // - 3
    CubicKeyframe(-90.0, duration: 1.0) // - 4
    CubicKeyframe(90.0, duration: 4.0) // - 5
    MoveKeyframe(-90.0) // - 6
    LinearKeyframe(0, duration: 1.0) // - 7
}

このように、KeyframeTrackのクロージャで指定したKeyframeTrackContentの順にアニメーションが発火し最終的に一つのアニメーションの動きを実装することができます。

アニメーションの複合実装例

先ほどの例では、y軸のoffsetの値だけをkeyframeでアニメーションさせましたが、複数の値を同時にアニメーションさせてより複雑なアニメーションを表現することも可能です。

import SwiftUI

struct MultiKeyFrameAnimationView: View {
    
    @State var animationTrigger = true
    
    var body: some View {
        getReactionView(reaction: "😇")
            .keyframeAnimator(initialValue: AnimationValue(), trigger: animationTrigger) { content, value in
                content
                    .scaleEffect(value.scale)
                    .rotationEffect(value.rotation)
                    .scaleEffect(y: value.verticalStretch)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    LinearKeyframe(1, duration: 0.36)
                    SpringKeyframe(3, duration: 1.0)
                    SpringKeyframe(1, duration: 0.36)
                }
                KeyframeTrack(\.rotation) {
                    LinearKeyframe(.degrees(90), duration: 1.0)
                    SpringKeyframe(.degrees(360), duration: 6.0, spring: .bouncy(duration: 6.0))
                    SpringKeyframe(.degrees(0), duration: 1.0)
                }
                KeyframeTrack(\.verticalStretch) {
                    LinearKeyframe(1, duration: 0.5)
                    MoveKeyframe(1.5)
                    SpringKeyframe(3.0, duration: 4.5)
                    LinearKeyframe(1, duration: 1.0)
                }
            }
            .onTapGesture {
                animationTrigger.toggle()
            }
    }
    
    func getReactionView(reaction: String) -> some View {
        Text(reaction)
            .font(.largeTitle)
    }
}

extension MultiKeyFrameAnimationView {
    
    struct AnimationValue {
        
        var scale = 1.0
        var verticalStretch = 1.0
        var rotation: Angle = .degrees(0)
    }
}

multiKeyframeAnimationGif.gif

実装方法は、最初の例とそれほど変わりません。
initialValueでは、複数の値を変更するためにstructを新たに定義して初期のインスタンスを設定します。
今回の例では、大きさと縦比率、角度を同時にアニメーションさせるので、それぞれアニメーションさせるための値をもったstructを定義しています。

struct AnimationValue {
        
    var scale = 1.0
    var verticalStretch = 1.0
    var rotation: Angle = .degrees(0)
}

contentクロージャでは、大きさと縦比率、角度を変更するmodifireを設定しています。

{ content, value in
    content
        .scaleEffect(value.scale)
        .rotationEffect(value.rotation)
        .scaleEffect(y: value.verticalStretch)
}

最後にkeyframesクロージャでは、アニメーションさせる値ごとにKeyframeTrackを生成して、アニメーションする要素毎にアニメーションの値とタイミングを指定します。
scalerotationverticalStretchの指定したKeyframeTrackは並列に動き出します。
しかし、それぞれのアニメーションの動作のタイミングや時間は各々のKeyframeTrackで指定しているので、実際にアニメーションするタイミングやアニメーションが終了するタイミングは別々です。

keyframes: { _ in
    KeyframeTrack(\.scale) {
        LinearKeyframe(1, duration: 0.36)
        SpringKeyframe(3, duration: 1.0)
        SpringKeyframe(1, duration: 0.36)
    }
    KeyframeTrack(\.rotation) {
        LinearKeyframe(.degrees(90), duration: 1.0)
        SpringKeyframe(.degrees(360), duration: 6.0, spring: .bouncy(duration: 6.0))
        SpringKeyframe(.degrees(0), duration: 1.0)
    }
    KeyframeTrack(\.verticalStretch) {
        LinearKeyframe(1, duration: 0.5)
        MoveKeyframe(1.5)
        SpringKeyframe(3.0, duration: 4.5)
        LinearKeyframe(1, duration: 1.0)
    }
}

keyframeAnimatorの使い所

見てきた通り、アニメーションの動作をかなり細かく指定できるAPIでした。
同じく高度なアニメーションを実装できるphaseAnimatorと比較しつつ、どういう時に使用すれば良いのか考えてみます。

phaseAnimatorについては、過去に記事を書いているので、よければこちらも合わせて参照していただければと思います。

https://qiita.com/stotic-dev/items/ce096831f04a1d3659f6

この二つのAPIは結構似ているところはあります。
それは、細かいアニメーションを繋ぎ合わせて一つの複雑なアニメーションを作り上げるという点です。
とはいえ、それぞれ違いもあるのでその違いから、どういう使い分けができるか考えます。

keyframeAnimatorのメリデメ

  • メリット

    • アニメーションの要素毎にタイミングやアニメーションの種類(springなど)を制御できること
      • phaseAnimatorでも複数の要素を同時にアニメーションさせることができますが、要素毎にタイミングまで指定することはできず同時に実行するようにしか実装できないため、keyframeAnimatorの方がより高度で複雑なアニメーションを実装できると言えます
  • デメリット

    • API仕様が複雑
      • アニメーションの要素毎に、一動作毎にアニメーションのタイミングや方法を指定するため、簡単なアニメーションを実装するには冗長な記載となってしまいます

上記より、keyframeAnimatorは表現できることが多い反面、より低レイヤーな要素を意識しなくてはならないためAPI仕様が複雑なのでphaseAnimatorで実装できるのであればそちらを利用した方が良さそうです。
phaseAnimatorで実装できないぐらい複雑なアニメーションを組みたいとなった場合のみ、keyframeAnimatorの利用を検討するのが良いと思いました。

おわり

今回は今まで知らなかったkeyframeAnimatorによるアニメーションの実装の仕方をキャッチアップしました。
触ってみた所感としては、業務で触ることは現状あまりなさそうと思いました。
というのも、keyframeAnimatorを必要とするほどの仕様があまりなさそうなので、ちょっと複雑なアニメーションを組みたいとなってもphaseAnimatorで事足りる気がしています。(無限ループのアニメーションとか)
ただ、いざという時こういう飛び道具があることを知っていればドヤ顔できるので、知れてよかったです。

今回作ったサンプルコードは以下リポジトリに上げていますので、興味がある方はご参照ください。

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?