Android嵌套滾動與協調滾動的實現方式匯總
Android的協調滾動的幾種實現方式
上一期,我們講瞭嵌套滾動的實現方式,為什麼有瞭嵌套滾動還需要協調滾動這種方式呢?(不細講原理,本文隻探討實現的方式與步驟!)
那在一些細度化的操作中,如我們需要一些控件隨著滾動佈局做一些粒度比較小的動畫、移動等操作,那麼我們就需要監聽滾動,然後改變當前控件的屬性。
如何實現這種協調滾動的佈局呢?我們使用 CoordinatorLayout + AppBarLayout 或者 CoordinatorLayout + Behavior 實現,另一種方案是 MotionLayout。我們看看都是怎麼實現的吧。
一、CoordinatorLayout + Behavior
CoordinatorLayout 顧名思義是協調佈局,其原理很簡單,在onMeasure()的時候保存childView,通過 PreDrawListener監聽childView的變化,最終通過雙層for循環找到對應的Behavior,分發任務即可。CoordinatorLayout實現瞭NestedScrollingParent2,那麼在childView實現瞭NestedScrollingChild方法時候也能解決滑動沖突問題。
而Behavior就是一個應用於View的觀察者模式,一個View跟隨者另一個View的變化而變化,或者說一個View監聽另一個View。
在Behavior中,被觀察View 也就是事件源被稱為denpendcy,而觀察View,則被稱為child。
一般自定義Behavior來說分兩種情況:
- 監聽另一個view的狀態變化,例如大小、位置、顯示狀態等
- 監聽CoordinatorLayout裡的滑動狀態
這裡我們以之前的效果為主來實現自定義的Behavior,先設置NestedScrollView在ImageView下面:
public class MyScrollBehavior extends ViewOffsetBehavior<NestedScrollView> { private int topImgHeight; private int topTextHeight; public MyScrollBehavior(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull NestedScrollView child, @NonNull View dependency) { return dependency instanceof ImageView ; } @Override protected void layoutChild(CoordinatorLayout parent, NestedScrollView child, int layoutDirection) { super.layoutChild(parent, child, layoutDirection); if (topImgHeight == 0) { final List<View> dependencies = parent.getDependencies(child); for (int i = 0, z = dependencies.size(); i < z; i++) { View view = dependencies.get(i); if (view instanceof ImageView) { topImgHeight = view.getMeasuredHeight(); } } } child.setTop(topImgHeight); child.setBottom(child.getBottom() + topImgHeight); } }
然後設置監聽CoordinatorLayout裡的滑動狀態,ImageView做同樣的滾動
public class MyImageBehavior extends CoordinatorLayout.Behavior<View> { private int topBarHeight = 0; //負圖片高度 private int downEndY = 0; //默認為0 public MyImageBehavior(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean onStartNestedScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View directTargetChild, @NonNull View target, int axes, int type) { //監聽垂直滾動 return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0; } @Override public void onNestedPreScroll(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) { if (topBarHeight == 0) { topBarHeight = -child.getMeasuredHeight(); } float transY = child.getTranslationY() - dy; //處理上滑 if (dy > 0) { if (transY >= topBarHeight) { translationByConsume(child, transY, consumed, dy); translationByConsume(target, transY, consumed, dy); } else { translationByConsume(child, topBarHeight, consumed, (child.getTranslationY() - topBarHeight)); translationByConsume(target, topBarHeight, consumed, (child.getTranslationY() - topBarHeight)); } } if (dy < 0 && !target.canScrollVertically(-1)) { //處理下滑 if (transY >= topBarHeight && transY <= downEndY) { translationByConsume(child, transY, consumed, dy); translationByConsume(target, transY, consumed, dy); } else { translationByConsume(child, downEndY, consumed, (downEndY - child.getTranslationY())); translationByConsume(target, downEndY, consumed, (downEndY - child.getTranslationY())); } } } @Override public boolean onNestedFling(@NonNull CoordinatorLayout coordinatorLayout, @NonNull View child, @NonNull View target, float velocityX, float velocityY, boolean consumed) { return super.onNestedFling(coordinatorLayout, child, target, velocityX, velocityY, consumed); } private void translationByConsume(View view, float translationY, int[] consumed, float consumedDy) { consumed[1] = (int) consumedDy; view.setTranslationY(translationY); } }
分別為ImageView和NestedScrollView設置對應的 Behavior。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" android:orientation="vertical"> <com.guadou.lib_baselib.view.titlebar.EasyTitleBar android:layout_width="match_parent" android:layout_height="wrap_content" app:Easy_title="CoordinatorLayout+Behavior" /> <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="150dp" android:layout_height="150dp" app:layout_behavior="com.google.android.material.appbar.MyImageBehavior" android:layout_gravity="center_horizontal" android:contentDescription="我是測試的圖片" android:src="@mipmap/ic_launcher" /> <TextView android:layout_width="match_parent" android:layout_height="50dp" android:background="#ccc" android:gravity="center" android:text="我是測試的分割線" android:visibility="gone" /> <androidx.core.widget.NestedScrollView android:id="@+id/nestedScroll" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_behavior="com.google.android.material.appbar.MyScrollBehavior"> <TextView android:id="@+id/nestedScrollLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/scroll_content" /> </androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> </LinearLayout>
我們先把TextView隱藏先不處理TextView。效果如下:
這樣我們就實現瞭自定義 Behavior 監聽滾動的實現。那麼我們加上TextView 的 Behavior 監聽ImageView的滾動,做對應的滾動。
先修改 MyScrollBehavior 讓他在ImageView和TextView下面
public class MyScrollBehavior extends ViewOffsetBehavior<NestedScrollView> { private int topImgHeight; private int topTextHeight; public MyScrollBehavior(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull NestedScrollView child, @NonNull View dependency) { return dependency instanceof ImageView || dependency instanceof TextView ; } @Override protected void layoutChild(CoordinatorLayout parent, NestedScrollView child, int layoutDirection) { super.layoutChild(parent, child, layoutDirection); if (topImgHeight == 0) { final List<View> dependencies = parent.getDependencies(child); for (int i = 0, z = dependencies.size(); i < z; i++) { View view = dependencies.get(i); if (view instanceof ImageView) { topImgHeight = view.getMeasuredHeight(); } else if (view instanceof TextView) { topTextHeight = view.getMeasuredHeight(); view.setTop(topImgHeight); view.setBottom(view.getBottom() + topImgHeight); } } } child.setTop(topImgHeight + topTextHeight); child.setBottom(child.getBottom() + topImgHeight + topTextHeight); } }
然後設置監聽ImageView的滾動:
public class MyTextBehavior extends CoordinatorLayout.Behavior<View> { private int imgHeight; public MyTextBehavior(Context context, AttributeSet attrs) { super(context, attrs); } @Override public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { return dependency instanceof ImageView; } @Override public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) { //跟隨ImageView滾動,ImageView滾動多少我滾動多少 float translationY = dependency.getTranslationY(); if (imgHeight == 0) { imgHeight = dependency.getHeight(); } float offsetTranslationY = imgHeight + translationY; child.setTranslationY(offsetTranslationY); return true; } }
xml修改如下:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" android:orientation="vertical"> <com.guadou.lib_baselib.view.titlebar.EasyTitleBar android:layout_width="match_parent" android:layout_height="wrap_content" app:Easy_title="CoordinatorLayout+Behavior" /> <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> <ImageView android:layout_width="150dp" android:layout_height="150dp" app:layout_behavior="com.google.android.material.appbar.MyImageBehavior" android:layout_gravity="center_horizontal" android:contentDescription="我是測試的圖片" android:src="@mipmap/ic_launcher" /> <TextView android:layout_width="match_parent" android:layout_height="50dp" android:background="#ccc" app:layout_behavior="com.google.android.material.appbar.MyTextBehavior" android:gravity="center" android:text="我是測試的分割線" android:visibility="visible" /> <androidx.core.widget.NestedScrollView android:id="@+id/nestedScroll" android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_behavior="com.google.android.material.appbar.MyScrollBehavior"> <TextView android:id="@+id/nestedScrollLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/scroll_content" /> </androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> </LinearLayout>
Ok,修改完成之後我們看看最終的效果:
看到上面的示例,我們把常用的幾種 Behavior 都使用瞭一遍,系統的ViewOffsetBehavior 和監聽滾動的 Behavior 監聽View的 Behavior。
為瞭實現這麼一個簡單的效果就用瞭這麼多類,這麼復雜。我分分鐘就能實現!
行行,我知道你厲害,這不是為瞭演示同樣的效果,使用不同的方式實現嘛。通過 Behavior 可以實現一些嵌套滾動不能完成的效果,比如鼎鼎大名的支付寶首頁效果,美團詳情效果等。Behavior 更加的靈活,控制的粒度也更加的細。
但是如果隻是簡單實現上面的效果,我們可以用 AppBarLayout + 內部自帶的 Behavior 也能實現類似的效果,AppBarLayout內部已經封裝並使用瞭 Behavior 。我們看看如何實現。
二、CoordinatorLayout + AppBarLayout
其實內部也是基於 Behavior 實現的,內部實現為 HeaderBehavior 和 HeaderScrollingViewBehavior 。
對一些場景使用進行瞭封裝,滾動效果,吸頂效果,折疊效果等。我們看看同樣的效果,使用 AppBarLayout 如何實現吧:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" android:orientation="vertical"> <com.guadou.lib_baselib.view.titlebar.EasyTitleBar android:layout_width="match_parent" android:layout_height="wrap_content" app:Easy_title="CoordinatorLayout+AppBarLayout" /> <androidx.coordinatorlayout.widget.CoordinatorLayout android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.appbar.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" app:elevation="0dp" android:background="@color/white" android:orientation="vertical"> <ImageView android:layout_width="match_parent" android:layout_height="150dp" android:contentDescription="我是測試的圖片" android:src="@mipmap/ic_launcher" app:layout_scrollFlags="scroll" /> <TextView android:layout_width="match_parent" android:layout_height="50dp" android:background="#ccc" android:gravity="center" android:text="我是測試的分割線" app:layout_scrollFlags="noScroll" /> </com.google.android.material.appbar.AppBarLayout> <androidx.core.widget.NestedScrollView android:layout_width="match_parent" android:layout_height="wrap_content" app:layout_behavior="@string/appbar_scrolling_view_behavior"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/scroll_content" /> </androidx.core.widget.NestedScrollView> </androidx.coordinatorlayout.widget.CoordinatorLayout> </LinearLayout>
效果:
So Easy ! 真的是太方便瞭,類似的效果我們都能使用 AppbarLayout 來實現,比如一些詳情頁面頂部圖片,下面列表或ViewPager的都可以使用這種方式,更加的便捷。
三、MotionLayout
不管怎麼說,AppbarLayout 隻能實現一些簡單的效果,如果想要一些粒度比較細的效果,我們還得使用自定義 Behavior 來實現,但是它的實現確實是有點復雜,2019年谷歌推出瞭 MotionLayout 。
淘寶的出現可以說讓世上沒有難做的生意,那麼 MotionLayout 的出現可以說讓 Android 沒有難實現的動畫瞭。不管是動畫效果,滾動效果,MotionLayout 絕殺!能用 Behavior 實現的 MotionLayout 幾乎是都能做。
使用 MotionLayout 我們隻需要定義起始點和結束點就行瞭,我們這裡不需要根據百分比Fram進行別的操作,所以隻定義最簡單的使用。
我們看看如何用 MotionLayout 實現同樣的效果:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" xmlns:app="http://schemas.android.com/apk/res-auto" android:background="@color/white" android:orientation="vertical"> <com.guadou.lib_baselib.view.titlebar.EasyTitleBar android:layout_width="match_parent" android:layout_height="wrap_content" app:Easy_title="MotionLayout的動作" /> <androidx.constraintlayout.motion.widget.MotionLayout android:layout_width="match_parent" android:layout_weight="1" app:layoutDescription="@xml/scene_scroll_13" android:layout_height="0dp"> <ImageView android:id="@+id/iv_img" android:layout_width="150dp" android:layout_height="150dp" android:scaleType="centerCrop" android:contentDescription="我是測試的圖片" android:src="@mipmap/ic_launcher" /> <TextView android:id="@+id/tv_message" android:layout_width="match_parent" android:layout_height="50dp" android:background="#ccc" android:gravity="center" android:text="我是測試的分割線" tools:layout_editor_absoluteY="150dp" /> <androidx.core.widget.NestedScrollView android:id="@+id/nestedScroll" android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/nestedScrollLayout" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/scroll_content" /> </androidx.core.widget.NestedScrollView> </androidx.constraintlayout.motion.widget.MotionLayout> </LinearLayout>
定義的scene_scroll_13.xml
<?xml version="1.0" encoding="utf-8"?> <MotionScene xmlns:android="http://schemas.android.com/apk/res/android" xmlns:motion="http://schemas.android.com/apk/res-auto"> <Transition motion:constraintSetEnd="@+id/end" motion:constraintSetStart="@+id/start"> <OnSwipe motion:dragDirection="dragUp" motion:touchAnchorId="@id/nestedScroll" /> </Transition> <ConstraintSet android:id="@+id/start"> <Constraint android:id="@id/iv_img" android:layout_width="150dp" android:layout_height="150dp" android:translationY="0dp" motion:layout_constraintLeft_toLeftOf="parent" motion:layout_constraintRight_toRightOf="parent" motion:layout_constraintTop_toTopOf="parent" /> <Constraint android:id="@id/tv_message" android:layout_width="match_parent" android:layout_height="50dp" motion:layout_constraintTop_toBottomOf="@id/iv_img" /> <Constraint android:id="@id/nestedScroll" android:layout_width="match_parent" android:layout_height="0dp" motion:layout_constraintBottom_toBottomOf="parent" motion:layout_constraintTop_toBottomOf="@id/tv_message" /> </ConstraintSet> <ConstraintSet android:id="@+id/end"> <Constraint android:id="@id/iv_img" android:layout_width="150dp" android:layout_height="150dp" android:translationY="-150dp" motion:layout_constraintLeft_toLeftOf="parent" motion:layout_constraintRight_toRightOf="parent" motion:layout_constraintTop_toTopOf="parent" /> <Constraint android:id="@id/tv_message" android:layout_width="match_parent" android:layout_height="50dp" motion:layout_constraintLeft_toLeftOf="parent" motion:layout_constraintRight_toRightOf="parent" motion:layout_constraintTop_toTopOf="parent" /> <Constraint android:id="@id/nestedScroll" android:layout_width="match_parent" android:layout_height="0dp" motion:layout_constraintBottom_toBottomOf="parent" motion:layout_constraintTop_toBottomOf="@id/tv_message" /> </ConstraintSet> </MotionScene>
效果:
非常的簡單,效果很流暢,性能也很好。有時候都不得不感慨一句,有瞭 MotionLayout 要你 Behavior 何用。
總結
Android真的是太卷瞭,以前學RxJava Dagger2 NestedScrolling Behavior 等,這些都是很難學的,更難以應用,如果能學會,那都是高工瞭。現在谷歌新框架層出不窮,越來越易用瞭,越來越好入門瞭。以前學的都已經被淘汰,新入Android的同學已經可以無需門檻,直接學谷歌的腳手架就能完成效果瞭。
言歸正傳,這幾種方案大傢都理解瞭嗎?什麼時候需要用協調滾動,什麼時候需要用嵌套滾動,大傢可以做到心中有數。能用 MotionLayout 的還是推薦使用 MotionLayout 實現,畢竟實現簡單,性能優秀嘛!
當然如果僅限這種效果來說,還有很多的方式實現如RV ListView,純粹的自定義View也能實現是吧,自定義ViewGroup,ViewDragHelper一樣能實現,就是稍微麻煩點,這裡也僅從嵌套滾動和協調滾動這點來實現的。
好瞭,如果大傢理解瞭協調滾動和嵌套滾動,那萬變不離其宗,幾乎應用開發中全部的滾動效果都是基於這兩條,內部的具體實現方案幾乎都是基於這6種方案來實現。
後面如果大傢有興趣,我會出一期超復雜的嵌套具體實現相關的功能,類似美團外賣點餐的頁面分為上、中、下佈局。下佈局又分左右列表佈局 ,還分上佈局抽屜效果和中佈局吸頂效果。
到此這篇關於Android嵌套滾動與協調滾動的幾種實現方式的文章就介紹到這瞭,更多相關Android嵌套滾動內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- Android 詳解沉浸式狀態欄的實現流程
- Android實現背景圖片輪播
- Android移動應用開發指南之六種佈局詳解
- Android用viewPager2實現UI界面翻頁滾動的效果
- Android如何使用ViewPager2實現頁面滑動切換效果