Search code examples
iosswiftuicollectionviewuikit

Multiple freezes in collection view


I have collection view with UICollectionViewCompositionalLayout. Inside my collection I have items with images. When I launch my app I see collection view. And when I start scrolling I have multiple freezes. But when I scrolled the all collection, the freezes disappear. I think that freezes are disappearing after full collection scroll because the images are already loaded. But how can I fix the problem so that after launching the app and scrolling the collection for the first time, it does not freeze?

Video to reproduce the problem:

https://drive.google.com/file/d/145Bl2Oc3UHgwpJQZjG38YNVOZyWz7euM/view?usp=share_link

There is a lot of code in the app, so I posted the project on GitHub:

https://github.com/user234567890354678/carousel

Main code:

collection view cell

class CVCell: UICollectionViewCell, SelfConfiguringCell {
    func configure(with item: Item) {
        title.text = item.title
        textView.backgroundColor = UIColor(item.backgroundColor)
        textView.layer.borderColor = UIColor(item.borderColor).cgColor
        titleImageView.image = UIImage(named: item.titleImage)
        imageView.image = UIImage(named: item.image)
    }
}

collection controller

class CVController: UIViewController, UICollectionViewDelegate {
    var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>?
    let sections = Bundle.main.decode([Section].self, from: "img1.json")

    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)
                return cell
            default: return self.configure(CarouselCell.self, with: item, for: indexPath)
            }
        }
    }
    
    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)
    }
        
    func setupCollectionView() {
        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()
    }
    
    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
            section.visibleItemsInvalidationHandler = { (items, offset, environment) in
                
                items.forEach { item in
                    let distanceFromCenter = abs((item.frame.midX - offset.x) - environment.container.contentSize.width / 2.0)
                    let minScale: CGFloat = 0.7
                    let maxScale: CGFloat = 1.1
                    let scale = max(maxScale - (distanceFromCenter / environment.container.contentSize.width), minScale)
                    item.transform = CGAffineTransform(scaleX: scale, y: scale)
                }
            }
            
            return section
        }
    }
    
    func reloadItem(indexPath: IndexPath) {
        guard let needReloadItem = dataSource!.itemIdentifier(for: indexPath) else { return }
        var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
        snapshot.appendSections(sections)
        for section in sections { snapshot.appendItems(section.item, toSection: section) }
        dataSource?.apply(snapshot)
        snapshot.reloadItems([needReloadItem])
        dataSource?.apply(snapshot, animatingDifferences: false)
    }

}

What did I try to do to solve this problem?

I have the same problem in a UITableView with a lot of images. To fix this I use this code:

func loadImageAsync(imageName: String, completion: @escaping (UIImage) -> ()) {
    DispatchQueue.global(qos: .userInteractive).async {
        guard let image = UIImage(named: imageName) else {return}
        DispatchQueue.main.async {
            completion(image)
        }
    }
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! RecipesViewCell

    loadImageAsync(imageName: imageName) { (image) in
        guard cell.imageName == imageName else { return }
        cell.cardImageView.image = image
    }
}

And after running, after a few seconds the images are loaded into the cells and after that I can scroll the table without freezing. Then I tried to apply this code for my collectionView in question like this:

class CVCell: UICollectionViewCell, SelfConfiguringCell {

    func configure(with item: Item) {
        loadImageAsync(imageName: item.image) { (image) in
            self.imageView.image = image
        }
    }
    
    func loadImageAsync(imageName: String, completion: @escaping (UIImage) -> ()) {
        DispatchQueue.global(qos: .userInteractive).async {
            guard let image = UIImage(named: imageName) else {return}
            DispatchQueue.main.async {
                completion(image)
            }
        }
    }

}

But this didn't help me at all. What am I doing wrong?


Solution

  • For iOS 15 and above, you can use func prepareForDisplay(completionHandler: @escaping (UIImage?) -> Void) for decoding image asynchronously.

    Since the cells are reused and images are set asynchronously, the images might display on wrong cells during scroll. To fix this you can set the image to nil on prepareForReuse in CarouselCell.

    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
    }
    

    And to avoid loading cells which goes out of screen during scroll, you can use Combine to handle task cancellation in CarouselCell.

    import Combine
    
    var imageTask: AnyCancellable?
    
    override func prepareForReuse() {
        super.prepareForReuse()
        imageView.image = nil
        imageTask?.cancel()
    }
    
    func configure(with item: Item) {
        ...
        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 = image
                    }
    }