LoginSignup
57
33

More than 1 year has passed since last update.

SwiftUI におけるアニメーションの基本的な実装方法について iOS 15 から deprecated になるものも交えて紹介

Last updated at Posted at 2021-12-16

はじめに

こんにちは、アイカワと申します。
この記事は Qiita iOS Advent Calendar 2021 の 17 日目の記事になります。

この記事では SwiftUI でアニメーションを実装する際の基本的な実装方法を紹介します。
また、その中で iOS 15 以降では deprecated になってしまっているものについても触れます。

本当はこの記事の中で Combine を利用した時のアニメーションについても触れようと思っていたのですが、記事の内容が長くなりそうだったので、iOS Advent Calendar 2021 のカレンダー2の方で今回の続きの記事を公開する予定です。

SwiftUI のアニメーションの基本的な実装方法

SwiftUI でアニメーションを実装するとすれば、大きく2つの方法が存在すると思います。
1つは animation View Modifier を利用した暗黙的なアニメーションで、もう1つは withAnimation を利用した明示的なアニメーションになります。
これらについて次以降で簡単に説明していきます。

暗黙的なアニメーション( animation View Modifer )

まずは暗黙的なアニメーションである animation View Modifier を利用した方法について説明します。
animation View Modifier は2つ存在していて、iOS 15 以降では片方の View Modifier は deprecated となりましたが、deprecated になったものも含めて2つの animation View Modifier について触れようと思います。

iOS 15 までは普通に利用できていた方法

まず iOS 15 以降は deprecated になった animation(_:) View Modifier について説明します。

コードをもとに説明した方が早いと思うため、まずコードとその実行結果を示します。

struct ContentView: View {
    @State var circleCenter = CGPoint.zero

    var body: some View {
        VStack {
            Circle()
                .frame(width: 50, height: 50)
                .offset(x: circleCenter.x - 25, y: circleCenter.y - 25)
                .animation(.spring(response: 0.3, dampingFraction: 0.1))
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { value in
                        circleCenter = value.location
                    }
                )
        }
    }
}

コードは非常に少ないですが、これだけのコードで円を指でドラッグした時に spring アニメーションを発生させることができています。(少し gif がわかりにくいですが🙇‍♂️ )
コードでやっていることとしては、以下のようなものになります。

  • animation(.spring(response: 0.3, dampingFraction: 0.1)) View Modifier で Circle に対して spring アニメーションが付与されるようにしている
  • DragGesture で円を動かした時に circleCenter という状態を指の位置で変更するようにしている
  • offset View Modifier で circleCenter の状態を利用して円の位置が調整されるようにしている

この animation View Modifier を利用したアニメーション方法は、明示的にどこをアニメーションさせたいかを指定するわけではないため、暗黙的なアニメーション( implicit animation )と呼ばれているようです。
上記のコードのように非常に簡単に利用できることがメリットですが、簡単であるがゆえにデメリットも存在します。

Circle に対してもう1つアニメーションを追加してみるとそのデメリットを理解することができます。
まず、それを理解するためのコードを示します。

struct ContentView: View {
    @State var circleCenter = CGPoint.zero
    @State var isCircleScaled = false

    var body: some View {
        VStack {
            Circle()
                .frame(width: 50, height: 50)
                .scaleEffect(isCircleScaled ? 2 : 1)
                .animation(.easeInOut)
                .offset(x: circleCenter.x - 25, y: circleCenter.y - 25)
                .animation(.spring(response: 0.3, dampingFraction: 0.1))
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { value in
                        circleCenter = value.location
                    }
                )

            Toggle(
                "Scale",
                isOn: $isCircleScaled
            )
        }
    }
}

先ほどと大きく変わっている部分を以下に抜粋します。

struct ContentView: View {
    // ...
    @State var isCircleScaled = false

    var body: some View {
        VStack {
            Circle()
                // ...
                .scaleEffect(isCircleScaled ? 2 : 1)
                .animation(.easeInOut)
                // ...

            Toggle(
                "Scale",
                isOn: $isCircleScaled
            )
        }
    }
}

変更点としてはコードを見るとわかりますが、以下のようなものになります。

  • animation(.easeInOut) View Modifier によって easeInOut なアニメーション効果を Circle に付与しようとしている
  • isCircleScaled という新たな状態を追加し、それを Toggle で変更できるようにしている
  • scaleEffect View Modifier によって、isCircleScaled の状態に応じて Circle が2倍の大きさになったり元の大きさになるという動作を表現しようとしている

上記のコードにより、Toggle を動かすと円が easeInOut なアニメーション効果で2倍になったり元の大きさに戻ったりする動作を期待します。
しかし、実行結果としては以下のようなものになってしまいます。

Toggle を動かした時に円が easeInOut なアニメーション効果で拡大・縮小していることは期待通りの動作ですが、ドラッグした時には spring で動作して欲しいはずのアニメーションまで easeInOut な動作になってしまっています。

