Dynamically Loading Table View Images in iOS

11/13/2018 Updated for Xcode 10/Swift 4.2

iOS applications often display thumbnail images in table views alongside other text-based content such as contact names or product descriptions. However, these images are not usually delivered with the initial response, but must instead be retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the main thread, which would temporarily render the user interface unresponsive.

For example, consider this web service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "http://placehold.it/600/92c952",
    "thumbnailUrl": "http://placehold.it/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "http://placehold.it/600/771796",
    "thumbnailUrl": "http://placehold.it/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "http://placehold.it/600/24f355",
    "thumbnailUrl": "http://placehold.it/150/24f355"
  },
  ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

View Controller

A basic user interface for displaying results returned by this service is shown below:

Row data is stored in an array of Photo instances:

struct Photo: Decodable {
    let id: Int
    let albumId: Int
    let title: String?
    var url: URL?
    var thumbnailUrl: URL?
}

Previously loaded thumbnail images are stored in a dictionary that associates UIImage instances with photo IDs:

class ViewController: UITableViewController {
    // Row data
    var photos: [Photo]?

    // Image cache
    var thumbnailImages: [Int: UIImage] = [:]

    ...    
}

The photo list is loaded the first time the view appears. The WebServiceProxy class provided by the open-source Kilo framework is used to retrieve the data:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    // Load photo data
    if (photos == nil) {
        let serviceProxy = WebServiceProxy(session: URLSession.shared, serverURL: URL(string: "https://jsonplaceholder.typicode.com")!)

        serviceProxy.invoke(.get, path: "/photos") { (result: [Photo]?, error: Error?) in
            self.photos = result ?? []

            self.tableView.reloadData()
        }
    }
}

Table view cells are represented by the following class, implemented using the open-source Lima layout framework:

class PhotoCell: LMTableViewCell {
    var thumbnailImageView: UIImageView!
    var titleLabel: UILabel!

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        setContent(LMRowView(
            UIImageView(contentMode: .scaleAspectFit, width: 50, height: 50) { self.thumbnailImageView = $0 },
            LMSpacer(width: 0.5, backgroundColor: UIColor.lightGray),
            LMColumnView(spacing: 0,
                UILabel(font: UIFont.preferredFont(forTextStyle: .body), numberOfLines: 2) { self.titleLabel = $0 },
                LMSpacer()
            )
        ), ignoreMargins: false)
    }

    required init?(coder decoder: NSCoder) {
        return nil
    }
}

Cell content is generated as follows. The corresponding Photo instance is retrieved from the photos array and used to configure the cell. If the thumbnail image is already available in the cache, it is used to populate the cell's thumbnail image view. Otherwise, it is loaded from the server and added to the cache. If the cell is still visible when the image request returns, it is updated immediately:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return photos?.count ?? 0
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let photoCell = tableView.dequeueReusableCell(withIdentifier: PhotoCell.description(), for: indexPath) as! PhotoCell

    guard let photo = photos?[indexPath.row] else {
        fatalError()
    }

    // Attempt to load image from cache
    photoCell.thumbnailImageView.image = thumbnailImages[photo.id]

    if photoCell.thumbnailImageView.image == nil,
        let url = photo.thumbnailUrl,
        let scheme = url.scheme,
        let host = url.host,
        let serverURL = URL(string: String(format: "%@://%@", scheme, host)) {
        // Request image
        let serviceProxy = WebServiceProxy(session: URLSession.shared, serverURL: serverURL)

        serviceProxy.invoke(.get, path: url.path, responseHandler: { content, contentType in
            return UIImage(data: content)
        }) { (result: UIImage?, error: Error?) in
            // Add image to cache and update cell, if visible
            if let thumbnailImage = result {
                self.thumbnailImages[photo.id] = thumbnailImage

                if let cell = tableView.cellForRow(at: indexPath) as? PhotoCell {
                    cell.thumbnailImageView.image = thumbnailImage
                }
            }
        }
    }

    photoCell.titleLabel.text = photo.title

    return photoCell
}

Finally, if the system is running low on memory, the image cache is cleared:

override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()

    thumbnailImages.removeAll()
}

Summary

This article provided an overview of how images can be dynamically loaded to populate table view cells in iOS. Complete source code for this example can be found here.