Swift Images change to wrong images while scrolling after async image loading to a UITableViewCell
Asked Answered
M

12

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
    }
Metamerism answered 12/3, 2016 at 14:26 Comment(0)
S
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
    }
Subdivision answered 12/3, 2016 at 14:31 Comment(7)
I tried using AlamofireImage library that should have resolved this as well, but I couldn't get it to work, It had the same effect on the images. You mind explaining which methods should I use of the Kingfisher library?Metamerism
I've added sample code for your case. This should work just fine.Subdivision
how are you declaring the imageView?Metamerism
Sorry, it's the imageView from your cellSubdivision
so I just use cell.friendAvatar ?Metamerism
friendAvatar in your case. Gonna update it right now.Subdivision
king fish load images. but same image multiple time when scrolling. any solution ?Maryannamaryanne
S
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;
   }});
});
Squinch answered 19/4, 2016 at 5:10 Comment(5)
This did the trick for me, alongside setting the image view to nil in the cell class code.Malaguena
Simple and effective!Glaudia
How can i use this approach if using a cell subclass and setting everything there instead of on the viewcontroller ?Inextensible
Although until now I considered using tags useless, this method is amazingly simple and effective. I am not loading images from web so using libraries like Kingfisher would be an overkill. Thank you!Wales
this is the way ;)Knobloch
S
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
    }
Subdivision answered 12/3, 2016 at 14:31 Comment(7)
I tried using AlamofireImage library that should have resolved this as well, but I couldn't get it to work, It had the same effect on the images. You mind explaining which methods should I use of the Kingfisher library?Metamerism
I've added sample code for your case. This should work just fine.Subdivision
how are you declaring the imageView?Metamerism
Sorry, it's the imageView from your cellSubdivision
so I just use cell.friendAvatar ?Metamerism
friendAvatar in your case. Gonna update it right now.Subdivision
king fish load images. but same image multiple time when scrolling. any solution ?Maryannamaryanne
M
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

Madewell answered 10/6, 2017 at 16:40 Comment(2)
Images getting replaced by other images when I scrolled, any solution?Danedanegeld
@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.Amido
A
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
    }
}
Amido answered 23/7, 2019 at 21:39 Comment(2)
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.Jenny
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.Amido
G
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.

Goat answered 24/3, 2018 at 18:58 Comment(0)
S
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.

Surfacetosurface answered 12/1, 2019 at 18:6 Comment(0)
S
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()
 }
}
Sangfroid answered 30/11, 2019 at 4:1 Comment(0)
S
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()
   } 
 }
Sangfroid answered 30/11, 2019 at 4:8 Comment(0)
M
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!)
Meredi answered 27/10, 2017 at 11:37 Comment(2)
can you please tell me that what exact error you are facingMeredi
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.Amido
I
0

Swift 3

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

    if cell.tag == indexPath.row {
        var tmpImage = UIImage(data: imgData)
        thumbnailImageView.image = tmpImage
    }
})
Idonna answered 6/8, 2018 at 7:13 Comment(1)
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).Amido
C
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.

Clemente answered 1/12, 2019 at 6:38 Comment(0)
G
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))

Gebler answered 14/10, 2020 at 6:39 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.