Fluent Pagination - no more jumpy scrollinghtml
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.
对于iOS Table Views,Android的List Views 以及移动网页,分页是在处理大数据集的时候的一个通用解决方案,能够避免相似电量消耗过大,网络太慢的问题。ios
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.
经典的实现是当新的一组数据加载的时候,扩展滚动区域,能够经过”加载更多”按钮或者当用户下滚的时候自动进行。虽然这个技术很是常见,可是有几个使用上的缺点。git
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.
这篇文章,我提出了一个更加流畅的方案来处理有限数据集的分页方案,使用占位符而不是改变用户的滚动区域。github
There will also be an iOS sample implementation forUITableView
andUICollectionView
, including a data structure for abstracting pagination which I'm releasing as a CocoaPod. More on that further down.
同时也会有一个iOS端UITableView
and UICollectionView
的示例,包括抽象分页的数据结构(发布在CocoaPod)web
UPDATE 2015-03-08: I have now created a new, Swift version ofAWPagedArray
, the data structure used in the iOS example implementation. The Swift version is simply calledPagedArray
and can be found on GitHub.
2015-03-08更新:我建立了一个新的,Swift版本的“AWPagedArray”,这个数据在iOS结构使用数据库
So what's wrong with expanding a scroll view?数组
"It's like catching red lights while driving"
它就像驾驶的时候遇到红灯。
https://static1.squarespace.c...promise
https://static1.squarespace.c...服务器
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.
我总结了扩展scroll view三个的问题。
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.
这个技术对于排序以及分块的结果也适应得很差。scroll view扩展是在一个特定的方向,你不得不加载所有的数据来获取另外一边的排序列表。对于相似字母表排列的分块内容,你也须要更多的UI来实现开始跳转到特定区域。也就是说用户并不能在数据集中尽情跳转。
"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.
最终,滚动条就会失去它暗示到底用户在数据集哪里的功能。所以,用户须要另外的接口来告诉这个数据集的大小。既然它没法记住滚动位置,浏览回有意思的内容也会变得困难。我尤为记得在浏览一个app,想着“分类不可能很大,我能全看完”,在按了“加载更多”按钮十次之后并无作到之后,不得不放弃这条路径。
Fluent pagination
https://static1.squarespace.c...
https://static1.squarespace.c...
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.
我建议用于处理分页的方法旨在尽量减小阻碍,最小化UI并向用户提供始终存在的数据错觉。
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.
要注意到这个方法,它只限用于有限数据集。但若是你愿意为Twitter式服务建立客户端,也能够将实际显示在一个视图中的结果限制为几百左右,并仍然使用此技术进行分页。人们还能够将流畅的分页与传统的view scroll扩展相结合,以实现与无限数据集一块儿使用的折衷方案。
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.
固然,为了知足绑定data的scroll view,须要服务器提供良好的API。
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.
客户端实现fluent pagination须要的额外信息是数据集的大小。而后就是实际的分页技巧,如何去改变页面大小以及偏移量。如今有不少关于使用REST-ful服务提供元数据的讨论。要么在header里面放置连接,要么麻烦点从客户端访问header的value,包含实际的数据并将元数据放入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
以流畅的方式处理分组的结果须要API提供额外的元数据。在这个例子中,你可能想要API调用来得到元数据,构建不一样的URL去访问不一样的分块。
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 }, ... ]
iOS implementation & sample Code
UPDATE I have now created a new, Swift version ofAWPagedArray
, the data structure used below. The Swift version is simply calledPagedArray
and can be found on GitHub .
更新:我当前建立了新的Swift版本的AWPagedArray,这个数据结构会在下面使用。Swift版本会简单地称为PagedArray,在github上面能找到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.
对于客户端实现,我想要提供代码和用户体验同样透明的解决方案。model层持有数据,提供模拟使用静态数据集同样的API, 而分页的工做的实现细节应该隐藏在model之中,新的数据加载之后,view controller调用callback。
The most crucial piece in this puzzle was creating a data structure that could support paging with a clean, familiar API. My inspiration for the solution was CoreData and more specifically,
NSFetchRequest
.
问题最关键的部分是建立一个数据结构,这个结构支持以干净的,熟悉的API来支持分页。我解决方案的灵感来自CoreData,或者确切地说是NSFetchRequest。
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:
许多人确定使用fetchBatchSize而不去考虑更多的实现细节。当你有一个容纳几千个cell的table view的时候,它让你不用抢先从存储中加载全部的数据,而是能够分批量获取数据。
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.)
若是你设置了一个非0的批量数据大小,当获取数据被分红几批的时候,每次获取数据返回的是数据集。获取执行的时候,整个请求会被评估,全部符合要求的对象被记录,对batchSize更多的数据不会被记录。
从执行请求返回的数组将是一个代理对象,可根据须要透明地对批处理进行故障处理。 (在数据库术语中,这是一个内存中的游标。)
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.
高亮的行对咱们的目标来讲很是有意思,当设置 fetchBatchSize,一个代理对象返回。这个代理就像一个普通的有着全部数据集的,接收者能够与它交互。 可是当一个对象没有接受数据的,一个同步的fetch就会被处罚,虽然flash disk和mobile net的环境不一样,可是咱们可使用一样的原则。
Fluent paging architecture
AWPagedArray
is anNSProxy
subclass which uses anNSMutableDictionary
as its backbone to provide transparent paging through a standardNSArray
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 returnsNSNull
values.
AWPagedArray是一个 代理子类,使用AWPagedArray
是一个NSProxy
子类,它使用NSMutableDictionary
做为经过标准NSArray
API提供透明分页的主干。这意味着数据提供者能够在内部填充页面,而数据接收者则不知道分页的实际工做方式。对于还没有加载的对象,代理只返回NSNull
值。
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.
虽然AWPagedArray不是继承NSArray,可是老是回答yes,当它问是否是na array的实例时候,它回复yes虽然他并不继承自NSArray。
https://static1.squarespace.c...
Setting up an AWPagedArray is very simple
设置AWPagedArray很是简单。
_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).
在实例化paged array之后,data provider将paged array转回NSArray给数据使用者的同时使用setObjects:forPage:方法来设置page,
// 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.
经过AWPagedArrayDelegate协议,data provider当从paged array中访问数据的时候得到回调。经过这个方式,data provider 当NSNull数据能够访问的时候就能加载数据以及预加载下一页。
- (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.
提供给delegate一个指针指向返回的对象,它也能动态改变返回给consumer的东西,好比,用别的东西代替NSNull占位符。
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.
使用实体模型层,view controller的实现变得微不足道。注意 dataObjects属性可以被当作一个普通的NSArray同样被访问,虽然它其实是一个NSProxy的子类。
- (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.
当配置cell的时候,检查NSNull实例,应用占位符风格,在这个例子里面,数据对象在UILabel里面输出的时候是一个NSNumber。
- (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.
当dataprovider加载新的页面的时候,经过代理协议唤起view controller的回调,若是屏幕有占位cell,他们会被新的数据从新加载和从新配置。
- (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.
若是你有CocoaPods以及优秀的CocoaPods try plugin,那么就是简单地在终端输入pod try AWPagedArray
。
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.
类AWPagedArray做为CocoaPod发行,剩下的示例代码能够在GitHub的pod demo项目中找到。
Further improvements for production environments
Some considerations if you want to use this technique in production:
若是你想在实际的生产中使用这个技术,请考虑一下事项:
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.
做为设计者和开发者,咱们应该尽量简易化UI和隐藏实现细节。我相信这个分页方法可以实现这些目标而且它已经在大型app中使用获得了一个良好的结果。虽然这篇文章的示例实现是iOS平台的,可是这个技术自己能够在Android,Web以及其余平台使用。
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.
对于电池和网络限制的设备来讲,建立服务老是具备挑战的。可是使用这个技术,用户不会有这个意识,这就是技术变得神奇的时候。