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

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

##### できたこと
- [セル（に当たる部分）に触れた時に少し縮むアニメーションをさせる](#セル（に当たる部分）に触れた時に少し縮むアニメーションをさせる)
![shrink.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/37d78500-35a8-4c85-d2a6-0d0b5ace655f.gif)

- [セル（に当たる部分）をタップしたら画面全体に内容を表示させる](#セル（に当たる部分）をタップしたら画面全体に内容を表示させる)
![tapgesture.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/fd1996e4-f52b-dc02-5d3d-333a51db9006.gif)

- [全画面表示の状態から、ScrollView ではない View を下にスワイプすると閉じる](#全画面表示の状態から、ScrollView-ではない-View-を下にスワイプすると閉じる)
![draggesture.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/471ec65c-0a7b-fe2e-3444-d786265464b9.gif)

ここで ScrollView と言っているのは以下の青枠部分のことです：
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/4d86a284-3c3e-bbc8-632f-c618cc9ce59e.png" width=200>

##### できなかったこと
- セル（に当たる部分）上でスクロールすること
![scroll1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/ee3cc03e-60d0-7c39-6738-f08e2e6c2fcc.gif)
- ScrollView をスクロールした時に ScrollView 以外の View もスクロールさせること
![scroll2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/c9f0833e-e002-3789-715d-301d8e2cdd04.gif)
- ScrollView を下にスワイプすると全画面表示をやめること
![shrink.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/747207/962b3143-14c1-58a0-5132-4233a0cf4130.gif)

# 実装
## セル（に当たる部分）に触れた時に少し縮むアニメーションをさせる
### 準備
まず、元となる一覧画面を作ります。ScrollView の中に、セルに当たる `ContentView` を置きます。（今回は簡単のため、1つだけ置きます）
```swift
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` の実装は以下です。
```swift
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)](https://github.com/ozumin/AnimationLikeAppStore/commit/7850b1ea7539ab1dd61bdec8cafd0fbf3e858e43)
### アニメーション
`ContentView` に触れると縮むアニメーションを追加します。
```swift
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)](https://github.com/ozumin/AnimationLikeAppStore/commit/7ad427d15544ccf6ecebc3d4c635edbcbbaf340b)

## セル（に当たる部分）をタップしたら画面全体に内容を表示させる
次に、`CardView`をタップしたら画面全体を覆って内容を表示させるようにします。
### 準備
内容を表示する状態を`showDetailView = true`として、その状態の時に必要／不要な`View`を表示／非表示させます（実際にリストっぽくする場合は考えていません）

```swift
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`を変化させます。
```swift
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`部分は描画できないため、`ContentView`の`ScrollView`に`.ignoresSafeArea()`をつけます。
また、全画面表示にした時にも、下の`ScrollView`のスクロールが動いてしまうので、これもオフにします。（参考：[StackOverflow](https://stackoverflow.com/questions/57933593/how-to-only-disable-scroll-in-scrollview-but-not-content-view)）
```swift
struct ContentView: View {
    // 中略

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

    var body: some View {

        ScrollView(axes) {
            // 中略
        }
        .ignoresSafeArea()
    }
}
```
[ここまでのcommit (GitHub)](https://github.com/ozumin/AnimationLikeAppStore/commit/017ad7562f9aa4eb0152194bb051077e290c6faa)
## 全画面表示の状態から、ScrollView ではない View を下にスワイプすると閉じる
最後に、`CardView`の`ScrollView`より上にある`VStack`をスワイプすると、全画面表示から一覧画面に戻るようにします。
## 準備
```swift
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)
            }
        // 中略
        }
    }
```
## アニメーション
```swift
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`の中の`ScrollView`と`DragGesture`がバッティングするのか、途中でアニメーションが止まってしまう現象がありました。
そこで、`.scaleEffect()`を使うのではなく、`frame`自体を変化させるようにしました。そうすると、全画面表示から縮んで戻る時に左上を中心に縮んでしまうため、無理やり`padding`をつけました。
```swift
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)](https://github.com/ozumin/AnimationLikeAppStore/commit/14532c0430add05d58de93c65cd4fd1c0ccba3d0)
# 終わりに
SwiftUI の ScrollView はスクロール位置などの情報が取れないため、より凝ったアニメーションを作るには UIKit の ScrollView を使うしかなさそうだと思いました。