このように、ここまで紹介してきた animation(_:) View Modifier は簡単に利用できる一方で、挙動が想像しにくいという問題があります。
また、Apple の Documentation の Discussion にも以下のような記載があります。

Use this modifier on leaf views rather than container views. The animation applies to all child views within this view; calling animation(_:) on a container view can lead to unbounded scope.

できるだけ小さい範囲の View が対象となるように、この View Modifier を使用しろという話ですが、挙動がわかりにくいという点と合わせてこれも animation(_:) View Modifier が deprecated になってしまった要因なのではないかなと想像しています。

iOS 15 以降推奨されるようになった方法

では、上記のような挙動が想像しにくい問題を解決する方法は何か存在するのでしょうか?

そのために SwiftUI にはもう1つの animation View Modifier が存在しています。
それはanimation(_:value:) という View Modifier で、先ほどまでの View Modifier とは異なり、アニメーション実行のトリガーとなる value を指定する必要があるものになります。
一番最初に紹介した方の animation(_:) View Modifier は iOS 15 以降 deprecated となっていますが、animation(_:value:) View Modifier は deprecated な animation(_:) View Modifier のドキュメントにも記載がある通り、代替の方法として公式的に推奨されているものになります。(推奨されるようになったのは iOS 15 以降ですが、iOS 13 からこの View Modifier は利用できます)

この View Modifier を利用して先ほどの問題を解決してみます。
まずは、これまでと同じようにコードの全体像から示します。

struct ContentView: View {
    @State var circleCenter = CGPoint.zero
    @State var isCircleScaled = false

    var body: some View {
        VStack {
            Circle()
                .frame(width: 50, height: 50)
                .scaleEffect(isCircleScaled ? 2 : 1)
                .animation(.easeInOut, value: isCircleScaled)
                .offset(x: circleCenter.x - 25, y: circleCenter.y - 25)
                .animation(.spring(response: 0.3, dampingFraction: 0.1), value: circleCenter)
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { value in
                        circleCenter = value.location
                    }
                )

            Toggle(
                "Scale",
                isOn: $isCircleScaled
            )
        }
    }
}

一見は最初に紹介した方のコードと差がないように見えますが、以下の部分が異なっています。

// ...
.animation(.easeInOut, value: isCircleScaled)
// ...
.animation(.spring(response: 0.3, dampingFraction: 0.1), value: circleCenter)
// ...

それぞれの animation View Modifier で value を指定していることがわかります。
今回であれば、isCircleScaled の状態が変更された時には easeInOut、circleCenter の状態が変更された時には spring な animation になっていて欲しいため、そのように value を指定しています。
このコードの実行結果を以下に示します。

期待通り、ドラッグの場合は spring、Toggle を操作した場合は easeInOut のアニメーションになっています👏

このように value を指定する方の View Modifier はどの状態によってどのアニメーションが行われるかが明確になるため、開発時の挙動もだいぶ理解しやすくなります。

しかし、この View Modifier を利用していても少し困ったことが起きるパターンがあります。
それについても見ていくために、まずはコードの全体像から示します。

struct ContentView: View {
    @State var isResetting = false
    @State var circleCenter = CGPoint.zero
    @State var isCircleScaled = false

    var body: some View {
        VStack {
            Circle()
                .frame(width: 50, height: 50)
                .scaleEffect(isCircleScaled ? 2 : 1)
                .animation(isResetting ? nil : .easeInOut, value: isCircleScaled)
                .offset(x: circleCenter.x - 25, y: circleCenter.y - 25)
                .animation(isResetting ? nil : .spring(response: 0.3, dampingFraction: 0.1), value: circleCenter)
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { value in
                        circleCenter = value.location
                    }
                )

            Toggle(
                "Scale",
                isOn: $isCircleScaled
            )

            Button("Reset") {
                isResetting = true
                circleCenter = .zero
                isCircleScaled = false
                isResetting = false
            }
        }
    }
}

こちらのコードについても、追加された部分を抜粋します。

struct ContentView: View {
    @State var isResetting = false
    // ...

    var body: some View {
        VStack {
            Circle()
                // ...
                .animation(isResetting ? nil : .easeInOut, value: isCircleScaled)
                // ...
                .animation(isResetting ? nil : .spring(response: 0.3, dampingFraction: 0.1), value: circleCenter)
                // ...

            // ...

            Button("Reset") {
                isResetting = true
                circleCenter = .zero
                isCircleScaled = false
                isResetting = false
            }
        }
    }
}

コードについて今まで通り簡単に説明します。

  • isResetting という状態のリセット用の状態を追加している
  • isResetting の状態が true なら animationnil にするようにしている
    • animationnil が指定された場合、何もアニメーションしないという効果を生み出します
  • Reset 用の Button を押したら、isResetting の状態を状態リセットの前後で変更しつつ、アプリが保持する状態を全てリセットしている

isResetting という Reset 用の変数を導入しなければならないのが微妙ですが、この処理により Reset ボタンを押したら、 Circle がアニメーションなしで元の状態に戻るような挙動が期待できると考えられます。
では、実際にこのコードを動かしてみます。

