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

SwiftUI切换设备方向时如何从0重算ScrollView元素yOffset

问题根因

原实现存在三个核心问题导致方向切换后选中状态不同步:

  • 缓存的子项偏移值是基于ScrollView视口坐标系的动态值,不是内容层的固定相对位置,滚动、尺寸变化、方向切换时该值会实时变动,首次加载后一次性缓存的数值会直接失效
  • 子项偏移采集逻辑加了numberPreferenceKeys.count < 6的采集上限,还附带了仅对number=1做偏移补正的硬编码补丁,方向切换清空数组后重新采集的数值完全不准
  • 滚动偏移PreferenceKey的reduce逻辑用了累加计算,会导致滚动偏移值统计错误
修复方案

不要缓存静态偏移值,每次布局变化时实时计算每个子项相对于ScrollView内容起点的固定位置,不需要监听方向切换通知,也不需要手动重置缓存:

  • 拆分两层独立坐标空间:外层ScrollView视口坐标空间用来计算当前滚动距离,内层内容容器坐标空间用来获取子项相对于内容起点的固定偏移,该偏移和当前滚动位置、设备方向无关
  • 用字典存储子项偏移,避免数组重复添加、顺序错乱问题,布局刷新时自动更新值
  • 修正滚动偏移PreferenceKey的reduce逻辑,直接取内容容器的实时偏移,不做累加
  • 优化选中项匹配规则:以屏幕中线为阈值,匹配起点在阈值左侧的最后一个子项,避免跳选问题
修复后可直接运行的代码
import SwiftUI

// 存储每个区块的相对位置
struct ItemOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: [Int: CGFloat] = [:]
    static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

// 实时获取滚动偏移值
struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = nextValue()
    }
}

struct ContentView: View {
    @State private var itemOffsets: [Int: CGFloat] = [:]
    @State private var selectedNumber = 0
    @State private var rectangleWidths: [CGFloat] = (0..<5).map { _ in CGFloat.random(in: 500..<2000) }
    let colors: [Color] = [.blue, .red, .green, .gray, .purple]
    
    var body: some View {
        VStack {
            ScrollViewReader { reader in
                ScrollView(.horizontal) {
                    // 内容容器单独设置命名坐标空间,子项在该空间下的minX为固定相对位置
                    HStack(spacing: 0) {
                        ForEach(0..<5) { number in
                            Rectangle()
                                .fill(colors[number])
                                .frame(width: rectangleWidths[number], height: 200)
                                .id("\(number)")
                                .background(
                                    GeometryReader { proxy in
                                        Color.clear
                                            .preference(
                                                key: ItemOffsetPreferenceKey.self,
                                                value: [number: proxy.frame(in: .named("scrollContent")).minX]
                                            )
                                    }
                                )
                        }
                    }
                    .coordinateSpace(name: "scrollContent")
                    .background(
                        GeometryReader { contentProxy in
                            Color.clear
                                .preference(
                                    key: ScrollOffsetPreferenceKey.self,
                                    value: -contentProxy.frame(in: .named("scrollView")).origin.x
                                )
                        }
                    )
                }
                .coordinateSpace(name: "scrollView")
                .onPreferenceChange(ItemOffsetPreferenceKey.self) { newOffsets in
                    itemOffsets = newOffsets
                }
                .onPreferenceChange(ScrollOffsetPreferenceKey.self) { scrollOffset in
                    let threshold = scrollOffset + UIScreen.main.bounds.width / 2
                    selectedNumber = itemOffsets
                        .filter { $0.value <= threshold }
                        .max(by: { $0.value < $1.value })?
                        .key ?? 0
                }
                
                // 底部导航栏
                HStack(spacing: 20) {
                    ForEach(0..<5) { number in
                        ZStack {
                            if number == selectedNumber {
                                Rectangle()
                                    .fill(.black)
                                    .frame(width: 30, height: 30)
                            }
                            Rectangle()
                                .fill(colors[number])
                                .frame(width: 25, height: 25)
                                .onTapGesture {
                                    withAnimation {
                                        reader.scrollTo("\(number)", anchor: .leading)
                                    }
                                }
                        }
                    }
                }
                .padding(.vertical)
            }
        }
    }
}

内容的提问来源于stack exchange,提问作者Hugo de Groot

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.28 10:27:18