SwiftUIでStickyなタブを実現する

こんにちは。クックパッド事業部でiOSアプリの開発をしている新堀(@tk108gabalian)です。
iOS版のクックパッドアプリではスクロール時にコンポーネントが上部に固着する画面があります。 所謂StickyHeaderというやつです。 今回はそのStickyHeaderをSwiftUIで、かつTabViewを使用つつ実現する方法について紹介します。

導入の背景

2022年7月にリリースした「のせる」画面には以下の要件がありました。

  • 画面上部にユーザー情報を表示する。
  • ユーザー情報の下にタブを表示する。
  • タブをタップするか、タブより下を横にスワイプすることでタブの切り替えが可能。
  • 画面全体をスクロールできるが、ユーザー情報が隠れるまでスクロールしたら画面上部にタブが固着し、以降はタブより下の部分のみスクロールする。(逆方向にスクロールする場合は再度ユーザー情報が表出する。)

また、事業的な希望ではありませんが、開発メンバー達の中ではSwiftUIで画面を実装したいという気持ちがありました。 というのも、この「のせる」以前の画面は基本的にUIKitで作られており、SwiftUIと比べてUIの変更に時間がかかることが課題としてあったからです。

UIKitでこのStickyHeader実現することは可能だと分かっていました。
なぜなら、同クックパッドアプリの「きろく」画面にて、UIKit製のStickyHeaderが既に実装されていたからです。
そのため、UIKitを使えば確実に要件を満たすものが作れると分かりつつも、SwiftUIで同様の振る舞いを実現する方法を探求することにしました。

TabViewなしのStickyHeaderの実現方法

横スクロールによるタブの切り替えを実現するため、SwiftUIのTabViewを使うことを検討していました。 しかし、検証を進めると、TabViewを使いつつStickyHeaderを実現する難しさが分かってきました。

まずはTabViewを使わない場合のStickyHeaderの実現方法を見てみます。
これはScrollView内のコンテンツでLazyVStackなどを使用し、pinnedViews.sectionHeadersを指定した上で、LazyVStackの中のSectionにheaderとなるViewを指定するだけです。

struct ContentView: View {
    var body: some View {
        ScrollView {
            LazyVStack(pinnedViews: .sectionHeaders) {
                Section(header: Text("ここが固着する")) {
                    ForEach(1...50, id: \.self) { number in
                        Text("Row \(number)")
                    }
                }
            }
        }
    }
}

しかし、上記の方針だとScrollViewの中にTabViewを入れる時に問題が発生します。
ScrollViewにジェスチャーが吸われるので、TabViewの中身を横スワイプで切り替えることができません。 (一応TabViewのselectionを切り替えるボタンを用意すればタブを切り替えることはできます。)

また、ScrollView内にTabViewを配置する場合、TabViewの高さがframe modifierのheightによって明示的に指定されないと、高さが確定せずに何も表示されません。 よって、TabView内のコンテンツの高さを全て計算してheightを設定する必要があり、かつそれがタブごとに、またタブの切り替えごとに必要になります。

このように、TabViewとpinnedViewsを併用してStickyHeaderを実現しようと思うと、TabViewの旨みである横スワイプが無効になり、さらにTab内のすべてのViewの高さ計算が必要になってしまいます。

ここまでの調査でTabViewを使用しつつStickyHeaderを実現することが簡単ではないことが分かりましたが、検証を進めていく中で要件を満たす実装に辿り着くことができました。

動作環境

iOS Deployment Targetが 14.0 以上を想定しています。 ※ iOS13での動作は未検証となっています。

画面構成

まず初めに、StickeyHeaderを実現している画面の構成について説明します。

前提として、iOS版クックパッドアプリでSwiftUIを採用している画面では、SwiftUIのViewをUIHostingControllerでラップし、そのUIHostingControllerのviewを親となるUIViewControllerに載せて使っています。
上記の構成はSwiftUI を活用した「レシピ」×「買い物」の新機能開発で紹介されているので、詳しくはこちらをご参照ください。

SwiftUIのViewは以下のような階層になっています。

struct FooTabView: View {
    @Binding var selection: TabType

    private var topAreaHeight: CGFloat {
        Header.height + TabBar.height
    }

    var body: some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing: 0) {
                Header()
                TabBar(tabTypes: TabType.allCases, selection: $selection)
            }
        }
    }

    @ViewBuilder
    private var tabView: some View {
        TabView(selection: $selection) {
            ForEach(TabType.allCases) { _ in
                ScrollView {
                    Content()
                }
            }
        }
        .padding(.top, topAreaHeight)
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }
}

