越來越多的應用中使用的嵌套滾動的效果,Google也在Material Design中加入了原生支持,CoordinatorLayout、AppbarLayout等控件也能讓我們很方便的實現一些嵌套滾動效果。但是碰到自定義需求時,我們還是需要弄懂CoordinatorLayout這些控件的原理,在此基礎上進行自定義。
Google提供的這套嵌套滾動方案是基於NestedScrollingParent和NestedScrollingChild這兩個接口實現的,像CoordinatorLayout就是實現了NestedScrollingPartent接口。
當我們實現嵌套滾動效果時,我們有2種辦法:
1、自定義view實現NestedScrollingChild或者NestedScrollingParent接口;
2、使用原生控件,對原生控件設置Behavior;
同一個效果,這兩種方法都是可以實現的,看你具體的應用場景,自定選擇,本文講解的是Behavior實現方式。
Behavior是一個抽象類,在我們自定義時,主要關注如下幾個重要方法:
//事件分發攔截
onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent ev)
//child:使用該behavior的view
//dependency:會遍歷查找,你只用在這裏判斷本次傳入的dependency是否符合自己需求,以此返回true or false
layoutDependsOn(CoordinatorLayout parent, V child, View dependency)
//dependency狀態發生變化時,會回調此方法,在這裏可以對child進行操作,實現同步更新的效果。
onDependentViewChanged(CoordinatorLayout parent, V child, View dependency)
//在此寫入自己的邏輯,可以對child進行重佈局
onLayoutChild(CoordinatorLayout parent, V child, int layoutDirection)
//在滑動開始之前,調動此方法。我們實現自己的邏輯設置返回結果(true or false)來決定是否要進行嵌套滑動:當返回值爲true的時候表明CoordinatorLayout 充當NestedScrollingPartent處理這次滑動;若返回false,後續回調將不會觸發,也就無法執行嵌套滾動了。
onStartNestedScroll(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)
//onStartNestedScroll返回true纔會回調該方法,參數和onStartNestedScroll一樣,嗯,不知道爲什麼分開。。可以做些初始化工作吧?
onNestedScrollAccepted(CoordinatorLayout coordinatorLayout, V child, View directTargetChild, View target, int nestedScrollAxes)
//嵌套滾動前回調該方法。dx,dy分別是x、y方向上單次滑動(ACTION_DOWN ACTION_UP之間產生)的距離;consumed數組存放的是child在本次滑動中消耗掉的距離,數據0,1元素分別對應x,y;dy-consumed[1]就是y軸沒有被消耗的距離,這個距離會在後續仍然交由觸發滑動事件的view來消費,x軸同理。如RecyclerView滑動100,執行嵌套滑動,child消費40,那麼RecyclerView將滑動60.
onNestedPreScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dx, int dy, int[] consumed)
//嵌套滾動時調用
onNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed)
//嵌套滾動完成後調用,進行一些資源釋放回收操作
onStopNestedScroll(CoordinatorLayout coordinatorLayout, V child, View target)
//慣性滾動前調動,依據返回結果決定是否消費慣性滾動
onNestedPreFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY)
//慣性滾動回調
onNestedFling(CoordinatorLayout coordinatorLayout, V child, View target, float velocityX, float velocityY, boolean consumed)
基礎知識介紹到此,我們來看看如何實現知乎日報的嵌套滾動效果吧,先看看知乎的效果:
我們分析一下這個界面的佈局,可以分爲3部分:
頂部的Toolbar
中間的圖片標題Header
底部的新聞內容Content
再來看看滾動過程,可以分爲2步:
1、Header和Content向上滾動,其中Header滾動速度較慢;Content的頂部到達Toolbar底部時,Toolbar正好透明;
2、Header和Content繼續向上滾動,最終Header被Content覆蓋。
Content移動的距離爲Toolbar的高度 + Header的高度;
Header的滑動速率一直是小於Content,因此Header的滑動距離肯定是小於Content的,他們的速率差其實就是體現在最終滑動距離的差異,因此可以按照百分比來確定Header滑動的距離。(百分比數值影響速率差)
上文介紹Behavior是有dependency這個概念,這個dependency是可以自己對要實現效果的理解來靈活選取的。在這個例子中,我是如下選擇的:
因爲Header總共移動距離header_y是可以預先計算的,設計Content依賴於Header滑動,當Header滑動時,Content的滑動距離都是可以對應計算的。
Toolbar前半段是改變透明度,後半段是移動,這都需要知道Content的狀態,因此選取其dependency爲Content。
具體實現,佈局:
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:apps="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_header"
android:layout_width="match_parent"
android:layout_height="@dimen/news_header_pager_height"
android:layout_marginTop="@dimen/news_tool_bar_height"
android:background="@drawable/pic1"
apps:layout_behavior="com.snick.zzj.myapplication.HeaderViewBehavior"/>
<!-- AppBarLayout內部實現了NestedScrollingChild接口-->
<FrameLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="@dimen/news_tool_bar_height"
apps:layout_behavior="com.snick.zzj.myapplication.ToolbarBehavior">
<!-- Toolbar沒有實現NestedScrollingChild接口,因此必須外層嵌套AppBarLayout才能實現嵌套滾動效果-->
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="@dimen/news_tool_bar_height"
android:minHeight="?attr/actionBarSize"
android:background="@color/colorPrimary">
</android.support.v7.widget.Toolbar>
</FrameLayout>
<android.support.v7.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
apps:layout_behavior="com.snick.zzj.myapplication.ContentBehavior">
</android.support.v7.widget.RecyclerView>
</android.support.design.widget.CoordinatorLayout>
我們先來實現HeaderViewBehavior,先貼代碼:
public class HeaderViewBehavior extends CoordinatorLayout.Behavior<ImageView> {
private static final String TAG = "HeaderViewBehavior";
private Context context;
//一定要實現這個構造函數,否則會報Could not inflate Behavior subclass xxx 異常,可查看源碼
public HeaderViewBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
this.context = context;
}
@Override
public boolean onStartNestedScroll(CoordinatorLayout coordinatorLayout, ImageView child, View directTargetChild, View target, int nestedScrollAxes) {
boolean result = canScroll(child, 0);
return result;
}
//getTranslationY計算的是view對於parent的偏移量
private boolean canScroll(View child, float pendingDy) {
int pendingTranslationY = (int) (child.getTranslationY() - pendingDy);
Log.d(TAG, "canScroll:"+pendingTranslationY+"------"+getHeaderOffsetRange()+"-------"+getHeaderOffsetRangeHideToolBar());
if (pendingTranslationY >= 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar() && pendingTranslationY <= 0) {
return true;
}
return false;
}
//onNestedPreScroll該方法的會傳入內部View移動的dx,dy,如果你需要消耗一定的dx,dy,
// 就通過最後一個參數consumed進行指定,例如我要消耗一半的dy,就可以寫consumed[1]=dy/2
//dy是單次滑動的距離,下次滑動會重新計數
@Override
public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, ImageView child, View target, int dx, int dy, int[] consumed) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed);
//dy>0 scroll up;dy<0,scroll down
float halfOfDis = dy / 4.0f; //消費掉其中的4分之1,不至於滑動效果太靈敏
//在快速滑動時halfOfDis有可能一次跳變超過20以上,如果原本translationY差19到達頂部,這樣一來就會判斷成無法scroll,造成頂部有縫隙
if (canScroll(child, halfOfDis)) {
child.setTranslationY(child.getTranslationY() - halfOfDis);
} else if(halfOfDis > Math.abs(child.getTranslationY() + getHeaderOffsetRange() + getHeaderOffsetRangeHideToolBar())) {
Log.d(TAG,"direct to top");
child.setTranslationY(0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar());
} else if(halfOfDis < child.getTranslationY()) {
child.setTranslationY(0);
}
//當滑動到頂部時,繼續往上滑應該是不允許滑動,但是向下應該是可以滑動
//但是我們在onStartNestedScroll中沒法判斷滑動的方向,因此只好在這裏判斷了。
Log.d(TAG,"Y:"+child.getTranslationY());
if((dy>0&&child.getTranslationY() == 0-getHeaderOffsetRange()-getHeaderOffsetRangeHideToolBar()) ||
(dy<0&&child.getTranslationY() == 0))
consumed[1] = 0;
else
consumed[1] = dy;
}
//Header偏移量
private int getHeaderOffsetRange() {
return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
}
//爲了保證Header滑動速率保持一直,第二段的Header移動距離我們計算出來。
//第一段移動:header移動了header_offset_first的距離,content移動了news_header_pager_height的距離,toolbar透明瞭
//第二段移動:content移動news_tool_bar_height的距離,toolbar也移動news_tool_bar_height距離,header的距離是可以比例計算的。
private int getHeaderOffsetRangeHideToolBar() {
return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
/ context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
}
}
在canScroll方法中處理是否處理嵌套滑動的邏輯;
onNestedPreScroll方法中處理嵌套滑動的邏輯,這裏有2個注意點:
1、在滑動到頂部或者底部時,可能會有最後一組滑動數據無法觸發(可看看代碼中的註釋),我們單獨判斷滑動距離是否超越了頂部或底部邊界,然後直接setTranslationY到頂部或底部;
2、在此例中,Content滑動到頂部時,是不能向上滑,但是可以向下滑,因此在canScroll中需要知道滑動方向,這在onStartNestedScroll中是無法獲取的,因此我放在了onNestedPreScroll中處理:當到達頂部,向上滑時,child並不處理,但是通過consumed數組將該次滑動數據“丟棄”。
再來實現ContentViewBehavior,代碼如下:
public class ContentBehavior extends CoordinatorLayout.Behavior<RecyclerView> {
private static final String TAG = "ContentBehavior";
private Context context;
public ContentBehavior(Context context, AttributeSet attrs) {
super(context, attrs);
this.context = context;
}
@Override
public boolean layoutDependsOn(CoordinatorLayout parent, RecyclerView child, View dependency) {
return isDependOn(dependency);
}
@Override
public boolean onDependentViewChanged(CoordinatorLayout parent, RecyclerView child, View dependency) {
//初始設置,content要在header之下,因此初始需要有一個Y的偏移
if(dependency.getTranslationY() == 0)
child.setTranslationY(getContentInitOffset());
else {
child.setTranslationY(getContentInitOffset() +
dependency.getTranslationY() * dependency.getHeight() / getHeaderOffsetRange());
}
return false;
}
private boolean isDependOn(View dependency) {
return dependency != null && dependency.getId() == R.id.iv_header;
}
//Header偏移量
private int getHeaderOffsetRange() {
return context.getResources().getDimensionPixelOffset(R.dimen.header_offset_first);
}
private int getContentInitOffset() {
return context.getResources().getDimensionPixelOffset(R.dimen.content_offset_init);
}
//爲了保證Header滑動速率保持一直,第二段的Header移動距離我們計算出來。
//第一段移動:header移動了header_offset_first的距離,content移動了news_header_pager_height的距離,toolbar透明瞭
//第二段移動:content移動news_tool_bar_height的距離,toolbar也移動news_tool_bar_height距離,header的距離是可以比例計算的。
private int getHeaderOffsetRangeHideToolBar() {
return getHeaderOffsetRange() * context.getResources().getDimensionPixelOffset(R.dimen.news_tool_bar_height)
/ context.getResources().getDimensionPixelOffset(R.dimen.news_header_pager_height);
}
}
主要就是判斷滑動邊界,決定是否進行嵌套滑動;然後獲取滑動距離,自己決定消耗多少。
Toolbar的代碼就不貼了,和Content的類似,比較簡單。
總結下:
有dependency的Behavior實現比較簡單,跟隨dependency的移動做變化就好了;
需要根據滑動狀態來進行滑動的,需要重寫onStartNestedScroll和onNestedPreScroll等方法,比較複雜
同時使用dependency和onNestedPreScroll可以實現更復雜的滾動效果,本例中沒有這麼複雜,無需使用。
貼上最終實現效果圖:
和知乎的效果還差2部分:
1、fling慣性滑動我還沒有做
2、在Header上滑動時,知乎是可以滑動到,我的Demo無法滑動。maybe知乎是自定義view,我仿知乎日報時注意到這個頁面是個WebView,頂部的Header是一個空白,也許是故意留白,然後疊上一個HeaderView?
最後放上鍊接:
github地址:https://github.com/zzjivan/NestedScrollDemo