📌

SwiftUIで動的なStickyHeaderを自作する

2023/12/23に公開

今回作るStickyHeader

このようなStickeyHeaderを作ります

  • ① 画面上部にとどまる
  • ② Viewはスクロール量に応じて動的に変化
  • ③ 余分に引き下げた場合にいい感じに伸びる

完成形

今回は自作するため動的に制御できるなどカスタマイズ性は高いですが、SwiftUI標準のLazyVStack + Sectionで済む場合はそちらがおすすめです。
SwiftUI標準

LazyVStack + Sectionのコード
ScrollView {
    LazyVStack(pinnedViews: [.sectionHeaders]) {
        Section {
            ForEach(0..<100) {
                Text($0.description)
                    .frame(maxWidth: .infinity)
                    .frame(height: 50)
                    .background(.black.opacity(0.1))
            }
        } header: {
            Text("Header")
                .foregroundStyle(.white)
                .frame(maxWidth: .infinity)
                .frame(height: 50)
                .background(.blue)
        }
    }
}

実装

Headerの見た目

今回は50未満をHeaderの最小サイズとして扱います。
最小サイズかどうかで分岐しminimumContentstandardContentの2つの見た目を定義しています。
boundsはScrollViewに対してのものを渡す想定です。

StickyHeaderContent.swift
struct StickyHeaderContent: View {
    /// ScrollViewに対する自身のbounds
    var bounds: CGRect
    @Namespace private var ns
    
    var body: some View {
        let isMinimum = bounds.maxY < 50
        let pullDownOffsetY = max(0, bounds.minY)
        ZStack {
            if isMinimum {
                minimumContent
            } else {
                standardContent
            }
        }
        .animation(.default, value: isMinimum)
        .frame(height: bounds.height + pullDownOffsetY)
        .frame(maxWidth: .infinity)
        .background(isMinimum ? .red : .blue)
        .offset(y: isMinimum ? 0 : -(pullDownOffsetY / 2))
    }
    
    private var minimumContent: some View {
        VStack {
            Spacer()
            HStack {
                Circle()
                    .fill(.black.opacity(0.2))
                    .matchedGeometryEffect(id: "circle", in: ns)
                    .frame(width: 30, height: 30)
                Rectangle()
                    .fill(.black.opacity(0.2))
                    .matchedGeometryEffect(id: "rectangle", in: ns)
                    .frame(width: 200, height: 30)
                Spacer()
            }
            .frame(height: 50)
            .padding(.horizontal)
        }
    }
    
    private var standardContent: some View {
        VStack {
            Circle()
                .fill(.black.opacity(0.2))
                .matchedGeometryEffect(id: "circle", in: ns)
                .frame(width: 100, height: 100)
            Rectangle()
                .fill(.black.opacity(0.2))
                .matchedGeometryEffect(id: "rectangle", in: ns)
                .frame(width: 200, height: 30)
        }
        .padding()
    }
}

② Viewはスクロール量に応じて動的に変化
基本的にはisMinimumの分岐で動的に変化させていて、matchedGeometryEffectというものを利用してアニメーションをつけています。解説は割愛させていただきますが、今回の用途としては「同じNameSpace」の中で「同じid」を持つものを同一のViewとみなして、よしなに補完のアニメーションをつけてくれるものと認識いただければと思います。

③ 余分に引き下げた場合にいい感じに伸びる
ScrollViewに対する引き下げ量はbounds.minYが使えるので、0を下回らないようにpullDownOffsetYとして定義しています。ZStackのframe.heightに加算しますが、それだけだと下方向にViewが伸びてしまうため、offset.yで中央に来るよう調整を行っています。

Stickyな部分

さきほど作ったStickyHeaderContentはGeometryReader > ScrollView > StickyHeaderの中に定義しています。

SampleView.swift
struct SampleView: View {
    
    var body: some View {
        GeometryReader { proxy in
            ScrollView {
                VStack {
                    StickyHeader(proxy: proxy, defaultHeight: 200, minHeight: 50) { bounds in
                        StickyHeaderContent(bounds: bounds) // 上で作ったHeader
                    }
                    ForEach(0..<100) {
                        Text($0.description)
                            .frame(maxWidth: .infinity)
                            .frame(height: 50)
                            .background(.black.opacity(0.1))
                    }
                }
            }
        }
    }
}

続いてStickyHeaderのコードです。
ContentはどのViewでも表示できるようジェネリクスで受け取り、位置・サイズを取得するためにanchorPreferenceを使用しました。(こちらも詳細は割愛)
また、スクロール時に下の要素より前面に表示されるようzIndexを1としています。

