SwiftUI でちょっぴりリッチアニメーション


はじめに

執筆環境:Xcode 12.4 / macOS Big Sur 11.2.3

この記事は 寄付を身近にする dim. の開発を行う中での学びを共有する目的で執筆しております。
TechCrunch さんにも取り上げていただきましたので御覧ください。

アニメーション

このように、ロゴが一定区間障害物を避けるアニメーションの実装方法をご紹介します!

1. Offset が取れる前提で記述

SwiftUI で何が難しいかというと、 ScrollView の offset 取得 だということは有名かと思います。
なので、一旦その offset が取れる前提でアニメーションを記述してみます。

struct ContentView: View {
    @State private var offset: CGRect? // 問題はこれをどのように更新するか

    var body: some View {
        ScrollView() {
            GridView() // スクロールできるコンテンツ View
                .padding(.top, 50) // ロゴなどを表示させる領域を確保
        }
        .overlay(topAreaView, alignment: .top)
    }
}

アニメーション方法は他にも考えられますが、ここでは最もシンプルな方法と判断したものでご紹介します。

extension ContentView {
    private var topAreaView: some View {
        VStack {
            Spacer()
            Assets.logo.image.toImage // ロゴ画像
                .resizable()
                .scaledToFit()
                .frame(height: 35) // ロゴのサイズ指定
            Spacer()
                .frame(height: logoBottomHeight) // ここを変動させることで動かす
        }
    }
}

Y軸方向に最大値・最小値、アニメーション時の座標値の3種類に分けて計算した数値を返します。
細かな計算式は実装方法によって異なるため、省略しております。

extension ContentView {
    private var logoBottomHeight: CGFloat {
        let max = ... // ロゴが上昇する最大値Y
        let diff = ... // offset?.origin.y を利用してスクロール量計算
        if diff > .zero && diff <= max {
            return diff // アニメーション可能
        } else if diff > .zero {
            return max // 最大位置で停止
        } else {
            return .zero // 最小位置で停止
        }
    }
}

2. Offset の取得

Offset value の伝達には preference を利用します。
今回は offset を管理したいので CGRect を型に指定します。

struct OffsetPreferenceKey: PreferenceKey {
    typealias Value = CGRect

    static var defaultValue: Value = .zero
    static func reduce(value: inout Value, nextValue: () -> Value) {}
}

offset は GeometryReader 経由で取得取得可能です。
ダミーの Color.clear を準備し、その offset を preference 側に渡しています(このようにダミーを多用する方法は パフォーマンス低下を招くため多用厳禁 です。参考としてご覧ください)。

struct OffsetReader: View {
    var body: some View {
        GeometryReader { geometry in
            Color.clear
                .preference(
                    key: OffsetPreferenceKey.self,
                    value: geometry.frame(in: .frameLayer)
                )
        }
    }
}

private extension CoordinateSpace {
    static let frameLayer: CoordinateSpace = .named("frameLayer")
}

3. 結合

  1. OffsetReader(View)をスクロールされるコンテンツのバックグラウンドに設定
  2. onPreferenceChange 経由で offset の変化を取得
  3. offset を更新

以上が結合のために追加した処理になります。
注意点としては、 offset の更新が onAppear 呼び出し前に行われた際、 動作が安定しない場合がある 点です。以下の例をそのまま利用せず、実際に実装してみて 動作が安定しなかったとき、参考に してみてください。

struct ContentView: View {
    @State private var offset: CGRect?
    @State private var isOnAppear = false

    var body: some View {
        ScrollView() {
            GridView()
                .padding(.top, 50)
                .background(OffsetReader()) // リーダーをバックグラウンドに設定
        }
        .overlay(topAreaView, alignment: .top)
        .onAppear {
            isOnAppear = true
        }
        // preference 経由で値変化を取得
        .onPreferenceChange(OffsetPreferenceKey.self, perform: { offset in
            // 値変化をアニメーションに対応
            withAnimation {
                // onAppear が呼ばれる前にアニメーション制御が走ると不具合が発生する場合あり
                if isOnAppear {
                    self.offset = offset // offset を更新
                }
            }
        })
    }
}

さいごに

最後までご覧いただきありがとうございます。
今回は「 希望のアニメーションを実現する 」ことを最優先にした場合の方法でご紹介しました。ぜひ、パフォーマンスのことも頭の片隅に置いた上で参考にしていただければと思います。