iosswiftuicollectionviewuicollectionviewcompositionallayoutuicollectionviewflowlayout

How to fix animation hitch in collection view?


I have a collection view. When I touch the cell, the animation from the collection view cell class CarouselCell playing. When I press on a cell, the process of downloading the file begins in the method didSelectItemAt. To update the process I use the code from here reloadItem(indexPath: IndexPath) The problem is that if while downloading a file I try to touch a cell, the animation will play once, like a jump or hitch, and then it won’t work at all when touched until the file is downloaded. Also, during downloading, when the cell is clicked again, the method didSelectItemAt will not be called until the file is downloaded. The fact that the didSelectItemAt method is not called during file downloading is good. This prevents the same file from being downloaded multiple times. But the problem is that I don’t understand why this happens because I haven’t written such code anywhere. How to fix the animation and why didSelectItemAt not called during the file is downloading?

code:

I removed all the download related code to save space in question and replaced it with a timer to simulate the file download progress

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)
    }
    
}

cell

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")
    }
    
}

additional:

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 }

[ {
        "identifier": "carouselCell",
        "item": [
            
            {
                "index": 1,
                "title": "0",
                "image": "cover1",
            },
            
] }, ]

based on @Rob's answer

KVO variant (not working)

I get progress inside the observer, but the cell text is not updated. Why?

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
        }
    }
}

Notification variant (not working).

collection is despairing after press

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)

        }
                
    }

Solution

  • There are a few issues:

    1. The reloading of a cell while isHighlighted is true can confuse the cell’s isHighlighted state management.

      I might advise that you refrain from reloading of the whole cell, and instead just have the cell use a different mechanism for updating itself based upon the state of the download. E.g., maybe add a progress property for the have the cell observe a published property. Or use the notification center post notifications with the progress and have the cell observe those notifications. See https://github.com/robertmryan/Carousel for an example.

      But having the cell update itself (rather than having the view controller repeatedly reload it, especially during the isHighlighted animation), avoids this animation interruption.

    2. This may be a secondary concern, but I found that the selection of the cell was more reliable when all of the cell subviews specify isUserInteractionEnabled of false (unless, of course, you need user interaction for those subviews, which is not the case at this point at least).


    Some unrelated observations:

    1. I would advise against using the IndexPath to identify which cell is being updated. You want to make sure that your asynchronous process will be unaffected by the insertion or deletion of cells in the collection view. That might not be something you are contemplating at this point, but using the IndexPath is a brittle pattern that can bite you later.

      Needless to say, once you move the progress updates to the cell itself, you will also make sure you correctly handle the cell being reused for a different download (as the cell in question might scroll out of view and be reused).

    2. You said:

      during downloading, when the cell is clicked again, the method didSelectItemAt will not be called until the file is downloaded. The fact that the didSelectItemAt method is not called during file downloading is good.

      I would suggest that you really do want didSelectItemAt to be called whenever the cell is selected. Now, obviously, you will want to keep track of whether the download has already started or not so you don't start duplicative downloads. But do not rely on the current behavior where didSelectItemAt wasn't called, as that is a mistake.

    3. Note, your view controller has a progress state variable. You presumably want to support multiple concurrent downloads. This progress really belongs in your model at the row level, not the view controller level. (I suspect you introduced this for the sake of the MRE, but just saying…)