LoginSignup
7
5

More than 1 year has passed since last update.

AppStoreのアニメーションをSwiftUIで再現しようとした

Last updated at Posted at 2022-05-20

概要

AppStore のアニメーションがカッコよかったので、SwiftUI で再現できないか試してみました。

できたこと

ここで ScrollView と言っているのは以下の青枠部分のことです:

できなかったこと
  • セル(に当たる部分)上でスクロールすること
    scroll1.gif
  • ScrollView をスクロールした時に ScrollView 以外の View もスクロールさせること
    scroll2.gif
  • ScrollView を下にスワイプすると全画面表示をやめること
    shrink.gif

実装

セル(に当たる部分)に触れた時に少し縮むアニメーションをさせる

準備

まず、元となる一覧画面を作ります。ScrollView の中に、セルに当たる ContentView を置きます。(今回は簡単のため、1つだけ置きます)

import SwiftUI

struct ContentView: View {

    var body: some View {

        ScrollView {
            VStack(spacing: 30) {
                Spacer()

                Text("Today")
                    .font(.title)
                    .bold()
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding()

                CardView()
            }
        }
    }
}

ContentView の実装は以下です。

struct CardView: View {

    private let image: Image = Image(systemName: "star")

    private let text: String = "test"

    private var width: CGFloat {
        UIScreen.main.bounds.width - 10
    }

    private var height: CGFloat {
        300
    }

    var body: some View {
        VStack {
            image
                .frame(maxWidth: .infinity)

            Text(text)
                .frame(maxWidth: .infinity)
        }
        .frame(width: width, height: height)
        .background(Color.blue)
        .cornerRadius(20)
    }
}

ここまでのcommit (GitHub)

アニメーション

ContentView に触れると縮むアニメーションを追加します。

struct CardView: View {

    @State private var scale: CGFloat = 1

    // 中略

    var body: some View {
        let shrinkGesture = LongPressGesture(minimumDuration: 0.1)
            .onChanged { _ in
                withAnimation(.spring()) {
                    self.scale = 0.9
                }
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                    withAnimation(.spring()) {
                        self.scale = 1
                    }
                }
            }
        
        VStack {
            // 中略
        }
        // 中略
        .scaleEffect(scale)
        .gesture(shrinkGesture)
    }
}

ここで、

  • ScrollView内ではLongPressGesture(minimumDuration: 0)だとジェスチャーが効かなかったので、minimumDuration: 0.1にした
  • minimumDuration: 0の時に.simultaneousGesture(shrinkGesture)としてもジェスチャーが効かなかった

の挙動がありました。

ここまでのcommit (GitHub)

セル(に当たる部分)をタップしたら画面全体に内容を表示させる

次に、CardViewをタップしたら画面全体を覆って内容を表示させるようにします。

準備

内容を表示する状態をshowDetailView = trueとして、その状態の時に必要/不要なViewを表示/非表示させます(実際にリストっぽくする場合は考えていません)

struct ContentView: View {

    @State private var showDetailView = false // 追加

    var body: some View {

        ScrollView {
            VStack(spacing: 30) {

                if showDetailView == false { // 追加
                    Spacer()

                    Text("Today")
                        .font(.title)
                        .bold()
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding()
                }

                CardView(showDetailView: $showDetailView) // CardView に showDetailView を渡す
            }
        }
    }
}

struct CardView: View {

    @Binding var showDetailView: Bool // showDetailView を受け取る

    // 中略
    var body: some View {

        VStack {
            if showDetailView {
                Spacer()
                    .frame(height: 200)
            } // 追加

            Image(systemName: "star")
                .frame(maxWidth: .infinity)

            Text("test")
                .frame(maxWidth: .infinity)

            if showDetailView {
                ScrollView {
                    VStack {
                        ForEach(1..<100) { _ in
                            Text("Can you scroll?")
                                .frame(maxWidth: .infinity)
                        }
                    }
                }
            } // 追加
        }
        // 中略
    }
}

アニメーション

タップ時のアニメーションを追加し、frameを変化させます。

struct CardView: View {
    // 中略

    private var width: CGFloat {
        // showDetailView の時はスクリーンサイズにする
        showDetailView ? UIScreen.main.bounds.width : UIScreen.main.bounds.width - 10
    }

