Say you have a view controller with a table view, and you want to load 100 rows of data into it, these data are retrieved from a web API, how would you approach this?

This post assume that you already knew what is URLSession, JSON parsing, delegate and tableview.

tableView Demo

Table of contents :

  1. Load all data at once
  2. Load data by batch when user reach the bottom
  3. Introducing UITableViewDataSource Prefetching
  4. How prefetching works
  5. Example - Just in time data loading using Prefetch
  6. Summary

You can download the sample Xcode project (prefetching) here : Prefetch demo Xcode project

1 - Load all 100 data at once

The simplest approach would be loading all 100 data using URLSession at once in viewDidLoad, then update the table view data source and reload table view after the response is retrieved.

// Load all data (100 rows) at once
URLSession(configuration: URLSessionConfiguration.default).dataTask(with: URL(string: "https://jsonplaceholder.typicode.com/posts")!) { data, response, error in
  // ensure there is data returned from this HTTP response
  guard let data = data else {
    print("No data")
    return
  }
  
  // Parse JSON into Post array struct using JSONDecoder
  guard let posts = try? JSONDecoder().decode([Post].self, from: data) else {
    print("Error: Couldn't decode data into post model")
    return
  }
  
  // postArray of data source
  self.postArray = posts
  
  // Make sure to update UI in main thread
  DispatchQueue.main.async {
    self.postTableView.reloadData()
  }
}.resume()

This approach is straightforward but getting 100 rows at once and loading them to cells might take longer time / a spike in memory usage. And in some case it is impossible to get all the data in one go (eg: Facebook timeline, too much info to fit into iPhone memory if the facebook app fetched all of your statuses since creation of your account)

2 - Load data by batch when user reaches bottom

A more advanced approach would be loading data by batches, like loading the initial 20 rows, then load another 20 when user has reached the bottom, this is similar to how Facebook / Twitter load their timelines.


The batch loading code might be similar to this :

class BatchViewController: UIViewController, UITableViewDataSource{

  @IBOutlet weak var coinTableView: UITableView!
  
  var coinArray : [Coin] = []
  
  let baseURL = "https://api.coinmarketcap.com/v2/ticker/?"
  
  // fetch 15 items for each batch
  let itemsPerBatch = 15
  
  // current row from database
  var currentRow : Int = 1
  
  // URL computed by current row
  var url : URL {
    return URL(string: "\(baseURL)start=\(currentRow)&limit=\(itemsPerBatch)")!
  }
  
  // ... skipped viewDidLoad stuff
    
  // MARK : - Tableview data source
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    // +1 to show the loading cell at the last row
    return self.coinArray.count + 1
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // if reached last row, load next batch
    if indexPath.row == self.coinArray.count {
      let cell = tableView.dequeueReusableCell(withIdentifier: loadingCellIdentifier, for: indexPath) as! LoadingTableViewCell
      loadNextBatch()
      return cell
    }

    // else show the cell as usual
    let cell = tableView.dequeueReusableCell(withIdentifier: coinCellIdentifier , for: indexPath) as! CoinTableViewCell
    
    // get the corresponding post object to show from the array
    let coin = coinArray[indexPath.row]
    cell.configureCell(with: coin)
    
    return cell
  }
  
  // MARK : - Batch
  func loadNextBatch() {
    URLSession(configuration: URLSessionConfiguration.default).dataTask(with: url) { data, response, error in
      
      // Parse JSON into array of Car struct using JSONDecoder
      guard let coinList = try? JSONDecoder().decode(CoinList.self, from: data!) else {
        print("Error: Couldn't decode data into coin list")
        return
      }
      
      // contain array of tuples, ie. [(key : ID, value : Coin)]
      let coinTupleArray = coinList.data.sorted {$0.value.rank < $1.value.rank}
      for coinTuple in coinTupleArray {
        self.coinArray.append(coinTuple.value)
      }
      
      // increment current row
      self.currentRow += self.itemsPerBatch
      
      // Make sure to update UI in main thread
      DispatchQueue.main.async {
        self.coinTableView.reloadData()
      }
      
    }.resume()
  }

}

