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

SwiftUI滚动时更新视图致ScrollView卡顿的优化方案咨询

解决方案

核心问题分析

当前滚动卡顿的原因有两点:

  1. 滚动开始/结束的回调被频繁触发,导致横幅状态反复更新,触发包含ScrollView的整个父视图重绘。
  2. 横幅的动画会带动父视图布局变化,间接影响ScrollView的渲染性能。

优化方案分版本实现

一、iOS 14+ 优化方案

利用@StateObject隔离状态管理,结合Transaction控制动画作用范围,同时优化TrackableScrollView的回调触发逻辑:

1. 优化TrackableScrollView的回调触发逻辑

避免滚动过程中重复调用onScrollingStarted,仅在滚动启动的第一帧触发:

class ScrollViewHelper: ObservableObject {
    @Published var currentOffset: CGFloat = 0
    @Published var offsetAtScrollEnd: CGFloat = 0
    @Published var isScrolling: Bool = false // 新增滚动状态标记

    private var cancellable: AnyCancellable?
    private var scrollStartTriggered = false

    init() {
        cancellable = $currentOffset
            .debounce(for: 0.3, scheduler: DispatchQueue.main)
            .dropFirst()
            .sink { [weak self] offset in
                guard let self = self else { return }
                self.offsetAtScrollEnd = offset
                self.isScrolling = false
                self.scrollStartTriggered = false // 重置触发标记
            }
    }

    // 新增方法:仅在滚动开始时触发一次回调
    func triggerScrollStart() {
        guard !scrollStartTriggered else { return }
        scrollStartTriggered = true
        isScrolling = true
    }
}

// 更新TrackableScrollView的手势处理
struct TrackableScrollView<Content>: View where Content: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    @StateObject var scrollViewHelper = ScrollViewHelper() // iOS14+使用@StateObject
    let content: Content

    public init(@ViewBuilder content: () -> Content,
                onScrollingStarted: @escaping () -> Void = {},
                onScrollingFinished: @escaping () -> Void = {}) {
        self.content = content()
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
    }

    public var body: some View {
        GeometryReader { outsideProxy in
            ScrollView(.vertical, showsIndicators: true) {
                ZStack(alignment: .top) {
                    GeometryReader { insideProxy in
                        Color.clear
                            .preference(key: ScrollOffsetPreferenceKey.self, value: [calculateContentOffset(fromOutsideProxy: outsideProxy, insideProxy: insideProxy)])
                    }
                    content
                }
            }
            .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in
                scrollViewHelper.currentOffset = value[0]
            }
            .simultaneousGesture(
                DragGesture().onChanged { _ in
                    scrollViewHelper.triggerScrollStart()
                    if scrollViewHelper.isScrolling {
                        onScrollingStarted()
                    }
                }
            )
            .onReceive(scrollViewHelper.$isScrolling) { isScrolling in
                if !isScrolling {
                    onScrollingFinished()
                }
            }
        }
    }

    private func calculateContentOffset(fromOutsideProxy outsideProxy: GeometryProxy, insideProxy: GeometryProxy) -> CGFloat {
        return outsideProxy.frame(in: .global).minY - insideProxy.frame(in: .global).minY
    }
}

private struct ScrollOffsetPreferenceKey: PreferenceKey {
    typealias Value = [CGFloat]
    static var defaultValue: [CGFloat] = [0]

    static func reduce(value: inout [CGFloat], nextValue: () -> [CGFloat]) {
        value.append(contentsOf: nextValue())
    }
}

2. 隔离横幅视图,控制动画作用范围

将横幅封装为独立子视图,用Transaction确保动画仅作用于横幅,不影响ScrollView:

struct ContentView: View {
    @State private var messageBannerVisibility: Bool = false

    var body: some View {
        VStack(spacing: 0) {
            TrackableScrollView {
                VStack(alignment: .center, spacing: 0) {
                    ForEach(0...100, id: \.self) { i in
                        Rectangle()
                            .frame(width: 200, height: 100)
                            .foregroundColor(.green)
                            .overlay(Text("\(i)"))
                            .padding()
                    }
                }
            } onScrollingStarted: {
                hideMessageBanner()
            } onScrollingFinished: {
                showMessageBanner()
            }

            // 独立的横幅视图,状态变化仅重绘自身
            BannerView(isVisible: $messageBannerVisibility)
        }
        .navigationBarHidden(true)
        .onAppear {
            showMessageBanner()
        }
    }