    private var height: CGFloat {
        // showDetailView の時はスクリーンサイズにする
        showDetailView ? UIScreen.main.bounds.height : 300
    }

    var body: some View {
        // 中略

        let tapGesture = TapGesture()
            .onEnded() {
                withAnimation(.spring()) {
                    self.showDetailView = true
                }
            }

        VStack {
            // 中略
        }
        .cornerRadius(showDetailView ? 0 : 20) // 追加
        // 中略
        .gesture(shrinkGesture)
    }
}

このままではsafeArea部分は描画できないため、ContentViewScrollView.ignoresSafeArea()をつけます。
また、全画面表示にした時にも、下のScrollViewのスクロールが動いてしまうので、これもオフにします。(参考:StackOverflow

struct ContentView: View {
    // 中略

    private var axes: Axis.Set {
        showDetailView ? [] : .vertical
    } // showDetailView の時にはスクロールさせない

    var body: some View {

        ScrollView(axes) {
            // 中略
        }
        .ignoresSafeArea()
    }
}

ここまでのcommit (GitHub)

全画面表示の状態から、ScrollView ではない View を下にスワイプすると閉じる

最後に、CardViewScrollViewより上にあるVStackをスワイプすると、全画面表示から一覧画面に戻るようにします。

準備

struct CardView: View {
    // 中略
    var body: some View {
        // 中略
        VStack {
            VStack { // VStack で Text までを囲う
                if showDetailView {
                    Spacer()
                        .frame(height: 200)
                }

                Image(systemName: "star")
                    .frame(maxWidth: .infinity)

                Text("test")
                    .frame(maxWidth: .infinity)
            }
        // 中略
        }
    }

アニメーション

struct CardView: View {

    // 中略

    var body: some View {
        // 中略

        // 下にドラッグすると showDetailView = false になるジェスチャーの追加
        let dragGesture = DragGesture(minimumDistance: 0)
            .onChanged { endedGesture in
                let diff = 1 - endedGesture.translation.height / height

                withAnimation(.spring()) {
                    if diff < 0.8 {
                        showDetailView = false
                        scale = 1
                    } else if diff < 1 {
                        scale = diff
                    }
                }
            }
            .onEnded { _ in
                withAnimation(.spring()) {
                    if scale < 0.8 {
                        showDetailView = false
                    }
                    scale = 1
                }
            }

        VStack {
            VStack {
                // 中略
            }
            .contentShape(Rectangle()) // Spacer 領域もジェスチャー認識できるようにする
            .gesture(showDetailView ? dragGesture : nil) // showDetailView == true の時のみ有効にする

            if showDetailView {
                ScrollView {
                    // 中略
                }
            }
        }
        // 中略
        .gesture(showDetailView ? nil : tapGesture) // showDetailView == false の時のみ有効にする
        .simultaneousGesture(showDetailView ? nil : shrinkGesture) // showDetailView == false の時のみ有効にする、何かがバッティングするようなので .simultaneousGesture にした
    }
}

このままでは、CardViewの中のScrollViewDragGestureがバッティングするのか、途中でアニメーションが止まってしまう現象がありました。
そこで、.scaleEffect()を使うのではなく、frame自体を変化させるようにしました。そうすると、全画面表示から縮んで戻る時に左上を中心に縮んでしまうため、無理やりpaddingをつけました。

struct CardView: View {

    // 中略

    var body: some View {
        // 中略

        VStack {
            // 中略
        }
        .frame(width: width * scale, height: height * scale) // frame を操作
        .background(Color.blue)
        .padding(.leading, showDetailView ? (width * (1 - scale)) / 2 : 0) // 全画面表示から戻る時に真ん中になるようにする
        .padding(.top, showDetailView ? (height * (1 - scale)) / 2 : 0) // 全画面表示から戻る時に真ん中になるようにする
        .cornerRadius(showDetailView ? 0 : 20)
        .gesture(showDetailView ? nil : tapGesture)
        .simultaneousGesture(showDetailView ? nil : shrinkGesture)
    }
}

ここまでのcommit (GitHub)

終わりに

SwiftUI の ScrollView はスクロール位置などの情報が取れないため、より凝ったアニメーションを作るには UIKit の ScrollView を使うしかなさそうだと思いました。

7
5
0

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
7
5