You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在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 {}

关键实现细节

  1. 独立滚动状态管理:使用ScrollOffsetManager这个ObservableObject存储每个标签页的滚动位置,避免全局变量,符合SwiftUI状态管理最佳实践。
  2. ScrollViewReader精确控制:通过ScrollViewReader获取滚动代理,在切换标签时调用scrollTo恢复到之前保存的元素ID位置。
  3. 通知触发滚动恢复:切换标签时视图可能未完全渲染,通过通知延迟触发滚动操作,确保滚动生效。
  4. 粘性头部实现:利用LazyVStack的pinnedViews: [.sectionHeaders]属性,让Section的Header固定在顶部。

注意事项

  • 对于超大量数据的列表,onAppear记录滚动位置的方式依然高效,因为LazyVStack只会加载可见范围内的元素。
  • 如果需要更精确的滚动偏移(而非元素ID),可以结合UIScrollView的contentOffset,通过UIKit与SwiftUI的交互实现,但复杂度会更高,上述方案已经能满足大多数场景需求。

内容的提问来源于stack exchange,提问作者Alisa Martirosyan

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 22:55:56