[Swift] SwiftUI の ScrollView で offset を検知する

# 初めに

iOS14 から使える `ScrollViewReader` を使って、ある程度のことはできます。
ただ、実現したいことができなかったため、他で何か実現できないか考えてみました。
また、**使い回しが効くように拡張**してみました。

## 実装

実現イメージを先に載せておきます。（Swift Playgrounds のデモ）

![offset gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/8b27b6db-7d7b-89bf-b439-7ba9a283dac0.gif)

このようにスクロールに応じて座標を取得できます。
では実装を見ていきます。

### 1. GeometryReader で offset を取得する

この方法自体はいろんなところに記述があるため、特別難しい新しいことはありません。

`PreferenceKey` を作成して、`preference(key:,value:)` でターゲットに設定します。そして、`onPreferenceChange(,perform:)` で値を取得する形です。

【example code】

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

```.swift
ScrollView() {
    GeometryReader { geometry in
        LazyVStack {
            ForEach(0..<100, id: \.self) { y in
                Text("\(y)")
            }
        }
        .preference(
            key: OffsetPreferenceKey.self, 
            value: $0.frame(in: .global).origin.y
        )
    }
}
.onPreferenceChange(OffsetPreferenceKey.self) { offset in
    print(offset)
}
```

### 2. background に設定する

「1. GeometryReader で offset を取得する」のままでも良いですが、**階層を跨ぐ実装は一般化しても使い勝手が悪い**です。
`modifier` のように使いたいので、`background()`に設定することで、分離できるようにします。

【example code】

```.swift
ScrollView() {
    LazyVStack {
        ForEach(0..<100, id: \.self) { y in
            Text("\(y)")
        }
    }
    .background(GeometryReader {
        Color.clear.preference(
            key: OffsetPreferenceKey.self, 
            value: $0.frame(in: .global).origin.y
        )
    })
}
.onPreferenceChange(OffsetPreferenceKey.self) { offset in
    print(offset)
}
```

画面に影響を出さないために `Color.clear` を使用して値を取得します。
（`EmptyView` だと動かない）

### 3. x軸 と y軸 を同時に監視する

現状だと、x軸 と y軸 をそれぞれ監視する必要があり、どちらも実装するとコードが長くなり冗長です。 そのため同時にどちらも監視できるようにします。

まず、`PreferenceKey` の設定を変更します。

```.swift
struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue = CGPoint.zero // CGPoint に変更
    
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.x += nextValue().x
        value.y += nextValue().y
    }
}
```

これで座標に対応しました。次に受け取り側も `CGPoint` になるように修正します。


```diff:CGPoint に変更箇所
- $0.frame(in: .global).origin.y
+ $0.frame(in: .global).origin
```

これで両方とも取得できるようになりました。
（※ 例は縦スクロールしかないので X軸 の値は変わっていない）
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175242/001aa550-20ed-8844-6170-9dcf1b4f3611.png)

ご覧の通り、このままだとスクロール度に値がマイナス値に大きくなってしまうので、`PreferenceKey` の方を少し修正します。

```.swift
struct OffsetPreferenceKey: PreferenceKey {
    static var defaultValue = CGPoint.zero // CGPoint に変更
    
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value.x += nextValue().x
        value.y += nextValue().y
        value.x = -value.x // 追加分
        value.y = -value.y // 追加分
    }
}
```

返す値を反転することで、取得する値が**正の値**になります。
（もっと良い方法があれば教えてほしいところ...笑）

### 4. 独自の ScrollView に隠蔽化する

`ScrollView` 直下の `View` で使用しない場合、**基準となる値が変わってしまう**ため注意が必要です。

そのため、`ScrollView` でラップした独自の `View` を作ることで実装をある程度縛ることができます。

```.swift
struct ScrollWrapperView<Content: View>: View {
    let axes: Axis.Set
    let showsIndicators: Bool
    @ViewBuilder var content: Content
    let perform: (CGPoint) -> Void
    
    init(
        axes: Axis.Set = .vertical, 
        showsIndicators: Bool = true,
        @ViewBuilder content: () -> Content,
        perform: @escaping (CGPoint) -> Void
    ) {
        self.axes = axes
        self.showsIndicators = showsIndicators
        self.content = content()
        self.perform = perform
    }
    
    var body: some View {
        ScrollView(axes, showsIndicators: showsIndicators, content: {
            content
                .background(GeometryReader {
                    Color.clear.preference(
                        key: OffsetPreferenceKey.self, 
                        value: $0.frame(in: .global).origin
                    )
                })
                .onPreferenceChange(
                    OffsetPreferenceKey.self,
                    perform: perform
                )
        })
    }
}
```

こうすることで以下のように使用できます。

```.swift
ScrollWrapperView(content: {
    LazyVStack {
        ForEach(0..<100, id: \.self) { y in
            Text("\(y)")
        }
    }
}, perform: {
    print($0)
})
```


### 5. extension に分離する（おまけ）

使いやすいように `View` の `extension` にすることで、どこからでも使用できるようになります。

ただし、先ほども記載した通り `ScrollView` 直下の `View` で使用しない場合、**基準となる値が変わってしまう**ため、使用者側で注意が必要です。



```.swift
extension View {
    
    func onChangeParentScrollViewOffset(perform: @escaping (CGPoint) -> Void) -> some View {
        self
            .background(GeometryReader {
                Color.clear.preference(
                    key: OffsetPreferenceKey.self, 
                    value: $0.frame(in: .global).origin
                )
            })
            .onPreferenceChange(
                OffsetPreferenceKey.self,
                perform: perform
            )
    }
}
```

こうすることで以下のように使用できます。

```.swift
ScrollView {
    LazyVStack {
        ForEach(0..<100, id: \.self) { y in
            Text("\(y)")
        }
    }
    .onChangeParentScrollViewOffset {
        print($0)
    }
}
```

個人的には「4. 独自の ScrollView に隠蔽化する」のような使い方が、ある程度縛った実装ができておすすめです。


### その他

`GeometryReader` から取得できるフレームは `.global` 指定しています。ちゃんと名前をつけてあげることで、独自の座標系との関係性をとることも可能になります。

```diff:CGPoint に変更箇所
- $0.frame(in: .global).origin
+ $0.frame(in: .named("nameSpace")).origin
```

ターゲットの `View` に `coordinateSpace` で指定します。

```.swift
Text("target")
    .coordinateSpace(name: "nameSpace")
```

正直なところうまく動作しないこともある？（使い勝手がまだちゃんとわかっていない部分がある）ので、`coordinateSpace` の理解が必要です。

https://daringsnowball.net/articles/swiftui-coordinatespace/

また、これまでの実装は `ScrollView` に限らず `List` などでも取得可能です。

# 終わりに

これくらいライトなラッパーであれば、**メンテナンスコストも低い**ため扱いやすいかなと思います。

比較的簡単に使えるので、ぜひみなさんも使ってみてくださいmm