【SwiftUI】明示的アニメーションと暗黙的アニメーションを理解して使い分けよう

この記事はand factory.inc Advent Calendar 2022 7日目の記事です。
昨日は　@chitomo12 さんの [ライブラリ管理をCarthageからSwiftPackageManagerに移行する](https://qiita.com/chitomo12/items/78ab3ce66fda8300c5bf) でした。

## はじめに

SwiftUIでアニメーションを使っていると、
アニメーションの表現に二種類あることはすぐにわかりますが、
意識的に使い分けるには少々学習が必要だと思います。

SwiftUIのアニメーションをキャッチアップする過程で大事なことだなと思ったので下記の2つのアニメーション

- ```animation(_:value:)```（暗黙的アニメーション）
- ```withAnimation(_:_:)```（明示的アニメーション）

についてサンプルコードを交えながらまとめていこうと思います。

## 暗黙的アニメーション

[animation(_:value:)](https://developer.apple.com/documentation/swiftui/view/animation(_:value:))を使った実装方法についてです。

以下のようなサンプルとしてタップで星がアニメーションするコードを書きます。

![simple_sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/89bf1524-c69f-0c52-4a00-502ec04731bd.gif)


```Swift
struct ContentView: View {
  
  @State var willColorAnimate: Bool = false
  @State var willScaleAnimate: Bool = false
  @State var willOffsetAnimate: Bool = false
  
  var body: some View {
    VStack {
      Image(systemName: "star.fill")
        .font(.system(size: 64))
        .foregroundColor(willColorAnimate ? Color.yellow : Color.gray)
        .animation(.easeInOut(duration: 0.5), value: willColorAnimate)
        .scaleEffect(willScaleAnimate ? 1.0 : 0.4)
        .animation(
          .interpolatingSpring(
            mass: 1.0,
            stiffness: 240.0,
            damping: 12.0,
            initialVelocity: 20.0
          ),
          value: willScaleAnimate
        )
        .offset(y: willOffsetAnimate ? 10 : 0)
        .animation(.liner(duration: 0.5), value: willOffsetAnimate)
      
      Button {
        willScaleAnimate = true
        willOffsetAnimate = true
        willColorAnimate = true
      } label: {
        Text("TAP")
      }
      .disabled(willScaleAnimate)
    }
  }
}
```

星は以下のアニメーションで構成されていて、
プロパティ指定とanimation modierが近くにあって、
ぱっと見でもわかりやすいかと思います。

- カラーのアニメーション

```Swift
        .foregroundColor(willColorAnimate ? Color.yellow : Color.gray)
        .animation(.easeInOut(duration: 0.5), value: willColorAnimate)
```

- scaleのアニメーション

```Swift
        .scaleEffect(willScaleAnimate ? 1.0 : 0.4)
        .animation(
          .interpolatingSpring(
            mass: 1.0,
            stiffness: 240.0,
            damping: 12.0,
            initialVelocity: 20.0
          ),
          value: willScaleAnimate
        )
```

- offsetのアニメーション（見栄えの調整）

```Swift
        .offset(y: willOffsetAnimate ? 10 : 0)
        .animation(.liner(duration: 0.5), value: willOffsetAnimate)
```

このアニメションはシンプルなアニメーションの集合でできていて、
一見して見通しがよくすっきりとしたコードに見えます。

このように暗黙的アニメーションは少ない記述でアニメーションが書けるのが大きなメリットです。
一方で複雑なアニメーションを実現しようと思ったときにデメリットもあります。

例として先程の画面にリセットボタンを追加して、
戻るときには異なるアニメーションでリセットされるようにしてみます。

実現したいのは下記のアニメーションです。

![リセットあり.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/58576797-3173-0b29-41ea-1533c8ecdbe2.gif)


```Swift
struct ContentView: View {
  
  @State var willColorAnimate: Bool = false
  @State var willScaleAnimate: Bool = false
  @State var willOffsetAnimate: Bool = false
  
  // for reset
  @State var isResseting: Bool = false
  
  var body: some View {
    VStack {
      Image(systemName: "star.fill")
        .font(.system(size: 64))
        .foregroundColor(willColorAnimate ? Color.yellow : Color.gray)
        .animation(.easeInOut(duration: 0.5), value: willColorAnimate)
        .scaleEffect(willScaleAnimate ? 1.0 : 0.4)
        // ★リセット時はアニメーションの方法を変更
        .animation(
          isResseting
          ? .linear(duration: 0.5)
          : .interpolatingSpring(
            mass: 1.0,
            stiffness: 240.0,
            damping: 12.0,
            initialVelocity: 20.0
          ),
          value: willScaleAnimate
        )
        .offset(y: willOffsetAnimate ? 10 : 0)
        .animation(Easing.out.quart(duration: 0.5), value: willOffsetAnimate)
      
      HStack(spacing: 32) {
        Button {
          willScaleAnimate = true
          willOffsetAnimate = true
          willColorAnimate = true
        } label: {
          Text("スタート")
        }
        .disabled(willScaleAnimate)
        
        Button {
          // ★ リセットボタンタップ時にフラグを更新
          isResseting = true
          willScaleAnimate = false
          willOffsetAnimate = false
          willColorAnimate = false
          isResseting = false
        } label: {
          Text("リセット")
        }
        .disabled(!willScaleAnimate)
      }
      
    }
  }
}
```

主な変更点は2点で、一つはscaleのアニメーションに```isResseting```での評価を追加した点

```Swift
        .scaleEffect(willScaleAnimate ? 1.0 : 0.4)
        // ★リセット時はアニメーションの方法を変更
        .animation(
          isResseting
          ? .linear(duration: 0.5)
          : .interpolatingSpring(
            mass: 1.0,
            stiffness: 240.0,
            damping: 12.0,
            initialVelocity: 20.0
          ),
          value: willScaleAnimate
        )
```

もう一点はリセットボタンを押したときに、
isRessetingを更新→他のフラグを更新→リセット完了としてisRessetingを初期化している点です

```Swift
        Button {
          // ★ リセットボタンタップ時にフラグを更新
          isResseting = true
          willScaleAnimate = false
          willOffsetAnimate = false
          willColorAnimate = false
          isResseting = false
        } label: {
          Text("リセット")
        }
        .disabled(!willScaleAnimate)
```

ただし、このアニメーションでは残念ながらリセットボタンを押しても初期状態に戻ってくれません。
linearにはならず戻るときにもspringアニメーションになってしまいます。

![spring.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287671/75fe17e7-85b4-585a-f9a5-ed84abd734a2.gif)


これらの変更はすべて一つのトランザクションにまとめられ、
ビューの本体で一瞬だけisResettingがtrueであることを検知することができないからです。

この方法には以下のようにディレイを入れることで動作させる方法はありますが、
状態管理が複雑になるのは免れないので、アニメーションが複雑なケースでは避けておいたほうがベターかなと思います。

```Swift
        Button {
          isResseting = true
          willScaleAnimate = false
          willOffsetAnimate = false
          willColorAnimate = false
          DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            isResseting = false
          }
        } label: {
          Text("リセット")
        }
        .disabled(!willScaleAnimate)
```

また、animation modifierによるアニメーションは、
作用させる順番などによって結果が変わったり、直感に版下挙動になることもよくある印象です。

[animation(_:)](https://developer.apple.com/documentation/swiftui/view/animation(_:)-1hc0p)メソッドがdeprecatedになったことで、
挙動が以前よりはわかりやすくなったかなとは思いますが、
複雑なアニメーションを与えるほどデバッグや実装コストが実は高くつくこともしばしばかと思います。

## 明示的アニメーション

では上記のリセットボタン付きのアニメーションを
明示的なアニメーションで実装した場合です。

```Swift
struct ContentView: View {
  
  @State var willColorAnimate: Bool = false
  @State var willScaleAnimate: Bool = false
  @State var willOffsetAnimate: Bool = false
  
  var body: some View {
    VStack(spacing: 32) {
      Image(systemName: "star.fill")
        .font(.system(size: 64))
        .foregroundColor(willColorAnimate ? Color.yellow : Color.gray)
      //                .animation(.easeInOut(duration: 0.5), value: willColorAnimate)
        .scaleEffect(willScaleAnimate ? 1.0 : 0.4)
      //                .animation(
      //                    isResseting
      //                    ? .linear(duration: 0.5)
      //                    : .interpolatingSpring(
      //                        mass: 1.0,
      //                        stiffness: 240.0,
      //                        damping: 12.0,
      //                        initialVelocity: 20.0
      //                    ),
      //                    value: willScaleAnimate
      //                )
        .offset(y: willOffsetAnimate ? 10 : 0)
      //                .animation(Easing.out.quart(duration: 0.5), value: willOffsetAnimate)
      
      HStack(spacing: 32) {
        Button {
          withAnimation(.interpolatingSpring(
            mass: 1.0,
            stiffness: 240.0,
            damping: 12.0,
            initialVelocity: 20.0
          )) {
            willScaleAnimate = true
          }
          
          withAnimation(.easeInOut(duration: 0.5)) {
            willColorAnimate = true
          }
          
          withAnimation(Easing.out.quart(duration: 0.5)) {
            willOffsetAnimate = true
          }
        } label: {
          Text("スタート")
        }
        .disabled(willScaleAnimate)
        
        Button {
          withAnimation(.linear(duration: 0.3)) {
            willScaleAnimate = false
          }
          
          withAnimation(Easing.out.quart(duration: 0.5)) {
            willOffsetAnimate = false
          }
          
          withAnimation(.easeInOut(duration: 0.5)) {
            willColorAnimate = false
          }
        } label: {
          Text("リセット")
        }
        .disabled(!willScaleAnimate)
      }
    }
  }
}
```

ここで使われている[```withAnimation(_:_:)```](https://developer.apple.com/documentation/swiftui/withanimation(_:_:))ではアニメーションとクロージャーを引数として指定して、
クロージャー内で明示的にアニメーションさせたいプロパティを指定することができます。

例では```.animation(_:value:)```はコメントアウトで用済みになっており、
スタート・リセットボタンのタップ時に`withAnimation(_:_:)`でアニメーション方法と変更するプロパティを指定します。

また以下のように```withAnimation(_:_:)```でくくらなければアニメーションさせずに即時で変更させることも可能です。

```Swift
        Button {
          withAnimation(.linear(duration: 0.3)) {
            willScaleAnimate = false
          }
          // この場合、アニメーションなしで初期化される
          willOffsetAnimate = false
          willColorAnimate = false
        } label: {
          Text("リセット")
        }
        .disabled(!willScaleAnimate)

```

こちらの方法は場合によっては冗長になってしまいますが、
明示的にプロパティを指定可能で管理しやすいこと、
プロパティが変更されるアニメーションの記述が近くなり挙動がわかりやすくなるのが、
この方法のメリットと言えると思います。



- ```animation(_:value:)```（暗黙的アニメーション）
    - メリット
        - 短い記述量で多様なアニメーションを表現できる
        - アニメーションの状態管理が簡単な場合はコードが簡潔になる
    - デメリット
        - アニメーションと変化させるプロパティの変更箇所が離れるためわかりづらいことがある
        - アニメーションの順序や変更するプロパティの組み合わせによって比較的デバッグが難しくなりがち
- ```withAnimation(_:_:)```（明示的アニメーション）
    - メリット
        - modifierとして組み合わせる必要がなく、アニメーションの挙動が直感的
        - 複雑なアニメーションの状態管理がしやすい
    - デメリット
        - 記述量は多くなりがちなので、シンプルなアニメーションには向かないかもしれない

## 最後に

ここまで書きましたが、以下の記事がとってもわかりやすく、大変理解につながったのでこちらを読んだらすっごくいいと思います

- [Point-Free SwiftUI Animation: The Basics](https://www.pointfree.co/episodes/ep135-swiftui-animation-the-basics#t933)
- [SwiftUI におけるアニメーションの基本的な実装方法について iOS 15 から deprecated になるものも交えて紹介](https://qiita.com/kalupas226/items/153fdaf783379e7d865a#%E6%98%8E%E7%A4%BA%E7%9A%84%E3%81%AA%E3%82%A2%E3%83%8B%E3%83%A1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3-withanimation-)