UICollectionView Tutorial: Prefetching APIs

In this UICollectionView prefetching tutorial, you’ll learn how to achieve smooth scrolling in your app using Operations and Prefetch APIs.

Version

  • Swift 4.2, iOS 12, Xcode 10

As a developer, you should always strive to provide a great user experience. One way to do this in apps that display lists is to make sure scrolling is silky smooth. In iOS 10, Apple introduced UICollectionView prefetching APIs, and corresponding UITableView prefetching APIs, that allow you to fetch data before your Collection Views and Table Views need it.

When you come across an app with choppy scrolling, this is usually due to a long running process that’s blocking the main UI thread from updating. You want to keep the main thread free to respond to things like touch events. A user can forgive you if you take a tad long to fetch and display data, but they won’t be as forgiving if your app is not responding to their gestures. Moving heavy work to a background thread is a great first step in building a responsive app.

In this tutorial, you’ll start working with EmojiRater, an app that displays a collection of emojis. Unfortunately, its scrolling performance leaves a lot to be desired. You’ll use the prefetch APIs to find out which cells your app is likely to display soon and trigger related data fetches in the background.

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project. Build and run the app. You should see something like this as you try and scroll:

starter

Painful, isn’t it? Does it remind you of the chalkboard experience? You know the one where… never mind. The good news is that you can fix this.

A little bit about the app. The app displays a collection view of emojis that you can downvote or upvote. To use, click one of the cells, then press firmly until you feel some haptic feedback. The rating selection should appear. Select one and see the result in the updated collection view:

app_2

Note: If you’re having trouble getting 3D Touch to work in your simulator, you’ll first need a Mac or MacBook with a trackpad with “Force Touch” capability. You can then go to System Preferences ▸ Trackpad and enable Force Click and haptic feedback. If you don’t have access to such a device or an iPhone with 3D Touch, you’ll still be able to get the essentials of this tutorial.

Take a look at the project in Xcode. These are the main files:

  • EmojiRating.swift: Model representing an emoji.
  • DataStore.swift: Loads an emoji.
  • EmojiViewCell.swift: Collection view cell that displays an emoji.
  • RatingOverlayView.swift: View that allows the user to rate an emoji.
  • EmojiViewController.swift: Displays the emojis in a collection view.

You’ll add functionality to DataStore and EmojiViewController to enhance the scroll performance.

Understanding Choppy Scrolling

You can achieve smooth scrolling by making sure your app meets the 60 frames per second (FPS) display constraint. This means that your app needs to be able to refresh its UI 60 times a second, so each frame has about 16ms to render content. The system drops frames that takes too long to show content.

This results in a choppy scrolling experience as the app skips the frame and moves onto the next frame. A possible reason for a dropped frame is a long-running operation that’s blocking the main thread.

bored

Apple has provided some handy tools to help you out. Firstly, you can split out your long-running operations and move them to a background thread. This allows you to handle any touch events, as they happen, on the main thread. When the background operation completes, you can make any required UI updates, based on the operation, on the main thread.

The following shows the dropped frame scenario:

no-concurrency

Once you move work to the background, things look like this:

with-concurrency

You now have two concurrent threads running to improve your app’s performance.

Wouldn’t it be even better if you could start fetching data before you had to display it? That’s where the UITableView and UICollectionView prefetching APIs come in. You’ll use the collection view APIs in this tutorial.

Loading Data Asynchronously

Apple provides a number of ways to add concurrency to your app. You can use Grand Central Dispatch (GCD) as a lightweight mechanism to execute tasks concurrently. Or, you can use Operation, which is built on top of GCD.

Operation adds more overhead but makes it easy to reuse and cancel operations. You’ll use Operation in this tutorial so that you can cancel an operation that previously started loading an emoji that you no longer need.

It’s time to start investigating where you can best leverage concurrency in EmojiRater.

Open EmojiViewController.swift and find the data source method collectionView(_:cellForItemAt:). Look at the following code:

if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
  cell.updateAppearanceFor(emojiRating, animated: true)
}

This loads the emoji from the data store before displaying it. Let’s find out how that is implemented.

Open DataStore.swift and take a look at the loading method:

