Swift UITableView Prefetching: A Guide to Smoother Scrolling

Bevan christian
IDN Engineering

--

When developing a UICollectionView or UITableView, the conventional approach involves downloading images within the cellForRow method. However, this method can adversely affect user experience, especially as cells are scrolled. The simultaneous execution of displaying cells and downloading images can lead to a decrease in frame rate, as stipulated by Apple, resulting in slower image loading, particularly when downloads commence only upon cell visibility.

To tackle this challenge, iOS offers a built-in solution called prefetching. By setting the prefetchDataSource of your table or collection view to a delegate, usually implemented by the view controller, and conforming to its protocol, you can significantly enhance the performance of image loading.

Here’s an illustration of implementing prefetching for a UITableView:

Set PrefetchDataSource

class YourTableViewController: UITableViewController, UITableViewDataSource, UITableViewDelegate, UITableViewDataSourcePrefetching {

var operationCell: [IndexPath: DataLoadOperation] = [:]
let operationQueue: OperationQueue = {
let operation = OperationQueue()
return operation
}()

override func viewDidLoad() {
super.viewDidLoad()

// Set the prefetching data source
tableView.prefetchDataSource = self
}

// Implement UITableViewDataSourcePrefetching method
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
// Perform image downloading for the indexPaths
// This is where you initiate the image downloads in advance
}

// Other UITableViewDataSource methods...

// Other UITableViewDelegate methods...
}

With this implementation, the prefetchRowsAt method is called when the table view anticipates that cells will soon be displayed. In this method, you can initiate the download of images for the specified IndexPaths in advance, improving the overall performance and user experience by reducing lag and accelerating image loading.

Implement PrefetchDataSource Protocol

Once the prefetchDataSource has been implemented, it's crucial to conform to its protocol. Below is an example of how to adapt the UITableView delegate and data source, along with the UITableViewDataSourcePrefetching protocol, in Swift.

extension YourTableViewController: UITableViewDelegate, UITableViewDataSource, UITableViewDataSourcePrefetching {

// Implement the prefetching method
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
// Check if the operation for the cell at indexPath is nil
if operationCell[indexPath] == nil {
// Retrieve view data from the presenter
guard let viewData = presenter?.viewData as? [ImageCellData] else { return }

// Create a data loader operation for downloading the image
let dataLoader = DataLoadOperation(viewData[indexPath.row].coverSourceUrl)

// Add the data loader operation to the operation queue
operationQueue.addOperation(dataLoader)

// Store the data loader operation in the dictionary with indexPath as the key
operationCell[indexPath] = dataLoader
}
}
}

// Optional method to cancel prefetching for specific rows
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
// Check if there is a data loader operation for the indexPath
if let dataLoader = operationCell[indexPath] {
// Cancel the data loader operation
dataLoader.cancel()

// Remove the data loader operation from the dictionary
operationCell.removeValue(forKey: indexPath)
}
}
}

// Other UITableViewDelegate and UITableViewDataSource methods...
}

The prefetchRowsAt method is responsible for initiating prefetch operations, such as downloading images, videos, or other assets required for cells about to be displayed. On the other hand, the cancelPrefetchingForRowsAt method is optional and allows for the cancellation of prefetching operations. This is useful, for instance, when a user abruptly scrolls in the opposite direction, preventing unnecessary downloads for cells that won't be displayed.

Configure Existing CellForRowAt

The next steps involve adjusting the cellForRowAt, willDisplayCell, and didEndDisplaying methods. In cellForRowAt, the focus shifts from downloading assets to setting the necessary data for display, such as starting animations and hide shimmering. The code snippet below demonstrates this adjustment:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// Switch statement to handle different cell types
switch cellType {
case .content:
// Dequeue a reusable cell and cast it as ListArticleSmall
guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageCell.identifier) as? ImageCell else {
return UITableViewCell()
}

