下拉刷新、上拉加載實戰:帶你理解自定義 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