    private func hideMessageBanner() {
        var transaction = Transaction()
        transaction.animation = .easeOut(duration: 0.3)
        withTransaction(transaction) {
            messageBannerVisibility = false
        }
    }

    private func showMessageBanner() {
        var transaction = Transaction()
        transaction.animation = .easeIn(duration: 0.3)
        withTransaction(transaction) {
            messageBannerVisibility = true
        }
    }
}

// 独立封装的横幅视图
struct BannerView: View {
    @Binding var isVisible: Bool

    var body: some View {
        if isVisible {
            Rectangle()
                .frame(height: 100)
                .foregroundColor(.red)
                .overlay(Text("Random bottom view"))
                .transition(.move(edge: .bottom).combined(with: .opacity))
        }
    }
}

二、iOS 13 兼容方案

由于iOS13不支持@StateObject和Transaction,改用@ObservedObject结合UIViewRepresentable封装横幅(利用UIKit的动画隔离):

1. 调整TrackableScrollView的状态管理

// 将ScrollViewHelper改为用@ObservedObject,初始化时外部传入或内部创建
struct TrackableScrollView<Content>: View where Content: View {
    private let onScrollingStarted: () -> Void
    private let onScrollingFinished: () -> Void
    @ObservedObject var scrollViewHelper: ScrollViewHelper // iOS13使用@ObservedObject
    let content: Content

    public init(@ViewBuilder content: () -> Content,
                scrollViewHelper: ScrollViewHelper = ScrollViewHelper(),
                onScrollingStarted: @escaping () -> Void = {},
                onScrollingFinished: @escaping () -> Void = {}) {
        self.content = content()
        self.scrollViewHelper = scrollViewHelper
        self.onScrollingStarted = onScrollingStarted
        self.onScrollingFinished = onScrollingFinished
    }

    // 其余代码同iOS14+版本的TrackableScrollView
}

2. 用UIKit封装横幅视图,隔离动画

// UIKit封装的横幅视图,动画完全在UIKit层面处理,不影响SwiftUI视图重绘
class BannerUIView: UIView {
    private let label = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private func setupView() {
        backgroundColor = .red
        label.text = "Random bottom view"
        label.textColor = .white
        label.textAlignment = .center
        addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: centerXAnchor),
            label.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
        isHidden = true
        transform = CGAffineTransform(translationX: 0, y: bounds.height)
    }

    func show(animated: Bool) {
        let duration = animated ? 0.3 : 0.0
        UIView.animate(withDuration: duration) {
            self.isHidden = false
            self.transform = .identity
        }
    }

    func hide(animated: Bool) {
        let duration = animated ? 0.3 : 0.0
        UIView.animate(withDuration: duration) {
            self.transform = CGAffineTransform(translationX: 0, y: self.bounds.height)
        } completion: { _ in
            self.isHidden = true
        }
    }
}

// SwiftUI桥接视图
struct BannerView: UIViewRepresentable {
    var isVisible: Bool

    func makeUIView(context: Context) -> BannerUIView {
        BannerUIView()
    }

    func updateUIView(_ uiView: BannerUIView, context: Context) {
        if isVisible {
            uiView.show(animated: true)
        } else {
            uiView.hide(animated: true)
        }
    }
}

// 更新ContentView
struct ContentView: View {
    @State private var messageBannerVisibility: Bool = false
    private let scrollHelper = ScrollViewHelper()

    var body: some View {
        VStack(spacing: 0) {
            TrackableScrollView(content: {
                VStack(alignment: .center, spacing: 0) {
                    ForEach(0...100, id: \.self) { i in
                        Rectangle()
                            .frame(width: 200, height: 100)
                            .foregroundColor(.green)
                            .overlay(Text("\(i)"))
                            .padding()
                    }
                }
            }, scrollViewHelper: scrollHelper,
               onScrollingStarted: {
                messageBannerVisibility = false
            }, onScrollingFinished: {
                messageBannerVisibility = true
            })

            BannerView(isVisible: messageBannerVisibility)
                .frame(height: 100)
        }
        .navigationBarHidden(true)
        .onAppear {
            messageBannerVisibility = true
        }
    }
}

额外优化建议

  • 避免在滚动回调中执行复杂逻辑,尽量只做状态标记,延迟处理非紧急操作。
  • 对于长列表,将VStack替换为LazyVStack(iOS14+),减少初始渲染压力。
  • 给ScrollView添加.drawingGroup()修饰符,将ScrollView内容渲染为位图,减少重绘次数(仅当内容无频繁动态变化时使用)。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.16 19:55:20