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

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

https://developer.apple.com/documentation/swiftui/view/keyframeanimator(initialvalue:trigger:content:keyframes:)

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

https://developer.apple.com/videos/play/wwdc2023/10157/?time=492

# `keyframeAnimator`とは
>```swift
>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
>```

https://developer.apple.com/documentation/swiftui/view/keyframeanimator(initialvalue:trigger:content:keyframes:)

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

https://qiita.com/kahomorishita/items/35b3a0102f1897fc82f9

### APIの使用例
実際に上下するアニメーションを`keyframeAnimator`を用いて実装する例を見て、使い方を説明していきます。
```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/b23c9bc4-b2ca-a733-865a-641c7815fcf0.gif)

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

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

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

`content`のクロージャでは、アニメーション対象の`View`に対してどんなアニメーションを行うかを指定します。
指定の仕方としては、クロージャの第一引数に`keyframeAnimator`modifierを付与した`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。

https://developer.apple.com/documentation/swiftui/view/keyframeanimator(initialvalue:trigger:content:keyframes:)#discussion

```swift
} 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](https://developer.apple.com/documentation/swiftui/linearkeyframe) | 線形のアニメーションを表現する |
| [SpringKeyframe](https://developer.apple.com/documentation/swiftui/springkeyframe) | スプリング関数を使用したアニメーションを表現する |
| [MoveKeyframe](https://developer.apple.com/documentation/swiftui/movekeyframe) | アニメーションをせずに指定した値をUIに反映させる |
| [CubicKeyframe](https://developer.apple.com/documentation/swiftui/cubickeyframe) | 3字曲線を使用したアニメーションを表現する |

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

補足すると、以下コードでは
1. 1秒間offsetを変化させず待機: セグメント時間1秒
1. Springアニメーションで90.0pxの位置に移動する(アニメーションする速度は[Springのデフォルト](https://developer.apple.com/documentation/swiftui/spring/init(duration:bounce:))): セグメント時間4秒
1. Springアニメーションで-90.0pxの位置に移動する(アニメーションする時間は4秒): セグメント時間4秒
1. 90.0pxの位置にアニメーション無しで移動する: セグメントの時間無し
1. -90.0pxの位置に1秒かけてスムーズにアニメーションする様に自動計算された曲線で移動する: セグメント時間1秒
1. 90.0pxの位置に4秒かけてスムーズにアニメーションする様に自動計算された曲線で移動する: セグメント時間4秒
1. 1秒かけて線形で元の位置に移動する: セグメント時間1秒
```swift
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`でアニメーションさせましたが、複数の値を同時にアニメーションさせてより複雑なアニメーションを表現することも可能です。

```swift
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3359094/142eabc4-a0d3-2f40-47dc-ca047e0a8a12.gif)


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

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

`content`クロージャでは、大きさと縦比率、角度を変更するmodifireを設定しています。
```swift
{ content, value in
    content
        .scaleEffect(value.scale)
        .rotationEffect(value.rotation)
        .scaleEffect(y: value.verticalStretch)
}
```

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

```swift
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`と比較しつつ、どういう時に使用すれば良いのか考えてみます。

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

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

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

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

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

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

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

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

https://github.com/stotic-dev/SwiftUIAnimationSamples/tree/main/AnimationSample/AnimationViews/KeyframeAnimationSample