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

为何ScrollView在真机/模拟器中随机变内容高度,预览却正常?

问题描述

我当前使用Xcode Version 14.3.1 (14E300c)。代码中的ScrollView通过ForEach遍历对象数组,想要实现用户切换视图后回到精确滚动位置(类似社交应用的行为)。尝试用ScrollViewReader.scrollTo(<id: , anchor:>)实现,仅在预览中正常,在模拟器或iPhone 14真机中,ScrollView内容高度会随机变化。该问题仅在滚动至内容接近底部时出现,滚动至顶部附近则恢复正常。

原始代码
// 疑似引发问题的视图
struct ScrollViewWithSavedPosition: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    @State private var isViewLoaded: Bool = false
    let geoProxy: GeometryProxy
    let maxScrollableHeight: CGFloat = 4809
    
    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView(showsIndicators: false) {
                LazyVStack {
                    ForEach(0..<34) { poster in
                        ZStack {
                            Rectangle()
                                .fill(.black)
                                .frame(width: geoProxy.size.width * 0.4,
                                       height: geoProxy.size.height * 0.2)
                            
                            Text("\(poster)")
                                .foregroundColor(.orange)
                        }
                    }
                }
                .id("scrollPosition")
                .background(
                    GeometryReader {
                        Color.orange
                            .preference(key: CGPointPK2.self,
                                        value: $0.frame(in: .global).origin)
                    }
                )
                .onPreferenceChange(CGPointPK2.self) { scrollPosition in
                    DispatchQueue.main.async {
                        isViewLoaded = true
                    }
                    
                    if isViewLoaded {
                        let offsetValue = (-1 * (scrollPosition.y - geoProxy.safeAreaInsets.top)) / maxScrollableHeight
                        scrollViewSavedValue.scrollOffsetValue = offsetValue
                    }
                    
                    print(scrollViewSavedValue.scrollOffsetValue)
                }
            }
            .onAppear {
                scrollProxy.scrollTo("scrollPosition", anchor: UnitPoint(x: 0, y: scrollViewSavedValue.scrollOffsetValue))
            }
        }
        .preferredColorScheme(.light)
    }
}

struct CGPointPK2: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) { }
}

class ScrollViewSavedValue: ObservableObject {
    @Published var scrollOffsetValue: CGFloat = 0
    @Published var selectedTab: Int = 1
}

struct TabBarView: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    let geoProxy: GeometryProxy
    
    var body: some View {
        VStack {
            Spacer()
            
            ZStack {
                Rectangle()
                    .fill(.blue)
                    .frame(width: geoProxy.size.width, height: 80)
                
                HStack(spacing: 100) {
                    Button {
                        DispatchQueue.main.async {
                            scrollViewSavedValue.selectedTab = 1
                        }
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 1")
                                .foregroundColor(.white)
                                .font(.system(size: 12))
                                .fontWeight(.bold)
                        }
                    }
                    
                    Button {
                        DispatchQueue.main.async {
                            scrollViewSavedValue.selectedTab = 2
                        }
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 2")
                                .foregroundColor(.white)
                                .font(.system(size: 12))
                                .fontWeight(.bold)
                        }
                    }
                }
            }
        }
        .ignoresSafeArea()
    }
}

struct PresentationView: View {
    @StateObject private var scrollViewSavedValue = ScrollViewSavedValue()
    
    var body: some View {
        GeometryReader { geoProxy in
            switch scrollViewSavedValue.selectedTab {
            case 1:
                ScrollViewWithSavedPosition(scrollViewSavedValue: scrollViewSavedValue, geoProxy: geoProxy)
            default:
                Text("View 2")
                    .foregroundColor(.black)
            }
            
            TabBarView(scrollViewSavedValue: scrollViewSavedValue, geoProxy: geoProxy)
        }
    }
}
问题原因分析
  • 硬编码滚动高度:maxScrollableHeight是固定值,不同设备、屏幕尺寸下实际可滚动高度存在差异,接近底部时比例计算的误差被放大,导致scrollTo的anchor参数偏离真实位置,引发内容高度异常。
  • 滚动恢复时机过早:onAppear中直接调用scrollTo时,LazyVStack的内容可能还未完成渲染,ScrollView的布局尚未稳定,导致滚动位置计算错误。
  • 偏移计算逻辑缺陷:使用全局坐标系的原点计算偏移,容易受父布局、安全区变化影响,且isViewLoaded的异步设置存在竞态,导致偏移值计算不准确。
