初めに
iOS14 から使える ScrollViewReader
を使って、ある程度のことはできます。
ただ、実現したいことができなかったため、他で何か実現できないか考えてみました。
また、使い回しが効くように拡張してみました。
実装
実現イメージを先に載せておきます。(Swift Playgrounds のデモ)
このようにスクロールに応じて座標を取得できます。
では実装を見ていきます。
1. GeometryReader で offset を取得する
この方法自体はいろんなところに記述があるため、特別難しい新しいことはありません。
PreferenceKey
を作成して、preference(key:,value:)
でターゲットに設定します。そして、onPreferenceChange(,perform:)
で値を取得する形です。
【example code】
struct OffsetPreferenceKey: PreferenceKey {
static var defaultValue = CGFloat.zero
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value += nextValue()
}
}
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】
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
の設定を変更します。
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
になるように修正します。
- $0.frame(in: .global).origin.y
+ $0.frame(in: .global).origin
これで両方とも取得できるようになりました。
(※ 例は縦スクロールしかないので X軸 の値は変わっていない)
ご覧の通り、このままだとスクロール度に値がマイナス値に大きくなってしまうので、PreferenceKey
の方を少し修正します。
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
を作ることで実装をある程度縛ることができます。
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
)
})
}
}
こうすることで以下のように使用できます。
ScrollWrapperView(content: {
LazyVStack {
ForEach(0..<100, id: \.self) { y in
Text("\(y)")
}
}
}, perform: {
print($0)
})
5. extension に分離する(おまけ)
使いやすいように View
の extension
にすることで、どこからでも使用できるようになります。
ただし、先ほども記載した通り ScrollView
直下の View
で使用しない場合、基準となる値が変わってしまうため、使用者側で注意が必要です。
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
)
}
}
こうすることで以下のように使用できます。
ScrollView {
LazyVStack {
ForEach(0..<100, id: \.self) { y in
Text("\(y)")
}
}
.onChangeParentScrollViewOffset {
print($0)
}
}
個人的には「4. 独自の ScrollView に隠蔽化する」のような使い方が、ある程度縛った実装ができておすすめです。
その他
GeometryReader
から取得できるフレームは .global
指定しています。ちゃんと名前をつけてあげることで、独自の座標系との関係性をとることも可能になります。
- $0.frame(in: .global).origin
+ $0.frame(in: .named("nameSpace")).origin
ターゲットの View
に coordinateSpace
で指定します。
Text("target")
.coordinateSpace(name: "nameSpace")
正直なところうまく動作しないこともある?(使い勝手がまだちゃんとわかっていない部分がある)ので、coordinateSpace
の理解が必要です。
また、これまでの実装は ScrollView
に限らず List
などでも取得可能です。
終わりに
これくらいライトなラッパーであれば、メンテナンスコストも低いため扱いやすいかなと思います。
比較的簡単に使えるので、ぜひみなさんも使ってみてくださいmm