【SwiftUI】ScrollViewでScroll方向を取得してViewの表示を切り替える！

こんにちは。[ymurao2](https://twitter.com/ymurao2)です！
ScrollViewでScroll方向に応じてぴょこぴょこするあれの実装が難しかったので、ご紹介します！

# 成果物
![Simulator Screen Recording - iPhone 15 Pro - 2023-12-03 at 13.39.03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/10888886-8083-ee7a-c57e-955c600d4bbd.gif)
※ この画面なんて呼ぶんですかね :thinking: 

# 実装
お急ぎの方のために全体像です。
```swift:InteractiveStickyHeaderScrollView.swift
public struct InteractiveStickyHeaderScrollView: View {
    @State var offset: CGFloat = .zero
    @State var shouldShowStickyView: Bool = true
    
    public var body: some View {
        NavigationView {
            ScrollViewOffsetReader(offset: $offset) {
                ForEach(0...100, id: \.self) { i in
                    cell(i)
                }
            }
            /// iOS17以上はこちら
            .onChange(of: offset) { oldValue, newValue in
                if offset >= 0 {
                    shouldShowStickyView = true
                } else {
                    shouldShowStickyView = oldValue <= newValue
                }
            }
            /// iOS16以下はこちら
            /*
            .onChange(of: offset) { [offset] newValue in
                if newValue >= 0 {
                    shouldShowStickyView = true
                } else {
                    shouldShowStickyView = offset <= newValue
                }
            }
            */
            .safeAreaInset(edge: .top, spacing: 0) {
                stickyView
            }
            .navigationTitle("TItle")
            .navigationBarTitleDisplayMode(.inline)
        }
    }
    
    var stickyView: some View {
        Text("Sticky View")
            .frame(maxWidth: .infinity, alignment: .leading)
            .frame(height: 40)
            .background(Color.orange)
            .offset(y: shouldShowStickyView ? 0 : -40)
            .opacity(shouldShowStickyView ? 1 : 0)
            .animation(.easeIn, value: shouldShowStickyView)
    }
    
    func cell(_ i: Int) -> some View {
        Text("\(i)：hogehoge")
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
    }
}
```

```swift:ScrollViewOffsetReader.swift
struct ScrollViewOffsetReader<Content: View>: View {
    @Binding var offset: CGFloat
    let content: () -> Content
    private let scrollViewCoordinateSpace: String = "scrollViewCoordinateSpace"
    
    init(
        offset: Binding<CGFloat>,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self._offset = offset
        self.content = content
    }
    
    var body: some View {
        ScrollView {
            offsetReader
            content()
                .padding(.top, -8)
        }
        .coordinateSpace(name: scrollViewCoordinateSpace)
        .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) {
            self.offset = $0
        }
    }
    
    private var offsetReader: some View {
        GeometryReader { proxy in
            Color.clear
                .preference (
                    key: ScrollViewOffsetPreferenceKey.self,
                    value: proxy.frame(in: .named(scrollViewCoordinateSpace)).minY
                )
        }
        .frame (height: 0)
    }
}

fileprivate struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
```

# 雰囲気で解説する
## 1. ScrollViewのラッパー（ScrollViewのOffsetを検知する）

PreferenceKeyを定義します。情報伝搬に使います。
```swift
fileprivate struct ScrollViewOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}
```

次に上で定義した`PreferenceKey`を使います。
GeometryReaderを使用し、親ビュー（今回は`ScrollView`）のサイズと位置にアクセスし、子ビュー（今回は`Color.clear`)にそれらの情報を伝えています。
`proxy.frame(in: .named(scrollViewCoordinateSpace)).minY`のように指定することで、
`scrollViewCoordinateSpace`が設定された`ScrollView`の上端と、`Color.clear`までの距離を取得できます。

```swift
private var offsetReader: some View {
    GeometryReader { proxy in
        Color.clear
            .preference (
                key: ScrollViewOffsetPreferenceKey.self,
                value: proxy.frame(in: .named(scrollViewCoordinateSpace)).minY
            )
    }
    .frame (height: 0)
}
```
先ほどの高さ０の`offsetReader`を`content()`の上に配置することによって、スクロールオフセットの読み取りが可能になります。
`coordinateSpace`を使用して`ScrollView`の座標空間に名前をつけます。
`onPreferenceChange`で`ScrollViewOffsetPreferenceKey`の値を監視し、`offset`の値を更新します。

