SwiftUI封装UICollectionView时滚动动画中断问题解决
UICollectionView封装SwiftUI时滚动动画中断+自定义下拉刷新解决方案
一、解决滚动动画中断问题
滚动动画中断的核心原因是SwiftUI状态更新触发updateUIViewController时,错误使用全量刷新(比如reloadData)直接打断了UICollectionView的滚动状态。以下是具体修复方案:
1. 用批量插入替代全量刷新
不要在状态更新时直接调用reloadData,而是计算新增数据的索引路径,通过performBatchUpdates执行插入操作,保留原有滚动动画:
- 在Coordinator中维护上一次的数据数量
lastDataCount - 在
updateUIViewController中对比新旧数据长度,仅对新增部分执行插入 - 确保操作在主线程执行(SwiftUI的update方法本身就在主线程,无需额外处理)
2. 优化封装逻辑,减少不必要的update触发
- 让数据源模型实现
Equatable,在updateUIViewController中先判断数据是否真的变化,再执行更新操作 - 避免在
update中重复设置数据源、代理等,这些只需要在makeUIViewController中初始化一次
二、自定义下拉刷新实现
直接基于UICollectionView的UIRefreshControl实现自定义下拉刷新,无需依赖SwiftUI复杂方案:
1. 系统UIRefreshControl快速实现
在makeUIViewController中为UICollectionView添加UIRefreshControl,通过Target-Action绑定刷新逻辑,刷新完成后调用endRefreshing()结束动画。
2. 自定义下拉刷新UI(可选)
如果系统样式不够用,可以自定义继承UIRefreshControl的控件,或者直接添加自定义UIView到UICollectionView的scrollView上,监听contentOffset的变化来控制下拉动画的进度。
完整封装示例代码
import SwiftUI struct CollectionViewWrapper: UIViewControllerRepresentable { @Binding var data: [ListItem] let onLoadMore: () -> Void let onRefresh: () -> Void let onRefreshComplete: () -> Void // 用于结束刷新动画 func makeUIViewController(context: Context) -> UICollectionViewController { // 自定义布局实现不同高度单元格吸附 let layout = UICollectionViewCompositionalLayout { _, _ in let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(120)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(120)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) // 开启section吸附效果 section.orthogonalScrollingBehavior = .none return section } let vc = UICollectionViewController(collectionViewLayout: layout) vc.collectionView.dataSource = context.coordinator vc.collectionView.delegate = context.coordinator vc.collectionView.register(CustomCell.self, forCellWithReuseIdentifier: "CustomCell") vc.collectionView.alwaysBounceVertical = true // 添加下拉刷新控件 let refreshControl = UIRefreshControl() refreshControl.addTarget(context.coordinator, action: #selector(Coordinator.triggerRefresh), for: .valueChanged) vc.collectionView.refreshControl = refreshControl return vc } func updateUIViewController(_ uiViewController: UICollectionViewController, context: Context) { let oldCount = context.coordinator.lastDataCount let newCount = data.count // 仅处理新增数据,避免全量刷新打断滚动 guard newCount > oldCount else { return } let indexPaths = (oldCount..<newCount).map { IndexPath(item: $0, section: 0) } uiViewController.collectionView.performBatchUpdates({ uiViewController.collectionView.insertItems(at: indexPaths) }, completion: nil) context.coordinator.lastDataCount = newCount } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate { let parent: CollectionViewWrapper var lastDataCount = 0 init(parent: CollectionViewWrapper) { self.parent = parent } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { parent.data.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CustomCell", for: indexPath) as! CustomCell cell.configure(with: parent.data[indexPath.item]) return cell } func scrollViewDidScroll(_ scrollView: UIScrollView) { let bottomThreshold = scrollView.contentSize.height - scrollView.frame.height - 150 if scrollView.contentOffset.y > bottomThreshold { parent.onLoadMore() } } @objc func triggerRefresh() { parent.onRefresh() } } } // 自定义单元格 class CustomCell: UICollectionViewCell { private let contentLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) setupUI() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { contentLabel.numberOfLines = 0 contentLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(contentLabel) NSLayoutConstraint.activate([ contentLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12), contentLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), contentLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), contentLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -12) ]) contentView.backgroundColor = .systemBackground contentView.layer.cornerRadius = 8 } func configure(with item: ListItem) { contentLabel.text = item.content contentLabel.font = item.height > 150 ? .systemFont(ofSize: 16) : .systemFont(ofSize: 14) } } // 数据源模型 struct ListItem: Identifiable, Equatable { let id = UUID() let content: String let height: CGFloat } // SwiftUI中使用示例 struct ContentView: View { @State private var data: [ListItem] = [] var body: some View { CollectionViewWrapper( data: $data, onLoadMore: loadMoreData, onRefresh: refreshData, onRefreshComplete: { DispatchQueue.main.async { UIApplication.shared.windows.first?.rootViewController?.collectionView?.refreshControl?.endRefreshing() } } ) .onAppear { loadInitialData() } } private func loadInitialData() { // 模拟初始数据 data = (0..<10).map { ListItem(content: "初始内容\($0)", height: CGFloat(Int.random(in: 100...200))) } } private func loadMoreData() { // 模拟加载更多数据 DispatchQueue.main.asyncAfter(deadline: .now() + 1) { let newItems = (0..<5).map { ListItem(content: "加载更多内容\($0)", height: CGFloat(Int.random(in: 100...200))) } data.append(contentsOf: newItems) } } private func refreshData() { // 模拟刷新数据 DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { data = (0..<10).map { ListItem(content: "刷新后内容\($0)", height: CGFloat(Int.random(in: 100...200))) } } } }
关键注意点
- 批量插入时必须准确计算新增数据的
IndexPath,否则会导致UICollectionView崩溃 - 下拉刷新完成后必须调用
refreshControl?.endRefreshing(),否则控件会一直处于刷新状态 - 加载更多的阈值不要设得太靠近底部,避免频繁触发加载逻辑
内容的提问来源于stack exchange,提问作者ambassador
相关产品推荐
相关产品推荐

