Manav
Manav

Reputation: 2522

Image in Collection view cell is not updated when the image is downloaded asynchronously

The image in the collection view cell is not updated when the image is downloaded from the server. The image gets updated when the collection view is scrolled.

Every section of the table view has a collection view. And table view cell has datasource for the collection view.

extension OffersCell: UICollectionViewDataSource,UICollectionViewDelegate{
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {

        return photoViewModel.photos.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photoCell", for: indexPath)
        (cell as! PhotoCell).imageView.contentMode = .scaleAspectFill
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {

        let photo = self.photoViewModel.photos[indexPath.row]
        (cell as! PhotoCell).imageView.image = UIImage(named: "dummyImage")

        ImageDownloadManager.shared.downloadImage(photo, indexPath: indexPath) { (image, imageIndexPath, error) in
            if let indexPathNew = imageIndexPath, indexPathNew == indexPath {
                DispatchQueue.main.async {
                    (cell as! PhotoCell).imageView.image = image
                }
            }
        }
    }

}

Please find the image downloader class :

typealias ImageDownloadHandler = (_ image: UIImage?, _ indexPath: IndexPath?, _ error: Error?) -> Void

final class ImageDownloadManager {

    private var completionHandler: ImageDownloadHandler?
    lazy var imageDownloadQueue: OperationQueue = {
        var queue = OperationQueue()
        queue.name = "imageDownloadQueue"
        queue.qualityOfService = .userInteractive
        return queue
    }()

    let imageCache = NSCache<NSString, UIImage>()
    static let shared = ImageDownloadManager()
    private init () {}

    func downloadImage(_ photo: Photos, indexPath: IndexPath?, handler: @escaping ImageDownloadHandler) {
        self.completionHandler = handler
        guard let url = photo.getImageURL() else {
            return
        }
        if let cachedImage = imageCache.object(forKey: photo.id as NSString) {
            self.completionHandler?(cachedImage, indexPath, nil)
        } else {
                let operation = CustomOperation(url: url, indexPath: indexPath)
                if indexPath == nil {
                }
            operation.queuePriority = .high
                operation.downloadHandler = { (image, indexPath, error) in
                    if let newImage = image {
                        self.imageCache.setObject(newImage, forKey: photo.id as NSString)
                    }
                    self.completionHandler?(image, indexPath, error)
                }
                imageDownloadQueue.addOperation(operation)
        }
    }

    func cancelAll() {
        imageDownloadQueue.cancelAllOperations()
    }


}

Upvotes: 0

Views: 1310

Answers (3)

hfehrmann
hfehrmann

Reputation: 457

It depends on how you define the class CustomOperation, but the problem seems to be in the method downloadImage of ImageDownloadManager where in the next line you set self.completionHandler = handler. Note that ImageDownloadManager is a singleton. This means that every operation you start replaces completionHandler of the singleton object with the new completion (I bet only the last cell was refreshed). The solution consists of elimination the property completionHandler and replacing the operation download handler with this

operation.downloadHandler = { (image, indexPath, error) in
    if let newImage = image {
        self.imageCache.setObject(newImage, forKey: photo.id as NSString)
    }
    handler(image, indexPath, error)
}

Note that it calls the handler of the context and not the stored property of the download manager

Here is a full working example with all the class and struct definitions. Adapt it as needed.


typealias ImageDownloadHandler = (_ image: UIImage?, _ indexPath: IndexPath?, _ error: Error?) -> Void

enum ImageDownloadError: Error {
    case badDataURL
}

class CustomOperation: Operation {

    var downloadHandler: (UIImage?, IndexPath?, Error?) -> () = { _,_,_ in }

    private let url: URL
    private let indexPath: IndexPath?

    init(url: URL, indexPath: IndexPath?) {
        self.url = url
        self.indexPath = indexPath
    }

    override func main() {
        guard let imageData = try? Data(contentsOf: self.url) else {
            self.downloadHandler(nil, self.indexPath, ImageDownloadError.badDataURL)
            return
        }
        let image = UIImage(data: imageData)
        self.downloadHandler(image, self.indexPath, nil)
    }
}

final class ImageDownloadManager {

    private var completionHandler: ImageDownloadHandler?
    lazy var imageDownloadQueue: OperationQueue = {
        var queue = OperationQueue()
        queue.name = "imageDownloadQueue"
        queue.qualityOfService = .userInteractive
        return queue
    }()

    let imageCache = NSCache<NSString, UIImage>()
    static let shared = ImageDownloadManager()
    private init () {}

