Episode #315

UITableView Prefetching

22 minutes
Published on December 15, 2017

This video is only available to subscribers. Get access to this video and 572 others.

Extending our example from episode 309, here we implement automatic tableview paging support by utilizing the UITableViewDatasourcePrefetching protocol. With this protocol, our delegate is notified of upcoming rows the user is about to encounter, and gives us an opportunity to preemptively load data for those rows.

Episode Links

Setting up the prefetch datasource

The first thing we need to do is adopt the protocol and set ourselves as the prefetchDataSource on our controller.

extension BeerListViewController : UITableViewDataSourcePrefetching {
    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {

    }

    func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {

    }
}

Once we’ve added this, we can adopt the protocol in viewDidLoad:

    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Beers"

        tableView.prefetchDataSource = self

        //...
    }

Change the DataSource return all the rows

Our current datasource implementation only returns rows for beers we have already loaded in our beers array. We instead want to use a real value so that the user can scroll to data we may not have fetched yet.

To do this, we’ll add a new property to our view controller:

    var totalBeerCount: Int = 0

We’ll update this value when we get a response from the API:

    private func loadBeers(refresh: Bool = false) {
        breweryDBClient.fetchBeers(page: currentPage, styleId: 3) { page in
            DispatchQueue.main.async {
                self.totalBeerCount = page.totalResults

                //...
    }

And we can use this value in our numberOfRowsInSection: method:

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return totalBeerCount
    }

Fixing the loading row

Currently our loading row only shows if the shouldShowLoadingRow boolean is true. We don’t need this anymore, so we can remove it and all references to it. When the user scrolls beyond our loaded array they will see a bunch of loading rows.

We also need to avoid trying to fetch the next page on loading row appearance, since this will now happen repeatedly:

    // REMOVE THIS METHOD
    override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard isLoadingIndexPath(indexPath) else { return }
        fetchNextPage()
    }

Fetching Pages

Now we can leverage the prefetching calls to fetch the next page when we get a request for an element that is not in the bounds of our array.

    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        let needsFetch = indexPaths.contains { $0.row >= self.beers.count }
        if needsFetch {
            fetchNextPage()
        }
    }

This will happen repeatedly, so to avoid making multiple requests for the same page, we'll add a new property on our controller.

    private var isFetchingNextPage = false

Then we can avoid fetching when this value is set:

    private func fetchNextPage() {
        guard !isFetchingNextPage else { return }
        currentPage += 1
        loadBeers()
    }

All we need to do now is update this value before a request is made and return it back to false after the response comes back.

    private func loadBeers(refresh: Bool = false) {
        print("Fetching page \(currentPage)")
        isFetchingNextPage = true
        breweryDBClient.fetchBeers(page: currentPage, styleId: 3) { page in
            // ...
            self.isFetchingNextPage = false
        }
    }

Better Handling of New Rows

Instead of blindly calling reloadData, we'll instead animate rows that have been updated and are currently visible. Using a Set is perfect for this. We'll also move the refreshControl.endRefreshing() call into the case where we forced a refresh, since this has the unfortunate side-effect of halting scrolling.

        breweryDBClient.fetchBeers(page: currentPage, styleId: 3) { page in
            DispatchQueue.main.async {
                self.totalBeerCount = page.totalResults
                if refresh {
                    self.beers = page.data
                } else {
                    for beer in page.data {
                        if !self.beers.contains(beer) {
                            self.beers.append(beer)
                        }
                    }   
                }
                self.isFetchingNextPage = false

                if refresh {
                    self.refreshControl?.endRefreshing()
                    self.tableView.reloadData()
                } else {
                    let startIndex = self.beers.count - page.data.count
                    let endIndex = startIndex + page.data.count - 1
                    let newIndexPaths = (startIndex...endIndex).map { i in
                        return IndexPath(row: i, section: 0)
                    }
                    let visibleIndexPaths = Set(self.tableView.indexPathsForVisibleRows ?? [])
                    let indexPathsNeedingReload = Set(newIndexPaths).intersection(visibleIndexPaths)
                    self.tableView.reloadRows(at: Array(indexPathsNeedingReload), with: .fade)
                }         
            }
        }

Now scrolling is super smooth and if the user scrolls slowly they’ll never see a loading row!

This episode uses Alamofire 4.5.1, Xcode 9.1, Swift 4.