35
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-10-08

初めに

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

実装

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

offset 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 になるように修正します。

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

これで両方とも取得できるようになりました。
(※ 例は縦スクロールしかないので X軸 の値は変わっていない)

ご覧の通り、このままだとスクロール度に値がマイナス値に大きくなってしまうので、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 に分離する(おまけ)

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

ただし、先ほども記載した通り 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 指定しています。ちゃんと名前をつけてあげることで、独自の座標系との関係性をとることも可能になります。

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

ターゲットの ViewcoordinateSpace で指定します。

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

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

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

終わりに

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

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

35
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?