// Check if view data is available
if let viewData = presenter?.viewData as? [ImageCellData] {
// Configure the cell with the retrieved data
cell.selectionStyle = .default
cell.configureShimmer(isActive: false)
} else {
// Configure the cell with shimmer animation if view data is not available
cell.configureShimmer(isActive: true)
cell.selectionStyle = .none
}

// Return the configured cell
return cell
}
}

In this adjusted cellForRowAt method, the conditional logic ensures that the cell is configured appropriately based on the availability of view data. If data is present, the cell is configured with the necessary information, and animations are started. If data is not available, a shimmer animation is applied to indicate that content is loading. This separation enhances the efficiency of the table view by decoupling the display logic from the download logic, resulting in a smoother user experience.

Configure Existing WillDisplayMethod

In the willDisplay function, three conditions need to be checked:

  1. Asset has been downloaded and is ready:
  • This condition verifies whether the asset (presumably an image) has already been downloaded and is in a state of readiness.
  • If this condition is met, the code configures the cell with the downloaded image, removes the prefetch operation associated with the current index path (`operationCell.removeValue(forKey: indexPath)`), sets the cell’s selection style, deactivates any shimmer animation, and directly assigns the downloaded image to the cell.

2. Asset has been downloaded but is not yet ready:

  • This condition checks if the asset for the current cell has been downloaded but is not in a ready state.
  • If this condition is satisfied, the code checks whether there is an ongoing prefetch process for the current index (`if let dataLoader = operationCell[indexPath]`). If true, it further examines whether the image has already been downloaded (`if let image = dataLoader.image`).
  • If the image is already downloaded, the code configures the cell with the image and performs similar actions as in the first condition. If the image is not yet downloaded, it establishes a closure (`updateCellClosure`) to be executed when the image is ready.

3. Asset has not been downloaded

  • This condition confirms whether the asset for the current cell has not been downloaded.
  • If this condition holds, the code checks whether there is no ongoing prefetch process for the current index (if operationCell[indexPath] == nil). If true, it initiates a prefetch process by creating a DataLoadOperation instance for downloading the asset, sets up the update closure, adds the operation to the operation queue, and associates it with the current index in the operationCell dictionary.

In summary, these conditions address various scenarios related to downloading and displaying assets in a table view, covering cases where the asset is already downloaded and ready, downloaded but not yet ready, and not downloaded at all.

func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// Checking if the cell is of type ListArticleSmall and presenter's viewData is available
if let cell = cell as? ImageCell, let viewData = presenter?.viewData as? [ImageCellData] {

// Closure for updating the cell's image when the download process is complete
let updateCellClosure: (UIImage?) -> () = { [weak self] image in
guard let self = self else { return }

// Configuring the cell when the download is complete
operationCell.removeValue(forKey: indexPath)
cell.setImageDirectly(image: image)
}

// Checking if there is an ongoing prefetch process for this index
if let dataLoader = operationCell[indexPath] {

if let image = dataLoader.image {
// If there is an image, assign it directly to the cell

operationCell.removeValue(forKey: indexPath)
cell.setImageDirectly(image: image)
} else {
// If there is no image yet, assign the updateClosure
// so that when the image is ready, it can be assigned to the cell
dataLoader.loadingCompleteHandler = updateCellClosure
}
} else {
// If prefetch process has not occurred, initiate the prefetch
// and assign it to the completion handler for direct assignment when the image is ready
if operationCell[indexPath] == nil {
guard let viewData = presenter?.viewData as? [ListArticleSmallViewCellData] else { return }
let dataLoader = DataLoadOperation(viewData[indexPath.row].coverSourceUrl)
dataLoader.loadingCompleteHandler = updateCellClosure
operationQueue.addOperation(dataLoader)
operationCell[indexPath] = dataLoader
}
}
cell.configureCell(model: viewData[indexPath.row])
}
}

When didEndDisplayingis triggered, it is essential to halt prefetch operations since they are no longer needed, reducing resource consumption. The code snippet below demonstrates this in Swift:

