見出し画像

SwiftUIのScrollViewでスクロール量を取得してViewを操作する

SwiftUIで開発していると、ScrollViewからスクロール量を取得してViewを操作したい(動かしたり、opacityを変更したり、表示・非表示させたり)ケースが時々あるが、現状UIKitと違ってSwiftUIにはスクロール量を取得する方法はなく、力技でやるしかなさそう。

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

UIKitであれば、UIScrollViewDelegateのscrollViewDisScroll(_:)でcontentOffsetを取得して処理すればいいが、現状SwiftUIにはそのようなAPIはなく、iOS 17でscrollPosition(id:anchor:)が追加されたが、あくまで取得できるのはidなので、具体的なスクロール位置は取得できない。

そこで、ScrollViewの要素のbackgroundに透明なViewを敷いて、スクロール時にそのViewのScrollViewとの相対座標系におけるy座標の変化量を取得すれば、スクロール量を取得することができる。

なお、上のViewはScrollViewに対してy軸負の方向に動くので、取得できるoffsetは負の値となる。このoffsetをもとにViewを操作する場合は符号に注意が必要。

ScrollView {
    LazyVStack {
				...
    }
    .background {
        GeometryReader { proxy in
            // 透明なViewを下に敷いて、ScrollViewの座標系におけるy座標の変化量を取得する
            Color.clear.onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, offset in
                print("offset: \(offset)")
            }
        }
    }
}
.coordinateSpace(name: "ScrollView")

スクロール量を取得してViewのopacityを変える

先程の方法で取得したスクロール量をもとに、Viewのopacityを変更してみる。スクロールする前はアイコンが表示されていて、スクロールしていくとだんだんと透明になっていく想定なので、opacity: 1.0に負のoffsetをスクロールの閾値で割ったものを足せばよい。

@State private var offset: CGFloat = 0

ZStack(alignment: .topTrailing) {
    ScrollView {
        LazyVStack(spacing: 0) {
            ...
        }
        .background {
            GeometryReader { proxy in
                Color.clear.onChange(of: proxy.frame(in: .named("ScrollView")).minY) { _, offset in
                    self.offset = offset
                }
            }
        }
    }
    .coordinateSpace(name: "ScrollView")
    
    Image(systemName: "person.circle.fill")
        .resizable()
        .frame(width: 64, height: 64)
        .foregroundStyle(.blue)
        .opacity(1 + (offset/500.0))
        .padding()
}

スクロールした時にヘッダーを固定する

先程の方法を使って、スクロールした時にヘッダーを固定する、いわゆるSticky Headerをやってみる。

まずは、ScrollViewの中でZStackでheaderViewとLazyVStackを配置して、スクロール時にヘッダーが一緒に動くようにする。次に、スクロール量が一定以上になった場合に、保持するoffsetを更新して、offset(y: -offset)でheaderViewをy軸の正の方向に動かせば、スクロール量を相殺してSticky Headerを実現できる。

さらに、コンテンツがheaderViewの下に潜り込めるように、stickedの場合にheaderViewのzIndexを調整する(最初からheaderViewが上だとスクロールできない)。おまけとして、stickedの場合にheaderViewのレイアウトをコンパクトに変更してみる。

@State private var offset: CGFloat = 0
@State private var sticked: Bool = false

private let headerHeight: CGFloat = 150
private let stickyHeaderHeight: CGFloat = 50
private var topAreaHeight: CGFloat {
    headerHeight + stickyHeaderHeight
}

var body: some View {
    ScrollView {
        ZStack(alignment: .top) {
            headerView(sticked: sticked)
                .frame(maxWidth: .infinity)
                .frame(height: sticked ? stickyHeaderHeight : topAreaHeight)
                .background(sticked ? Color.red : Color.blue)
                .zIndex(sticked ? 1 : 0)
                .offset(y: -offset)
            
            LazyVStack(spacing: 0) {
                ...
            }
            .padding(.top, topAreaHeight)
            .background {
                GeometryReader { backgroundProxy in
                    // 透明なViewを下に敷いて、ScrollViewの座標系におけるy座標の変化量を取得する
                    Color.clear.onChange(of: backgroundProxy.frame(in: .named("ScrollView")).minY) { _, newOffset in
                        if newOffset <= -headerHeight {
                            offset = newOffset
                            sticked = true
                        } else {
                            offset = 0
                            sticked = false
                        }                        
                    }
                }
            }
        }
    }
    .coordinateSpace(name: "ScrollView")
    .navigationTitle("ScrollView with sticky header")
}

@ViewBuilder
private func headerView(sticked: Bool) -> some View {
    if sticked {
        HStack(spacing: 16) {
            Image(systemName: "person.circle.fill")
                .resizable()
                .frame(width: 24, height: 24)
            
            Text("テストテストテストテストテストテストテストテストテストテスト")
                .frame(maxWidth: .infinity)
        }
        .padding(16)
    } else {
        VStack(spacing: 16) {
            Image(systemName: "person.circle.fill")
                .resizable()
                .frame(width: 40, height: 40)
            
            Text("テストテストテストテストテストテストテストテストテストテスト")
        }
        .padding(16)
    }
}

補足

こちらの記事で紹介されているScrollViewからスクロール量を取得するOffsetReadableVerticalScrollViewを使えば、onChangeOffsetで簡単にoffsetを扱うことができる。タブ切り替えのあるSticky Headerの実装方法もとても参考になるのでおすすめ。

リポジトリ

参考


この記事が気に入ったらサポートをしてみませんか?