SwiftUI带.paging的ScrollView前后台切换后卡在页面间
问题描述
我在SwiftUI中实现了一个带分页的横向ScrollView,将滚动位置绑定到@State selectedIndex,并使用.scrollTargetBehavior(.paging)和.scrollPosition(id:)方法。滑动到第2页时功能正常,但APP退到后台再重新打开后,ScrollView会停在第1页和第2页之间,而非预期的第2页。改用LazyHStack可避免此问题,但滑动加载新页面时会出现卡顿,因此不想使用它。
复现步骤
- 启动视图
- 滑动到第二页(此时
selectedIndex = 1) - 将APP退到后台
- 重新打开APP
预期结果
ScrollView停留在第2页
实际结果
ScrollView偏移量停在第1页和第2页之间
环境
- iOS 17 / Xcode 16.2
- SwiftUI
代码示例
struct SavingsPromptPagerView: View { @State var selectedIndex: Int? = nil @Binding var selectedPage: Int var items: [SavingsPromptPageView] var body: some View { VStack { ScrollView(.horizontal) { HStack(spacing: .zero) { ForEach(items.indices) { index in items[index] .frame(maxWidth: .infinity) .containerRelativeFrame(.horizontal, count: 1, spacing: 0) .id(index) .scrollTransition(.interactive) { content, phase in content .opacity(phase.isIdentity ? 1 : 0) } } } .scrollTargetLayout() } .scrollPosition(id: $selectedIndex, anchor: .center) // .scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.paging) .scrollIndicators(.hidden) .onChange(of: selectedIndex) { oldValue, newValue in guard let newValue else { return } if newValue != selectedPage { selectedPage = newValue } } } } } struct SavingsPromptPageView: View, Identifiable { let id: Int @ViewBuilder let content: AnyView init(id: Int, @ViewBuilder content: @escaping () -> any View) { self.id = id self.content = AnyView(content()) } var body: some View { content } } extension SavingsPromptPageView: @preconcurrency Hashable { static func == (lhs: SavingsPromptPageView, rhs: SavingsPromptPageView) -> Bool { lhs.id == rhs.id } func hash(into hasher: inout Hasher) { hasher.combine(id) } }
问题原因
这是iOS 17 SwiftUI ScrollView的一个已知状态恢复bug:当APP进入后台时,系统会优先保存ScrollView的原始像素偏移量,而非绑定的selectedIndex对应的分页位置。恢复前台时,系统直接还原该原始偏移量,没有根据绑定的ID重新对齐到分页目标。而LazyHStack因懒加载特性,系统会优先基于绑定ID恢复位置,因此无此问题,但会带来首次加载新页面的卡顿。
解决方案
方案1:ScrollViewReader强制对齐(优化你的临时方案)
通过ScrollViewReader监听APP前台通知,在APP即将进入前台时强制滚动到当前选中的索引位置,修正原代码中通知监听的API错误:
struct SavingsPromptPagerView: View { @State var selectedIndex: Int? = nil @Binding var selectedPage: Int var items: [SavingsPromptPageView] var body: some View { VStack { ScrollViewReader { proxy in ScrollView(.horizontal) { HStack(spacing: .zero) { ForEach(items.indices) { index in items[index] .frame(maxWidth: .infinity) .containerRelativeFrame(.horizontal, count: 1, spacing: 0) .id(index) .scrollTransition(.interactive) { content, phase in content .opacity(phase.isIdentity ? 1 : 0) } } } .scrollTargetLayout() } .scrollPosition(id: $selectedIndex, anchor: .center) .scrollTargetBehavior(.paging) .scrollIndicators(.hidden) .onChange(of: selectedIndex) { oldValue, newValue in guard let newValue else { return } if newValue != selectedPage { selectedPage = newValue } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in DispatchQueue.main.async { if let index = selectedIndex { proxy.scrollTo(index, anchor: .leading) } } } } } } }
方案2:同步状态绑定逻辑
确保selectedIndex与selectedPage始终保持同步,避免状态不一致导致的恢复异常:
struct SavingsPromptPagerView: View { @State var selectedIndex: Int? @Binding var selectedPage: Int var items: [SavingsPromptPageView] init(selectedPage: Binding<Int>, items: [SavingsPromptPageView]) { self._selectedPage = selectedPage self._selectedIndex = State(initialValue: selectedPage.wrappedValue) self.items = items } var body: some View { VStack { ScrollView(.horizontal) { HStack(spacing: .zero) { ForEach(items.indices) { index in items[index] .frame(maxWidth: .infinity) .containerRelativeFrame(.horizontal, count: 1, spacing: 0) .id(index) .scrollTransition(.interactive) { content, phase in content .opacity(phase.isIdentity ? 1 : 0) } } } .scrollTargetLayout() } .scrollPosition(id: $selectedIndex, anchor: .center) .scrollTargetBehavior(.paging) .scrollIndicators(.hidden) .onChange(of: selectedIndex) { oldValue, newValue in guard let newValue else { return } if newValue != selectedPage { selectedPage = newValue } } .onChange(of: selectedPage) { oldValue, newValue in if selectedIndex != newValue { selectedIndex = newValue } } } } }
方案3:切换滚动目标行为(可选)
尝试将.scrollTargetBehavior(.paging)替换为.scrollTargetBehavior(.viewAligned),该行为下系统会更严格地基于绑定ID对齐滚动位置,但分页视觉效果略有不同,需根据业务需求测试适配。
内容的提问来源于stack exchange,提问作者torpheus

