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

Viewed 18411

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
    }
12 Answers

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
    }
}

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.

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()
 }
}

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()
   } 
 }

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!)

Swift 3

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

    if cell.tag == indexPath.row {
        var tmpImage = UIImage(data: imgData)
        thumbnailImageView.image = tmpImage
    }
})

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.

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))

Related