SwiftUIで動的なStickyHeaderを自作する
今回作る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の最小サイズとして扱います。
最小サイズかどうかで分岐しminimumContent
とstandardContent
の2つの見た目を定義しています。
boundsはScrollViewに対してのものを渡す想定です。
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
の中に定義しています。
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としています。
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)を受け取っているので、アニメーションを使わずにスクロール度合いによる位置調整や透明度の変更なども可能です。
割愛させてもらったanchorPreference
やmatchedGeometryEffect
は飛び道具的なイメージを持っていますが上手く使えれば強力なものだと思います。こちらもいつか記事をかければと思います。
Discussion