修复UICollectionView点击动画卡顿及didSelectItemAt失效问题
问题:UICollectionView点击动画失效+选择事件无响应(下载进度更新时)
我实现了一个带缩放动画的UICollectionView:点击CarouselCell会触发单元格缩放,同时在didSelectItemAt方法中启动模拟下载流程(用Timer替代真实下载),通过reloadItem(indexPath:)更新下载进度。
目前遇到的问题:
- 下载过程中点击Cell,缩放动画仅卡顿一次后完全失效,直到下载完成才恢复
- 下载期间
didSelectItemAt不再被调用(我没有编写任何限制重复点击的逻辑)
Collection控制器核心代码
class CarouselController: UIViewController, UICollectionViewDelegate { var collectionView: UICollectionView! var dataSource: UICollectionViewDiffableDataSource<Section, Item>? let sections = Bundle.main.decode([Section].self, from: "carouselData.json") var progressA = 0.0 override func viewDidLoad() { super.viewDidLoad() collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout()) collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] collectionView.isScrollEnabled = false collectionView.delegate = self collectionView.contentInsetAdjustmentBehavior = .never view.addSubview(collectionView) collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: CarouselCell.reuseIdentifier) createDataSource() reloadData() } @objc func createDataSource() { dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in switch self.sections[indexPath.section].identifier { case "carouselCell": let cell = self.configure(CarouselCell.self, with: item, for: indexPath) cell.title.text = "\(self.progressA)" print(self.progressA) return cell default: return self.configure(CarouselCell.self, with: item, for: indexPath) } } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { _ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in guard self.progressA <= 1.0 else { timer.invalidate() self.progressA = 0.0 return } self.progressA += 0.01 self.reloadItem(indexPath: .init(row: indexPath.row, section: 0)) } } func reloadItem(indexPath: IndexPath) { guard let needReloadItem = dataSource!.itemIdentifier(for: indexPath) else { return } guard var snapshot = dataSource?.snapshot() else { return } snapshot.reloadItems([needReloadItem]) dataSource?.apply(snapshot, animatingDifferences: false) } func createCompositionalLayout() -> UICollectionViewLayout { UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupWidth = (layoutEnvironment.container.contentSize.width * 1.05)/3 let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupWidth)) let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsets = NSDirectionalEdgeInsets( top: (layoutEnvironment.container.contentSize.height/2) - (groupWidth/2), leading: 0, bottom: 0, trailing: 0) section.interGroupSpacing = 64 section.orthogonalScrollingBehavior = .groupPagingCentered section.contentInsetsReference = .none return section } } func configure<T: SelfConfiguringCell>(_ cellType: T.Type, with item: Item, for indexPath: IndexPath) -> T { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellType.reuseIdentifier, for: indexPath) as? T else { fatalError("\(cellType)") } cell.configure(with: item) return cell } func reloadData() { var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() snapshot.appendSections(sections) for section in sections { snapshot.appendItems(section.item, toSection: section) } dataSource?.apply(snapshot) } }
CarouselCell代码
import Combine class CarouselCell: UICollectionViewCell, SelfConfiguringCell { static let reuseIdentifier: String = "carouselCell" var imageView: UIImageView = { let image = UIImageView() image.contentMode = .scaleToFill image.translatesAutoresizingMaskIntoConstraints = false return image }() var textView: UIView = { let view = UIView() view.backgroundColor = .blue view.translatesAutoresizingMaskIntoConstraints = false return view }() var title: UILabel = { let label = UILabel() label.textColor = .white label.translatesAutoresizingMaskIntoConstraints = false return label }() override var isHighlighted: Bool { didSet { if oldValue == false && isHighlighted { animateScale(to: 0.9, duration: 0.4) } else if oldValue == true && !isHighlighted { animateScale(to: 1, duration: 0.38) } } } var imageTask: AnyCancellable? override func prepareForReuse() { super.prepareForReuse() imageView.image = nil imageTask?.cancel() } override init(frame: CGRect) { super.init(frame: frame) contentView.addSubview(imageView) contentView.addSubview(textView) textView.addSubview(title) imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0).isActive = true imageView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.7).isActive = true imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0).isActive = true imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0).isActive = true textView.heightAnchor.constraint(equalTo: contentView.heightAnchor, multiplier: 0.16).isActive = true textView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24).isActive = true textView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24).isActive = true textView.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: -6).isActive = true textView.layer.cornerRadius = contentView.frame.size.height*0.16/2 title.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 0).isActive = true title.trailingAnchor.constraint(equalTo: textView.trailingAnchor, constant: 0).isActive = true title.topAnchor.constraint(equalTo: textView.topAnchor, constant: 0).isActive = true title.bottomAnchor.constraint(equalTo: textView.bottomAnchor, constant: 0).isActive = true } private func animateScale(to scale: CGFloat, duration: TimeInterval) { UIView.animate( withDuration: duration, delay: 0, usingSpringWithDamping: 1.0, initialSpringVelocity: 0.5, options: [.beginFromCurrentState], animations: { self.imageView.transform = .init(scaleX: scale, y: scale) self.textView.transform = .init(scaleX: scale, y: scale) }, completion: nil) } func configure(with item: Item) { title.text = "item.title" textView.backgroundColor = .green imageTask = Future<UIImage?, Never>() { promise in UIImage(named: item.image)?.prepareForDisplay(completionHandler: { loadedImage in promise(Result.success(loadedImage)) }) } .receive(on: DispatchQueue.main) .sink { image in self.imageView.image = UIImage(named: "cover2") } } required init?(coder: NSCoder) { fatalError("error") } }
附加工具代码
extension Bundle { func decode<T: Decodable>(_ type: T.Type, from file: String) -> T { guard let url = self.url(forResource: file, withExtension: nil) else { fatalError("Failed to locate \(file) in bundle.") } guard let data = try? Data(contentsOf: url) else { fatalError("Failed to load \(file) from bundle.") } let decoder = JSONDecoder() guard let loaded = try? decoder.decode(T.self, from: data) else { fatalError("Failed to decode \(file) from bundle.") } return loaded } } protocol SelfConfiguringCell { static var reuseIdentifier: String { get } func configure(with item: Item) } public struct Section: Decodable, Hashable { let identifier: String; let item: [Item] } public struct Item: Decodable, Hashable { let index: Int; let title: String; let image: String } // JSON示例 [ { "identifier": "carouselCell", "item": [ { "index": 1, "title": "0", "image": "cover1" } ] } ]
尝试过的无效方案
1. KVO方案
能获取进度值,但Cell文本未更新
var nameObservation: NSKeyValueObservation? @objc dynamic var progress = 0.0 func createDataSource() { dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in switch self.sections[indexPath.section].identifier { case "carouselCell": let cell = self.configure(CarouselCell.self, with: item, for: indexPath) self.nameObservation = self.observe(\.progress, options: .new) { vc, change in cell.title.text = "\(self.progress)" } return cell default: return self.configure(CarouselCell.self, with: item, for: indexPath) } } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { _ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in guard self.progress <= 1.0 else { timer.invalidate() self.progress = 0.0 return } } }
2. Notification方案
点击后CollectionView出现异常
var nameObservation: NSKeyValueObservation? @objc func createDataSource() { dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in switch self.sections[indexPath.section].identifier { case "carouselCell": let cell = self.configure(CarouselCell.self, with: item, for: indexPath) cell.title.text = "\(self.progressA)" print(self.progressA) return cell default: return self.configure(CarouselCell.self, with: item, for: indexPath) } } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { NotificationCenter.default.addObserver( self, selector: #selector(self.createDataSource), name: Notification.Name(rawValue: "sound-CarouselController"), object: nil) _ = Timer.scheduledTimer(withTimeInterval: 0.10, repeats: true) { timer in guard self.progressA <= 1.0 else { timer.invalidate() self.progressA = 0.0 return } self.progressA += 0.01 NotificationCenter.default.post(name: Notification.Name(rawValue: "sound-CarouselController"), object: nil) } }
问题原因与解决办法
核心问题分析
- 全局进度值逻辑错误:用
progressA这个全局变量绑定所有Cell的进度,导致所有Cell共享同一个进度状态,且reload时无法对应单个Item的下载状态。 - 频繁reload阻塞UI:Timer每0.1秒触发一次reload,会频繁重建Cell,重置
isHighlighted状态,同时占用UI线程资源,导致触摸事件无法正常响应。 - DiffableDataSource使用不当:reload时未更新Item本身的数据,只是强制刷新Cell,导致Cell状态丢失。
修复步骤
1. 修改Item模型,添加单个Item的进度属性
给每个Item独立存储进度,同时手动实现Hashable避免DiffableDataSource识别错误:
public struct Item: Decodable, Hashable { let index: Int let title: String let image: String var progress: Double // 新增单个Item的进度 // 手动实现Hashable,忽略progress的变化(只识别Item本身的唯一性) func hash(into hasher: inout Hasher) { hasher.combine(index) hasher.combine(title) hasher.combine(image) } static func == (lhs: Item, rhs: Item) -> Bool { lhs.index == rhs.index && lhs.title == rhs.title && lhs.image == rhs.image } }
2. 替换全局变量,跟踪单个Item的下载状态
在控制器中维护当前下载的Item和Timer,避免全局状态混乱:
class CarouselController: UIViewController, UICollectionViewDelegate { // ... 原有代码 ... private var currentDownloadTimer: Timer? private var currentDownloadItem: Item? // 修改数据源绑定逻辑,用Item自身的进度更新Cell func createDataSource() { dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in switch self.sections[indexPath.section].identifier { case "carouselCell": let cell = self.configure(CarouselCell.self, with: item, for: indexPath) cell.title.text = String(format: "%.2f", item.progress) return cell default: return self.configure(CarouselCell.self, with: item, for: indexPath) } } } // 修改选择事件处理,支持中断并重启下载 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // 停止之前的下载任务 currentDownloadTimer?.invalidate() currentDownloadTimer = nil guard var snapshot = dataSource?.snapshot(), let targetItem = dataSource?.itemIdentifier(for: indexPath) else { return } // 重置上一个下载Item的进度 if let prevItem = currentDownloadItem { var updatedPrev = prevItem updatedPrev.progress = 0.0 snapshot.reloadItems([updatedPrev]) dataSource?.apply(snapshot, animatingDifferences: false) } // 初始化当前Item的进度 var currentItem = targetItem currentItem.progress = 0.0 currentDownloadItem = currentItem // 创建Timer并加入common模式(避免UI阻塞) currentDownloadTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in guard let self = self, var snapshot = self.dataSource?.snapshot(), var currentItem = self.currentDownloadItem else { timer.invalidate() return } if currentItem.progress >= 1.0 { timer.invalidate() currentItem.progress = 0.0 self.currentDownloadItem = nil } else { currentItem.progress += 0.01 } // 更新Item并刷新 snapshot.reloadItems([currentItem]) self.dataSource?.apply(snapshot, animatingDifferences: false) self.currentDownloadItem = currentItem } RunLoop.current.add(currentDownloadTimer!, forMode: .common) } // ... 原有代码 ... }
3. 修复Cell动画状态丢失问题
在prepareForReuse中恢复缩放状态,避免reload时重置动画:
class CarouselCell: UICollectionViewCell, SelfConfiguringCell {
相关产品推荐
相关产品推荐