func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// If there's a data loader for this index path and it's no longer needed,
// cancel the operation and dispose of it.
if let dataLoader = operationCell[indexPath] {
dataLoader.cancel()
operationCell.removeValue(forKey: indexPath)
}
}

Explanation:

- This function is invoked when a cell is no longer visible in the table view, indicating that prefetch operations for that cell are no longer required.
- It checks whether there is a data loader (prefetch operation) associated with the current index path (operationCell[indexPath]).
- If a data loader exists, it is canceled using dataLoader.cancel() to terminate the ongoing prefetch operation.
- Subsequently, the data loader is removed from the operationCell dictionary to efficiently manage resources.

In the given context, the prefetch process is designed to operate in the background, preventing disruption to the main thread and avoiding lag. This is achieved by utilizing the Operation class, built on top of Grand Central Dispatch (GCD). The Operationclass enables concurrent task execution and straightforward cancellation, making it easier to control and manage asynchronous operations. Consequently, a class is created that inherits from the Operation abstraction to encapsulate the prefetching functionality.

import SDWebImage

class DataLoadOperation: Operation {
var image: UIImage?
var loadingCompleteHandler: ((UIImage?) -> ())?
private var urlString: String?

init(_ urlString: String?) {
self.urlString = urlString
}

override func main() {
if isCancelled { return }
let replacedUrl = urlString?.replacingOccurrences(of: " ", with: "%20")
guard let urlString = replacedUrl,
let url = URL(string: urlString)
else { return }
SDWebImageManager.shared.loadImage(
with: url,
options: .continueInBackground,
progress: nil) { [weak self] (image, data, error, cacheType, finished, url) in
guard let self = self else { return }
if error != nil {
let placeholder = UIImage(image: .placeholder)
self.image = placeholder
self.loadingCompleteHandler?(placeholder)
} else {
self.image = image
self.loadingCompleteHandler?(self.image)
}
}
}
}

The purpose of the above code is straightforward: when this operation is executed, it downloads the given URL. Upon completion, the downloaded image is assigned to the image variable, and the loadingCompleted closure is invoked if necessary. Additionally, if there is a cancelation process, the operation is promptly canceled.

Since the code uses DataLoadOperation, we need to initiate the operation. Therefore, the prefetching code looks like this:

let dataLoader = DataLoadOperation(viewData[indexPath.row].coverSourceUrl)
operationQueue.addOperation(dataLoader)
operationCell[indexPath] = dataLoader

Explanation:

- A DataLoadOperation instance named `dataLoader` is created, responsible for downloading the coverSourceUrl associated with the current cell.
- The dataLoader operation is added to the operationQueue in order to execute it asynchronously.
- The dataLoaderoperation is then stored in the operationCell dictionary, associated with the current index path. This serves as a way to hold a reference to the operation, allowing access in places such as the willDisplay function and elsewhere in the code.

Summary

This article explores the challenges of synchronous image loading in UITableView and introduces iOS prefetching as a solution for smoother scrolling. It provides a concise implementation guide, covering the setup of the prefetchDataSource, adoption of UITableViewDataSourcePrefetching, and handling prefetch operations in the tableView(_:prefetchRowsAt:) method.

The detailed implementation includes adapting UITableViewDelegate and UITableViewDataSource, configuring cellForRowAt and willDisplayCell methods, and addressing various scenarios for asset readiness. To streamline prefetch operations, the article introduces the DataLoadOperation class, utilizing the Operation class for asynchronous image downloading. Instances of this class are managed in an operation queue and stored in the operationCell dictionary for efficient reference.

In summary, the article guides developers in implementing UITableView prefetching to optimize image loading, improve user experience, and achieve smoother scrolling, utilizing iOS’s built-in capabilities for prefetching.

--

--

iOS Developer @IDN Media Stay up to date with the latest iOS development insights from Bevan Christian. Follow them for expert tips about things iOS dev.