ZStackを配置し、ZStack内にTabViewとヘッダー部分を配置します。 この時、ヘッダーが前面、TabViewが背面になります。 背面のTabViewにはTabごとに縦方向のScrollViewを配置します。 そのままではTabViewが上部のヘッダーに隠れてしまうので、TabViewのtopにpaddingを付けます。

TabViewありのStickyHeaderの実現方法

次にスクロールに合わせて前面のコンポーネントが動くように修正し、さらに一定以上は動かなくなる(画面上部で固着する)振る舞いを実現する方法を紹介します。
以下の2つのステップに分けて説明します。

  1. SwiftUIのScrollViewからスクロール量を取得する。
  2. 取得したスクロール量を使って上部のコンポーネントを動かす。

1. SwiftUIのScrollViewからスクロール量を取得する

スクロール量を取得するため、SwiftUIのGeometryReaderとPreferenceKeyを使います。
GeometryReaderは親となるViewのサイズや座標を取得するAPIです。 ScrollView内にGeometryReaderを配置することで、ScrollView内のコンテンツのY座標を取得できます。(このY座標が実質スクロール量になります。) GeometryReaderから座標とサイズを取得するには、GeometryReaderのinitializerのclosureからGeometryProxyを受け取る必要があります。 しかしこのclosureは@ViewBuilderとなっており、このスコープ内で@Stateな変数を上書きすることはできません。

そこでPreferenceKeyが登場します。
PreferenceKeyを使用すると子Viewから親Viewに値を受け渡すことができます。 まずGeometryReader内に透明なViewを配置します。これで@ViewBuilderのコンパイルできる条件を満たします。 そして上記の透明なViewでPreferenceKeyを使用し、親ViewにGeometryProxyのy座標を渡します。

ここまでの一連の処理を一つのViewで行えるようにし、利用元はclosureからスクロール量を受け取ることができるようにしました。

private struct ScrollViewOffsetYPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = .zero
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

public struct OffsetReadableVerticalScrollView<Content: View>: View {
    private struct CoordinateSpaceName: Hashable {}

    private let showsIndicators: Bool
    private let onChangeOffset: (CGFloat) -> Void
    private let content: () -> Content

    public init(
        showsIndicators: Bool = true,
        onChangeOffset: @escaping (CGFloat) -> Void,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.showsIndicators = showsIndicators
        self.onChangeOffset = onChangeOffset
        self.content = content
    }

    public var body: some View {
        ScrollView(.vertical, showsIndicators: showsIndicators) {
            ZStack(alignment: .top) {
                GeometryReader { geometryProxy in
                    Color.clear.preference(
                        key: ScrollViewOffsetYPreferenceKey.self,
                        value: geometryProxy.frame(in: .named(CoordinateSpaceName())).minY
                    )
                }
                .frame(width: 1, height: 1)
                content()
            }
        }
        .coordinateSpace(name: CoordinateSpaceName())
        .onPreferenceChange(ScrollViewOffsetYPreferenceKey.self) { offset in
            onChangeOffset(offset)
        }
    }
}

2. 取得したスクロール量を使って上部のコンポーネントを動かす

スクロール量が取得できたらそれを使ってヘッダー部分を動かします。
SwiftUIではoffset modifierを変更することで描画地点をずらすことができます。
①これを利用し、取得したスクロール量をoffsetに渡すことでヘッダーを動かします。

before after

しかし、スクロール量をそのままoffsetに反映させ続けると、固着して欲しいタブの部分が画面の上に突き抜けていってしまったり、動いて欲しい方向とは逆向きにヘッダーが動いてしまいます。
②そこで、offsetの範囲をあらかじめ決めておき、その範囲内でoffsetを変えるようにしています。

before after

また、ヘッダーが上に動いているのにヘッダーより下のコンポーネントが動いていないと、ヘッダーの下に隙間ができてしまいます。
③これを防ぐために、ヘッダーより下のコンポーネント(参考実装で言うところのtabView)のpaddingにoffsetを反映させ、ヘッダーに追従して動くようにしています。

before after

しかし、上記のpaddingの変更によってヘッダー以下のコンポーネントの位置が変わると、その分ScrollView内のコンテンツのY座標も動いてしまい、スクロールとpadding変更の両方が合わさった結果、倍速のスクロールが発生してしまいます。
④これを防ぐために、ScrollView内のコンテンツのoffset modifierにて反転したoffsetを指定することで、paddingの変更分を相殺し、倍速でスクロールしないようにしています。

before after
struct FooTabView: View {
    @Binding var selection: s
    @State private var offset: CGFloat = .zero

    private var topAreaHeight: CGFloat {
        Header.height + TabBar.height
    }

