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!

推薦閱讀: