17

I'm trying to async load pictures inside my FriendsTableView (UITableView) cell. The images load fine but when I'll scroll the table the images will change a few times and wrong images are getting assigned to wrong cells.

I've tried all methods I could find in StackOverflow including adding a tag to the raw and then checking it but that didn't work. I'm also verifying the cell that should update with indexPath and check if the cell exists. So I have no idea why this is happening.

Here is my code:

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("friendCell", forIndexPath: indexPath) as! FriendTableViewCell
        var avatar_url: NSURL
        let friend = sortedFriends[indexPath.row]

        //Style the cell image to be round
        cell.friendAvatar.layer.cornerRadius = 36
        cell.friendAvatar.layer.masksToBounds = true

        //Load friend photo asyncronisly
        avatar_url = NSURL(string: String(friend["friend_photo_url"]))!
        if avatar_url != "" {
                getDataFromUrl(avatar_url) { (data, response, error)  in
                    dispatch_async(dispatch_get_main_queue()) { () -> Void in
                        guard let data = data where error == nil else { return }
                        let thisCell = tableView.cellForRowAtIndexPath(indexPath)
                        if (thisCell) != nil {
                            let updateCell =  thisCell as! FriendTableViewCell
                            updateCell.friendAvatar.image = UIImage(data: data)
                        }
                    }
                }
        }
        cell.friendNameLabel.text = friend["friend_name"].string
        cell.friendHealthPoints.text = String(friend["friend_health_points"])
        return cell
    }
BenNov
  • 1,086
  • 2
  • 10
  • 18

12 Answers12

28

On cellForRowAtIndexPath:

1) Assign an index value to your custom cell. For instance,

cell.tag = indexPath.row

2) On main thread, before assigning the image, check if the image belongs the corresponding cell by matching it with the tag.

dispatch_async(dispatch_get_main_queue(), ^{
   if(cell.tag == indexPath.row) {
     UIImage *tmpImage = [[UIImage alloc] initWithData:imgData];
     thumbnailImageView.image = tmpImage;
   }});
});
Alvin George
  • 14,148
  • 92
  • 64
9

This is because UITableView reuses cells. Loading them in this way causes the async requests to return at different time and mess up the order.

I suggest that you use some library which would make your life easier like Kingfisher. It will download and cache images for you. Also you wouldn't have to worry about async calls.

https://github.com/onevcat/Kingfisher

Your code with it would look something like this:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier("friendCell", forIndexPath: indexPath) as! FriendTableViewCell
        var avatar_url: NSURL
        let friend = sortedFriends[indexPath.row]

        //Style the cell image to be round
        cell.friendAvatar.layer.cornerRadius = 36
        cell.friendAvatar.layer.masksToBounds = true

        //Load friend photo asyncronisly
        avatar_url = NSURL(string: String(friend["friend_photo_url"]))!
        if avatar_url != "" {
            cell.friendAvatar.kf_setImageWithURL(avatar_url)
        }
        cell.friendNameLabel.text = friend["friend_name"].string
        cell.friendHealthPoints.text = String(friend["friend_health_points"])
        return cell
    }
Stefan Salatic
  • 4,513
  • 3
  • 22
  • 30
9

UPDATE

There are some great open source libraries for image caching such as KingFisher and SDWebImage. I would recommend that you try one of them rather than writing your own implementation.

END UPDATE

So there are several things you need to do in order for this to work. First let's look at the caching code.

// Global variable or stored in a singleton / top level object (Ex: AppCoordinator, AppDelegate)
let imageCache = NSCache<NSString, UIImage>()

extension UIImageView {

    func downloadImage(from imgURL: String) -> URLSessionDataTask? {
        guard let url = URL(string: imgURL) else { return nil }

        // set initial image to nil so it doesn't use the image from a reused cell
        image = nil

        // check if the image is already in the cache
        if let imageToCache = imageCache.object(forKey: imgURL as NSString) {
            self.image = imageToCache
            return nil
        }

        // download the image asynchronously
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let err = error {
                print(err)
                return
            }

            DispatchQueue.main.async {
                // create UIImage
                let imageToCache = UIImage(data: data!)
                // add image to cache
                imageCache.setObject(imageToCache!, forKey: imgURL as NSString)
                self.image = imageToCache
            }
        }
        task.resume()
        return task
    }
}

You can use this outside of a TableView or CollectionView cell like this

let imageView = UIImageView()
let imageTask = imageView.downloadImage(from: "https://unsplash.com/photos/cssvEZacHvQ")

To use this in a TableView or CollectionView cell you'll need to reset the image to nil in prepareForReuse and cancel the download task. (Thanks for pointing that out @rob

final class ImageCell: UICollectionViewCell {

    @IBOutlet weak var imageView: UIImageView!
    private var task: URLSessionDataTask?

    override func prepareForReuse() {
        super.prepareForReuse()

        task?.cancel()
        task = nil
        imageView.image = nil
    }

