SwiftUI滚动时更新视图致ScrollView卡顿的优化方案咨询
解决方案
核心问题分析
当前滚动卡顿的原因有两点:
- 滚动开始/结束的回调被频繁触发,导致横幅状态反复更新,触发包含ScrollView的整个父视图重绘。
- 横幅的动画会带动父视图布局变化,间接影响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
相关产品推荐
相关产品推荐