This approach works reasonably well and it lessen the burden on memory as it only load 15 items per batch and only load it when user reached the bottom. One minor inconvenience is that user might need to wait for the next batch to finish load when they reached the bottom. What if we can make it so that the upcoming rows are loaded just before they are displayed on screen? Wouldn't it be better that the user doesn't have to see the loading screen at all? 🤔

Introducing UITableViewDataSource Prefetching

UITableViewDataSourcePrefetching protocol for UITableView and UICollectionView is available since iOS 10. The delegate function tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) will be called when a row is near the display area, you can call the web API and load them inside this delegate function.

Prefetch rows

There's two function in the UITableViewDataSourcePrefetching protocol :

protocol UITableViewDataSourcePrefetching {
  // This is called when the rows are near to the visible area
  public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath])
  
  // This is called when the rows move further away from visible area, eg: user scroll in an opposite direction
  optional public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath])
}

Similar to tableView.dataSource from UITableViewDataSource, there's another property for table view to store the Prefetch Data Source, prefetchDataSource.

// similar to tableview.dataSource = self
tableView.prefetchDataSource = self

How prefetching (kinda) works

Here are just my observations as Apple didn't mention exactly how many rows outside of visible area will be prefetched.

  1. tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) doesn’t get called for rows which are visible on screen initially without scrolling.
  2. Right after initial visible rows became visible, tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) will be called, and the indexPaths variable contain around 10 rows nearest to the visible area.
  3. Depending on the scrolling speed, the indexPaths in prefetch method will contain different amount of rows. On normal scrolling speed, usually it will contain 1 row. If you scroll at a fast speed, it will contain multiple rows.

Below is a demo video of calling the prefetchRowsAt and cancelPrefetchingForRowsAt method from UITableViewDataSourcePrefetching.

func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
		
  print("prefetching row of \(indexPaths)")
}
	
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
		
  print("cancel prefetch row of \(indexPaths)")
}


Example - Just in time data loading using Prefetch

For this example, we will load the top 100 stories from Hacker News API using Prefetch. Assume we already got the news ID from the Top Stories API . We will display the news using the newsID as ordered below:

let newsID = [17549050, 17549099, 17548768, 17546915, 17534858, ... ]

In the view controller, we will use an array to store News object retrieved from the API, this will be the datasource for the tableview. We also have another array to keep track of the URLSessionDataTask used to call the API.

class PrefetchViewController: UIViewController {	
  // this is the data source for table view
  // fill the array with 100 nil first, then replace the nil with the loaded news object at its corresponding index/position
  var newsArray : [News?] = [News?](repeating: nil, count: 100)
	
  // store task (calling API) of getting each news
  var dataTasks : [URLSessionDataTask] = []
}

In viewDidLoad() , we set up the table view's data source and prefetch data source to the view controller itself.

override func viewDidLoad() {
  super.viewDidLoad()
  newsTableView.dataSource = self
  newsTableView.prefetchDataSource = self
}

Here is the function to fetch the news data from the API. We will fetch the data by creating a URLSessionDataTask, and after this data task is started, we will append it to the dataTasks array we defined earlier.

// the 'index' parameter indicates the row index of tableview
// we will fetch and show the correspond news data for that row
func fetchNews(ofIndex index: Int) {
  let newsID = newsIDs[index]
  let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(newsID).json")!
  
  // if there is already an existing data task for that specific news url, it means we already loaded it previously / currently loading it
  // stop re-downloading it by returning this function
  if dataTasks.index(where: { task in
    task.originalRequest?.url == url
  }) != nil {
    return
  }
  
  let dataTask = URLSession.shared.dataTask(with: url) { data, response, error in
    guard let data = data else {
      print("No data")
      return
    }
    
    // Parse JSON into array of Car struct using JSONDecoder
    guard let news = try? JSONDecoder().decode(News.self, from: data) else {
      print("Error: Couldn't decode data into news")
      return
    }
    
    // replace the initial 'nil' value with the loaded news
    // to indicate that the news have been loaded for the table view
    self.newsArray[index] = news
    
    // Update UI on main thread
    DispatchQueue.main.async {
      let indexPath = IndexPath(row: index, section: 0)
      // check if the row of news which we are calling API to retrieve is in the visible rows area in screen
      // the 'indexPathsForVisibleRows?' is because indexPathsForVisibleRows might return nil when there is no rows in visible area/screen
      // if the indexPathsForVisibleRows is nil, '?? false' will make it become false
      if self.newsTableView.indexPathsForVisibleRows?.contains(indexPath) ?? false {
        // if the row is visible (means it is currently empty on screen, refresh it with the loaded data with fade animation
        self.newsTableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .fade)
      }
    }
  }
  
  // run the task of fetching news, and append it to the dataTasks array
  dataTask.resume()
  dataTasks.append(dataTask)
}

