AdapterView 和 RecyclerView 的連續滾動
概述
應用中一個常見的使用場景就是:當用戶滾動瀏覽的項目時,會自動加載更多的項目(又叫做無限滾動)。它的原理是:當滾動到達底部之前,一旦當前剩余可見的項目達到了一個設定好的閾值,就會觸發加載更多數據的請求。
本文列舉了 ListView
、GridView
和 RecyclerView
的實現方法。它們的實現都是類似的,除了 RecyclerView
還需要傳入 LayoutManager
,這是因為它需要給無限滾動提供一些必要的信息。
無論哪個控件,實現無限滾動所需要的信息無非就包括這么幾點:檢測列表中剩余的可見元素,在到達最后一個元素之前開始獲取數據的閾值。這個閾值可以用來決定什么時候開始加載更多。

要實現連續滾動的一個重點就是:一定要在用戶到達列表的末尾前就獲取數據。因此,添加一個閾值來讓列表在預期的時候就加載數據。

ListView 和 GridView 的實現方式
每個 AdapterView
(例如 ListView
或 GridView
)都支持 onScrollListener
事件的綁定,只要用戶滑動列表,就會觸發該事件。使用該體系,我們就可以定義一個基礎類:EndlessScrollListener
,它繼承自 OnScrollListener
,可以適用於大多數情況:
- import android.widget.AbsListView;
-
- public abstract class EndlessScrollListener implements AbsListView.OnScrollListener {
- // The minimum number of items to have below your current scroll position
- // before loading more.
- private int visibleThreshold = 5;
- // The current offset index of data you have loaded
- private int currentPage = 0;
- // The total number of items in the dataset after the last load
- private int previousTotalItemCount = 0;
- // True if we are still waiting for the last set of data to load.
- private boolean loading = true;
- // Sets the starting page index
- private int startingPageIndex = 0;
-
- public EndlessScrollListener() {
- }
-
- public EndlessScrollListener(int visibleThreshold) {
- this.visibleThreshold = visibleThreshold;
- }
-
- public EndlessScrollListener(int visibleThreshold, int startPage) {
- this.visibleThreshold = visibleThreshold;
- this.startingPageIndex = startPage;
- this.currentPage = startPage;
- }
-
- // This happens many times a second during a scroll, so be wary of the code you place here.
- // We are given a few useful parameters to help us work out if we need to load some more data,
- // but first we check if we are waiting for the previous load to finish.
- @Override
- public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount)
- {
- // If the total item count is zero and the previous isn't, assume the
- // list is invalidated and should be reset back to initial state
- if (totalItemCount < previousTotalItemCount) {
- this.currentPage = this.startingPageIndex;
- this.previousTotalItemCount = totalItemCount;
- if (totalItemCount == 0) { this.loading = true; }
- }
- // If it's still loading, we check to see if the dataset count has
- // changed, if so we conclude it has finished loading and update the current page
- // number and total item count.
- if (loading && (totalItemCount > previousTotalItemCount)) {
- loading = false;
- previousTotalItemCount = totalItemCount;
- currentPage++;
- }
-
- // If it isn't currently loading, we check to see if we have breached
- // the visibleThreshold and need to reload more data.
- // If we do need to reload some more data, we execute onLoadMore to fetch the data.
- if (!loading && (firstVisibleItem + visibleItemCount + visibleThreshold) >= totalItemCount ) {
- loading = onLoadMore(currentPage + 1, totalItemCount);
- }
- }
-
- // Defines the process for actually loading more data based on page
- // Returns true if more data is being loaded; returns false if there is no more data to load.
- public abstract boolean onLoadMore(int page, int totalItemsCount);
-
- @Override
- public void onScrollStateChanged(AbsListView view, int scrollState) {
- // Don't take any action on changed
- }
- }
注意:這是一個抽象類,要使用它,必須實現該類中的抽象方法:onLoadMore
,用於檢索新的數據。在 activity
中,可以用一個匿名內部類來實現這個抽象類,並把它綁定到適配器上。例如:
- public class MainActivity extends Activity {
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- // ... the usual
- ListView lvItems = (ListView) findViewById(R.id.lvItems);
- // Attach the listener to the AdapterView onCreate
- lvItems.setOnScrollListener(new EndlessScrollListener() {
- @Override
- public boolean onLoadMore(int page, int totalItemsCount) {
- // Triggered only when new data needs to be appended to the list
- // Add whatever code is needed to append new items to your AdapterView
- loadNextDataFromApi(page);
- // or loadNextDataFromApi(totalItemsCount);
- return true; // ONLY if more data is actually being loaded; false otherwise.
- }
- });
- }
-
-
- // Append the next page of data into the adapter
- // This method probably sends out a network request and appends new data items to your adapter.
- public void loadNextDataFromApi(int offset) {
- // Send an API request to retrieve appropriate paginated data
- // --> Send the request including an offset value (i.e `page`) as a query parameter.
- // --> Deserialize and construct new model objects from the API response
- // --> Append the new data objects to the existing set of items inside the array of items
- // --> Notify the adapter of the new items made with `notifyDataSetChanged()`
- }
- }
現在,當你滾動列表時,每當剩余元素到達閾值時,列表就會自動加載下一頁的數據。該方法對於 GridView
來說,一樣的有效。
RecyclerView 的實現方式
對於 RecyclerView
來說,我們也可以使用一個相似的方法:定義接口 EndlessRecyclerViewScrollListener
;一個必須實現的方法 onLoadMore()
。在 RecyclerView
中,LayoutManager
用於渲染列表元素並管理滾動,即提供與適配器相關的當前滾動位置的信息。基於上述理由,我們需要傳入一個 LayoutManager
的實例,用於收集必須的信息,和用於確定加載更多數據的時機。
因此,RecyclerView
實現連續分頁需要以下幾個步驟:
把 EndlessRecyclerViewScrollListener.java 類拷貝到你的項目中
在
RecyclerView
上調用addOnScrollListener()
方法來啟用連續分頁。給該方法傳入EndlessRecyclerViewScrollListener
的實例,當新頁需要加載時,實現onLoadMore()
方法在
onLoadMore()
方法中,加載更多數據,並把它們填充到列表中
代碼示例如下:
- public class MainActivity extends Activity {
- // Store a member variable for the listener
- private EndlessRecyclerViewScrollListener scrollListener;
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- // Configure the RecyclerView
- RecyclerView rvItems = (RecyclerView) findViewById(R.id.rvContacts);
- LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
- rvItems.setLayoutManager(linearLayoutManager);
- // Retain an instance so that you can call `resetState()` for fresh searches
- scrollListener = new EndlessRecyclerViewScrollListener(linearLayoutManager) {
- @Override
- public void onLoadMore(int page, int totalItemsCount, RecyclerView view) {
- // Triggered only when new data needs to be appended to the list
- // Add whatever code is needed to append new items to the bottom of the list
- loadNextDataFromApi(page);
- }
- };
- // Adds the scroll listener to RecyclerView
- rvItems.addOnScrollListener(scrollListener);
- }
-
- // Append the next page of data into the adapter
- // This method probably sends out a network request and appends new data items to your adapter.
- public void loadNextDataFromApi(int offset) {
- // Send an API request to retrieve appropriate paginated data
- // --> Send the request including an offset value (i.e `page`) as a query parameter.
- // --> Deserialize and construct new model objects from the API response
- // --> Append the new data objects to the existing set of items inside the array of items
- // --> Notify the adapter of the new items made with `notifyItemRangeInserted()`
- }
- }
復位連續滾動狀態
當你准備執行新的搜索時,要確保清除列表上已經存在的數據,並馬上通知適配器數據的變化。當然,還需要使用 resetState()
方法來重置 EndlessRecyclerViewScrollListener
的狀態:
- // 1. First, clear the array of data
- listOfItems.clear();
- // 2. Notify the adapter of the update
- recyclerAdapterOfItems.notifyDataSetChanged(); // or notifyItemRangeRemoved
- // 3. Reset endless scroll listener when performing a new search
- scrollListener.resetState();
完整的連續滾動代碼可以參考:code sample for usage,this code sample。
故障排查
如果在開發中遇到問題,請考慮下述的建議:
對於
ListView
來說,請一定在Activity
的onCreate()
方法 或Fragment
的onCreateView()
方法中,給它設置setOnScrollListener()
監聽。否則,你可能會遇到一些想不到的問題要使分頁系統可以可靠地、持續地工作,在給列表添加新的數據之前,你應該確保清除適配器的數據。對
RecyclerView
來說,當需要通知適配器數據有更新時,強烈建議使用精度更細的通知方法。要觸發分頁,始終記得
loadNextDataFromApi
方法調用時,需要把新數據添加到已經存在的數據源。按句話說,只有首次加載時才清除數據,以后的每次分頁都是把新增的數據添加到原有的數據集中。如果你遇到了下述的錯誤:
Cannot call this method in a scroll callback. Scroll callbacks might be run during a measure & layout pass where you cannot change the RecyclerView data
,那你應該按照 Stack Overflow 中的解決辦法對代碼進行改造:
- // Delay before notifying the adapter since the scroll listeners
- // can be called while RecyclerView data cannot be changed.
- view.post(new Runnable() {
- @Override
- public void run() {
- // Notify adapter with appropriate notify methods
- adapter.notifyItemRangeInserted(curSize, allContacts.size() - 1);
- }
- });
在自定義的適配器中顯示進度
想要在 ListView
的底部顯示加載數據的進度,需要對適配器進行特殊處理。使用 getItemViewType(int position)
定義兩種不同的視圖類型,既正常行與最后一行的樣子不同。