下拉重新整理與上拉加載
在使用清單元件展示資料的時候,更新資料的互動曾經是一個沒有定論的問題,有留一個重新整理按鈕的,有按時自動重新整理的,還有根本不重新整理的。但是随着移動平台的普及,移動應用的使用者群越來越大,資料重新整理的互動就慢慢固定下來了,而在各種互動方式中脫穎而出的一種就是人們熟悉的“下拉重新整理”。
下拉重新整理是個很簡單也很友好的互動方式,清單滾動到頂端後可以強制下拉一段,拉出來的多餘部分會顯示一些提示,随後使用者松手讓清單回彈,重新整理即刻開始。這個互動方式來自iPhone上的一款應用Tweetie,随後被大量用到iOS平台上的各種應用中,其友善快捷,所見所得的互動方式迅速在使用者中确立了幾乎不可動搖的地位,時至今日依然長盛不衰。
如此好用的互動手段,安卓應用當然應該試着引進,事實也是如此,随着下拉重新整理模式的普及,安卓開發者們也找到了屬于自己的解決方案,大量的第三方開源庫或是拓展原有元件或是自己重新定義,最終都實作了下拉重新整理的功能。
如果是商業開發,那麼直接使用現成的第三方庫是最好的選擇,現在的下拉重新整理元件已經很成熟了,比如XListView系列,直接拓展原生ListView,代碼簡單易讀,BUG也很少,大部分情況下可以滿足需求。
但如果一方面想清楚地了解下拉重新整理功能是如何實作的,另一方面也希望當有特殊需求出現的時候能應付得來,那麼自己實作一遍下拉重新整理是非常不錯的學習方式。
從簡單的想法開始
下拉重新整理一般是針對清單的,那麼用最簡單直接的思路,自定義一個清單元件是否可行呢?答案是肯定的,有名的XListView系列就是用的這種思路,自定義實作一個ListView來獲得下拉重新整理的功能。
順着這個思路往下,我們首先要考慮實作功能的手段。所謂下拉重新整理,其實就是要求元件能捕捉到使用者的動作,如果清單已經到頂了,使用者依然下拉,則判斷這是重新整理的信号,等到使用者松手清單回彈,即可開始重新整理流程。
那麼為了實作這些需要些什麼?ListView是肯定需要的,還需要一個Header,顯示在使用者強行下拉的空白處,整體邏輯在View的onTouchEvent方法中編寫,這樣也不必實作更多其它的接口,保留下來友善别的需求。
一步步來,首先是Header,雖然我們隻需要一個很簡單的Header,但它不能被直接寫到XML檔案中,使用過ListView後都應該知道其Header隻能通過代碼設定。針對這個問題XListView采用的解決方案是自定義View,自定義一個HeaderView然後在ListView的初始化過程中就設定好Header。
是以先聲明一個Header類
public class HeaderView extends LinearLayout {
private Context context;
private LinearLayout container;
private TextView tvIndicator;
private int headerState = RefreshListView.STATE_IDLE;
public HeaderView(Context context) {
super(context);
this.context = context;
initView();
}
public HeaderView(Context context,
@Nullable AttributeSet attrs) {
super(context, attrs);
this.context = context;
initView();
}
public HeaderView(Context context,
@Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.context = context;
initView();
}
private void initView() {
container = new LinearLayout(context);
LinearLayout.LayoutParams param = new LinearLayout.LayoutParams(ViewGroup.LayoutParams
.MATCH_PARENT, );
addView(container, param);
setGravity(Gravity.BOTTOM);
tvIndicator = new TextView(context);
param = new LinearLayout.LayoutParams(ViewGroup
.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
tvIndicator.setTextSize(TypedValue.COMPLEX_UNIT_DIP, );
tvIndicator.setText("下拉重新整理");
tvIndicator.setGravity(Gravity.CENTER);
container.addView(tvIndicator, param);
}
void setState(int state) {
if(headerState != state) {
if(state == RefreshListView.STATE_REFRESH) {
tvIndicator.setTextColor();
} else {
tvIndicator.setTextColor();
}
switch (state) {
case RefreshListView.STATE_IDLE:
tvIndicator.setText("下拉重新整理");
break;
case RefreshListView.STATE_READY:
if(headerState != RefreshListView.STATE_READY) {
tvIndicator.setText("松開重新整理資料");
}
break;
case RefreshListView.STATE_REFRESH:
tvIndicator.setText("正在加載...");
break;
default:
break;
}
headerState = state;
}
}
void setVisiableHeight(int height) {
if(height < ) {
height = ;
}
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) container
.getLayoutParams();
params.height = height;
container.setLayoutParams(params);
}
int getVisiableHeight() {
return container.getHeight();
}
}
然後需要一個回調接口,友善靈活地編寫重新整理邏輯
public interface IRefreshListViewListener {
void onRefresh();
}
RefreshListView的完整代碼如下
public class RefreshListView extends ListView {
public final static int STATE_IDLE = ; // 正常狀态
public final static int STATE_READY = ; // 準備重新整理狀态
public final static int STATE_REFRESH = ; // 正在重新整理狀态
private final int SCROLL_DURATION = ; // 重新整理頭回彈的時間長度,時間長則回彈慢
private final float OFFSET_RATIO = f; // 下拉阻尼系數,使得下拉時呈現出彈簧效果
private Scroller extraScroller; // 重新整理頭回彈所用的輔助Scroller
private IRefreshListViewListener refreshListViewListener; // 重新整理回調
private HeaderView headerView; // 重新整理頭布局
private int headerHeight; // 頭布局高度
private boolean isPullRefreshEnable = false; // 下拉重新整理開關
private boolean isRefreshing = false; // 正在重新整理标志
private float lastY = -; // 滑動事件坐标記錄
public RefreshListView(Context context) {
super(context);
initListView(context);
}
public RefreshListView(Context context, AttributeSet attrs) {
super(context, attrs);
initListView(context);
}
public RefreshListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initListView(context);
}
private void initListView(Context context) {
extraScroller = new Scroller(context, new DecelerateInterpolator());
headerView = new HeaderView(context);
addHeaderView(headerView);
// 添加布局渲染回調,用于擷取頭布局的高度
headerView.container.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
headerHeight = headerView.container.getHeight();
Log.i("Header Height", "Value:"+headerHeight);
getViewTreeObserver().removeOnGlobalLayoutListener(this);
}
});
}
// 處理滑動事件檢測是否需要重新整理
@Override
public boolean onTouchEvent(MotionEvent ev) {
if(lastY == -) {
lastY = ev.getRawY();
}
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
lastY = ev.getRawY();
break;
case MotionEvent.ACTION_MOVE:
final float deltaY = ev.getRawY() - lastY;
lastY = ev.getRawY();
if(getFirstVisiblePosition() ==
&& (headerView.getVisiableHeight() > || deltaY > )) {
updateHeaderHeight(deltaY / OFFSET_RATIO);
}
break;
case MotionEvent.ACTION_UP:
lastY = -;
if(getFirstVisiblePosition() == ) {
if(isPullRefreshEnable
&& headerView.getVisiableHeight() > headerHeight) {
isRefreshing = true;
headerView.setState(STATE_REFRESH);
if(refreshListViewListener != null) {
refreshListViewListener.onRefresh();
}
}
resetHeaderHeight();
}
break;
}
return super.onTouchEvent(ev);
}
@Override
public void computeScroll() {
if(extraScroller.computeScrollOffset()) {
headerView.setVisiableHeight(extraScroller.getCurrY());
postInvalidate();
}
super.computeScroll();
}
// 更新重新整理頭布局的高度
private void updateHeaderHeight(float delta) {
headerView.setVisiableHeight((int)delta + headerView.getVisiableHeight());
if(isPullRefreshEnable && !isRefreshing) {
if(headerView.getVisiableHeight() > headerHeight) {
headerView.setState(STATE_READY);
} else {
headerView.setState(STATE_IDLE);
}
}
setSelection();
}
// 重設重新整理頭高度,啟動回彈
private void resetHeaderHeight() {
int height = headerView.getVisiableHeight();
if(height != ) {
if(!isRefreshing || height > headerHeight) {
int finalHeight = ;
if(isRefreshing) {
finalHeight = headerHeight;
}
extraScroller.startScroll(, height, , finalHeight - height,
SCROLL_DURATION);
invalidate();
}
}
}
public void setRefreshListViewListener(
IRefreshListViewListener refreshListViewListener) {
this.refreshListViewListener = refreshListViewListener;
}
public void setPullRefreshEnable(boolean enable) {
isPullRefreshEnable = enable;
}
// 停止重新整理,重設狀态,用于在重新整理回調中完成工作後調用
public void stopRefresh() {
if (isRefreshing) {
isRefreshing = false;
resetHeaderHeight();
}
}
}
使用RefreshListView隻要預先設定回調接口即可按照需求進行下拉重新整理了
protected void onCreate(Bundle savedInstanceState) {
rlvContent = (RefreshListView) findViewById(R.id.rlvContent);
rlvAdapter = new MyRefreshListAdapter();
rlvContent.setAdapter(rlvAdapter);
rlvContent.setPullRefreshEnable(true);
rlvContent.setRefreshListViewListener(new RefreshListView.IRefreshListViewListener() {
@Override
public void onRefresh() {
refreshData();
}
});
}
public void refreshData() {
// 擷取資料并重新整理
rlvContent.stopRefresh(); // 通知RefreshListView停止重新整理,重設狀态
}
這種方法簡單好用,而且自定義程度高,能很友善地适配各種不同的需求。但它也有明顯的缺點,需要自定義多種View來友善使用,比如ExpandableListView就要自定義一個,GridView也要自定義一個,還不能使用ScrollView來進行自定義,因為ScrollView沒有Header可以設定。是以,需要考慮一個新的方案。
通用外接式下拉重新整理架構
這個名字僅僅是對接下來介紹的做法的一種描述,在前面的“自定義View”方案無法滿足要求的時候,可以嘗試一個新的思路,那就是能不能把下拉重新整理做成一個容器,隻要将符合标準的清單或者滑動元件放進去便可以使用?
答案是肯定的,比如為人熟知的PullToRefreshLayout架構就是采用的這種思路,做了一個通用的下拉重新整理容器,隻需要在容器中按需放入ListView或者ExpandableListView之類的元件即可使用。
谷歌官方在v4相容包中也提供了類似的SwipeRefreshLayout友善開發下拉重新整理的頁面,需要Support Library 19.1以上,其使用方法也是在SwipeRefreshLayout中包裹一個目标Layout來進行下拉重新整理。
這種方式的思路跟之前的簡單想法差不太多,隻不過把目标改成了一個Layout,而不是一個ListView或者ExpandableListView之類的元件,順着這個類似的思路便可以嘗試寫出一個簡單可用的PullToRefreshLayout架構了。
首先需要選擇一個基礎布局元件來進行改造,一般而言選擇RelativeLayout是個非常不錯的選擇,不但可以實作功能,還能提供自定義重新整理頭這類增加實用性的功能;但現在隻是簡單嘗試,那麼可以使用固定重新整理頭的方案來減少重寫的代碼量。
標明了基礎布局後就可以先确定使用方式了,因為外接式下拉重新整理不比前文提到的重寫ListView那種方法隻能通過添加Header來加入重新整理頭,它的重新整理頭可以在XML布局檔案中寫好,可以在初始化元件時添加,也可以在使用前自定義。
既然是簡單嘗試,可以考慮最簡單的方案,直接寫在XML檔案中。
<com.game.personal.exampleproj.PullToRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/refreshHeader"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<TextView
android:id="@+id/tvHeader"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
/>
</LinearLayout>
<com.game.personal.exampleproj.PullableListView
android:id="@+id/plvContent"
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.game.personal.exampleproj.PullableListView>
</com.game.personal.exampleproj.PullToRefreshLayout>
XML的結構大約如圖所示,refreshHeader可以像例子中這樣直接寫好也可以使用include标簽來引用另外的XML
決定了這個使用方式,接下來看代碼該如何組織。自定義元件繼承RelativeLayout自不必多說,考慮下拉重新整理功能都需要至少知道目前是否處于“已經下拉”的狀态,在前文重寫ListView時通過getFirstVisibleView能友善地判斷出來,但現在重寫RelativeLayout卻不再有這樣的方法可以使用,是以需要找到一個方法用來判斷目前情況。
既然前文中ListView能友善地判斷出所需的狀态,那麼一個很自然的想法就是用接口,将ListView裡判斷得到的狀态引出來交由RelativeLayout處理。
是以定義一個接口
public interface IPullToRefresh {
boolean canPullDown(); // 判斷目前是否可以下拉的方法
}
簡單明了,就是用來傳回目前是否處于可以下拉狀态,如果傳回true則可以将重新整理頭拉出,進行重新整理,如果是false則不可以拉出重新整理頭,說明List還在可以滑動的狀态.
有了這個接口,後面的設計也就順理成章了,首先簡單重寫一下ListView類。
public class PullableListView extends ListView implements IPullToRefresh {
private boolean canPullDown = true;
public PullableListView(Context context) {
super(context);
}
public PullableListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public PullableListView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setCanPullDown(boolean canPullDown) {
this.canPullDown = canPullDown;
}
@Override
public boolean canPullDown() {
View firstView = getChildAt();
return canPullDown && firstView != null && firstView.getTop() == ;
}
}
非常簡單地添加了一個開關标志來判定目前是否處于可以下拉出重新整理頭的狀态中。
然後來看PullToRefreshLayout類的定義。
public class PullToRefreshLayout extends RelativeLayout {
// 初始狀态
public static final int INIT = ;
// 釋放重新整理
public static final int RELEASE_TO_REFRESH = ;
// 正在重新整理
public static final int REFRESHING = ;
// 操作完畢
public static final int DONE = ;
// 重新整理成功
public static final int SUCCEED = ;
// 重新整理失敗
public static final int FAIL = ;
// 目前狀态
private int state = INIT;
// 重新整理回調接口
private OnRefreshListener onRefreshListener;
// 按下Y坐标,上一個事件點Y坐标
private float downY, lastY;
// 下拉的距離。注意:pullDownY和pullUpY不可能同時不為0
public float pullDownY = ;
// 上拉的距離
private float pullUpY = ;
// 釋放重新整理的距離
private float refreshDist = ;
// 釋放加載的距離
private float loadmoreDist = ;
// 復原速度
public float MOVE_SPEED = ;
// 第一次執行布局
private boolean isLayout = false;
// 在重新整理過程中滑動操作
private boolean isTouch = false;
// 手指滑動距離與下拉頭的滑動距離比,中間會随正切函數變化
private float radio = ;
// 下拉頭
private View refreshView;
// 重新整理結果:成功或失敗
private TextView refreshStateTextView;
// 實作了Pullable接口的View
private View pullableView;
// 過濾多點觸碰
private int mEvents;
// 這兩個變量用來控制pull的方向,如果不加控制,當情況滿足可上拉又可下拉時沒法下拉
private boolean canPullDown = true;
private boolean canPullUp = true;
private Context resContext;
public PullToRefreshLayout(Context context) {
super(context);
resContext = context;
}
public PullToRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
resContext = context;
}
public PullToRefreshLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
resContext = context;
}
public void setOnRefreshListener(
OnRefreshListener onRefreshListener) {
this.onRefreshListener = onRefreshListener;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!isLayout) {
// 這裡是第一次進來的時候做一些初始化
refreshView = getChildAt();
pullableView = getChildAt();
isLayout = true;
initView();
refreshDist = ((ViewGroup) refreshView).getChildAt().getMeasuredHeight();
}
// 改變子控件的布局,這裡直接用(pullDownY + pullUpY)作為偏移量,這樣就可以不對目前狀态作區分
refreshView.layout(, (int) (pullDownY + pullUpY) - refreshView
.getMeasuredHeight(), refreshView.getMeasuredWidth(),
(int) (pullDownY + pullUpY));
pullableView.layout(, (int) (pullDownY + pullUpY), pullableView
.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView
.getMeasuredHeight());
}
private void initView() {
// 初始化下拉布局
refreshStateTextView = (TextView) refreshView.findViewById(R.id.tvHeader);
}
private void releasePull() {
canPullDown = true;
canPullUp = true;
}
private void changeState(int to) {
state = to;
switch (state) {
case INIT:
// 下拉布局初始狀态
refreshStateTextView.setText("下拉可以重新整理");
refreshStateTextView.setTextColor();
break;
case RELEASE_TO_REFRESH:
// 釋放重新整理狀态
refreshStateTextView.setText("松開進行重新整理");
refreshStateTextView.setTextColor();
break;
case REFRESHING:
// 正在重新整理狀态
refreshStateTextView.setText("正在重新整理...");
refreshStateTextView.setTextColor();
break;
case DONE:
// 重新整理或加載完畢,啥都不做
break;
}
}
public void refreshFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 重新整理成功
refreshStateTextView.setText("重新整理完畢");
break;
case FAIL:
default:
// 重新整理失敗
refreshStateTextView.setText("重新整理失敗");
break;
}
if (pullDownY > ) {
// 重新整理結果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(, );
} else {
changeState(DONE);
hide();
}
}
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downY = ev.getY();
lastY = downY;
mEvents = ;
releasePull();
break;
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_POINTER_UP:
// 過濾多點觸碰
mEvents = -;
break;
case MotionEvent.ACTION_MOVE:
if (mEvents == ) {
if (pullDownY >
|| (((IPullToRefresh) pullableView).canPullDown()
&& canPullDown)) {
// 可以下拉
// 對實際滑動距離做縮小,造成用力拉的感覺
pullDownY = pullDownY + (ev.getY() - lastY) / radio;
if (pullDownY < ) {
pullDownY = ;
canPullDown = false;
canPullUp = true;
}
if (pullDownY > getMeasuredHeight())
pullDownY = getMeasuredHeight();
if (state == REFRESHING) {
// 正在重新整理的時候觸摸移動
isTouch = true;
}
} else
releasePull();
} else
mEvents = ;
lastY = ev.getY();
// 根據下拉距離改變比例
radio = (float) ( + * Math.tan(Math.PI / / getMeasuredHeight() * (pullDownY + Math.abs(pullUpY))));
if (pullDownY > || pullUpY < )
requestLayout();
if (pullDownY > ) {
if (pullDownY <= refreshDist && (state == RELEASE_TO_REFRESH || state == DONE)) {
// 如果下拉距離沒達到重新整理的距離且目前狀态是釋放重新整理,改變狀态為下拉重新整理
changeState(INIT);
}
if (pullDownY >= refreshDist && state == INIT) {
// 如果下拉距離達到重新整理的距離且目前狀态是初始狀态重新整理,改變狀态為釋放重新整理
changeState(RELEASE_TO_REFRESH);
}
}
// 因為重新整理和加載操作不能同時進行,是以pullDownY和pullUpY不會同時不為0,是以這裡用(pullDownY +
// Math.abs(pullUpY))就可以不對目前狀态作區分了
if ((pullDownY + Math.abs(pullUpY)) > ) {
// 防止下拉過程中誤觸發長按事件和點選事件
ev.setAction(MotionEvent.ACTION_CANCEL);
}
break;
case MotionEvent.ACTION_UP:
if (pullDownY > refreshDist || -pullUpY > loadmoreDist)
// 正在重新整理時往下拉(正在加載時往上拉),釋放後下拉頭(上拉頭)不隐藏
{
isTouch = false;
}
if (state == RELEASE_TO_REFRESH) {
changeState(REFRESHING);
// 重新整理操作
if (onRefreshListener != null)
onRefreshListener.onRefresh(this);
}
hide();
default:
break;
}
// 事件分發交給父類
super.dispatchTouchEvent(ev);
return true;
}
private void hide() {
pullDownY = ;
requestLayout();
}
public interface OnRefreshListener {
// 重新整理操作
void onRefresh(PullToRefreshLayout pullToRefreshLayout);
}
}
初始化要放到onLayout重寫中,因為Header已經寫在XML裡了;主要的部分在于重寫的dispatchTouchEvent方法。
重寫dispatchTouchEvent而非onTouchEvent的原因主要在于目前方案使用的是嵌套,重寫的類是父元件而ListView是子元件,重寫dispatchTouchEvent有助于在必要的時候傳回true來攔截事件進行控制。
使用這個元件的方法如下
pullToRefreshLayout = (PullToRefreshLayout) findViewById(R.id.refreshLayout);
contentList = (PullableListView) findViewById(R.id.plvContent);
rlvAdapter = new MyRefreshListAdapter();
pullToRefreshLayout.setOnRefreshListener(new PullToRefreshLayout.OnRefreshListener() {
@Override
public void onRefresh(PullToRefreshLayout pullToRefreshLayout) {
refreshData();
pullToRefreshLayout.refreshFinish(PullToRefreshLayout.SUCCEED);
}
});
public void refreshData() {
// 重新整理資料
rlvAdapter.notifyDataSetChanged();
}
該方案通用性很強,隻要實作了IPullToRefresh接口的元件都可以放在其中做下拉重新整理功能,并不局限于ListView或者GridView,ScrollView乃至一般的LinearLayout都可以成為下拉重新整理的内容。
上拉加載與觸底加載
說完了下拉重新整理,接着就必須提一提加載了。在一個頁面顯示的資料量比較小的時候,一般的方案都是直接從遠端端擷取全部的資料一次性展示,隻要展示用的ListView等具有重用特性的元件能做好重用代碼,再多的資料都能無障礙地展現出來。
但這并不表示一次性展示所有資料就一定适用于所有的場景,有些情況下一次性展示對使用者體驗的影響巨大,比如當資料量過于龐大,單是傳回資料和處理資料就非常耗時;或者時伺服器承受不起大批量的對大量資料傳回的請求,強行請求會造成卡頓乃至失去響應的情況時,優化方案勢在必行。
而優化的思路非常簡單,最基本的一種算法思路就可以解決這個問題,那就是分治法。
分治法旨在通過将待解決的問題分割為小問題分别解決并最後整合來快速解決問題,放到大量資料這樣的場景下其實就是要将大批量的資料拆分成小塊并分别傳回,最後本地整合成清單予以顯示,這說的其實就是現在很常見的一種資料通路方法——分頁資料。
背景将資料按照通路參數分好頁,每次傳回指定頁碼的資料,接收端隻需要按照自己定義的分頁标準讀取并展示資料即可,有效地解決了大批量資料通路的問題。
但同時,另一個問題也浮出水面,分頁加載是很好,但什麼時候加載下一頁呢?按照傳統思維,放個按鈕,點選一下加載一頁。這樣固然能解決問題,但這個互動性比較低,現在依然有些應用使用了這種方案,其最大的優勢就在于簡單,完全不需要任何第三方架構或者重寫元件之類的,隻要使用ListView的FootView就能解決問題。
那麼除了這個簡單的方案之外還有哪些互動性相對高一些的方案呢?從下拉重新整理可以得到提示,既然清單到頂再下拉就是重新整理,那麼反過來清單到底再上拉讓它加載下一頁不就好了?
這種想法完全沒有問題,而且也是現實中已經存在的解決方案,實作也不難,根據前文提到的下拉重新整理方案,增加一種狀态和一個Footer來表示加載即可,接口中也多一個方法用來加載下一頁。
public class PullToRefreshLayout extends RelativeLayout {
public static final String TAG = "PullToRefreshLayout";
// 初始狀态
public static final int INIT = ;
// 釋放重新整理
public static final int RELEASE_TO_REFRESH = ;
// 正在重新整理
public static final int REFRESHING = ;
// 釋放加載
public static final int RELEASE_TO_LOAD = ;
// 正在加載
public static final int LOADING = ;
// 操作完畢
public static final int DONE = ;
// 目前狀态
private int state = INIT;
// 重新整理回調接口
private OnRefreshListener mListener;
// 重新整理成功
public static final int SUCCEED = ;
// 重新整理失敗
public static final int FAIL = ;
// 按下Y坐标,上一個事件點Y坐标
private float downY, lastY;
// 下拉的距離。注意:pullDownY和pullUpY不可能同時不為0
public float pullDownY = ;
// 上拉的距離
private float pullUpY = ;
// 釋放重新整理的距離
private float refreshDist = ;
// 釋放加載的距離
private float loadmoreDist = ;
// 復原速度
public float MOVE_SPEED = ;
// 第一次執行布局
private boolean isLayout = false;
// 在重新整理過程中滑動操作
private boolean isTouch = false;
// 手指滑動距離與下拉頭的滑動距離比,中間會随正切函數變化
private float radio = ;
// 下拉頭
private View refreshView;
// 重新整理結果:成功或失敗
private TextView refreshStateTextView;
// 上拉頭
private View loadmoreView;
// 加載結果:成功或失敗
private TextView loadStateTextView;
// 實作了Pullable接口的View
private View pullableView;
// 過濾多點觸碰
private int mEvents;
// 這兩個變量用來控制pull的方向,如果不加控制,當情況滿足可上拉又可下拉時沒法下拉
private boolean canPullDown = true;
private boolean canPullUp = true;
private Context mContext;
public void setOnRefreshListener(OnRefreshListener listener) {
mListener = listener;
}
public PullToRefreshLayout(Context context) {
super(context);
initView(context);
}
public PullToRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public PullToRefreshLayout(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initView(context);
}
private void initView(Context context) {
mContext = context;
}
/**
* 完成重新整理操作,顯示重新整理結果。注意:重新整理完成後一定要調用這個方法
**/
public void refreshFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 重新整理成功
refreshStateTextView.setText("重新整理成功");
break;
case FAIL:
default:
// 重新整理失敗
refreshStateTextView.setText("重新整理失敗");
break;
}
if (pullDownY > ) {
// 重新整理結果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(, );
} else {
changeState(DONE);
hide();
}
}
/**
* 加載完畢,顯示加載結果。注意:加載完成後一定要調用這個方法
*
**/
public void loadmoreFinish(int refreshResult) {
switch (refreshResult) {
case SUCCEED:
// 加載成功
loadStateTextView.setText("加載成功");
break;
case FAIL:
default:
// 加載失敗
loadStateTextView.setText("加載失敗");
break;
}
if (pullUpY < ) {
// 重新整理結果停留1秒
new Handler() {
@Override
public void handleMessage(Message msg) {
changeState(DONE);
hide();
}
}.sendEmptyMessageDelayed(, );
} else {
changeState(DONE);
hide();
}
}
private void changeState(int to) {
state = to;
switch (state) {
case INIT:
// 下拉布局初始狀态
refreshStateTextView.setText("下拉可以重新整理");
// 上拉布局初始狀态
loadStateTextView.setText("上拉可以加載");
break;
case RELEASE_TO_REFRESH:
// 釋放重新整理狀态
refreshStateTextView.setText("松開進行重新整理");
break;
case REFRESHING:
// 正在重新整理狀态
refreshStateTextView.setText("正在重新整理...");
break;
case RELEASE_TO_LOAD:
// 釋放加載狀态
loadStateTextView.setText("松開進行加載");
break;
case LOADING:
// 正在加載狀态
loadStateTextView.setText("正在加載...");
break;
case DONE:
// 重新整理或加載完畢,啥都不做
break;
}
}
/**
* 不限制上拉或下拉
*/
private void releasePull() {
canPullDown = true;
canPullUp = true;
}
/*
* (非 Javadoc)由父控件決定是否分發事件,防止事件沖突
*
* @see android.view.ViewGroup#dispatchTouchEvent(android.view.MotionEvent)
*/
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
downY = ev.getY();
lastY = downY;
mEvents = ;
releasePull();
break;
case MotionEvent.ACTION_POINTER_DOWN:
case MotionEvent.ACTION_POINTER_UP:
// 過濾多點觸碰
mEvents = -;
break;
case MotionEvent.ACTION_MOVE:
if (mEvents == ) {
if (pullDownY > || (((IPullToRefresh) pullableView).canPullDown() && canPullDown && state != LOADING)) {
// 可以下拉,正在加載時不能下拉
// 對實際滑動距離做縮小,造成用力拉的感覺
pullDownY = pullDownY + (ev.getY() - lastY) / radio;
if (pullDownY < ) {
pullDownY = ;
canPullDown = false;
canPullUp = true;
}
if (pullDownY > getMeasuredHeight())
pullDownY = getMeasuredHeight();
if (state == REFRESHING) {
// 正在重新整理的時候觸摸移動
isTouch = true;
}
} else if (pullUpY < || (((IPullToRefresh) pullableView).canPullUp() && canPullUp && state != REFRESHING)) {
// 可以上拉,正在重新整理時不能上拉
pullUpY = pullUpY + (ev.getY() - lastY) / radio;
if (pullUpY > ) {
pullUpY = ;
canPullDown = true;
canPullUp = false;
}
if (pullUpY < -getMeasuredHeight())
pullUpY = -getMeasuredHeight();
if (state == LOADING) {
// 正在加載的時候觸摸移動
isTouch = true;
}
} else
releasePull();
} else
mEvents = ;
lastY = ev.getY();
// 根據下拉距離改變比例
radio = (float) ( + * Math.tan(Math.PI / / getMeasuredHeight() * (pullDownY + Math.abs(pullUpY))));
if (pullDownY > || pullUpY < )
requestLayout();
if (pullDownY > ) {
if (pullDownY <= refreshDist && (state == RELEASE_TO_REFRESH || state == DONE)) {
// 如果下拉距離沒達到重新整理的距離且目前狀态是釋放重新整理,改變狀态為下拉重新整理
changeState(INIT);
}
if (pullDownY >= refreshDist && state == INIT) {
// 如果下拉距離達到重新整理的距離且目前狀态是初始狀态重新整理,改變狀态為釋放重新整理
changeState(RELEASE_TO_REFRESH);
}
} else if (pullUpY < ) {
// 下面是判斷上拉加載的,同上,注意pullUpY是負值
if (-pullUpY <= loadmoreDist && (state == RELEASE_TO_LOAD || state == DONE)) {
changeState(INIT);
}
// 上拉操作
if (-pullUpY >= loadmoreDist && state == INIT) {
changeState(RELEASE_TO_LOAD);
}
}
// 因為重新整理和加載操作不能同時進行,是以pullDownY和pullUpY不會同時不為0,是以這裡用(pullDownY +
// Math.abs(pullUpY))就可以不對目前狀态作區分了
if ((pullDownY + Math.abs(pullUpY)) > ) {
// 防止下拉過程中誤觸發長按事件和點選事件
ev.setAction(MotionEvent.ACTION_CANCEL);
}
break;
case MotionEvent.ACTION_UP:
if (pullDownY > refreshDist || -pullUpY > loadmoreDist)
// 正在重新整理時往下拉(正在加載時往上拉),釋放後下拉頭(上拉頭)不隐藏
{
isTouch = false;
}
if (state == RELEASE_TO_REFRESH) {
changeState(REFRESHING);
// 重新整理操作
if (mListener != null)
mListener.onRefresh(this);
} else if (state == RELEASE_TO_LOAD) {
changeState(LOADING);
// 加載操作
if (mListener != null)
mListener.onLoadMore(this);
}
hide();
default:
break;
}
// 事件分發交給父類
super.dispatchTouchEvent(ev);
return true;
}
private void initView() {
// 初始化下拉布局
refreshStateTextView = (TextView) refreshView.findViewById(R.id.tvHeader);
// 初始化上拉布局
loadStateTextView = (TextView) loadmoreView.findViewById(R.id.tvFooter);
}
public int getState() {
return state;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!isLayout) {
// 這裡是第一次進來的時候做一些初始化
refreshView = getChildAt();
pullableView = getChildAt();
loadmoreView = getChildAt();
isLayout = true;
initView();
refreshDist = ((ViewGroup) refreshView).getChildAt().getMeasuredHeight();
loadmoreDist = ((ViewGroup) loadmoreView).getChildAt().getMeasuredHeight();
}
// 改變子控件的布局,這裡直接用(pullDownY + pullUpY)作為偏移量,這樣就可以不對目前狀态作區分
refreshView.layout(, (int) (pullDownY + pullUpY) - refreshView.getMeasuredHeight(), refreshView.getMeasuredWidth(), (int) (pullDownY + pullUpY));
pullableView.layout(, (int) (pullDownY + pullUpY), pullableView.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight());
loadmoreView.layout(, (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight(), loadmoreView.getMeasuredWidth(), (int) (pullDownY + pullUpY) + pullableView.getMeasuredHeight() + loadmoreView.getMeasuredHeight());
}
private void hide() {
pullDownY = ;
pullUpY = ;
requestLayout();
}
/**
* 重新整理加載回調接口
*
*/
public interface OnRefreshListener {
/**
* 重新整理操作
*/
void onRefresh(PullToRefreshLayout pullToRefreshLayout);
/**
* 加載操作
*/
void onLoadMore(PullToRefreshLayout pullToRefreshLayout);
}
}
這樣的實作固然很簡單,但互動性依然一般,使用者需要拉到底部再強行上拉才能加載下一頁的資料,這樣看上去似乎還有提升的空間,能不能讓清單智能化一點,隻要到達底部自動就加載下一頁資料呢?
答案是肯定的,而且這樣的解決方案并不罕見,它不但能很好地契合分頁資料的通路方式,在互動性方面也是深得人心,畢竟不用操心資料怎麼來的,隻要往下拖動清單就行的操作方式人們都喜歡。
安卓要實作這個方案非常簡單,比下拉重新整理還要簡單,因為隻是在滑動過程中判斷是否需要加載而不涉及任何狀态切換,是以OnScrollListener足以應付這個需求,甚至都不需要重寫任何元件。
class ScrollToLoadImpl implements AbsListView.OnScrollListener {
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
//
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
if(view.getLastVisiblePosition() == totalItemCount) {
// 在此處載入資料
}
}
}
使用getLastVisiblePosition是個比較粗糙的方案,雖然一樣能實作功能,但實際上加載是在最後一個項目出現的時候開始而非觸底,是以在某些時候也可以使用getChildAt來擷取更為精确的位置資訊。
至此,簡單的下拉重新整理,上拉加載以及觸底加載(無限清單)的實作方案就介紹完畢,在實際生産環境中這些方案都會根據實際需求有所變化,是以僅供參考。
更多關于谷歌官方SwipeRefreshLayout以及第三方下拉重新整理架構的資訊請參考如下文章資料。
- Android SwipeRefreshLayout 官方下拉重新整理控件介紹
- Android幾種強大的下拉重新整理庫