StickyHeader.swift
struct StickyHeader<Content: View>: View {
    /// 親Viewのproxy(全画面を想定)
    var proxy: GeometryProxy
    /// 通常のHeaderの高さ
    var defaultHeight: CGFloat
    /// 最小のHeaderの高さ
    var minHeight: CGFloat
    /// 表示するHeader
    var content: ((CGRect) -> Content)
    
    var body: some View {
        Color.clear
            .frame(height: defaultHeight)
            .frame(maxWidth: .infinity)
            .anchorPreference(key: StickyHeaderPreferenceKey.self, value: .bounds, transform: { $0 })
            .overlayPreferenceValue(StickyHeaderPreferenceKey.self) { value in
                if let value {
                    let bounds = proxy[value]
                    content(bounds)
                        .frame(height: bounds.height)
                        .offset(y: bounds.maxY < minHeight ? minHeight - bounds.maxY : 0)
                }
            }
            .zIndex(1)
    }
}

struct StickyHeaderPreferenceKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>?
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = nextValue()
    }
}

① 画面上部にとどまる
画面上部に固定するためのStickyHeaderのoffsetは下の図のような考え方で実装しています。
offsetイメージ(左)maxYが50以上(右)maxYが50未満

ここまでで冒頭に記載した要件を満たすStickyHeaderが完成しました🙌

次項では複数のStickyHeaderを表示できるようカスタマイズしていきます。

複数のStickyHeaderを表示する

StickyHeaderが複数重なるものを作ってみます。

複数のStickyHeader

StickyHeaderは重なり順と上部固定位置を変更できるよう修正します。

struct StickyHeader<Content: View>: View {
    /// 親Viewのproxy(全画面を想定)
    var proxy: GeometryProxy
    /// 通常のHeaderの高さ
    var defaultHeight: CGFloat
    /// 最小のHeaderの高さ
    var minHeight: CGFloat
+    /// zIndex(昇順になるようにする)
+    var zIndex = 1.0
+    /// 上部固定部分のoffset
+    var stickyOffsetY = 0.0
    
    /// 表示するHeader
    var content: ((CGRect) -> Content)
    
    var body: some View {
        Color.clear
            .frame(height: defaultHeight)
            .frame(maxWidth: .infinity)
            .anchorPreference(key: StickyHeaderPreferenceKey.self, value: .bounds, transform: { $0 })
            .overlayPreferenceValue(StickyHeaderPreferenceKey.self) { value in
                if let value {
-                    let bounds = proxy[value]
+		     let bounds = proxy[value].offsetBy(dx: 0, dy: -stickyOffsetY)
                    content(bounds)
                        .frame(height: bounds.height)
                        .offset(y: bounds.maxY < minHeight ? minHeight - bounds.maxY : 0)
                }
            }
-            .zIndex(1)
+            .zIndex(zIndex)
    }
}

StickyHeaderContentは「③ 余分に引き下げた場合にいい感じに伸びる」部分を削除しておきます。

struct StickyHeaderContent: View {
    /// ScrollViewに対する自身のbounds
    var bounds: CGRect
    @Namespace private var ns
    
    var body: some View {
        let isMinimum = bounds.maxY < 50
-        let pullDownOffsetY = max(0, bounds.minY)
        ZStack {
            if isMinimum {
                minimumContent
            } else {
                standardContent
            }
        }
        .animation(.default, value: isMinimum)
-        .frame(height: bounds.height + pullDownOffsetY)
        .frame(maxWidth: .infinity)
        .background(isMinimum ? .red : .blue)
-        .offset(y: isMinimum ? 0 : -(pullDownOffsetY / 2))
    }
    
    private var minimumContent: some View {
	// 省略
    }
    private var standardContent: some View {
	// 省略
    }

これで完成です。Xアプリのプロフィール画面のHeaderのような実装する際に活用できるのではないかと思います。

おわりに

今回はStickyHeaderをSwiftUIで自作する方法を記事にしてみました。

Headerの挙動はアプリによって理想の形が変わりそうなのでシンプルな実装にしてみましたが、仕様が決まっていればprotocolを使ったりと、もう少しかっちりとした仕組みも作れるのではないかと思います。
また、StickyHeaderContentの部分は常に変化する値(bounds)を受け取っているので、アニメーションを使わずにスクロール度合いによる位置調整や透明度の変更なども可能です。

割愛させてもらったanchorPreferencematchedGeometryEffectは飛び道具的なイメージを持っていますが上手く使えれば強力なものだと思います。こちらもいつか記事をかければと思います。

Discussion