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. By Christine Abernathy.

Leave a rating/review
Download materials
Save for later
Share

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.