SwiftUI でスクロールの上部にくっついて伸縮する View を作る

こんにちは。亀山です。Twitter のプロフィール画面上部のバナー画像のように、スクロールしても隙間が空かないで伸縮する View が欲しいことがあります。 今回はそんな View を introspect や複雑な座標計算無しに実現できたのでご紹介します。

import SwiftUI

struct ContentView: View {
    var body: some View {
        GeometryReader { proxy in
            ScrollView {
                StickyHeader(safeAreaInsetsTop: proxy.safeAreaInsets.top, height: 100) {
                    VStack {
                        Text("hello!")
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .background(Color.blue)
                }
                ForEach(0 ..< 100, id: \.self) { i in
                    Text("Row \(i)")
                        .padding()
                }
            }
        }
    }
}

struct StickyHeader<Content: View>: View {
    let safeAreaInsetsTop: CGFloat
    let height: CGFloat
    @ViewBuilder let content: Content

    @State private var offsetY: CGFloat = 0

    var body: some View {
        GeometryReader { proxy in
            let offsetY = proxy.frame(in: .global).minY
            Color.clear
                .overlay(alignment: .top) {
                    content
                        .frame(height: height + max(safeAreaInsetsTop, offsetY))
                        .frame(minWidth: 0)
                        .clipped()
                        .offset(y: -max(safeAreaInsetsTop, offsetY))
                }
        }
        .frame(height: height)
    }
}

解説

外側の GeometryReader で取得した safeAreaInsets の分だけ offset で上にずらし、同じ分だけ高さを大きくしています。 またスクロール位置を内側の GeometryReader で取得し、伸縮する View の高さに加算します。伸縮は下にスクロールしたときのみ発生してほしいので、max により上にスクロールしたときには普通に画面外にスクロールされていくようになっています。表示される位置やサイズは事前に決定しているので、スクロール位置を取得するために Color.clear を置き、中身の表示には overlay を用います。 今回はスクリーンの上端目いっぱいに表示するため、coordinateSpace などを気にせず .global の座標系でスクロール位置をそのまま利用することができます。

高さを事前に決めないといけない点が少し使いづらいかもしれませんが、そのおかげでシンプルなコードになっています。