gif を見ていただけるとわかりますが、Reset の時はアニメーションなしで戻って欲しいはずなのに、Reset 時にもアニメーションされてしまっています😢
この問題は以下のように isResetting の変更に微妙な遅延を加えることによって修正することが可能ですが、ハック的な方法でありあまり良いものとは言えません...

Button("Reset") {
    isResetting = true
    circleCenter = .zero
    isCircleScaled = false
    DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1)) {
        isResetting = false
    }
}

明示的なアニメーション( withAnimation )

先ほど紹介したように value を指定するタイプの View Modifier は挙動も把握しやすくなり簡単に扱うことができますが、アニメーションを動作させるための状態管理が複雑になってくると問題が起きる可能性もあります。

そのような問題を解決できる SwiftUI の残りのアニメーション実装方法として withAnimation(_:_:) を利用した明示的なアニメーション実装方法があるため、最後にそれについて説明します。

まずは例のごとく、コードの全体像から示します。(比較のために、animation View Modifier のコードを一部コメントアウトして残しています)

struct ContentView: View {
//    @State var isResetting = false
    @State var circleCenter = CGPoint.zero
    @State var isCircleScaled = false

    var body: some View {
        VStack {
            Circle()
                .frame(width: 50, height: 50)
                .scaleEffect(isCircleScaled ? 2 : 1)
//                .animation(isResetting ? nil : .easeInOut, value: isCircleScaled)
                .offset(x: circleCenter.x - 25, y: circleCenter.y - 25)
//                .animation(isResetting ? nil : .spring(response: 0.3, dampingFraction: 0.1), value: circleCenter)
                .gesture(
                    DragGesture(minimumDistance: 0).onChanged { value in
                        withAnimation(.spring(response: 0.3, dampingFraction: 0.1)) {
                            circleCenter = value.location
                        }
                    }
                )

            Toggle(
                "Scale",
                 isOn: $isCircleScaled.animation(.easeInOut)
            )

            Button("Reset") {
                circleCenter = .zero
                isCircleScaled = false
            }
        }
    }
}

コードについて説明する前に withAnimation(_:_:) の利用方法だけ説明しておきます。
withAnimation(_:_:) は必要に応じて任意のアニメーションを引数として与えた上で、アニメーションに関わる状態変更をブロックの中で行うことで、その状態を利用したコードが任意のアニメーションで動くようになります。

具体的に少しだけコードを掻い摘んで説明していきます。

  • animation(_:value:) View Modifier を利用した暗黙的なアニメーションを表現していたコードをコメントアウトしている
  • アニメーションに関わる状態変更を withAnimation(_:_:) で囲んでいる
  • Binding が必要な Toggle に関しては、Binding の extension として提供されている animation function を利用して状態変更をアニメーションさせている
  • Reset Button を押した時はアニメーションして欲しくないため、withAnimation(_:_:) も何も使わずに純粋に状態変更のみ行っている

このように withAnimation(_:_:) を利用したアニメーション実装方法は、アニメーションに関わる状態を変更したい時に「明示的に withAnimation(_:_:) ブロックでその変更を囲んであげる」または 「Binding の extension で提供されている function を利用する」ことによってアニメーションを表現します。
明示的にアニメーションをさせることから、このアニメーション実装方法は explicit animation と呼ばれたりしているみたいです。

上記のコードで animation(_:value:) View Modifier を利用していた時の最終時のコードと全く同じ動きを表現することができます(下図)。

明示的なアニメーションを利用することによって、isResetting という状態を導入することなく、しかも微妙な遅延を生じさせる必要もなくアニメーションをスマートに実装することができていることがわかります。

withAnimation の良いところはこれだけではなく、状態変更のタイミングでアニメーションさせたい時のみ明示的に囲んでいくだけであるため、アニメーションを意図通りに表現しやすいというメリットもあります。

具体的には、例えば Reset する時に任意のアニメーションを発生させたければ以下のようにできますし、

Button("Reset") {
    withAnimation(.linear) {
        circleCenter = .zero
        isCircleScaled = false
    }
}

もし一部だけアニメーションさせたいなら以下のようにコードを書くこともできます。

Button("Reset") {
    circleCenter = .zero
    withAnimation(.linear) {
        isCircleScaled = false
    }
}

おわりに

本記事では SwiftUI におけるアニメーションの基本的な実装方法について、暗黙的なアニメーション・明示的なアニメーションの2つを主に紹介しました。

本記事の例は同期的なコードだったため、大きな問題が起きることなくアニメーションを表現することができていましたが、非同期な処理になってくると少し工夫する必要も出てきます。
冒頭で説明しましたが、iOS Advent Calendar2 では非同期な処理として Combine を利用した時のアニメーション実装方法についての記事を書こうと思っているので、もしよろしければそちらも見て頂けたら嬉しいです🙏

参考

57
33
1

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
57
33