Image on CollectionView cell changes when scrolling

Viewed 689

I have a collectionView that has custom cells. Each custom cell has an image in it and other components. The problem I'm having is that when I scroll, the images changes to another images from other cells on the collectionView. I'm gonna put the code that I'm using so it's more clear to see how I'm implementing the collectionView and it's cell.

func registerCell() {
        collectionView.register(PageCell.self, forCellWithReuseIdentifier: cellId)
    }
    
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        
        let pageNumber = Int(targetContentOffset.pointee.x / view.frame.width)
        pageControl.currentPage = pageNumber
    }
    
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        
        return feedTitles.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellId, for: indexPath) as! PageCell

        cell.titleLabel.text = self.feedTitles[indexPath.item].uppercased()
        cell.textView.text = self.feedDescription[indexPath.item]

        guard let imageURL = URL(string: self.feedImages[indexPath.item]) else {return cell}
        cell.imageView.load(url: imageURL)
        
        return cell
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.frame.width, height: collectionView.frame.height)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
        return 0
    }

This is the code for getting the images from a server:

var feeds: [Feed]?
    func getFeed(){
        feedService.getFeed() { [weak self](feedRes) in
            switch feedRes {
            case .success(let successResponse):
                print("Success Feed")
                if successResponse.data.count < 1 {
                } else{
                    self?.feeds = successResponse.data
                    self?.feedTitles = self?.feeds?.compactMap({ $0.title
                    }) ?? ["Titulo Noticia"]
                    self?.feedImages = self?.feeds?.compactMap({ $0.image
                    }) ?? ["Imagen"]
                    self?.feedDescription = self?.feeds?.compactMap({ $0.content
                    }) ?? ["Descripcion Imagen"]
                    self?.collectionView.reloadData()
                    self?.pageControl.numberOfPages = (self?.feedTitles.count)!
                    self?.pageControl.isHidden = false
                }
            case .failure(let feedError):
                print("Fail Feed \(feedError)")
                print(feedError.localizedDescription)
                self?.pageControl.isHidden = true
            }
        }
    }

This is the "load" function:

func load(url: URL) {
        DispatchQueue.global().async { [weak self] in
            if let data = try? Data(contentsOf: url) {
                if let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self?.image = image
                    }
                }
            }
        }
    }
2 Answers

The issue is inside your cell.imageView.load(url: imageURL) implementation.

Downloading an image is an asynchronous operation. When you create a cell - you start an operation and you don't know when it's going to complete. Whenever it completes, it sets the image on your UIImageView instance.

The cell on the other hand is a reuseable component for collectionView and when you scroll, same cell instance that disappeared by going out of screen from top will also reappear by coming in from bottom of the screen. This means that one cell instance can trigger multiple image download requests and it doesn't cancel it's previous in-flight image download request. It also does not know which image download call completed - the last one (or the one before that etc.)

What you need to do is - add some additional protection in the imageView load method so that when a cell (and hence imageView) instance is reused - it only sets the image for last request (not the ones done prior to the last one).

Copy paste following code into your project.

import UIKit
import ObjectiveC.runtime

private var keyTagIdentifier: String?

public extension UIView {
    var tagIdentifier: String? {
        get { return objc_getAssociatedObject(self, &keyTagIdentifier) as? String }
        set { objc_setAssociatedObject(self, &keyTagIdentifier, newValue, .OBJC_ASSOCIATION_RETAIN) }
    }
}

Update your image load extension like this

public extension UIImageView {
    func load(url: URL) {
        // 1. Assign the current request identifier on this instance
        let lastRequestIdentifier = url.absoluteString
        self.tagIdentifier = lastRequestIdentifier
        
        // 2. Download the image as you are doing today

        // 3. After the image has been downloaded and you set the image
        // Please check whether we are still interested in same image or not
        // This will be written inside your image download completion block
        if self.tagIdentifier == lastRequestIdentifier {
            self.image = // the image you downloaded
        }
    }
}

I believe the issue is due to UICollectionView reusing cells. Meaning the cell that disappears from the top, is rendered at the bottom, usually with the same state as the cell that disappeared from the top and vice-versa for the cells at the bottom. You can avoid this behaviour by adding the following to you code to your PageCell,

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