SwiftUI でScrollView のスクロール量を取得する

3 min read読了の目安(約2800字

ScrollView のスクロールに連動して見た目が変わるUI を作りたいと思った時のメモです。

困ったこと

UIKit のUIScrollView では、UIScrollViewDelegate のscrollViewDidScroll を利用することで、スクロールビューのスクロールがどのくらい行われたかを取得することができます。
一方SwiftUI のScrollView には、同じことをやろうとするためのAPI が見つかりませんでした。(ScrollViewReader あたりで出来るのかなと思ったのですが...)

実装前

次のView 要素に、コンテントオフセットを取得する実装を追加していきます。

var body: some View {
    ZStack(alignment: .top) {
        ScrollView() {
           contentA
	   contentB
	   contentC
        }
        header
    }
}

コンテントオフセットを取得する

【SwiftUI】 ListでOffsetを取得する - Qiita を参考にさせて頂きました。
GeometryReaderpreference を使うことによって、要素の位置が変わる度にその位置を取得できます。

@State private var offsetY = CGFloat(0)

struct OffsetYPreferenceKey: PreferenceKey {
    typealias Value = [CGFloat]
    static var defaultValue: [CGFloat] = [0]
    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }
}

var body: some View {
    ZStack(alignment: .top) {
        ScrollView() {
            GeometryReader { geometry in
                VStack {
                    contentA
                    contentB
                    contentC
                }
                .preference(key: OffsetYPreferenceKey.self, value: [geometry.frame(in: .global).minY])
            }
        }
        .onPreferenceChange(OffsetYPreferenceKey.self) { value in
            self.offsetY = value[0]
            print(self.offsetY)
        }
        header
    }
}

アプリをビルドして実際にスクロールを行うと、次のようなログが出力されます。

47.009440104166686
36.34277343750002
25.342773437500018
17.342773437500018
9.67610677083335
3.0094401041666856
-1.6572265624999787
-6.657226562499979

今回はコードを簡易にするためglobal の座標情報を直接使うようにしました。
参考記事のように座標の差分を取得することで、汎用的に利用できるView になりそうです。

余談

GeometryReader を追加したことでレイアウトが崩れてしまいました。
具体的にはVStack 要素が画面内に収まる高さに固定されるようになってしまいました。 (複数行テキストが1行固定になってしまったのですぐに気がつきました。)
そこで対策として以下を行いました。

  • ZStack の中にGeometryReader とVStack 要素を並べる
  • GeometryReader の中の要素にはColor.clear を置く
var body: some View {
    ZStack(alignment: .top) {
        ScrollView() {
	    ZStack {
                GeometryReader { geometry in
                    Color.clear
                        .preference(key: OffsetYPreferenceKey.self, value: [geometry.frame(in: .global).minY])
                }
                VStack {
                    contentA
                    contentB
                    contentC
                }
            }
	    //

取得した値を使う

見た目を変えたい要素の方で、State で宣言したoffset 値を利用できます。

let offsetYThreshold = CGFloat(32)

var header: some View {
    ZStack {
        //
    }
    .opacity(offsetYThreshold < offsetY ? 1 : 0)
    .animation(.easeInOut)
}
``