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

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.04 09:45:41