下拉刷新、上拉加載實戰:帶你理解自定義 View 整個過程
寫在前面的話
這篇文章主要是對以前學習的自定義View的一個小總結,拿這個例子來做再合適不過了。簡單介紹一下,主要內容是參照 自個兒寫Android的下拉刷新/上拉加載控件 這篇文章里面的內容(不是自定義ListView,而是ViewGroup,更有難度),但是我還是略有改動,感謝作者無私分享。前面也看了一些關于自定義View,事件分發,滑動沖突等內容,特別是郭神的書,讓我受益匪淺。我的目的就是想帶大家從實際的例子,來認識自定義View中幾個關鍵的步驟,以及怎樣與動畫相結合,希望對一些童鞋能有所幫助。
效果圖

Github地址
建議直接下載整個例子代碼,然后跟著下面的步驟來理解
https://github.com/yixiaoming/PullRefreshLayout
正式開始
如果自定義View還不熟悉的,可以看看這篇基礎知識,能對你有幫助 自定義View應該明白的基礎知識 。
首先明確任務,我們要做的是自定義一個ViewGroup,然后你可以在這個ViewGroup中放入 ListView,RecyclerView,ScrollView只能的可滑動的view,然后給它們添加下拉刷新和上拉加載更多的功能。這和直接自定義ListView還是有一定的區別,后者可以直接使用 addHeader() ,addFooter() 添加頭和尾,而我們需要自己測量,布局,處理滑動沖突等。來看一個圖:

下面的代碼不建議邊看邊貼,主要是理清思路,然后看完整項目再寫
第一步:添加Header和Footer,并隱藏
我們定義一個PullRefreshLayout類,繼承ViewGroup,需要重寫構造方法(如果有自定義屬性),onFinishInflate(),onMeasure(),onLayout(),如果你在這4個函數里面分別加上Log的話,你會發現它們的調用順序就是前面的出現順序,但是 onMeasure 和 onLayout 都會被多次調用。
下面展示的是主要過程,便于理解,具體代碼可以看Github上完整源碼。
onFinishInflate
Called after a view and all of its children has been inflated from XML.
public class PullRefreshLayout extends ViewGroup {
//...
//保持原樣
public PullRefreshLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
// 當view的所有child從xml中被初始化后調用
@Override
protected void onFinishInflate() {
super.onFinishInflate();
lastChildIndex = getChildCount() - 1;
addHeader();
addFooter();
}
}</pre>
這個函數會在View的所有child從xml中被初始化后調用,緊接著構造函數。 lastChildIndex 記錄xml中配置的最后一個child的索引,下面這樣寫,就可以獲得 listview的索引,后面我們將用這個 索引獲取到View,來判斷footer是否顯示。
<org.yxm.pullrefreshlayout.PullRefreshLayout
android:id="@+id/main_pullrefresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ListView
android:id="@+id/main_listview"
android:layout_width="match_parent"
android:layout_height="match_parent">
</ListView>
</org.yxm.pullrefreshlayout.PullRefreshLayout></pre>
然后還有 addHeader 和 addFooter,就是為 整個layout添加 Header和 Footer,以及初始化 header和footer中的 textview等。
private void addHeader() {
mHeader = LayoutInflater.from(getContext()).inflate(R.layout.pull_header, null, false);
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
addView(mHeader, params);
mHeaderText = (TextView) findViewById(R.id.header_text);
mHeaderProgressBar = (ProgressBar) findViewById(R.id.header_progressbar);
}
private void addFooter() {
mFooter = LayoutInflater.from(getContext()).inflate(R.layout.pull_footer, null, false);
RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
addView(mFooter, params);
mFooterText = (TextView) findViewById(R.id.footer_text);
mFooterProgressBar = (ProgressBar) findViewById(R.id.footer_progressbar);
}</pre>
onMeasure
Called to determine the size requirements for this view and all of its children.
我們都知道 onMeasure 的作用是計算自己和 所有孩子 所需要的尺寸,上面我們提到 onMeasure 和 onLayout 都會被多次調用,就是因為我們定義的View中還有child,所以會被調用多次。所以我們還需要在里面計算所有child的尺寸。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}</pre>
onLayout
Called when this view should assign a size and position to all of its children.
onLayout在自己或child,的大小和位置發生變化時會被調用。它個主要的作用還是決定這個View應該放在那兒,怎么放。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
mLayoutContentHeight = 0;
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == mHeader) {
child.layout(0, 0 - child.getMeasuredHeight(), child.getMeasuredWidth(), 0);
mEffectiveHeaderHeight = child.getHeight();
} else if (child == mFooter) {
child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
mEffictiveFooterHeight = child.getHeight();
} else {
child.layout(0, mLayoutContentHeight, child.getMeasuredWidth(), mLayoutContentHeight + child.getMeasuredHeight());
if (i < getChildCount()) {
if (child instanceof ScrollView) {
mLayoutContentHeight += getMeasuredHeight();
continue;
}
mLayoutContentHeight += child.getMeasuredHeight();
}
}
}
}</pre>
里面有幾個重要的地方: layout 函數 的參數是 :(left,top,right,bottom)
如果是header,應該擺放在:
(0,- header height,header width,0)
footer應該擺放在:
(0,content height, footer width,content height + footer height)
如果是 ViewGroup 里面的內容,應該擺放在:
(0,content height,content width,content height + 當前加進來的child height)
需要注意的是, mLayoutContentHeight 是指所有content的高度,就是所有child加起來的高度,是一個不斷累加的值,添加一個child就添加一些,但是不包括header和footer。
將內容擺放好,那么我們的第一步就完成了,并且header隱藏在上面,footer隱藏在下面。
第二步:處理滑動事件
處理滑動事件,我們需要注意兩個函數: onTouchEvent 和 onInterceptTouchEvent ,onTouchEvent處理touch事件,如按下,滑動,松開等。onInterceptTouchEvent 會在 onTouchEvent 前面執行,在這里需要判斷是否應該攔截這個事件,然后交由我的 onTouchEvent 處理。一旦 onInterceptTouchEvent 返回 true 表示攔截,后續事件都會交給 onTouchEvent 處理,onInterceptTouchEvent 都不會再執行,下一次按下事件。不知道這樣描述有沒有問題,如果不清楚,你可以在兩個函數里面添加 Log ,然后試一試。
onInterceptTouchEvent
我們需要在這個函數中判斷是否應該攔截滑動事件,例如child是一個ListView,那么 它沒有滑到頭或者沒有滑到尾 的時候,我們都不應該攔截,ACTION_DOWN和ACTION_UP和不需要攔截,當事件為 ACTION_MOVE 時,如果是向下滑動,判斷第一個child是否滑倒最上面,如果是,則更新狀態為 TRY_REFRESH;如果是向上滑動,則判斷最后一個child是否滑動最底部,如果是,則更新狀態為TRY_LOADMORE。然后返回 intercept = true。這樣接下來的滑動事件就會傳給本類的 onTouchEvent 處理。
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
int y = (int) event.getY();
if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
return false;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN: {
// 攔截時需要記錄點擊位置,不然下一次滑動會出錯
mlastMoveY = y;
intercept = false;
break;
}
case MotionEvent.ACTION_MOVE: {
//向下滑動
if (y > mLastYIntercept) {
View child = getChildAt(0);
intercept = getRefreshIntercept(child);
if (intercept) {
updateStatus(mStatus.TRY_REFRESH);
}
}
//向上滑動
else if (y < mLastYIntercept) {
View child = getChildAt(lastChildIndex);
intercept = getLoadMoreIntercept(child);
if (intercept) {
updateStatus(mStatus.TRY_LOADMORE);
}
} else {
intercept = false;
}
break;
}
case MotionEvent.ACTION_UP: {
intercept = false;
break;
}
}
mLastYIntercept = y;
return intercept;
}</pre>
至于怎么判斷是否應該攔截,這里不同的ViewGroup判斷方法不一樣,主要分為 ScrollView,ListView,RecyclerView,這里的內容要繁瑣一點,可以直接跳過。
/匯總判斷 刷新和加載是否攔截/
private boolean getRefreshIntercept(View child) {
boolean intercept = false;
if (child instanceof AdapterView) {
intercept = adapterViewRefreshIntercept(child);
} else if (child instanceof ScrollView) {
intercept = scrollViewRefreshIntercept(child);
} else if (child instanceof RecyclerView) {
intercept = recyclerViewRefreshIntercept(child);
}
return intercept;
}
private boolean getLoadMoreIntercept(View child) {
boolean intercept = false;
if (child instanceof AdapterView) {
intercept = adapterViewLoadMoreIntercept(child);
} else if (child instanceof ScrollView) {
intercept = scrollViewLoadMoreIntercept(child);
} else if (child instanceof RecyclerView) {
intercept = recyclerViewLoadMoreIntercept(child);
}
return intercept;
}
/匯總判斷 刷新和加載是否攔截/
/具體判斷各種View是否應該攔截/
// 判斷AdapterView下拉刷新是否攔截
private boolean adapterViewRefreshIntercept(View child) {
boolean intercept = true;
AdapterView adapterChild = (AdapterView) child;
if (adapterChild.getFirstVisiblePosition() != 0
|| adapterChild.getChildAt(0).getTop() != 0) {
intercept = false;
}
return intercept;
}
// 判斷AdapterView加載更多是否攔截
private boolean adapterViewLoadMoreIntercept(View child) {
boolean intercept = false;
AdapterView adapterChild = (AdapterView) child;
if (adapterChild.getLastVisiblePosition() == adapterChild.getCount() - 1 &&
(adapterChild.getChildAt(adapterChild.getChildCount() - 1).getBottom() >= getMeasuredHeight())) {
intercept = true;
}
return intercept;
}
// 判斷ScrollView刷新是否攔截
private boolean scrollViewRefreshIntercept(View child) {
boolean intercept = false;
if (child.getScrollY() <= 0) {
intercept = true;
}
return intercept;
}
// 判斷ScrollView加載更多是否攔截
private boolean scrollViewLoadMoreIntercept(View child) {
boolean intercept = false;
ScrollView scrollView = (ScrollView) child;
View scrollChild = scrollView.getChildAt(0);
if (scrollView.getScrollY() >= (scrollChild.getHeight() - scrollView.getHeight())) {
intercept = true;
}
return intercept;
}
// 判斷RecyclerView刷新是否攔截
private boolean recyclerViewRefreshIntercept(View child) {
boolean intercept = false;
RecyclerView recyclerView = (RecyclerView) child;
if (recyclerView.computeVerticalScrollOffset() <= 0) {
intercept = true;
}
return intercept;
}
// 判斷RecyclerView加載更多是否攔截
private boolean recyclerViewLoadMoreIntercept(View child) {
boolean intercept = false;
RecyclerView recyclerView = (RecyclerView) child;
if (recyclerView.computeVerticalScrollExtent() + recyclerView.computeVerticalScrollOffset()
>= recyclerView.computeVerticalScrollRange()) {
intercept = true;
}
return intercept;
}
/具體判斷各種View是否應該攔截/</pre>
onTouchEvent
這里面就是處理攔截后的touch事件,我們主要根據滑動的位置來做狀態的修改,和屬性動畫的控制。
下面的代碼我們先沒有加動畫,先理清楚思路。
@Override
public boolean onTouchEvent(MotionEvent event) {
int y = (int) event.getY();
// 正在刷新或加載更多,避免重復
if (mStatus == Status.REFRESHING || mStatus == Status.LOADING) {
return true;
}
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mlastMoveY = y;
break;
case MotionEvent.ACTION_MOVE:
int dy = mlastMoveY - y;
// 一直在下拉
if (getScrollY() <= 0 && dy <= 0) {
if (mStatus == Status.TRY_LOADMORE) {
scrollBy(0, dy / 100);
} else {
scrollBy(0, dy / 3);
}
}
// 一直在上拉
else if (getScrollY() >= 0 && dy >= 0) {
if (mStatus == Status.TRY_REFRESH) {
scrollBy(0, dy / 100);
} else {
scrollBy(0, dy / 3);
}
} else {
scrollBy(0, dy / 3);
}
beforeRefreshing();
beforeLoadMore();
break;
case MotionEvent.ACTION_UP:
// 下拉刷新,并且到達有效長度
if (getScrollY() <= -mEffectiveHeaderHeight) {
releaseWithStatusRefresh();
if (mRefreshListener != null) {
mRefreshListener.refreshFinished();
}
}
// 上拉加載更多,達到有效長度
else if (getScrollY() >= mEffictiveFooterHeight) {
releaseWithStatusLoadMore();
if (mRefreshListener != null) {
mRefreshListener.loadMoreFinished();
}
} else {
releaseWithStatusTryRefresh();
releaseWithStatusTryLoadMore();
}
break;
}
mlastMoveY = y;
return super.onTouchEvent(event);
}</pre>
第一個: mlastMoveY ,這里采取的是 scrollBy 相對滑動的方式,每向下移動一點,就會觸發 onTouchEvent,用當前event的y 減去 上一次記錄的y,就是我剛剛滑動的一點點距離,然后使用 scrollBy 將整個view 向下滑動一點點,如果動作連貫就形成了滑動的效果。
第二個:ACTION_MOVE 時的狀態變化,注意這里的兩個距離: getScrollY() 獲得的是整體,在我松開之前,整體的View在Y軸上滑動的距離,為負值表示整體往下滑動。 dy = mLastY - y ,表示剛剛 scrollBy 滑動的一小段距離是向上還是向下,如果為負,表示向下滑動一點點。
這里情況稍微復雜一點,這里舉下拉的例子,記住我們實在 onIntercetpTouchEvent 中做得事件攔截,并且如果是下拉就將 mStatus = Status.TRY_REFRESH。攔截之后知道你松開手指,所有事件都直接傳遞個 onTouchEvent ,而不會再經過地方。
滑動的距離分為下面幾種情況,假設有效距離20:
- 如果我們一直下拉,拉到20松開就可以更新,這是最好的情況。
- 如果一直下拉,拉了20。然后又慢慢向上移動滑上去到10松開,不應該更新。但是整體效果也是向下拉的,不會有問題。
- 如果一直下拉,拉了10,這時反向向上滑動,返回到原來位置,甚至負數,那么這個時候layout整體向上移動,導致下面的加載更多出現,這種情況是不對的。應該是在返回到原來位置時,將攔截設置為false,交給child去處理,但是我們剛剛說了,直到松開手指,onInterceptTouchEvent 都不會被調用。所以這里做了這種判斷,如果前面記錄了是想下拉,但是又反向超過了原來位置,則使反向拉特別費力 dy / 100 ,讓下半部無法出現,迫使用戶松開手指。這種處理不是太好,但是我也沒有想到更好的方法。
其他的情況都好處理,直接滑動就好, scrollBy 的距離是 實際距離/3 是想造成簡單的阻尼運動的效果。
if (getScrollY() >= 0 && dy >= 0) {
if (mStatus == Status.TRY_REFRESH) {
scrollBy(0, dy / 100);
} else {
scrollBy(0, dy / 3);
}
}
else {
scrollBy(0, dy / 3);
}
然后 beforeRefreshing 和 beforeLoadMore 就是和用戶交互所需要做的事情。比如滑動達到有效距離,更新文字,出現圖標。然后又滑回去,又修改文字,消失圖標,這里先做簡單的處理,后面需要和動畫相結合。
public void beforeRefreshing() {
if (getScrollY() <= -mEffectiveHeaderHeight) {
mHeaderText.setText("松開刷新");
} else {
mHeaderText.setText("下拉刷新");
}
}
public void beforeLoadMore() {
if (getScrollY() >= mEffectiveHeaderHeight) {
mFooterText.setText("松開加載更多");
} else {
mFooterText.setText("上拉加載更多");
}
}</pre>
第三個:當手指抬起的時候,會相應 ACTION_UP 事件,這時我們我們需要根據是否達到有效距離,做后續的工作,這里直接看代碼就可以理解。
// 下拉刷新,并且到達有效長度
if (getScrollY() <= -mEffectiveHeaderHeight) {
releaseWithStatusRefresh();
if (mRefreshListener != null) {
mRefreshListener.refreshFinished();
}
}
// 上拉加載更多,達到有效長度
else if (getScrollY() >= mEffictiveFooterHeight) {
releaseWithStatusLoadMore();
if (mRefreshListener != null) {
mRefreshListener.loadMoreFinished();
}
} else {
releaseWithStatusTryRefresh();
releaseWithStatusTryLoadMore();
}
具體實現
private void releaseWithStatusTryRefresh() {
scrollBy(0, -getScrollY());
mHeaderText.setText("下拉刷新");
updateStatus(Status.NORMAL);
}
private void releaseWithStatusTryLoadMore() {
scrollBy(0, -getScrollY());
mFooterText.setText("上拉加載更多");
updateStatus(Status.NORMAL);
}
private void releaseWithStatusRefresh() {
scrollTo(0, -mEffectiveHeaderHeight);
mHeaderProgressBar.setVisibility(VISIBLE);
mHeaderText.setText("正在刷新");
updateStatus(Status.REFRESHING);
}
private void releaseWithStatusLoadMore() {
scrollTo(0, mEffictiveFooterHeight);
mFooterText.setText("正在加載");
mFooterProgressBar.setVisibility(VISIBLE);
updateStatus(Status.LOADING);
}
public void refreshFinished() {
scrollTo(0, 0);
mHeaderText.setText("下拉刷新");
mHeaderProgressBar.setVisibility(GONE);
updateStatus(Status.NORMAL);
}
public void loadMoreFinished() {
mFooterText.setText("上拉加載");
mFooterProgressBar.setVisibility(GONE);
scrollTo(0, 0);
updateStatus(Status.NORMAL);
}</pre>
到這里主要的邏輯已經走完了,下面我們來看看和用戶的交互動畫怎么添加。
第三部:交互動畫
如果在這個過程中只使用文字,用戶體驗是很差的,所以我們需要用一些動畫效果來提示用戶應該怎么做,增強用戶體驗。一般下拉刷新都會有一個小圖標,指示下拉的程度,然后提示用戶松開, 我們這里用一個小箭頭來做指示,根據用戶拉下的距離計算小箭頭應該旋轉的角度 ,做一個小交互。
首先看一下 header的xml文件: pull_header.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="
<TextView
android:textSize="16sp"
android:id="@+id/header_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="下拉刷新"/>
<ProgressBar
android:id="@+id/header_progressbar"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_toLeftOf="@+id/header_text"
android:visibility="gone"/>
<ImageView
android:id="@+id/header_arrow"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_centerVertical="true"
android:layout_toLeftOf="@+id/header_text"
android:layout_toStartOf="@+id/header_text"
android:src="@mipmap/ic_action_arrow_bottom"/>
</RelativeLayout></pre>
計算旋轉角度
邏輯理清楚,3個控件:1. 文字提示,2.運行進度條在刷新時顯示,3.箭頭圖標根據滑動距離旋轉角度,刷新時隱藏。
首先解決旋轉問題, 根據滑動距離計算旋轉角度 ,首先我們應該想到在 onTouchEvent 中的ACTION_MOVE 中解決,還記得我們前面下了一個 beforeRefreshing 函數,專門用來處理文字的改變和動畫的處理,這里我們就直接在這個函數中添加交互動畫:
public void beforeRefreshing(float dy) {
//計算旋轉角度
int scrollY = Math.abs(getScrollY());
scrollY = scrollY > mEffectiveHeaderHeight ? mEffectiveHeaderHeight : scrollY;
float angle = (float) (scrollY 1.0 / mEffectiveHeaderHeight 180);
//旋轉角度
mHeaderArrow.setRotation(angle);
if (getScrollY() <= -mEffectiveHeaderHeight) {
mHeaderText.setText("松開刷新");
} else {
mHeaderText.setText("下拉刷新");
}
}</pre>
首先根據滑動的距離,最大是header的高度,然后計算旋轉角度比例*180,就得到了旋轉的角度,然后直接將ImageView的rotation設置旋轉角度,就完成了,就是這么簡單。在做之前我還想用屬性動畫來做,嘗試了一下,各種問題,呵呵,只怪自己沒有經驗,像這種瞬時的動畫,還是直接設置屬性來的簡單。
然后就是在松開手時隱藏箭頭,顯示進度條。
private void releaseWithStatusRefresh() {
scrollTo(0, -mEffectiveHeaderHeight);
mHeaderProgressBar.setVisibility(VISIBLE);
mHeaderText.setText("正在刷新");
// 新加
mHeaderArrow.setVisibility(GONE);
updateStatus(Status.REFRESHING);
}</pre>
加載完成隱藏進度條,顯示箭頭。
private void refreshFinished() {
scrollTo(0, 0);
mHeaderText.setText("下拉刷新");
mHeaderProgressBar.setVisibility(GONE);
// 新加
mHeaderArrow.setVisibility(VISIBLE);
updateStatus(Status.NORMAL);
}</pre>
這樣整個簡單的交互動畫也完成了。
寫在最后的話
到這里,3個步驟已經分析得很詳細,自定義View到底應該怎么做,并且將交互動畫也添加了進來,結合Github上的整個代碼,希望你能理解。自定義View也是有很多的套路的,自己可以琢磨琢磨。再次感謝參考文章的作者,從他的文章中我理解很多細節上的內容。
來自:http://blog.csdn.net/u013647382/article/details/58092102