As a refresh from ELI 5: Optional, "self.newsTableView .indexPathsForVisibleRows? .contains(indexPath)" contains optional chaining, as .indexPathsForVisibleRows? might return nil when there are no visible rows (no row at all) in the table view, if it return nil, the whole statement became nil. The ?? false at the back is nil coalescing, it means that if "indexPathsForVisibleRows? .contains(indexPath)" returns nil, the value after the double quotation mark will be used, which is false.

After the particular news data is loaded, if the row that is supposed to show the news data is in the visible rows area (it is currently blank now), we will update / reload that row with the loaded news data.

To optimize network calls, we are going to add a cancelFetchNews function which will cancel the dataTask used to fetch a specific news when user scroll away from the row that is supposed to show that news. This function will be used in the cancelPrefetchingForRowsAt delegate method.

// the 'index' parameter indicates the row index of tableview
func cancelFetchNews(ofIndex index: Int) {
  let newsID = newsIDs[index]
  let url = URL(string: "https://hacker-news.firebaseio.com/v0/item/\(newsID).json")!
  
  // get the index of the dataTask which load this specific news
  // if there is no existing data task for the specific news, no need to cancel it
  guard let dataTaskIndex = dataTasks.index(where: { task in
    task.originalRequest?.url == url
  }) else {
    return
  }
  
  let dataTask =  dataTasks[dataTaskIndex]
  
  // cancel and remove the dataTask from the dataTasks array
  // so that a new datatask will be created and used to load news next time
  // since we already cancelled it before it has finished loading
  dataTask.cancel()
  dataTasks.remove(at: dataTaskIndex)
}

Now we can plug the fetchNews function into the cellForRowAtIndexPath method, if the cell on screen didnt have its corresponding news data loaded, we will fetch the news data from API.

extension PrefetchViewController : UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return newsArray.count
  }
  
  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: newsCellIdentifier, for: indexPath) as! NewsTableViewCell
    
    // get the corresponding news object to show from the array
    if let news = newsArray[indexPath.row] {
      cell.configureCell(with: news)
    } else {
      // if the news havent loaded (nil havent got replaced), reset all the label
      cell.truncateCell()
      
      // fetch the news from API
      self.fetchNews(ofIndex: indexPath.row)
    }
    
    return cell
  }
}

configureCell is a custom method I wrote to assign the News data to different labels on the cell. truncateCell just set the labels text to blank.

Here is the secret sauce of this post, we will call the fetchNews function to retrieve the news data for the rows that are near to visible rows area , but still not visible yet. So that user can have the illusion that there is no loading delay đź‘€ (data is already loaded before the row is visible on screen, shhhh).

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

    // fetch News from API for those rows that are being prefetched (near to visible area)
    for indexPath in indexPaths {
      self.fetchNews(ofIndex: indexPath.row)
    }
  }
  
  func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { 
   
    // cancel the task of fetching news from API when user scroll away from them
    for indexPath in indexPaths {
      self.cancelFetchNews(ofIndex: indexPath.row)
    }
  }
}

We will also call cancelFetchNews in the delegate method cancelPrefetchingForRowsAt when user scroll away from the loading row. This way, we can optimize the network call by cancelling it since we dont need it at the moment.

The end result will look like below. Aside from the initial loading, user won't notice there's loading going on (hopefully đź‘€), making it seems smooth :


Summary

The prefetch method provided by Apple allows us to lazy load data, as in load it just before the row is presented, this allow for a better user experience as user will not see the loading process aside from the initial one (hopefully they dont scroll like crazy, of course).

Granted, sometimes it might be not feasible to use this prefetch due to HTTP API limitations set by backend developer / company. Feel free to use whichever method that you think will best deliver the user experience on tableview!

Apple's documentation on UITableViewDataSourcePrefetching