    // Called in cellForRowAt / cellForItemAt
    func configureWith(urlString: String) {
        if task == nil {
            // Ignore calls when reloading
            task = imageView.downloadImage(from: urlString)
        }
    }
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "imageCell", for: indexPath) as! ImageCell
    cell.configureWith(urlString: "https://unsplash.com/photos/cssvEZacHvQ") // Url for indexPath
    return cell
}

Keep in mind that even if you use a 3rd party library you'll still want to nil out the image and cancel the task in prepareForReuse

DoesData
  • 6,594
  • 3
  • 39
  • 62
  • 1
    Images getting replaced by other images when I scrolled, any solution? – Pankaj Bhardwaj Jun 24 '18 at 13:19
  • 2
    @PankajBhardwaj - That’s because DoesData is not canceling the prior image request should the cell have been reused by the time this asynchronous image retrieval is done. – Rob Oct 22 '19 at 02:28
4

If targeting iOS 13 or later, you can use Combine and dataTaskPublisher(for:). See WWDC 2019 video Advances in Networking, Part 1.

The idea is to let the cell keep track of the “publisher”, and have prepareForReuse:

  • cancel the prior image request;
  • set the image property of the image view to nil (or a placeholder); and then
  • start another image request.

For example:

extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return objects.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell", for: indexPath) as! CustomCell
        let url = ...
        cell.setImage(to: url)
        return cell
    }
}

class CustomCell: UITableViewCell {
    @IBOutlet weak var customImageView: UIImageView!

    private var subscriber: AnyCancellable?

    override func prepareForReuse() {
        super.prepareForReuse()
        subscriber?.cancel()
        customImageView?.image = nil
    }

    func setImage(to url: URL) {
        subscriber = ImageManager.shared.imagePublisher(for: url, errorImage: UIImage(systemName: "xmark.octagon"))
            .assign(to: \.customImageView.image, on: self)
    }
}

Where:

class ImageManager {
    static let shared = ImageManager()

    private init() { }

    private let session: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .returnCacheDataElseLoad
        let session = URLSession(configuration: configuration)

        return session
    }()

    enum ImageManagerError: Error {
        case invalidResponse
    }

    func imagePublisher(for url: URL, errorImage: UIImage? = nil) -> AnyPublisher<UIImage?, Never> {
        session.dataTaskPublisher(for: url)
            .tryMap { data, response in
                guard
                    let httpResponse = response as? HTTPURLResponse,
                    200..<300 ~= httpResponse.statusCode,
                    let image = UIImage(data: data)
                else {
                    throw ImageManagerError.invalidResponse
                }

                return image
            }
            .replaceError(with: errorImage)
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

If targeting earlier iOS versions, rather than using Combine, you can use URLSession, with the same idea of canceling the prior request in prepareForReuse:

class CustomCell: UITableViewCell {
    @IBOutlet weak var customImageView: UIImageView!

    private weak var task: URLSessionTask?

    override func prepareForReuse() {
        super.prepareForReuse()
        task?.cancel()
        customImageView?.image = nil
    }

    func setImage(to url: URL) {
        task = ImageManager.shared.imageTask(for: url) { result in
            switch result {
            case .failure(let error): print(error)
            case .success(let image): self.customImageView.image = image
            }
        }
    }
}

Where:

class ImageManager {
    static let shared = ImageManager()

    private init() { }

    private let session: URLSession = {
        let configuration = URLSessionConfiguration.default
        configuration.requestCachePolicy = .returnCacheDataElseLoad
        let session = URLSession(configuration: configuration)

        return session
    }()

    enum ImageManagerError: Error {
        case invalidResponse
    }

    @discardableResult
    func imageTask(for url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) -> URLSessionTask {
        let task = session.dataTask(with: url) { data, response, error in
            guard let data = data else {
                DispatchQueue.main.async { completion(.failure(error!)) }
                return
            }

            guard
                let httpResponse = response as? HTTPURLResponse,
                200..<300 ~= httpResponse.statusCode,
                let image = UIImage(data: data)
            else {
                DispatchQueue.main.async { completion(.failure(ImageManagerError.invalidResponse)) }
                return
            }

            DispatchQueue.main.async { completion(.success(image)) }
        }
        task.resume()
        return task
    }
}
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • 1
    Well sorry, I retract my comment. — I still think the example in the video is shockingly bad practice. My point was merely that the image itself needs to be stored _somewhere_, not downloaded again when we scroll to the same row again, which is what the example in the video does. – matt Oct 22 '19 at 02:28
  • Thanks for that. Yep, I agree that this approach relies upon the `URLCache` and web service with properly configured `Cache-Control` headers (which, IMHO, client apps should generally honor, and not override with their own caching). But you are certainly correct that if one has sloppily implemented image web services that don’t set `Cache-Control` headers correctly, then one can (and we both have, I’m sure) add custom caching mechanism to `ImageManager`. – Rob Oct 22 '19 at 02:43
2

Depending on the implementation there can be many things that will cause all of the answers here to not work (including mine). Checking the tag did not work for me, checking the cache neither, i have a custom Photo class that carries the full image, thumbnail and more data, so i have to take care of that too and not just prevent the image from being reused improperly. Since you will probably be assigning the images to the cell imageView after they're done downloading, you will need to cancel the download and reset anything you need on prepareForReuse()

Example if you're using something like SDWebImage

  override func prepareForReuse() {
   super.prepareForReuse() 

   self.imageView.sd_cancelCurrentImageLoad()
   self.imageView = nil 
   //Stop or reset anything else that is needed here 

}

If you have subclassed the imageview and handle the download yourself make sure you setup a way to cancel the download before the completion is called and call the cancel on prepareForReuse()

e.g.

imageView.cancelDownload()

You can cancel this from the UIViewController too. This on itself or combined with some of the answers will most likely solve this issue.

Sam Bing
  • 2,794
  • 1
  • 19
  • 29
2

I solve the problem just implementing a custom UIImage class and I did a String condition as the code below:

let imageCache = NSCache<NSString, UIImage>()

class CustomImageView: UIImageView {
    var imageUrlString: String?

    func downloadImageFrom(withUrl urlString : String) {
        imageUrlString = urlString

        let url = URL(string: urlString)
        self.image = nil

        if let cachedImage = imageCache.object(forKey: urlString as NSString) {
            self.image = cachedImage
            return
        }

        URLSession.shared.dataTask(with: url!, completionHandler: { (data, response, error) in
            if error != nil {
                print(error!)
                return
            }

            DispatchQueue.main.async {
                if let image = UIImage(data: data!) {
                    imageCache.setObject(image, forKey: NSString(string: urlString))
                    if self.imageUrlString == urlString {
                        self.image = image
                    }
                }
            }
        }).resume()
    }
}

It works for me.

Bruno Ramos
  • 149
  • 1
  • 4
2

TableView reuses cells. Try this:

import UIKit

class CustomViewCell: UITableViewCell {

@IBOutlet weak var imageView: UIImageView!

private var task: URLSessionDataTask?

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

func configureWith(url string: String) {
    guard let url = URL(string: string) else { return }

    task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data, let image = UIImage(data: data) {
            DispatchQueue.main.async {
                self.imageView.image = image
            }
        }
    }
    task?.resume()
 }
}
2