    var body: some View {
        ZStack(alignment: .top) {
            tabView

            VStack(alignment: .center, spacing: 0) {
                Header()
                TabBar(tabTypes: TabType.allCases, selection: $selection)
            }
            .offset(y: offset) // ①
        }
    }

    @ViewBuilder
    private var tabView: some View {
        TabView(selection: $selection) {
            ForEach(TabType.allCases) { _ in
                OffsetReadableVerticalScrollView(onChangeOffset: updateOffset) {
                    Content()
                        .offset(y: -offset) // ④
                        .padding(.bottom, topAreaHeight)   
                }
            }
        }
        .padding(.top, topAreaHeight + offset) // ③
        .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
    }

    private func updateOffset(_ newOffset: CGFloat) { // ②
        if newOffset <= -topAreaHeight { // HostingControllerを使わない場合、ここにsafeAreaを高さを足す必要がある。
            offset = -topAreaHeight
        } else if newOffset >= 0.0 {
            offset = 0
        } else {
            offset = newOffset
        }
    }
}

ここまでの実装で、上部コンポーネントの移動とタブの固着が実現できます。

対処した問題

前章で紹介した実装でタブの固着は実現できるのですが、実装に進むと以下の問題点に気づきました。

  1. タブを切り替える時にoffsetが大きくずれてしまう。
  2. iOS15系だとサイズクラスの変更の際にTabView以下の表示が崩れてしまう。
  3. TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう。

タブを切り替える時にoffsetが大きくずれてしまう

スクロール量の取得とoffsetの変更はタブごとに行なっています。
そのため、タブを切り替えるとスクロール量も大きく変更される可能性があります。
その際にヘッダー部分のoffsetが追従していないと、ScrollView内のコンテンツがヘッダー部分に隠れたり、ScrollView内のコンテンツのTopとヘッダーの間に大きな余白が見えてしまいます。

これを防ぐために、タブごとのスクロール量を保持しておき、タブの切り替えの際に変更先のタブのスクロール量を流すようにしています。 (コードは簡略化したものです。)

struct OffsetReadableTabContentScrollView<TabType: Hashable, Content: View>: View {
    let tabType: TabType
    var selection: TabType
    let onChangeOffset: (CGFloat) -> Void
    let content: () -> Content

    @State private var currentOffset: CGFloat = .zero

    public var body: some View {
        OffsetReadableVerticalScrollView(
            onChangeOffset: { offset in
                currentOffset = offset
                if tabType == selection {
                    onChangeOffset(offset)
                }
            },
            content: content
        )
        .onChange(of: selection) { selection in
            if tabType == selection {
                onChangeOffset(currentOffset)
            }
        }
    }
}

iOS15系だと、サイズクラスの変更の際にTabView以下の表示が崩れてしまう

これはSwiftUI側の問題のようなのですが、iOS15系でTabViewを使用している時、タブを2ページ目以降にした状態でサイズクラスを変更すると、タブ内の表示が崩れる不具合がありました。(iOS14、16系では再現しない。)

そのため、iOS15系のみサイズクラスの変更を検知した際のworkaroundを入れることにしました。
「のせる」画面ではUIHostingControllerを使用していたため、UIHostingControllerのviewを保持するUIViewControllerでサイズクラスの変更を検知し、SwiftUIのViewへイベントを流すようにしました。

ただし、この方法には一つ別の問題があります。
それはサイズクラスの変更によってViewが再生成されるため、showログが送り直されたりスクロール位置がリセットされてしまうことです。 しかし、ユーザー数の多いiPhoneでは画面回転を無効にしていてサイズクラスの変更が発生せず、iOS15系のiPadのみで発生する可能性があることから、許容する方針としました。

override public func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    if screenSizeDataSource.screenSize != view.bounds.size {
        screenSizeDataSource.screenSize = view.bounds.size
    }
}
struct FooTabView: View {

    @State private var layoutTrigger = false

    var body: some View {
        ZStack(alignment: .top) {
            if layoutTrigger {
                tabView
            } else {
                tabView
            }
            ... 
        }
        .onChange(of: screenSize) { _ in
            if #unavailable(iOS 16.0), UIDevice.current.userInterfaceIdiom == .pad {
                layoutTrigger.toggle()
            }
        }
    }
    ...
}

TabView内のコンテンツの高さがスクロール領域の高さと同じくらいの時に、画面がガタガタしてしまう

ちょっとgifだと分かりづらいのですが、以下のような挙動になります。

ヘッダーが完全に縮小していないかつスクロールの最下部に達した状態でさらにスクロールしようとすると、スクロールのバウンス*1によってoffsetが小刻みに増減し、Viewが振動しているような振る舞いをします。
この挙動を回避するため、ScrollViewがコンテンツの最下部に達したかどうかを判定し、さらにバウンスが発生する向きにスクロールしようとしている場合はoffsetを更新しないようにしました。