public func loadEmojiRating(at index: Int) -> EmojiRating? {
  if (0..<emojiRatings.count).contains(index) {
    let randomDelayTime = Int.random(in: 500..<2000)
    usleep(useconds_t(randomDelayTime * 1000))
    return emojiRatings[index]
  }
  return .none
}

This code returns a valid emoji after a random delay that can range from 500ms to 2,000ms. The delay is an artificial simulation of a network request under varying conditions.

Culprit uncovered! The emoji fetch is happening on the main thread and is violating the 16ms threshold, triggering dropped frames. You're about to fix this.

let-me-at-it

Add the following code to the end of DataStore.swift:

class DataLoadOperation: Operation {
  // 1
  var emojiRating: EmojiRating?
  var loadingCompleteHandler: ((EmojiRating) -> Void)?
  
  private let _emojiRating: EmojiRating
  
  // 2
  init(_ emojiRating: EmojiRating) {
    _emojiRating = emojiRating
  }
  
  // 3
  override func main() {
    // TBD: Work it!!
  }
}

Operation is an abstract class that you must subclass to implement the work you want to move off the main thread.

Here's what's happening in the code step-by-step:

  1. Create a reference to the emoji and completion handler that you'll use in this operation.
  2. Create a designated initializer allowing you to pass in an emoji.
  3. Override the main() method to perform the actual work for this operation.

Now, add the following code to main():

// 1
if isCancelled { return }
    
// 2
let randomDelayTime = Int.random(in: 500..<2000)
usleep(useconds_t(randomDelayTime * 1000))

// 3
if isCancelled { return }

// 4
emojiRating = _emojiRating

// 5  
if let loadingCompleteHandler = loadingCompleteHandler {
  DispatchQueue.main.async {
    loadingCompleteHandler(self._emojiRating)
  }
}

Going through the code step-by-step:

  1. Check for cancellation before starting. Operations should regularly check if they have been cancelled before attempting long or intensive work.
  2. Simulate the long-running emoji fetch. This code should look familiar.
  3. Check to see if the operation has been cancelled.
  4. Assign the emoji to indicate that the fetch has completed.
  5. Call the completion handler on the main thread, passing in the emoji. This should then trigger a UI update to display the emoji.

Replace loadEmojiRating(at:) with the following:

public func loadEmojiRating(at index: Int) -> DataLoadOperation? {
  if (0..<emojiRatings.count).contains(index) {
    return DataLoadOperation(emojiRatings[index])
  }
  return .none
}

There are two changes from the original code:

  1. You create a DataLoadOperation() to fetch the emoji in the background.
  2. This method now returns a DataLoadOperation optional instead an EmojiRating optional.

You now need to take care of the method signature change and make use of your brand new operation.

oh-yeah

Open EmojiViewController.swift and, in collectionView(_:cellForItemAt:), delete the following code:

if let emojiRating = dataStore.loadEmojiRating(at: indexPath.item) {
  cell.updateAppearanceFor(emojiRating, animated: true)
}

You will no longer kick off the data fetch from this data source method. Instead, you will do this in the delegate method that's called when your app is about to display a collection view cell. Don't get ahead of yourself yet...

Add the following properties near the top of the class:

let loadingQueue = OperationQueue()
var loadingOperations: [IndexPath: DataLoadOperation] = [:]

The first property holds the queue of operations. loadingOperations is an array that tracks a data load operation, associating each loading operation with its corresponding cell via its index path.

Add the following code to the end of the file:

// MARK: - UICollectionViewDelegate
extension EmojiViewController {
  override func collectionView(_ collectionView: UICollectionView,  
    willDisplay cell: UICollectionViewCell,
    forItemAt indexPath: IndexPath) {
    guard let cell = cell as? EmojiViewCell else { return }

    // 1
    let updateCellClosure: (EmojiRating?) -> Void = { [weak self] emojiRating in
      guard let self = self else {
        return
      }
      cell.updateAppearanceFor(emojiRating, animated: true)
      self.loadingOperations.removeValue(forKey: indexPath)
    }

    // 2
    if let dataLoader = loadingOperations[indexPath] {
      // 3
      if let emojiRating = dataLoader.emojiRating {
        cell.updateAppearanceFor(emojiRating, animated: false)
        loadingOperations.removeValue(forKey: indexPath)
      } else {
        // 4
        dataLoader.loadingCompleteHandler = updateCellClosure
      }
    } else {
      // 5
      if let dataLoader = dataStore.loadEmojiRating(at: indexPath.item) {
        // 6
        dataLoader.loadingCompleteHandler = updateCellClosure
        // 7
        loadingQueue.addOperation(dataLoader)
        // 8
        loadingOperations[indexPath] = dataLoader
      }
    }
  }
}

