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.

对于iOS Table Views,Android的List Views 以及移动网页,分页是在处理大数据集的时候的一个通用解决方案,可以避免类似电量消耗过大,网络太慢的问题。

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 data structure for abstracting pagination which I'm releasing as a CocoaPod. More on that further down.

同时也会有一个iOS端UITableView and UICollectionView的示例,包括抽象分页的数据结构(发布在CocoaPod

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 called PagedArray and can be found on GitHub.

2015-03-08更新:我创建了一个新的,Swift版本的“AWPagedArray”,这个数据在iOS结构使用

扩展scroll view有什么问题?

So what's wrong with expanding a scroll view?

"It's like catching red lights while driving"

它就像驾驶的时候遇到红灯。
https://static1.squarespace.c...
clipboard.png

https://static1.squarespace.c...
clipboard.png

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...
clipboard.png
https://static1.squarespace.c...
clipboard.png
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实现以及示例代码

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 .

更新:我当前创建了新的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。

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

Figure 3. Fluent paging architecture diagram

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.

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...
Figure 4. Console output for querying an AWPagedArray instance

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实现

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 AWPagedArrayin 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:

如果你想在实际的生产中使用这个技术,请考虑一下事项:

  • 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.

作为设计者和开发者,我们应该尽可能简易化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.

对于电池和网络限制的设备来说,创建服务总是具有挑战的。但是使用这个技术,用户不会有这个意识,这就是技术变得神奇的时候。


hardBegin
77 声望2 粉丝