如何在SwiftUI iOS15实现带独立滚动偏移的粘性头部标签页?
iOS 15 SwiftUI 实现带独立滚动偏移的粘性头部标签页
需求说明
要实现类似TikTok、X(原Twitter)和Instagram个人主页的粘性头部标签页效果:头部随滚动固定在顶部,且切换标签页时,每个标签页保留各自独立的滚动偏移量。现有方案要么共享滚动偏移,要么仅支持iOS 17+,因此需要适配iOS 15的原生实现方案。
解决方案
核心思路是通过ScrollViewReader追踪每个标签页的滚动位置,用状态变量存储各页面的滚动标识,切换标签时恢复对应位置,同时利用LazyVStack的pinnedViews实现粘性头部。
完整实现代码
import SwiftUI enum Page: Hashable { case first, second, third } // 状态管理器,统一管理滚动偏移,符合SwiftUI状态管理规范 class ScrollOffsetManager: ObservableObject { @Published var offsets: [Page: Int] = [.first: 0, .second: 0, .third: 0] } struct ContentView: View { @State var currentPage: Page = .first @StateObject private var offsetManager = ScrollOffsetManager() let numbers: [Int] = (0..<1000).map { $0 } let letters: [String] = (0..<125).map { String(UnicodeScalar(97 + ($0 % 26))!) } let combinedArray: [String] = (0..<123).map { "\($0 + 1)\(String(UnicodeScalar(97 + ($0 % 26))!))" } var body: some View { ScrollView { LazyVStack(alignment: .center, pinnedViews: [.sectionHeaders]) { // 顶部可滚动隐藏的内容 ForEach(0..<5) { _ in Text("Header to be hidden") .font(.headline) .padding() } Section(header: StickyHeader()) { switch currentPage { case .first: PageScrollView(page: .first, items: numbers, offsetManager: offsetManager) case .second: PageScrollView(page: .second, items: letters, offsetManager: offsetManager) case .third: PageScrollView(page: .third, items: combinedArray, offsetManager: offsetManager) } } } } } @ViewBuilder func StickyHeader() -> some View { HStack(spacing: 50) { Button(action: { switchToPage(.first) }) { Text("First") .fontWeight(currentPage == .first ? .bold : .regular) } Button(action: { switchToPage(.second) }) { Text("Second") .fontWeight(currentPage == .second ? .bold : .regular) } Button(action: { switchToPage(.third) }) { Text("Third") .fontWeight(currentPage == .third ? .bold : .regular) } } .padding() .frame(maxWidth: .infinity) .foregroundColor(.white) .background(.pink) } private func switchToPage(_ page: Page) { currentPage = page // 发送通知触发滚动恢复 NotificationCenter.default.post(name: NSNotification.Name("RestoreScroll"), object: page) } } // 通用滚动页面组件,复用滚动追踪逻辑 struct PageScrollView<Item: Identifiable>: View where Item.ID == Int, Item: CustomStringConvertible { let page: Page let items: [Item] @ObservedObject var offsetManager: ScrollOffsetManager var body: some View { ScrollViewReader { proxy in LazyVStack { ForEach(items) { item in Text(item.description) .id(item.id) .padding() .onAppear { // 记录当前可见元素的ID作为滚动位置标记 offsetManager.offsets[page] = item.id } } } .onAppear { // 监听滚动恢复通知,切换页面时恢复对应位置 NotificationCenter.default.addObserver(forName: NSNotification.Name("RestoreScroll"), object: nil, queue: .main) { notification in guard let targetPage = notification.object as? Page, targetPage == page else { return } if let targetId = offsetManager.offsets[page] { withAnimation(.easeInOut) { proxy.scrollTo(targetId, anchor: .top) } } } } } } } // iOS 15适配的App入口 @main struct StickyTabsApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate var body: some Scene { WindowGroup { ContentView() } } } class AppDelegate: NSObject, UIApplicationDelegate {}
关键实现细节
- 独立滚动状态管理:使用
ScrollOffsetManager这个ObservableObject存储每个标签页的滚动位置,避免全局变量,符合SwiftUI状态管理最佳实践。 - ScrollViewReader精确控制:通过
ScrollViewReader获取滚动代理,在切换标签时调用scrollTo恢复到之前保存的元素ID位置。 - 通知触发滚动恢复:切换标签时视图可能未完全渲染,通过通知延迟触发滚动操作,确保滚动生效。
- 粘性头部实现:利用
LazyVStack的pinnedViews: [.sectionHeaders]属性,让Section的Header固定在顶部。
注意事项
- 对于超大量数据的列表,
onAppear记录滚动位置的方式依然高效,因为LazyVStack只会加载可见范围内的元素。 - 如果需要更精确的滚动偏移(而非元素ID),可以结合
UIScrollView的contentOffset,通过UIKit与SwiftUI的交互实现,但复杂度会更高,上述方案已经能满足大多数场景需求。
内容的提问来源于stack exchange,提问作者Alisa Martirosyan
相关产品推荐
相关产品推荐

