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

修复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)

        }
                
    }

问题原因与解决办法

核心问题分析

  1. 全局进度值逻辑错误:用progressA这个全局变量绑定所有Cell的进度,导致所有Cell共享同一个进度状态,且reload时无法对应单个Item的下载状态。
  2. 频繁reload阻塞UI:Timer每0.1秒触发一次reload,会频繁重建Cell,重置isHighlighted状态,同时占用UI线程资源,导致触摸事件无法正常响应。
  3. 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 {
相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.15 00:11:54