Fluent Pagination - no more jumpy scrolling
Pagination of iOS Table Views, Android List Views and on the mobile web is a common way of circumventing the technical limitations of power hungry mobile devices and slow mobile networks when dealing with large datasets.
The classic implementation of this is to expand the scrolling area when new chunks of data are fetched, either by using a "load more"-button at the bottom, or automatically as the user scrolls down. Although this technique is very common, it has several usability drawbacks.
In this post, I'm proposing a more fluent approach for handling pagination within a finite dataset, using placeholders and without altering the scrolling area for the user.
There will also be an iOS sample implementation for UITableView
and UICollectionView
, including a datastructure for abstracting pagination which I'm releasing as a CocoaPod. More on that further down.
UPDATE 2015-03-08: I have now created a new, Swift version of
AWPagedArray
, the data structure used in the iOS example implementation. The Swift version is simply calledPagedArray
and can be found on GitHub.
So what's wrong with expanding a scroll view?
"It's like catching red lights while driving"
Figure 1. Classic paging example with load more-button (left) and automatic preloading (right)
There are in my opinion three big problems with expanding a scroll view as you load more results.
First, it makes for a choppy scrolling experience when the user hits the bottom of the scrollable area multiple times. It's like catching red lights while driving. This can of course be mitigated by preloading the next results page as the user approaches it, but that doesn't help users who quickly wants to reach the bottom in a sorted list. This leads us in to the next flaw.
The technique is also ill-suited for working with sorted and sectioned results. Since the scroll view expands in a certain direction, you have to load all results to get to the other side of a sorted list. For sectioned results such as in alphabetical sorting, you need more UI than the scroll view itself to quickly jump to a particular section, since the user can't scroll that far into the dataset.
"This category can't be that large, I'll browse it all"
Finally, the scroll indicator loses its function of indicating where the user is in the current dataset. Thus, the user needs another interface element to inform of the set's size. It also makes it difficult to navigate back to interesting items since you can't memorize the scroll position. I remember in particular browsing an e-commerce app thinking "This category can't be that large, I'll browse it all.". After pressing the "load more"-button ten times and still not being done, I had to give up and find ways to refine my search.
Fluent pagination
Figure 2. Fluent paging example
The method I propose for handling pagination aims to be as least obstructive as possible, minimizing UI and giving the user the illusion of data always being there.
Instead of making it very obvious to the user that data is in fact paginated by restricting scrolling, pages of data load fluently without scrolling being hindered. Placeholder views are laid out as soon as the total size is known, and views representing data animates in gently as results are populated. This enables the same interactions as if the entire dataset was loaded at once. Users can quickly scroll to the bottom or to any section while the scroll indicator always shows the current location within the entire dataset. Also, when quickly scrolling past pages, loading operations can be cancelled, improving performance and saving bandwidth.
Note that this method only works well with finite datasets. But even if you would, say create a client for a Twitter-esque service, you could limit the results you actually display in one view to a couple of hundreds or so and still use this technique for paging. One could also combine fluent pagination with traditional scroll view expanding for a compromise that works well with ininite datasets.
Web service considerations
Of course, for all of this to work there needs to be a good API on the service catering the scroll view with data.
The one bit of extra information the client need in order to implement fluent pagination is the total size of the dataset. Then there's the actual paging mechanism: how to set page sizes and offsets. Now there's a lot of discussion about how these sorts of metadata should be delivered using a REST-ful service. Either go with putting links in the header (see RFC 5988), or if you have trouble accessing header values from the client, envelop the actual data and put metadata in the body.
http://example.com/objects?pageSize=25&offset=0
{
"paging" : {
"next" : "http://example.com/objects?pageSize=25&offset=25",
"totalCount" : 1337
},
"objects" : [
...
]
}
Sectioned results
Dealing with grouped results in a fluent manner requires additional metadata from the API. In this case, you would probably want an API-call for just getting the metadata, and then construct URL's to access different sections
http://example.com/objects?groupBy=alphabetical&metadataOnly
[
{
"title" : "A",
"url" : "http://example.com/objects?beginsWith=A",
"count" : 72
},
{
"title" : "B",
"url" : "http://example.com/objects?beginsWith=B",
"count" : 24
},
...
]
Of course pagination within sections could be implemented using techniques mentioned above.
iOS implementation & sample Code
UPDATE I have now created a new, Swift version of AWPagedArray
, the data structure used below. The Swift version is simply called PagedArray
and can be found on GitHub .
For the client implementation, I wanted to go with a solution which in code is as transparent as the user experience. The model layer holding the data should provide an API that as closely as possible mimics working with a static dataset. Details about how the paging works should be deep inside the model, with the view controller just getting callbacks when new data is fetched.
The most crucial piece in this puzzle was creating a datastructure that could support paging with a clean, familiar API. My inspiration for the solution was CoreData and more specifically, NSFetchRequest
.
The magical fetchBatchSize property
Many have surely used the fetchBatchSize
property without thinking of it's implementation details. It basically lets you batch CoreData fetches so that you can have a table view with thousands of cells, without loading all data objects from the store preemptively. Let's check the documentation:
If you set a non-zero batch size, the collection of objects returned when the fetch is executed is broken into batches. When the fetch is executed, the entire request is evaluated and the identities of all matching objects recorded, but no more than batchSize objects’ data will be fetched from the persistent store at a time. The array returned from executing the request will be a proxy object that transparently faults batches on demand. (In database terms, this is an in-memory cursor.)
Now the highlighted line is very interesting for our purposes. When setting the fetchBatchSize
, an proxy object is returned. This proxy acts just as a regular NSArray
with the size of the entire dataset, meaning the receiver can interact with it, oblivious of it's true nature. But as soon as an object outside of the already fetched set tries to be accessed, a synchronous fetch to the datastore is triggered. That way, batching is completely transparent. Although a database fetch on a flash disk is much quicker than doing mobile network calls and can be done synchronously, we can use the same principles for an asynchronous solution.
Fluent paging architecture
Figure 3. Fluent paging architecture diagram
AWPagedArray & DataProvider
AWPagedArray
is an NSProxy
subclass which uses an NSMutableDictionary
as its backbone to provide transparent paging through a standard NSArray
API. This means a data provider can internally populate pages, while the receiver of data is agnostic of how the paging actually works. For objects not yet loaded, the proxy just returns NSNull
values.
What's interesting about NSProxy
subclasses is that they can almost completely mask themselves as the proxied class. For example, when asking an AWPagedArray
instance if it's kind of an NSArray
, it replies with YES
even though it doesn't inherit from NSArray
at all.
Figure 4. Console output for querying an AWPagedArray instance
Setting up an AWPagedArray
is very simple
_pagedArray = [[AWPagedArray alloc] initWithCount:DataProviderDataCount objectsPerPage:DataProviderDefaultPageSize];
_pagedArray.delegate = self;
[_pagedArray setObjects:objects forPage:1];
After instanciating the paged array, the data provider sets pages with the setObjects:forPage:
method while casting the paged array back as an NSArray
to the data consumer (in this case a UITableViewController
).
// DataProvider.h
@property (nonatomic, readonly) NSArray *dataObjects;
// DataProvider.m
- (NSArray *)dataObjects {
return (NSArray *)_pagedArray;
}
Through the AWPagedArrayDelegate
protocol, the data provider gets callbacks when data is access from the paged array. This way, the data provider can start loading pages as soon as an NSNull
value is being accessed or preload the next page if the user starts to get close to an empty index.
- (void)pagedArray:(AWPagedArray *)pagedArray
willAccessIndex:(NSUInteger)index
returnObject:(__autoreleasing id *)returnObject {
if ([*returnObject isKindOfClass:[NSNull class]] && self.shouldLoadAutomatically) {
[self setShouldLoadDataForPage:[pagedArray pageForIndex:index]];
} else {
[self preloadNextPageIfNeededForIndex:index];
}
}
Since the delegate is provided with a reference pointer to the return object, it can also dynamically change what gets returned to the consumer. For instance, replace the NSNull placeholder object with something else.
UITableViewController & UICollectionViewController implementations
With a solid model layer, the view controller implementation becomes trivial. Notice how the dataObjects
property can be accessd just as a regular NSArray
with subscripting, even though in reality it is an NSProxy
subclass.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *CellIdentifier = @"data cell";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath];
id dataObject = self.dataProvider.dataObjects[indexPath.row];
[self _configureCell:cell forDataObject:dataObject];
return cell;
}
When configuring the cell, check for NSNull
instances and apply your placeholder style. For this example, the data objects are just NSNumber
instances which get printed out on a UILabel
.
- (void)_configureCell:(UITableViewCell *)cell forDataObject:(id)dataObject {
if ([dataObject isKindOfClass:[NSNull class]]) {
cell.textLabel.text = nil;
} else {
cell.textLabel.text = [dataObject description];
}
}
As the dataprovider loads new pages, it calls back to the view controller through a delegate protocol. This way, if there are placeholder cells on screen, they can be reloaded or reconfigured with the new data.
- (void)dataProvider:(DataProvider *)dataProvider didLoadDataAtIndexes:(NSIndexSet *)indexes {
NSMutableArray *indexPathsToReload = [NSMutableArray array];
[indexes enumerateIndexesUsingBlock:^(NSUInteger idx, BOOL *stop) {
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:idx inSection:0];
if ([self.tableView.indexPathsForVisibleRows containsObject:indexPath]) {
[indexPathsToReload addObject:indexPath];
}
}];
if (indexPathsToReload.count > 0) {
[self.tableView reloadRowsAtIndexPaths:indexPathsToReload withRowAnimation:UITableViewRowAnimationFade];
}
}
How to get
If you have CocoaPods and the excellent CocoaPods try plugin, it's as easy as typing pod try AWPagedArray
in the terminal.
The AWPagedArray
class is released as a CocoaPod with the rest of the sample code above to be found as the demo project for the pod on GitHub.
Further improvements for production environments
Some considerations if you want to use this technique in production:
- Cancel ongoing loading operations for pages that the user already passed when scrolling fast
- Always prioritize loading pages the user is currently looking at
Conclusion
As designers and developers, we should always strive for minimizing UI and hiding implementation details wherever possible. I believe that this approach to paging fulfills those goals and it has been shipped in big apps with great results. Even though the sample implementation in this blog post is for the iOS platform, the technique itself works on Android, the Web and other platforms as well.
It's always a challenge creating services for devices with constraints on power and connectivity. But using techniques like this, the user doesn't need to be aware of it. That's when technology becomes magic.