    func downloadImage(_ photo: Photos, indexPath: IndexPath?, handler: @escaping ImageDownloadHandler) {
        //self.completionHandler = handler
        guard let url = photo.getImageURL() else {
            return
        }
        if let cachedImage = imageCache.object(forKey: photo.id as NSString) {
            //self.completionHandler?(cachedImage, indexPath, nil)
            handler(cachedImage, indexPath, nil)
        } else {
            let operation = CustomOperation(url: url, indexPath: indexPath)
            if indexPath == nil {
            }
            operation.queuePriority = .high
            operation.downloadHandler = { (image, indexPath, error) in
                if let newImage = image {
                    self.imageCache.setObject(newImage, forKey: photo.id as NSString)
                }
                //self.completionHandler?(image, indexPath, error)
                handler(image, indexPath, error)
            }
            imageDownloadQueue.addOperation(operation)
        }
    }

    func cancelAll() {
        imageDownloadQueue.cancelAllOperations()
    }
}

-------------------------------------------------------

struct Photos {
    let id: String
    let url: URL

    func getImageURL() -> URL? {
        return self.url
    }
}

struct PhotoViewModel {
    let photos: [Photos]
}

class PhotoCell: UICollectionViewCell {
    @IBOutlet weak var imageView: UIImageView!
}

class ViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView!

    private let photoViewModel: PhotoViewModel = PhotoViewModel(
        photos: [
            Photos(
                id: "kitty1",
                url: URL(
                    string: "https://cdn.pixabay.com/photo/2019/06/18/11/23/cat-4282110_960_720.jpg"
                )!
            ),
            Photos(
                id: "kitty2",
                url: URL(
                    string: "https://cdn.pixabay.com/photo/2019/07/23/20/08/cat-4358536_960_720.jpg"
                    )!
            ),
            Photos(
                id: "kitty3",
                url: URL(
                    string: "https://cdn.pixabay.com/photo/2016/09/28/13/15/kittens-1700474_960_720.jpg"
                    )!
            )
        ]
    )

    override func viewDidLoad() {
        super.viewDidLoad()
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.reloadData()
    }
}


extension ViewController: UICollectionViewDataSource,UICollectionViewDelegate{
    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 1
    }
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return photoViewModel.photos.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photoCell", for: indexPath)
        (cell as! PhotoCell).imageView.contentMode = .scaleAspectFill
        return cell
    }

    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {

        let photo = self.photoViewModel.photos[indexPath.row]
        (cell as! PhotoCell).imageView.image = UIImage(named: "dummyImage")

        ImageDownloadManager.shared.downloadImage(photo, indexPath: indexPath) { (image, imageIndexPath, error) in
            if let indexPathNew = imageIndexPath, indexPathNew == indexPath {
                DispatchQueue.main.async {
                    (cell as! PhotoCell).imageView.image = image
                }
            }
        }
    }

}

Upvotes: 1

Mahendra
Mahendra

Reputation: 8914

Yes, once image is downloaded is will not display unless collection view is scrolled as said by @Reinhard Männer

Instead you can go for the third-party SDKs(which fit your needs) for image downloading and caching in your app.

I will recommend to use Kingfisher SDK (developed in pure swift).

It is easy to use and integrate. it does lot of thing like async. downloading, caching(on memory or disk), built-in transition animation when setting images, etc. and it is popular too

For you'r problem it is one line code if you use Kingfisher SDK.

For eg.

To load image asynchronously you can use following in cellForRowAtItem: method.

let url = URL(string: "https://example.com/image.png")
imageView.kf.setImage(with: url)

What you all need to do is...

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "photoCell", for: indexPath) as! PhotoCell
    cell.imageView.contentMode = .scaleAspectFill

    //I'm assuming photo is URL(in string) of Photo. if 'photo' is URL type then you can pass it directly in 'setImage' method.
    let photo = self.photoViewModel.photos[indexPath.row]

    let imgUrl = URL(string: photo)

    //It will download image asynchronously and cache it for later use. If the image is failed to downloaded due to some issue then "dummyImage" will be set in image view.
    cell.imageView.kf.setImage(with: imgUrl, placeholder: UIImage(named: "dummyImage"))

    return cell
}

Here you can remove cell willDisplay: method.

Upvotes: 0

Reinhard M&#228;nner
Reinhard M&#228;nner

Reputation: 15247

After you downloaded the image, you execute the instruction (cell as! PhotoCell).imageView.image = image on the main thread. But this does not redisplay your collectionView cell.
Also, collectionView:willDisplayCell:forItemAtIndexPath: will normally not be called. The docs say

The collection view calls this method before adding a cell to its content.

It is however called, when you scroll in the cell, i.e. when it becomes visible. This is there reason why your image is displayed after the cell is scrolled in.

So my suggestion is:

  • After downloading the image, update your collectionView data source so that collectionView:cellForItemAtIndexPath: can configure the cell with the image.
  • Call reloadItems(at:) with an array that contains only the index path of the updated cell.

Upvotes: 3

Related Questions