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

如何自定义SwiftUI ScrollView以兼容LazyVStack懒加载子视图?

自定义ScrollView导致LazyVStack失去懒加载的原因及解决方案

为什么自定义MyScrollView会让LazyVStack失去懒加载?

原生ScrollView实现懒加载的核心逻辑是裁剪视口+仅渲染视口内/附近的内容,同时会给子视图传递“可见区域”的环境上下文——LazyVStack正是依赖这个上下文判断哪些子项需要渲染,哪些可以延迟加载。

而你的MyScrollView只是通过offset平移整个子视图,相当于把LazyVStack完整渲染后再移动位置,既没有限制它的渲染范围,也没有提供LazyVStack需要的可见性上下文。LazyVStack会认为所有内容都处于“需要显示”的状态,自然就触发了全量渲染,失去懒加载特性。

解决方案:优先修复原生ScrollView的定位问题(更高效)

你最初遇到的原生ScrollView+scrollTargetLayout定位不准,本质是SwiftUI无法提前获取动态高度子项的尺寸。可以通过提前收集子视图高度的方式解决,同时保留原生组件的懒加载和滚动性能:

步骤1:定义PreferenceKey收集子视图高度

struct ViewHeightKey: PreferenceKey {
    static var defaultValue: [String: CGFloat] = [:]
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue()) { $1 }
    }
}

extension View {
    func trackHeight(id: String) -> some View {
        background(
            GeometryReader { proxy in
                Color.clear
                    .preference(key: ViewHeightKey.self, value: [id: proxy.size.height])
            }
        )
    }
}

步骤2:结合ScrollViewReader实现精确滚动

struct ContentView: View {
    @State private var itemHeights: [String: CGFloat] = [:]
    let items = Array(0..<100) // 模拟大量子项
    
    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack {
                    ForEach(items, id: \.self) { item in
                        // 模拟变高子项:奇数行更高
                        Text("可变高Item \(item)")
                            .frame(maxWidth: .infinity)
                            .frame(height: item % 2 == 0 ? 50 : 120)
                            .background(item % 2 == 0 ? Color.blue : Color.green)
                            .trackHeight(id: "\(item)")
                            .id("\(item)") // 给每个子项绑定唯一ID
                    }
                }
            }
            .onPreferenceChange(ViewHeightKey.self) { newHeights in
                itemHeights = newHeights // 实时更新子项高度缓存
            }
            .toolbar {
                Button("滚动到第50项") {
                    // 计算目标位置的总偏移量
                    var totalOffset: CGFloat = 0
                    for i in 0..<50 {
                        totalOffset += itemHeights["\(i)"] ?? 0
                    }
                    // 先通过scrollTo定位,再微调偏移(解决锚点不准问题)
                    proxy.scrollTo("\(50)", anchor: .top)
                    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                        proxy.scrollTo("\(50)", anchor: .top)
                    }
                }
            }
        }
    }
}

替代方案:复刻原生ScrollView的懒加载特性(复杂但可控)

如果一定要自定义ScrollView,需要手动实现视口裁剪和可见区域上下文传递,核心是限制子视图的渲染范围:

struct LazyMyScrollView<Child>: View where Child: View {
    @State private var offsetY: CGFloat = 0
    @State private var contentHeight: CGFloat = 0
    @State private var containerHeight: CGFloat = 0
    private let child: Child
    
    init(@ViewBuilder child: () -> Child) {
        self.child = child()
    }
    
    var body: some View {
        GeometryReader { containerProxy in
            ZStack(alignment: .top) {
                child
                    .background(
                        GeometryReader { contentProxy in
                            Color.clear
                                .onAppear {
                                    contentHeight = contentProxy.size.height
                                    containerHeight = containerProxy.size.height
                                }
                        }
                    )
                    .offset(y: offsetY)
                    .clipShape(Rectangle()) // 裁剪视口外的内容
            }
            .frame(height: containerProxy.size.height)
            .gesture(
                DragGesture()
                    .onChanged { value in
                        // 限制滚动边界,不能滚出内容范围
                        let proposedOffset = offsetY + value.translation.height
                        offsetY = max(min(proposedOffset, 0), containerHeight - contentHeight)
                    }
                    .onEnded { value in
                        let proposedOffset = offsetY + value.predictedEndTranslation.height
                        // 加入惯性动画+边界限制
                        withAnimation(.interactiveSpring()) {
                            offsetY = max(min(proposedOffset, 0), containerHeight - contentHeight)
                        }
                    }
            )
        }
        .environment(\.scrollViewContentOffset, offsetY) // 模拟传递滚动偏移上下文
    }
}

// 自定义环境键,传递滚动偏移
private struct ScrollViewContentOffsetKey: EnvironmentKey {
    static var defaultValue: CGFloat = 0
}

extension EnvironmentValues {
    var scrollViewContentOffset: CGFloat {
        get { self[ScrollViewContentOffsetKey.self] }
        set { self[ScrollViewContentOffsetKey.self] = newValue }
    }
}

注:这种方式仍需额外处理LazyVStack的可见性判断,实际开发中不如原生+高度收集的方案高效。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.23 06:21:08