SwiftUIでズームイン・アウトするスクロールトランジションの実装


本記事は [ZOZO Advent Calendar 2021](https://qiita.com/advent-calendar/2021/zozo) その1の14日目の記事です。

# はじめに
横スクロールするとズームイン・ズームアウト表示をSwiftUIで実装してみたので紹介したいと思います。

|ズームイン・ズームアウト|
|:----:|
|![Dec-13-2021 11-04-10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308861/11f01159-d1ff-1e61-89cc-6b5ef517c0e7.gif)|



# SwiftUIでどう実装するのか？

スクロールに合わせてコンテンツのスケールを変える実装するとしたら、例えばUICollectionViewではUICollectionViewLayoutのサブクラスを作成してlayoutAttributesForElementsをオーバーライドしてズームイン・ズームアウトのスケール調整したUICollectionViewLayoutAttributesを返すなど思いつくと思います。
レイアウトクラスをわざわざ作成するのは少々面倒ですね。
UICollectionViewを使わない場合はどのコンテンツをズームイン・ズームアウトさせるか管理するのが大変そうです。


```swift
class AnimatedCollectionViewLayout: UICollectionViewFlowLayout {
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        // ズームイン・ズームアウトを調整したUICollectionViewLayoutAttributesを返す
    }
}
```

それがSwiftUIではサブクラスを作ることなく、`GeometryReader`と`scaleEffect(_:anchor:)`で、スクロールに合わせてスケールを変えることができます。

```swift
ScrollView(.horizontal) {
    // view ...

    GeometryReader { geometryReader in
        let scale = ...
        VStack {
            // view ..
        }
        .scaleEffect(x: scale, y: scale, anchor: .bottom)
    }
}
```


# 実装
`scaleEffect(_:anchor:)`を使うために、ズームイン・ズームアウトのスケールを求めます。

スケールは以下の図のように変化をします。

|赤いコンテンツのスケールの変化<br>スケール範囲(0 ~ 1)|
|:---:|
|![スクリーンショット 2021-12-13 22.08.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308861/5cc5af49-a484-07bf-a2e2-0c6ffc0a1c10.png)|


スケールを求めるためには、スクロール量が必要になり、スクロール量はスクロールした赤のコンテンツの位置と赤のコンテンツを表示しているコンテンツ(親コンテンツ)の横幅が必要になります。


**スクロールしたコンテンツの位置の取得**
SwiftUIでスクロールしたコンテンツの位置を取得するために、ここでGeometryReaderを使用します。GeometryReaderについては[SwiftUIの肝となるGeometryReaderについて理解を深める](https://qiita.com/masa7351/items/0567969f93cc88d714ac)の記事がとてのわかりやすくまとめてくださっています。

UIScrollViewなどでは、contentOffsetからスクロールした位置を計算して求めていましたが、GeometryReaderを使用すると簡単に取得できて便利ですね。

``` swift
ScrollView(.horizontal) {
    HStack(alignment: .center, spacing: 0) {
        ForEach(0..<colors.count) { num in
            GeometryReader { item in
                // スクロールしたコンテンツの位置を取得
                let itemFrame = item.frame(in: .global)
                 VStack { // view }
            }
        } 
    }
}
```



**スケールの設定**
コンテンツのクロール位置が取得できたら後は、スケールを設定するだけです。
以下がコードの全体像です。

```swift
struct ContentView: View {
    struct Page {
        var backgroundColor: Color
    }

    private let colors: [Color] = [.red, .orange, .yellow, .green, .mint, .blue, .purple]

    var body: some View {
        GeometryReader { mainView in
            let mainViewSize = mainView.frame(in: .global).size
            ScrollView(.horizontal) {
                HStack(alignment: .center, spacing: 0) {
                    ForEach(0..<colors.count) { num in
                        GeometryReader { item in
                            // スクロールによるズームイン・ズームアウトのスケールを計算
                            let scale = scale(mainFrame: mainView.frame(in: .global), itemFrame: item.frame(in: .global))
                            VStack {
                                EmptyView()
                            }
                            .frame(width: mainViewSize.width, height: mainViewSize.height)
                            .background(colors[num])
                            // コンテンツのスケールを変える
                            .scaleEffect(x: scale, y: scale, anchor: .bottom)
                        }
                        .frame(width: mainViewSize.width, height: mainViewSize.height)
                    }
                }
            }
        }
    }

    func scale(mainFrame: CGRect, itemFrame: CGRect) -> CGFloat {
        let scrollRate = itemFrame.minX / mainFrame.width
        let scale = scrollRate + 1
        return min(max(0, scale), 1)
    }
}
```


# まとめ
`GeometryReader`と`scaleEffect`のおかげで、スケールの変化だけを考えればよく、少ないコード量で実装できるのはとてもいいなと思いました。
またscale以外にも、opacityやtransformなども変えることができるので、色々と試して見たいと思います。