修复方案及代码
// 修复后的滚动视图
struct ScrollViewWithSavedPosition: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    @State private var contentHeight: CGFloat = 0
    @State private var scrollViewHeight: CGFloat = 0
    @State private var isViewReady = false
    
    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView(showsIndicators: false) {
                LazyVStack {
                    ForEach(0..<34) { poster in
                        ZStack {
                            Rectangle()
                                .fill(.black)
                                .frame(width: UIScreen.main.bounds.width * 0.4,
                                       height: UIScreen.main.bounds.height * 0.2)
                            
                            Text("\(poster)")
                                .foregroundColor(.orange)
                        }
                    }
                }
                .background(
                    GeometryReader { contentGeo in
                        Color.clear
                            .onAppear {
                                contentHeight = contentGeo.size.height
                            }
                    }
                )
            }
            .background(
                GeometryReader { scrollGeo in
                    Color.clear
                        .onAppear {
                            scrollViewHeight = scrollGeo.size.height
                            isViewReady = true
                        }
                }
            )
            .onAppear {
                guard isViewReady, contentHeight > scrollViewHeight else { return }
                // 转换保存的比例为实际像素偏移
                let scrollableRange = contentHeight - scrollViewHeight
                let pixelOffset = scrollViewSavedValue.scrollOffsetValue * scrollableRange
                
                // 先滚动到底部元素,再调整anchor实现精确偏移
                scrollProxy.scrollTo(33, anchor: .top)
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    let adjustRatio = (pixelOffset - scrollableRange) / contentHeight
                    scrollProxy.scrollTo(33, anchor: UnitPoint(x: 0, y: 1 + adjustRatio))
                }
            }
            .onScrollChange { offset in
                // 基于真实可滚动范围计算比例
                let scrollableRange = max(contentHeight - scrollViewHeight, 1)
                scrollViewSavedValue.scrollOffsetValue = -offset.y / scrollableRange
            }
        }
        .preferredColorScheme(.light)
    }
}

// 自定义滚动偏移监听修饰符(iOS 16+兼容,Xcode14支持)
extension View {
    func onScrollChange(perform action: @escaping (CGPoint) -> Void) -> some View {
        self.background(
            GeometryReader { geo in
                Color.clear
                    .preference(key: ScrollOffsetKey.self, value: geo.frame(in: .named("scrollView")).origin)
            }
        )
        .coordinateSpace(name: "scrollView")
        .onPreferenceChange(ScrollOffsetKey.self, perform: action)
    }
}

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

// 其他组件简化调整
class ScrollViewSavedValue: ObservableObject {
    @Published var scrollOffsetValue: CGFloat = 0
    @Published var selectedTab: Int = 1
}

struct TabBarView: View {
    @ObservedObject var scrollViewSavedValue: ScrollViewSavedValue
    
    var body: some View {
        VStack {
            Spacer()
            
            ZStack {
                Rectangle()
                    .fill(.blue)
                    .frame(height: 80)
                
                HStack(spacing: 100) {
                    Button {
                        scrollViewSavedValue.selectedTab = 1
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 1")
                                .foregroundColor(.white)
                                .font(.system(size: 12, weight: .bold))
                        }
                    }
                    
                    Button {
                        scrollViewSavedValue.selectedTab = 2
                    } label: {
                        ZStack {
                            Circle()
                                .fill(.black)
                                .frame(width: 60, height: 60)
                            
                            Text("View 2")
                                .foregroundColor(.white)
                                .font(.system(size: 12, weight: .bold))
                        }
                    }
                }
            }
        }
        .ignoresSafeArea()
    }
}

struct PresentationView: View {
    @StateObject private var scrollViewSavedValue = ScrollViewSavedValue()
    
    var body: some View {
        ZStack(alignment: .bottom) {
            switch scrollViewSavedValue.selectedTab {
            case 1:
                ScrollViewWithSavedPosition(scrollViewSavedValue: scrollViewSavedValue)
            default:
                Text("View 2")
                    .foregroundColor(.black)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
            
            TabBarView(scrollViewSavedValue: scrollViewSavedValue)
        }
    }
}
关键改进点
  • 动态计算滚动范围:移除硬编码高度,通过GeometryReader实时获取内容和ScrollView容器的真实高度,确保比例计算准确。
  • 优化滚动恢复时机:延迟执行scrollTo操作,等待LazyVStack内容渲染完成、布局稳定后再调整位置。
  • 更可靠的偏移监听:自定义onScrollChange修饰符,基于ScrollView自身坐标系监听偏移,避免全局坐标系的干扰。
  • 简化布局传递:移除GeometryProxy在组件间的传递,直接使用屏幕尺寸或内部GeometryReader获取布局信息,减少布局不确定性。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.19 21:56:59