private func updateOffset(_ newOffset: CGFloat, _ didUpdateByTabChange: Bool, _ hasReachedContentBottom: Bool) {
    // タブの中身のスクロールが最下部に達してもヘッダーが完全に縮小していない状態でさらに下にスクロールすると、スクロールのバウンスによって offset が小刻みに増減し画面がガタガタしてしまう
    // タブの中身のコンテンツの最下部が表示されている かつ さらに下にスクロールしようとしている場合は offset を更新しないようにする
    // ただし、タブの選択が切り替わった時( didUpdateByTabChange が true の時)の offset 更新は常に行う
    if !didUpdateByTabChange && hasReachedContentBottom && (newOffset < offset) { return }

    if newOffset <= -maxHeaderOffset {
        offset = -maxHeaderOffset
    } else if newOffset >= 0.0 {
        offset = 0
    } else {
        offset = newOffset
    }
}

ScrollViewのコンテンツが最下部に達したかどうかの判定は透明なViewのonAppear/onDisapperによって行なっています。

private struct ReachedContentBottomTracker: View {
    @Binding var hasReachedContentBottom: Bool

    var body: some View {
        // View が表示された時に onAppear/onDisappear が呼ばれるようにしたいため LazyVStack で囲っている
        LazyVStack {
            Color.clear
                .frame(width: 1, height: 1)
                .onAppear { hasReachedContentBottom = true }
                .onDisappear { hasReachedContentBottom = false }
        }
    }
}

Content()
    .background(ReachedContentBottomTracker(hasReachedContentBottom: $hasReachedContentBottom), alignment: .bottom)

その他検討した実現方法

以下の2つの方法も実現が可能か調査しました。

  1. UIKitのUIScrollViewをUIViewRepresentableで使用する。
  2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

1. UIKitのUIScrollViewをUIViewRepresentableで使用する

UIViewRepresentableはUIKitのViewをSwiftUIで使用するためのAPIです。
UIKitのUIScrollViewではcontentOffsetを取得することでスクロール量が分かります。 そのため、UIScrollViewをSwiftUIのViewに組み込めば、SwiftUIのScrollViewを使わずにスクロール量を取得することが可能です。
しかし、UIViewRepresentableを使用すると、過去にサイズクラスの変更やdynamicTypeの変更を追従してくれない不具合が発生しており、採用は見送りになりました。

一方で、UIKitのUIScrollViewを使用するメリットもありました。 それは精度の高いスクロール量を取得できることです。

後述しますが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。 取得できるスクロール量の精度はヘッダーの動きの滑らかさに直結するのですが、プロトタイプを元にデザイナーに相談しならがら検証を進めていき、許容できる範囲と判断してSwiftUIを採用することにしました。

2. Introspectを使用し、ScrollViewの裏側のUIScrollViewのdelegateを使用する。

IntrospectはSwiftUIの裏側で使用されているUIKitにアクセスし、UIKitの機能を使用できるライブラリです。
これを使用すればSwiftUIのScrollViewでUIScrollViewを取得し、スクロール量を取得することが可能です。 しかし、SwiftUIの内部実装の変更によって裏側のUIKitを取得できなくなる可能性があることから、採用は見送りになりました。

PreferenceKeyとGeometryReaderで取得できるスクロール量の精度についての注意点

「UIKitのUIScrollViewをUIViewRepresentableで使用する」でも書きましたが、今回紹介した方法でScrollViewから取得できるスクロール量は、UIScrollViewから取得できるスクロール量より粗いです。

スクロール量の元にViewを動かすのが今回の紹介したStickyHeaderの実現方法のため、取得するスクロール量の精度が粗いとその分Viewのカクつきが気になってきます。
そのため、ヘッダーの動く範囲が大きくてカクつきが目立ってしまうような画面での使用はお勧めできません。
その場合、UIScrollViewを使用して実装するか、そもそもStickyHeaderが必要ないデザインで同じ機能を実現できないか考えることも必要だと思いました。

まとめ

今回紹介させていただいたSwiftUIでStickyなタブを実現する方法は、注意点こそあれど、SwiftUIで実現できる要件を増やす一つの手段になると思っています。 同じようにSwiftUIは使いたいけどタブを実現できるか分からないという方がいましたら、この記事がお役に立てると幸いです。

また、本記事で紹介した内容はほぼすべて同チームの@miichan_ochaが検証、実装してくれたものです。 @miichan_ochaを筆頭に、調査や検証に協力してくれた同僚の皆さんに感謝します。

*1:スクロール領域の最後まで行った時にそれまでのスクロール方向とは逆向きに小さくコンテンツが跳ねること