SwiftUIで画像切り替えのアニメーションを実現する


はじめに

Pixelfieという画層をピクセル風に変換するアプリをリリースしています。
最近、変換過程を時間経過とともに見せるという変更を行いました。
少し分かりづらいかもしれませんが、変換過程では下の例のように画像を差し替える際にクロスブラー的なアニメーションを入れています。

今回は同じことをやりたいと思っている方の参考になればと思いその方法を紹介します。

SwiftUIの画像切り替えのアニメーション実装方法

まず、アニメーション実装の方向性として

  • Transitionを使う
  • Animationを使う

の2つが考えられます。

Transitionとは

Transitionとは、あるViewを別のViewに切り替える際に適用されるアニメーションです。
AとBという2つのViewがあったとして、Aの代わりにBを表示したいという場合に、切り替わりをアニメーションで表現することができます。

Animationとは

Animationとは、そのView自体の色や大きさ位置などを変える際に適用されるアニメーションです。
例えばある操作でViewの大きさを2倍にしたいというときに、ムクムクっと大きくするなどのアニメーションを追加することができます。

で結局どうすれば良いの?

今回の用途であれば、後者のAnimationで表現する方が良いと思います。
なぜならばTransitionで表現するとなると、Image()が2つ必要になり、管理がややこしくなるためです。

具体的な例で示します。

Transitionの例

このように2つの画像を切り替えをアニメーション(Transition)で実現するとなった場合、次のように書くことができます。

import SwiftUI

struct ContentView: View {
    @State private var flag = true

    var body: some View {
        VStack {
            if flag {
                Image(systemName: "sun.max.fill")
                    .resizable()
                    .frame(width: 100, height: 100, alignment: .center)
                    .transition(.opacity)
                    .padding()
            } else {
                Image(systemName: "moon.fill")
                    .resizable()
                    .frame(width: 100, height: 100, alignment: .center)
                    .transition(.opacity)
                    .padding()
            }

            Button("Change") {
                withAnimation {
                    self.flag.toggle()
                }
            }
        }
    }
}

単純で分かりやすいですが、切り替える先のImage()を事前に定義しておく必要があるのが欠点です。
事前に切り替えしたい画像の数が分かっていれば良いですが、状況によって変わるとなった場合非常に扱いづらくなります。

用意するImage()の数は1つで同じようなことを表現することはできないでしょうか?
それにはAnimationと非同期処理を組み合わせれば実現ができます。

Animationの例

AnimationはそのViewのプロパティの変更をアニメーションとして表現するため、
次のポイントを抑えて切り替えを実現します。

  • opacityを設定し、flagのON/OFFによって消える、表示されるを制御する
  • flagをOFFにしたら消えっぱなしになってしまうため、ある程度時間が経過したらONにして再度表示させる
  • 再表示前に切り替えたい画像に変更する
  • アニメーションはメインスレッドで行われるため、ウェイトは別スレッドで行う
import SwiftUI

struct ContentView: View {
    @State private var flag = true
    @State private var imageName = "sun.max.fill"

    var body: some View {
        VStack {
            Image(systemName: imageName)
                .resizable()
                .frame(width: 100, height: 100, alignment: .center)
                .opacity(flag ? 1 : 0)
                .padding()

            Button("Change") {
                withAnimation {
                    let newImageName = (imageName == "sun.max.fill") ? "moon.fill" : "sun.max.fill"
                    self.flag.toggle()

                    DispatchQueue.global().async {
                        Thread.sleep(forTimeInterval: 0.2)
                        DispatchQueue.main.sync {
                            self.imageName = newImageName
                            self.flag.toggle()
                        }
                    }
                }
            }
        }
    }
}

実際の挙動はこんな感じです。ウェイトの時間を調節すれば、消え具合を調整することができます。

おまけ

DispatchQueueを使って実装すると、ネストが深くなるのと、他の非同期処理との待ち合わせが難しくなります。
きれいに書くのであればPromiseKitを使って書くことをお勧めします。

import SwiftUI
import PromiseKit

struct ContentView: View {
    @State private var flag = true
    @State private var image = UIImage()

    var body: some View {
        VStack {
            Image(uiImage: image)
                .resizable()
                .frame(width: 100, height: 100, alignment: .center)
                .opacity(flag ? 1 : 0)
                .padding()

            Button("Change") {
                withAnimation {
                    firstly { () -> Guarantee<UIImage> in
                        return Guarantee { seal in
                            DispatchQueue.global().async {
                                let newImage = convert(self.image) // 時間がかかる処理
                                seal(newImage)
                            }
                        }
                    } .then { image -> Guarantee<UIImage> in
                        self.flag.toggle()
                        return Guarantee { seal in
                            DispatchQueue.global().async {
                                Thread.sleep(forTimeInterval: 0.2)
                                seal(image)
                            }
                        }
                    } .done { image in
                        self.image = image
                        self.flag.toggle()
                    }
                }
            }
        }
    }
}