本文算是對之前的一篇博文《自個兒寫Android的下拉刷新/上拉加載控件》的續章,如果有興趣了解更多的朋友可以先看一看之前的這篇博客。
事實上之所以會有之前的那篇博文的出現,是起因于前段時間自己在寫一個練手的App時很快就遇到這種需求。其實我們可以發現類似這樣下拉刷新、上拉加載的功能正在變得越來越普遍,可以說如今基本上絕大多數的應用里面都會使用到。當然,隨著Android的發展,已經有不少現成的可以實現這種需求的“輪子”供我們使用了。
但轉過頭想一下想,既然本來就是自己練手之作。為什么還要去用別人的“輪子”呢?何不自己也試著造一造“輪子”?其實以上拉刷新、下拉加載這種需求來說,以前基本上都是應用在ListView上面的。所以回想一下:可以說前兩年PullToRefreshListView還依然是很多應用里都會使用到的開源庫。但是隨著Android的飛速發展,比如RecyclerView的出現等等。這時候,像PullToRefreshListView這種對于ListView所做的擴展,就顯得不夠了。
所以,我首先很快決定:自己要定義的是一個可以通用這種功能的ViewGroup,而不是針對某種特定的View來做擴展。(當然了,這兩種做法顯然各自有利也有弊,關鍵還是看實際的需求采用哪種方式更合適以及自己的取舍了)至于接下來的工作,自然就是整理思路,并逐步加以實現了。
當最后完成后,當然免不了記錄一下這個過程中的思路、收獲等來進行鞏固。于是,就有了之前的博文。在這之后,有收到了不少朋友的鼓勵;當然也有朋友提出了很多不足的地方和很多有用的建議。衷心感謝!!!其實因為水平、時間以及精力等緣故,對于最初的實現方式,之后我自己有空時再回過頭去看的時候,也發現很多地方不太滿意,很多地方可以改進。也正是基于這些原因,就有了之后的優化改良工作。于是,最終對應的又有了這篇博客的誕生,在這里總結一下這次優化的思路以及收獲。
一、上拉、下拉動畫效果的修改
實際上,在之前的實現里,自己選擇了如下的下拉刷新以及上拉加載的動畫效果:
這是因為之前覺著反正也是自己寫著爽爽,不如就搞點比較有意思的Loading效果。但后來有朋友告訴我,如果我感興趣,想要使用一下你的控件,那么這種動畫卻有點不太實用。自己想想也是,比如假設我打算把自己的應用發布到應用市場,那這種效果確實有點不太嚴肅。正好自己比較喜歡簡書IOS版以及新浪微博的下拉動畫,給人的感覺是簡練,并且提示清晰。所以這次自己也采用了這種動畫。現在的效果如下:
這里的改動并沒有什么難度,主要的思路仍然是根據滑動的距離讓ViewGroup進入不同的狀態(當然自己這次優化了onTouchEvent中根據滑動舉例切換視圖狀態的實現細節),而后根據不同的狀態來顯示不同的提示信息。而對于旋轉的提示箭頭,只需要一張向下的箭頭素材 + 屬性動畫就可以搞定了。
二、onMeasure和onLayout的思路優化
在自己最初的實現里,onMeasure和onLayout這里就一直是自己不太滿意的。我最初的思路是,既然是一個可上拉、下拉的ViewGroup,所以考慮選擇將ViewGroup里的child view按照定義的先后順序由上至下進行排列。導致后來只要稍微一回想,就會覺得這種方式實在是非常的想當然和糟糕。
這種方式直接導致我對于ViewGroup中的child view的measure工作以及后續的一系列滑動邏輯變得非常的十分缺乏邏輯嚴密性和合理性。舉例來說,我不得不在進行滑動沖突的處理的時候選擇:如果是處理下拉的滑動沖突的時候,只能通過getChildAt(firstChildIndex)這樣的方式來判斷位于最上方的子View是否需要處理滑動沖突;同理,在處理上拉時,則需要判斷getChildAt(lastChildIndex)是否存在滑動沖突。
這樣的處理方式時候讓我越看代碼,越有一種要犯尷尬癌的沖動。于是,這也成了自己著重想要進行優化和改變的地方。為此,自己抽空去閱讀了SwipeRefreshLayout的源碼。而最終也是選擇借鑒了SwipeRefreshLayout的實現方式。從而不得不感嘆:佩服!至于為什么這么說,我們先來看這樣的一個布局文件。并且,思考一下其最終呈現出來的效果應該是怎么樣的?
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="300dp"
android:orientation="vertical">
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="button" />
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/ic_launcher" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1200dp"
android:background="#000000"/>
</android.support.v4.widget.SwipeRefreshLayout>
在這個布局文件的最終效果呈現到屏幕上之前,不知道你對它最終效果的猜想是如何的呢?如果你和我之前一樣無法確定,那這里可以一起來看一下:
沒錯最終的效果就是這樣,可以發現:
- 雖然我們在布局文件中將SwipeRefreshLayout內的LinearLayout的高度設置為300dp,但其實最終的效果看上去是match_parent。
- 雖然我們已經明確將這之后的一個View的高度設置為了1200dp,但其實然并卵,這個View最終是無法顯示到屏幕上的。
形成這種效果的原因,顯然我們應該到SwipeRefreshLayout的onMeasure和onLayout中去尋找答案。首先,我們截取部分onMeasure的代碼:
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mTarget == null) {
ensureTarget();
}
if (mTarget == null) {
return;
}
mTarget.measure(MeasureSpec.makeMeasureSpec(
getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(
getMeasuredHeight() - getPaddingTop() - getPaddingBottom(), MeasureSpec.EXACTLY));
// 省略若干...
}
可以看到這里首先會進行一個mTarget是否為空的判斷。如果為空,則會調用叫做ensureTarget()的方法。顧名思義,該方法就是用來確認mTarget的。
private void ensureTarget() {
// Don't bother getting the parent height if the parent hasn't been laid
// out yet.
if (mTarget == null) {
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (!child.equals(mCircleView)) {
mTarget = child;
break;
}
}
}
}
其實這個方法的實現,非常簡單,就是遍歷SwipeRefreshLayout中的child view,遍歷到的第一個不為mCircleView的child就將作為mTarget。
那么,mCircleView這個東西究竟是什么鬼呢?其實就是SwipeRefreshLayout在進行下拉的時候的那個圈圈了,這是SwipeRefreshLayout默認添加的。
然后,讓我們回到onMeasure方法當中,就會看到對mTarget進行測量的代碼。從源碼中可以看到,其實無論我們對mTarget的寬高進行如何的設置,其實其最后的寬高都是EXACTLY模式的SwipeRefreshLayout的寬高減去內邊距。這就解釋了為什么對LinearLayout設置300dp的高度最終卻占滿了窗口。
最后,我們再看一看SwipeRefreshLayout的源碼中onLayout方法的實現:
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final int width = getMeasuredWidth();
final int height = getMeasuredHeight();
if (getChildCount() == 0) {
return;
}
if (mTarget == null) {
ensureTarget();
}
if (mTarget == null) {
return;
}
final View child = mTarget;
final int childLeft = getPaddingLeft();
final int childTop = getPaddingTop();
final int childWidth = width - getPaddingLeft() - getPaddingRight();
final int childHeight = height - getPaddingTop() - getPaddingBottom();
child.layout(childLeft, childTop, childLeft + childWidth, childTop + childHeight);
int circleWidth = mCircleView.getMeasuredWidth();
int circleHeight = mCircleView.getMeasuredHeight();
mCircleView.layout((width / 2 - circleWidth / 2), mCurrentTargetOffsetTop,
(width / 2 + circleWidth / 2), mCurrentTargetOffsetTop + circleHeight);
}
由此,我們可以發現對于SwipeRefreshLayout來說:其實不論是measure和layout工作,都只會針對于mTarget,即第一個child進行的。那么就可以解釋為什么之前我們放置在LinearLayout之后的高度設置為1200dp的View根本就不會顯示的原因了。
現在,了解了其中秘密。我們來分析一下為什么說這種方式更為的優秀呢?很簡單,因為我們根本的需求是定義一個支持下拉(上拉)的ViewGroup。
那么注意了!我們要明確在這里:支持下拉、上拉才是重點。所以說,這時我們其實根本不用去過多的考慮child view的measure和layout工作。
對于onMeasure來說,對于mTarget的寬、高的測量,其實就是需要與我們自定義的ViewGroup保持一致才對(當然需要計算內邊距)。因為試想一下:
- 假設SwipeRefreshLayout的寬度是200,里面的child內容卻是50,那么看上去不是很奇怪嗎?
- 同理,假設SwipeRefreshLayout的高度是500,而其中的child高度卻是1000,那不是又很尷尬了嗎?
而對于child的置位工作來說,Android本身就已經就為我們提供了足夠的ViewGroup(Layout)類型,如果有復雜的置位需求,使用這些ViewGroup不就行了嗎?而拋開了這些后顧之憂,有一個最大的好處就是:我們之后對于滑動沖突等事物的處理將變得明確,只需要針對于mTarget就可以了。
所以,最終我也選擇借鑒SwipeRefreshLayout的方式。之前的文字描述可能表達并不清晰,為了更加直觀看到我想描述的效果,看這樣一個布局文件:
<me.hwang.widgets.SmartPullableLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:library="http://schemas.android.com/apk/res-auto"
android:id="@+id/layout_pullable"
android:layout_width="match_parent"
android:layout_height="match_parent"
library:smart_ui_enable_pull_up="false">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="20dp">
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="按鈕一"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="按鈕二"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="按鈕三"/>
<ImageView
android:id="@+id/iv_content"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/ic_launcher"/>
</LinearLayout>
</me.hwang.widgets.SmartPullableLayout>
其最終的運行效果如下:
也就說,這里我的布局里其實并不止一個單獨的View,其包括3個Button以及1個ImageView。但是,對于它們的測量與置位工作,顯然不應該是我們這里定義的ViewGroup應該關心的。對于此類邏輯,讓它交給LinearLayout,RelativeLayout這類ViewGroup不就OK了嗎?當然了,是不是說這種方式就能做到萬無一失了呢?顯然不是,以SwipeRefreshLayout為例。假設寫出了類似下面這樣的布局的話,它也是會很糾結的:
<android.support.v4.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<ListView
android:id="@+id/lv_content"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
<Button
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
</android.support.v4.widget.SwipeRefreshLayout>
這種寫法顯然就會造成需求不明的情況,因為控件的作者在定義ViewGroup的時候肯定猜不到你的目的到底是針對于整個LinearLayout進行下拉刷新,還是針對于里面局部的ListView進行刷新。那么,顯然這時就無法避免的會出現滑動bug。所以如果你的需求是后者,那顯然應該使用如下的方式才對:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<android.support.v4.widget.SwipeRefreshLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<ListView
android:id="@+id/lv_content"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.v4.widget.SwipeRefreshLayout>
<Button
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
三、添加滑動阻力
這一點是寫了之前一篇博客之后,有一個朋友提出來的,我自己也覺得非常需要。同樣為了有一個比較直觀的印象,來看一看是否添加阻力的效果對比:
這里可以看到,最初沒有添加滑動阻力的時候,當我們快速的向下滑動屏幕,會發現整個視圖飛快的下滑。這種體驗顯然不是很好。并且,在這里我是做了判斷,當滑動的距離達到一定數值,就不再允許滑動。否則,當我們快速的滑動一段舉例,可能會出現視圖滑動了十萬八千里的情況。與之對比:
可以看到加入了滑動阻力過后,整個滑動給人的體驗確實要舒服不少。并且,這時我們也不再需要加入滑動達到一定距離后,便不再允許滑動的判斷了。因為加入了滑動阻力之后,當滑動達到一定距離之后,就很難再讓view繼續產生滑動了。
要為滑動添加上這種所謂的阻力其實也非常簡單,我們可以設置一個常量作為阻力因子,比如說0.5。那么,我們每次讓滑動的實際距離乘以阻力因子,不就是所謂的阻力了嗎?進一步來說,我們還可以在滑動距離達到可以進行釋放刷新的距離之后,再乘以一次阻力因子,這樣阻力就進一步的加大了。由此就會給人一種越往下越拉不動的感覺。
四、滑動沖突與事件攔截處理
在之前的實現當中,針對于滑動沖突。比如說與ListView配合使用,我的思路是覆寫onInterceptTouchEvent,在這里進行邏輯處理。比如如果是下拉的操作,則首先判斷ListView是否已經滑動到頂部。如果是,則將攔截事件滑動事件,自己處理滑動。否則則不攔截,讓ListView自身進行滑動。這里的邏輯其實是沒有問題的。但是,之前因為對ListView,具體來說應該是AbsListView了解不夠深入。最后發現會有很讓人不爽的一點:
我在上述截圖中的操作是先讓ListView向下滑動一點距離,然后又接著向上滑動。這時可以看到,雖然當我已經把ListView滑動到最頂端,然后再繼續下拉的時候,實際是不起作用的。這是為什么呢?原因在于AbsListView內部在處理觸摸事件的時候,會有類似如下的代碼處理:
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
也就是說,當AbsListView決定自己開始處理滑動的時候,則會通過調用父視圖的requestDisallowInterceptTouchEvent禁止父視圖攔截事件。簡單來說,就是這時候AbsListView已經開始耍流氓了,導致后續的觸摸事件根本就不會再經過我們自定義的ViewGroup內的onInterceptTouchEvent。這就意味著,這個時候我們對于滑動沖突的邏輯判斷根本就不會執行,最終自然也就無法處理觸摸事件了。這個問題的解決方法其實說難也不難,靈感同樣來自于SwipeRefreshLayout。查看SwipeRefreshLayout的源碼,可以看到如下代碼:
public void requestDisallowInterceptTouchEvent(boolean b) {
// if this is a List < L or another view that doesn't support nested
// scrolling, ignore this request so that the vertical scroll event
// isn't stolen
if ((android.os.Build.VERSION.SDK_INT < 21 && mTarget instanceof AbsListView)
|| (mTarget != null && !ViewCompat.isNestedScrollingEnabled(mTarget))) {
// Nope.
} else {
super.requestDisallowInterceptTouchEvent(b);
}
}
這段代碼的作用就和注釋中描述的一樣,防止在child view同樣具有垂直滑動能力的時候“偷”走滑動事件。在這之后,就可以解決上面談到的問題了:
五、NestedScroll 加入嵌套滑動機制
當完成了之前談到的一點的改造之后,是否還有繼續改進的余地呢?顯然時候有的。因為以之前談到的ListView的改進來說:雖然相對最初的效果體驗好了不少。但是,歸根結底,仍然沒有脫離通過事件攔截機制來處理滑動沖突的原理。這樣有一個非常顯著的問題就是,只要當我們自定義的作為Parent View的ViewGroup決定攔截事件過后:那么,很遺憾的,本次的一系列TouchEvent就不會再有機會傳遞給下面的childView(比如說ListView)了。
但對于RecyclerView這類更為“年輕”的控件來說,這個問題就不再是無解的了。因為Android在Lollipop版本之后,加入了一個牛逼的機制叫做Nested Scroll,即嵌套滑動。而RecyclerView自身就是支持這種機制的。于是,這次我也決定為自定義的ViewGroup加上這種玩意兒。
可以看到在加入嵌套滑動的處理后,這種體驗效果顯然是最好的。因為我們自定義的ViewGroup與作為child view的RecyclerView之間完成了非常默契的滑動配合。這里不會對NestedScroll做詳細的介紹,因為能力和篇幅都有限,有興趣的朋友可以自己查閱相關資料。
簡單的介紹一下核心的實現思路,總的來說,我們只需要知道:
- 首先,RecyclerView作為NestedScrollingChild,其在每次處理滑動之前會先通知NestedScrollingParent是否需要進行嵌套滑動。
- 這時,如果Parent決定進行嵌套滑動。那么,在Child處理滑動之前,Parent可以首先在onNestedPreScroll預先進行滑動。
- 最后,當Child在處理過滑動之后,還會通知parent執行onNestedScroll。在這里,Child沒有消耗的y軸滑動舉例將作為參數dyUnconsumed傳入。
有了這些基礎,我們可以做的工作是:
- 在onNestedPreScroll中,如果我們自定義的ViewGroup已經發生過滑動,那么我們需要先進行滑動,直到ViewGroup恢復到初始的位置。
- 反之,如果在onNestedPreScroll時,ViewGroup沒有發生過滑動,那么就沒有必要進行預消耗。直接讓NestedScrollingChild處理滑動就行了。
- 最后,我們說到在onNestedScroll中,NestedScrollingChild沒有消耗掉的滑動距離將會通過參數dyUnconsumed傳入。那么,我們要做的就是在這里把這些NestedScrollingChild沒有消耗完的距離給消耗掉。
總結
好了,以上差不多就是這次對于自定義上拉加載、下拉刷新控件的優化工作的思路總結以及收獲。再次感嘆,閱讀源碼可能真的是一件有些藍瘦但卻又最能帶來收獲的事情了,真是叫做有苦有甜。像以上談到的種種改動,很多的啟發都來自于SwipeRefreshLayout當中。除此之外,像當時在閱讀當中onMeasure的實現方式時,因為一些細節上的困惑,同時又逼迫自己重新走了一遍Android中View的繪制流程,從而又有不少以前自己忽略掉的收獲。至于本文中項目的源碼已經上傳到github,如果感興趣的朋友,具體的實現細節都可以參照源碼,多多指教。