```swift
var body: some View {
    ScrollView {
        offsetReader
        content()
            .padding(.top, -8)
    }
    .coordinateSpace(name: scrollViewCoordinateSpace)
    .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) {
        self.offset = $0
        print($0)
    }
}

```

printの結果

![Screen Recording 2023-12-03 at 14.30.24.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/33ebe8cf-a7ee-ce6d-d1eb-93aa6173f85a.gif)

:::note info
下にスクロールすると、`offset`は減っていき、上にスクロールすると`offset`は増えていますね！
:::

## 2. ぴょこぴょこの解説
### a.ぴょこぴょこのView
まずは、上部に表示するViewです.
肝は`offset`、`opacity`、`animation`だと思います。このように指定することで、ぴょこぴょこするようになります。
つまるところ、`shouldShowStickyView`のフラグをいい感じにできれば完成です！
```swift
var stickyView: some View {
    Text("Sticky View")
        .frame(maxWidth: .infinity, alignment: .leading)
        .frame(height: 40)
        .background(Color.orange)
        .offset(y: shouldShowStickyView ? 0 : -40)
        .opacity(shouldShowStickyView ? 1 : 0)
        .animation(.easeIn, value: shouldShowStickyView)
}
```
こちらを、`.safeAreaInset`を使用して配置します。
```swift
ScrollViewOffsetReader(offset: $offset) {
    /// content
}
.safeAreaInset(edge: .top, spacing: 0) {
    stickyView
}
```
#### safeAreaInsetについて余談
| edge: .bottom | spacing: 50 |
| --- | --- |
| `edge`に`.bottom`を指定すると、↓のように配置できます。 | `spacing`に値を入れることで、スペースを取ることができます |
| ![Simulator Screenshot - iPhone 15 Pro - 2023-12-03 at 15.24.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/0676ae2f-e662-aba3-df34-491ae597029b.png) | ![Simulator Screenshot - iPhone 15 Pro - 2023-12-03 at 15.27.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/147314a0-557e-402b-1255-047eb3d9bb7e.png) |


### b. offsetの検知
先ほどの、`ScrollViewOffsetReader`を使用し、`offset`を検知します。
`oldValue`と`newValue`の差分を計算することで、スクロール方向を取得することができそうです。

```swift
@State var offset: CGFloat = .zero
@State var shouldShowStickyView: Bool = true

public var body: some View {
    ScrollViewOffsetReader(offset: $offset) {
        ForEach(0...100, id: \.self) { i in
            cell(i)
        }
    }
    .onChange(of: offset) { oldValue, newValue in
        print("oldValue:\(oldValue)、newValue:\(newValue)、diff:\(oldValue-newValue)")
    }
}
```
`offSet`をprintしてみます。

![Screen Recording 2023-12-03 at 14.51.10.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/529173/4a2b08fe-5711-0090-9990-ee0fc428eee2.gif)

GIFだと少し分かりにくいので、表にしてみます。

|  | oldValue | newValue | 差分(oldValue-newValue) |
| --- | --- | --- | --- |
| 下にスクロール | -1.67 | -2.67 | 1.0 |
| 上にスクロール | 6.67 | 7.67 | -1.0 |

整理すると
* `oldValue`が`newValue`より**小さければ**、上にスクロールしている
* `pull To Refresh`のように引っ張ると、`newValue`は0以上になる


### c.ぴょこぴょこの出し分け
```swift
.onChange(of: offset) { oldValue, newValue in
    if offset >= 0 {
        shouldShowStickyView = true
    } else {
        shouldShowStickyView = oldValue <= newValue
    }
}
```
以上！

# おわりに
ScrollのOffsetを監視して、Viewをぴょこぴょこさせることができました！
苦しい戦いでしたが、なんとか実装できました！
ここまで読んでくださりありがとうございました！

## 参考記事
* [[Swift] SwiftUI の ScrollView で offset を検知する](https://qiita.com/hcrane/items/3f3a9ff2ef0fccd01338)
* ['onChange(of:perform:)' was deprecated in iOS 17.0となったので書き方を比較してみた](https://qiita.com/KenUehara/items/39683a7ee20158784d80)