📷

SwiftUI Sticky Tab Bar like Instagram

2025/02/05に公開

SwiftUI でインスタやTikTokのプロフィールのような、スクロールするとタブバーが固着するような UI を TabView を使って作りたい。CookPad さんのブログが詳しく解説されてますが offSet を使った調整が色々と難しく結局タブ切り替え時に余白ずれする問題を解決しきれませんでした。

しかしTabの中に表示するコンテンツの高さが一定なのであれば GeometryReader から計算してやってしまえば概ね理想通りに動きました。ただ posts の数が増えるとタブ切り替えがカクツク。

ScrollView in ScrollView は安定もしなければ悪手だし、UIKitを使ったサンプルを組み込むことも考えたがそれだと全部のView を ViewController x StoryBoard でまた作らないといけないので断念。Kavsoftがいくつかサンプルを出しているが、TabView を使わないサンプルだと横スワイプでのタブ切り替えができないなどイマイチだった。

なおこのコードはタブのスクロール量をタブごとに保持はしていないのでその点だけ注意。

import SwiftUI

struct ProfileView: View {
    @Environment(\.colorScheme) var colorScheme
    @StateObject var viewModel: PostGridViewModel

    let tabs = [
        "投稿順",
        "撮影日順"
    ]
    @State private var selection = 0

    init(path: Binding<NavigationPath>) {
        self._path = path
        _viewModel = StateObject(wrappedValue: PostGridViewModel(user: user))
    }

    var body: some View {
        GeometryReader { proxy in
            ScrollView(.vertical, showsIndicators: false) {
                ProfileHeaderView()

                LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
                    Section {
                        // 3カラムなので3の倍数になるように切り上げる
                        let rows = ceil(Double(viewModel.posts.count) / 3.0)
                        let tabViewHeight = (proxy.size.width / 3) * rows
                        TabView(selection: $selection) {
                            PostGridView(posts: viewModel.posts, path: $path)
                                .tag(0)

                            PostGridView(posts: viewModel.posts, path: $path)
                                .tag(1)
                        }                        
                        .tabViewStyle(.page(indexDisplayMode: .never))
                        .frame(maxWidth: .infinity, minHeight: tabViewHeight)
                    } header: {                        
                        HStack(spacing: 0) {
                            ForEach(tabs.indices, id: \.self) { index in
                                Text(tabs[index])
                                    .frame(maxWidth: .infinity)
                                    .contentShape(Rectangle()) // for tap gesture
                                    .tabSelectionStyle(isSelected: index == selection)
                                    .tag(index)
                                    .highPriorityGesture(
                                        TapGesture().onEnded {
                                            withAnimation {
                                                selection = index
                                            }
                                        }
                                    )
                            }
                        }
                        .background(colorScheme == .dark ? .black : .white)
                    }
                }
            }
            // TODO: keep scroll position for each tab

        }
    }
}

Reference

https://techlife.cookpad.com/entry/2023/02/28/163645

Discussion