Because TableView reuses cells. In your cell class try this code:

class CustomViewCell: UITableViewCell {

@IBOutlet weak var catImageView: UIImageView!

private var task: URLSessionDataTask?

override func prepareForReuse() {
    super.prepareForReuse()
    task?.cancel()
    catImageView.image = nil
}

func configureWith(url string: String) {
    guard let url = URL(string: string) else { return }

    task = URLSession.shared.dataTask(with: url) { (data, response, error) in
        if let data = data, let image = UIImage(data: data) {
            DispatchQueue.main.async {
                self.catImageView.image = image
            }
        }
    }
    task?.resume()
   } 
 }
0

the Best Solution for This Problem i have is for Swift 3 or Swift 4 Simply write these two lines

  cell.videoImage.image = nil

 cell.thumbnailimage.setImageWith(imageurl!)
  • can you please tell me that what exact error you are facing – Muhammad Zeshan Mazhar Jun 25 '18 at 18:13
  • The issue is that there is no `setImageWith` method. You’re clearly using some extension on `UIImageView` that is doing this asynchronous image retrieval, but this code alone is insufficient. – Rob Oct 22 '19 at 02:26
0

Swift 3

  DispatchQueue.main.async(execute: {() -> Void in

    if cell.tag == indexPath.row {
        var tmpImage = UIImage(data: imgData)
        thumbnailImageView.image = tmpImage
    }
})
Gowtham Sooryaraj
  • 3,639
  • 3
  • 13
  • 22
  • Relying on cell tag numbers is not advisable because if a row is inserted or deleted, all subsequent tag numbers will be invalid (unless you reload the whole table, which is inefficient). – Rob Oct 22 '19 at 02:16
0

I created a new UIImage variable in my model and load the image/placeholder from there when creating a new model instance. It worked perfectly fine.

0

It is an example that using Kingfisher caching at memory and disk after downloaded. It replace UrlSession downloading traditional and avoid re-download UIImageView after scroll down TableViewCell

https://gist.github.com/andreconghau/4c3b04205195f452800d2892e91a079a

Example Output

sucess
    Image Size:
    (460.0, 460.0)

    Cache:
    disk

    Source:
    network(Kingfisher.ImageResource(cacheKey: "https://avatars0.githubusercontent.com/u/5936?v=4", downloadURL: https://avatars0.githubusercontent.com/u/5936?v=4))

    Original source:
    network(Kingfisher.ImageResource(cacheKey: "https://avatars0.githubusercontent.com/u/5936?v=4", downloadURL: https://avatars0.githubusercontent.com/u/5936?v=4))

user3810914
  • 425
  • 2
  • 5
  • 9