Infinite UITableView Scroll – Special Case

How Can We Help?

Back
Print
Reading Time: 6 minutes

When we are working with loading large data, if we load all the available items and try to display everything at once it will cause a big delay and the app will not work smoothly. The solution in cases like this is a combination of back-end and in-app solution. We should adjust the BE to work with pagination responses. The BE should give us only chunks of data and we should control the size of these chunks from the app sending the batch size. I’ve made research on this topic on the net and there are solutions but all of this is going in one direction. Using pagination, but every time starting from page 1 and loading the next pages after that. One of the best things was discovering the iOS Prefetching Protocol that I’ve never used before. This protocol is a piece of this solution.

Prerequisites

From the BE we need at least two APIs:

The first one is an API that will return all the necessary data: starting page, optionally which element from the starting page to be focussed and the total number of elements in the database. Why is the total number of elements needed? This is because if we don’t know it we won’t know how many rows we should present. Things will become more clear when we will start coding. The suggested JSON response should look like this:

{
	"total_elements": 480,
	"data": [
		{
			"id": 1,
			"name": "Test1"
		},
		{
			"id": 3,
			"name": "Test1"
		}
		//elements from 60..89

	],
	"first_page": 3,
	"per_page": 30
}

The second API is the API that will return the data and we will send a page number as an argument. The example JSON response is provided below:

{
	"data": [
		{
			"id": 1,
			"name": "Test1"
		},
		{
			"id": 3,
			"name": "Test1"
		}
		//elements from 1..29

	],
	"page_number": 1,
	"per_page": 30
}

Solution explained

I read a lot of articles about infinite scrolling UITableView’s but none of this is solving my special case – an option to start in the middle, and optionally to focus on a particular row from the table inside that page. Here is how I solved this issue:

First, I’m defining few variables in the code, some static integer values – the batch size (number of elements per page), start page value that will be variable and we will fetch it from the BE, and the total number of elements – variable that will be fetched also from the BE:

In my example, I will work with a list of integer values instead of using some objects, but this could be easily adjusted with any kind of objects/models.

Also, I will use a list of used pages, and I will keep track of the already fetched pages from the BE. Here is also one useful boolean flag “isNewDataLoading”. This flag will prevent us from calling the BE if the previous BE call is not finished.

    let batchSize: Int = 30
    let totalElements: Int = 480
    var startPage: Int = 5
    var elements: [Int?] = []
    var isNewDataLoading: Bool = false
    var loadedPages = [Int]()

The first call is the initial load method. Here, I will call the BE API that will return all the necessary data to pre-populate the code variables:

    func initialLoad() {
                
        for _ in 0..<totalElements {
            elements.append(.none)
        }
        
        loadedPages.append(startPage)
        
        for value in startPage*batchSize..<(startPage+1)*batchSize {
            elements[value] = value
        }
        
        mainTableView.reloadData()
        let toIndex = startPage*batchSize + ((startPage+1)*batchSize - startPage*batchSize)/2
        mainTableView.scrollToRow(at: IndexPath(row: toIndex, section: 0), at: .middle, animated: true)
    }

After the initial loading is done we have to explain the UITableView data source methods.

The “cellForRow” method has a simple logic. If we don’t have fetched the value for one of the cells, the cell will show a spinner (UIActivityIndicator); in case the value for the cell is already loaded we are showing into a text label (UILabel):

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! TestTableViewCell
        if isLoadingCell(for: indexPath) {
            cell.configure(with: .none)
        } else {
            cell.configure(with: elements[indexPath.row])
        }
        return cell
    }

The Magic

Historically all of the older solutions use the UIScrollView delegate methods and inspect the current y-offset of the table. If the user reaches the maximum y-offset the API is called with the next page.

I made research on the topic and I recognized that the Prefetch Protocol should be useful in this situation. Some of the solutions on the net used the prefetch protocol in their solutions, but it needs some modifications if we want to make our code work with different starting pages. Let’s see how it looks into the code:

    func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
        if indexPaths.contains(where: isLoadingCell) {
            
            let index = indexPaths[0]
            let page = index.row/batchSize
            if !loadedPages.contains(page) {
                //fetch new
                DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2.0) {
                    self.getNewData(page: page)
                }
            }
        }
    }

Since iOS 10 Apple introduced the API for prefetching data in UITableView and UICollectionView. More details about the prefetching protocol can be found on the Apple Developer site: https://developer.apple.com/documentation/uikit/uitableviewdatasourceprefetching

Little explanation of the code above: If the IndexPath of the cell that should be prefetched is an index that is not yet downloaded using the row value of the IndexPath we will determine the page to which the index path belongs to. If this page is not downloaded, we will download it. The code for downloading a new page will be shown below:

    func getNewData(page: Int) {
                        
        if isNewDataLoading {
            return
        }
        
        isNewDataLoading = true
        
        var temp: [Int] = [Int]()
        for num in page*batchSize..<(page+1)*batchSize {
            elements[num] = num
            temp.append(num)
        }
        
        loadedPages.append(page)
        
        let indexes: [IndexPath] = temp.map {
            return IndexPath(row: $0, section: 0)
        }
        
        mainTableView.reloadRows(at: indexes, with: .none)
        isNewDataLoading = false
    }

What is important in this method? The most important is to add the page to the list of already loaded pages, and the second thing is to reload only the rows in the table that belongs to the actual page.

The full code can be downloaded by clicking on the “Download” button below:

Feel free to add your comments or suggestion.

Table of Contents
0 replies

Leave a Reply

Want to join the discussion?
Feel free to contribute!

Leave a Reply

Your email address will not be published. Required fields are marked *