This creates an extension for UICollectionViewDelegate and implements the collectionView(_: willDisplay:forItemAt:) delegate method. Going through the method step-by-step:

  1. Create a closure to handle how the cell is updated once the data is loaded.
  2. Check if there's a data-loading operation for the cell.
  3. Check if the data-loading operation has completed. If so, update the cell's UI and remove the operation from the tracking array.
  4. Assign the closure to the data-loading completion handler if the emoji has not been fetched.
  5. In the event that there's no data loading operation, create a new one for the relevant emoji.
  6. Add the closure to the data-loading completion handler.
  7. Add the operation to your operation queue.
  8. Add the data loader to the operation-tracking array.

You'll want to make sure you do some cleanup when a cell is removed from the collection view.

Add the following method to the UICollectionViewDelegate extension:

override func collectionView(_ collectionView: UICollectionView,
  didEndDisplaying cell: UICollectionViewCell,
  forItemAt indexPath: IndexPath) {
  if let dataLoader = loadingOperations[indexPath] {
    dataLoader.cancel()
    loadingOperations.removeValue(forKey: indexPath)
  }
}

This code checks for an existing data-loading operation that's tied to the cell. If one exists, it cancels the download and removes the operation from the array that tracks operations.

Build and run the app. Scroll through the emojis and note the improvement in the app's performance.

If you could optimistically fetch the data in anticipation of a collection view cell being displayed, that would be even better. You'll use the prefetch APIs to do this and give EmojiRater an extra boost.

Enabling UICollectionView Prefetching

The UICollectionViewDataSourcePrefetching protocol gives you advance warning that data for a collection view could be needed soon. You can use this information to begin prefetching data so that, when the cell is visible, the data may already be available. This works in conjunction with the concurrency work you've already done — the key difference being when the work gets kicked off.

The diagram below shows how this plays out. The user is scrolling upwards on a collection view. The yellow cell should be coming into view, soon — assume this happens in Frame 3 and you're currently in Frame 1.

prefetch-steps

Adopting the prefetch protocol informs the app about the next cells that may become visible. Without the prefetch trigger, the data fetch for the yellow cell starts in Frame 3 and the cell's data becomes visible some time later. Due to the prefetch, the cell data will be ready by the time the cell is visible.

love-it

Open EmojiViewController.swift and add the following code to the end of the file:

// MARK: - UICollectionViewDataSourcePrefetching
extension EmojiViewController: UICollectionViewDataSourcePrefetching {
  func collectionView(_ collectionView: UICollectionView,
      prefetchItemsAt indexPaths: [IndexPath]) {
    print("Prefetch: \(indexPaths)")
  }
}

EmojiViewController now adopts the UICollectionViewDataSourcePrefetching and implements the required delegate method. The implementation simply prints out the index paths that could be visible soon.

In viewDidLoad(), add the following after the call to super.viewDidLoad():

collectionView?.prefetchDataSource = self

This sets EmojiViewController as the collection view's prefetching data source.

Build and run the app and, before scrolling, check Xcode's console. You should see something like this:

Prefetch: [[0, 10], [0, 11], [0, 12], [0, 13], [0, 14], [0, 15]]

These correspond to cells that are yet to become visible. Now, scroll more and check the console log as you do. You should see log messages based on index paths that are not visible, yet. Try scrolling both upwards and downwards until you get a good sense of how this all works.

You might be wondering why this delegate method simply gives you index paths to work with. The idea is that you should kick off your data-loading processes from this method, then handle the results in collectionView(_:cellForItemAt:) or collectionView(_:willDisplay:forItemAt:). Note that the delegate method is not called when cells are required immediately. You should, therefore, not rely on loading data into the cells in this method.

