流暢的分頁

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 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的示例,包括抽象分頁的數據結構(發佈在CocoaPodweb

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

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.

對於電池和網絡限制的設備來講,建立服務老是具備挑戰的。可是使用這個技術,用戶不會有這個意識,這就是技術變得神奇的時候。

相關文章
相關標籤/搜索