Prefetching Data Asynchronously

In EmojiViewController.swift, modify collectionView(_:prefetchItemsAt:) by replacing the print() statement with the following:

for indexPath in indexPaths {
  // 1
  if let _ = loadingOperations[indexPath] {
    continue
  }
  // 2
  if let dataLoader = dataStore.loadEmojiRating(at: indexPath.item) {
    // 3
    loadingQueue.addOperation(dataLoader)
    loadingOperations[indexPath] = dataLoader
  }
}

The code loops through the index paths the method receives and does the following:

  1. Checks if there's an existing loading operation for this cell. If there is, there's nothing more to do.
  2. Creates a data-loading operation if it doesn't find a loading operation.
  3. Adds the operation to the queue and updates the dictionary that tracks data loading operations.

The index paths passed into collectionView(_:prefetchItemsAt:) are ordered by priority, based on the cells geometric distance to the Collection View's view. This allows you to fetch the cells you'll most likely need first.

Recall that you had previously added code in collectionView(_:willDisplay:forItemAt:) to handle the results of the loading operation. Look at the highlights from that method below:

override func collectionView(_ collectionView: UICollectionView,
    willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
  // ...
  let updateCellClosure: (EmojiRating?) -> Void = { [weak self] emojiRating in
    guard let self = self else {
      return
    }
    cell.updateAppearanceFor(emojiRating, animated: true)
    self.loadingOperations.removeValue(forKey: indexPath)
  }
  
  if let dataLoader = loadingOperations[indexPath] {
    if let emojiRating = dataLoader.emojiRating {
      cell.updateAppearanceFor(emojiRating, animated: false)
      loadingOperations.removeValue(forKey: indexPath)
    } else {
      dataLoader.loadingCompleteHandler = updateCellClosure
    }
  } else {
    // ...
  }  
}

After creating the cell update closure, you check the array that tracks operations. If it exists for the cell that's about to appear and the emoji is available, the cell's UI is updated. Note that the closure passed to the data loading operation also updates the cell's UI.

This is how everything ties in, from prefetch triggering an operation to the cell's UI being updated.

Build and run the app and scroll through the emojis. Emojis you scroll to should be visible much faster than before.

UICollectionView prefetching in action!

Pop-quiz time! Can you spot something that could be improved? No cheating by looking at the next section title. Well, if you scroll really fast, then your collection view will start fetching emojis that may never be seen. What's a obsessive programmer to do? Read on.

Canceling a Prefetch

UICollectionViewDataSourcePrefetching has an optional delegate method that lets you know that data is no longer required. This may happen because the user has started scrolling really fast and intermediate cells are likely not to be seen. You can use the delegate method to cancel any pending data-loading operations.

Still in EmojiViewController.swift, add the following method to your UICollectionViewDataSourcePrefetching protocol implementation:

func collectionView(_ collectionView: UICollectionView,
  cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
  for indexPath in indexPaths {
    if let dataLoader = loadingOperations[indexPath] {
      dataLoader.cancel()
      loadingOperations.removeValue(forKey: indexPath)
    }
  }
}

The code loops through the index paths and finds any loading operations attached to them. It then cancels the operation and removes it from the dictionary that tracks operations.

Build and run the app. As you scroll really quickly, operations that may have started should start to cancel. Visually, things won't look much different.

One thing to note is that, due to cell reuse, some previously visible cells may need to be re-fetched. Don't panic if you see the loading indicator on those puppies — uhm, emojis.

Where to Go From Here?

Congratulations! You've successfully turned a sluggish app into a verifiable speedster.

You can download the final project using the Download Materials button at the top or bottom of this tutorial.

This tutorial discussed collection views, but there's a similar prefetch API available for table views. You can see an example of how this is used in the UITableView Infinite Scrolling Tutorial.

In an app with latency loading data, smooth scrolling isn't possible without concurrency. Be sure to check out the Operation and OperationQueue Tutorial for a deeper dive on this topic.

Add these to your grab bag of tricks to make your app more responsive. It's these little things that add up and go a long way to keeping your users delighted. Happy scrolling!

I do hope you've enjoyed this tutorial. If you have any comments or questions about the tutorial, please join the forum discussion below!

Contributors

Comments