首頁 > 軟體

Android巢狀捲動和協調卷動的多種實現方法

2022-06-17 14:02:02

Android的巢狀捲動的幾種實現方式

很多 Android 開發者雖然做了幾年的開發,但是可能還是對捲動的幾種方式不是很瞭解,本系列也不會涉及到底層捲動原理,只是探討一下 Android 佈局捲動的幾種方式。

什麼叫巢狀捲動?什麼叫協調卷動?

只要是涉及到捲動那必然父容器和子容器,按照原理來說子容器先捲動,當子容器滾不動了再讓父容器捲動,或者先讓父容器捲動,父容器滾不動了再讓子容器捲動,這種就叫巢狀捲動。代表為 NestedScrollView 。

如果只是子容器捲動,父容器中的其他控制元件在子容器捲動過程中做一些佈局,透明度,動畫等操作,這種叫協調卷動。代表為 CoordinatorLayout 。

這裡我們從巢狀捲動的實現方式開始講起。(不細講原理,本文只探討實現的方式與步驟!)

一、巢狀捲動 NestedScrollingParent/Child

最近看到一些文章又開始講 NestedScrollingParent/Child 的巢狀捲動了,這...屬實是懷舊了。

依稀記得大概是2017年左右吧,谷歌出了一個 NestedScrollingParent/Child 巢狀捲動,當時應該是很轟動的。Android 開發者真的苦於巢狀捲動久矣。

NestedScrolling 機制能夠讓父view和子view在捲動時進行配合,其基本流程如下:

  • 當子view開始捲動之前,可以通知父view,讓其先於自己進行卷動;
  • 子view自己進行卷動
  • 子view捲動之後,還可以通知父view繼續捲動

要實現這樣的互動,父View需要實現 NestedScrollingParent 介面,而子View需要實現 NestedScrollingChild 介面。

作為一個可以嵌入 NestedScrollingChild 的父 View,需要實現 NestedScrollingParent,這個介面方法和 NestedScrollingChild 大致有一一對應的關係。同樣,也有一個 NestedScrollingParentHelper 輔助類來默默的幫助你實現和 Child 互動的邏輯。滑動動作是 Child 主動發起,Parent 就收滑動回撥並作出響應。

  • 從上面的 Child 分析可知,滑動開始的呼叫 startNestedScroll(),Parent 收到 onStartNestedScroll() 回撥,決定是否需要配合 Child 一起進行處理滑動,如果需要配合,還會回撥 onNestedScrollAccepted()。

  • 每次滑動前,Child 先詢問 Parent 是否需要滑動,即 dispatchNestedPreScroll(),這就回撥到 Parent 的 onNestedPreScroll(),Parent 可以在這個回撥中“劫持”掉 Child 的滑動,也就是先於 Child 滑動。

  • Child 滑動以後,會呼叫 onNestedScroll(),回撥到 Parent 的 onNestedScroll(),這裡就是 Child 滑動後,剩下的給 Parent 處理,也就是 後於 Child 滑動。

  • 最後,滑動結束,呼叫 onStopNestedScroll() 表示本次處理結束。

更詳細的教學大家可以看看鴻洋的文章。

這裡我做一個簡單的範例,後面的效果都是基於這個佈局實現。

public class MyNestedScrollChild extends LinearLayout implements NestedScrollingChild {
    private NestedScrollingChildHelper mScrollingChildHelper;
    private final int[] offset = new int[2];
    private final int[] consumed = new int[2];
    private int lastY;
    private int mShowHeight;
    public MyNestedScrollChild(Context context) {
        super(context);
    }
    public MyNestedScrollChild(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //第一次測量,因為佈局檔案中高度是wrap_content,因此測量模式為ATMOST,即高度不能超過父控制元件的剩餘空間
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mShowHeight = getMeasuredHeight();
        //第二次測量,對高度沒有任何限制,那麼測量出來的就是完全展示內容所需要的高度
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (e.getAction()) {
            case MotionEvent.ACTION_DOWN:
                lastY = (int) e.getRawY();
                break;
            case MotionEvent.ACTION_MOVE:
                int y = (int) (e.getRawY());
                int dy = y - lastY;
                lastY = y;
                if (startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) //如果找到了支援巢狀捲動的父類別
                        && dispatchNestedPreScroll(0, dy, consumed, offset)) {//父類別進行了一部分捲動
                    int remain = dy - consumed[1];//獲取捲動的剩餘距離
                    if (remain != 0) {
                        scrollBy(0, -remain);
                    }
                } else {
                    scrollBy(0, -dy);
                }
        }
        return true;
    }
    //scrollBy內部會呼叫scrollTo
    //限制捲動範圍
    @Override
    public void scrollTo(int x, int y) {
        int MaxY = getMeasuredHeight() - mShowHeight;
        if (y > MaxY) {
            y = MaxY;
        }
        if (y < 0) {
            y = 0;
        }
        super.scrollTo(x, y);
    }
    private NestedScrollingChildHelper getScrollingChildHelper() {
        if (mScrollingChildHelper == null) {
            mScrollingChildHelper = new NestedScrollingChildHelper(this);
            mScrollingChildHelper.setNestedScrollingEnabled(true);
        }
        return mScrollingChildHelper;
    }
    @Override
    public void setNestedScrollingEnabled(boolean enabled) {
        getScrollingChildHelper().setNestedScrollingEnabled(enabled);
    }
    @Override
    public boolean isNestedScrollingEnabled() {
        return getScrollingChildHelper().isNestedScrollingEnabled();
    }
    @Override
    public boolean startNestedScroll(int axes) {
        return getScrollingChildHelper().startNestedScroll(axes);
    }
    @Override
    public void stopNestedScroll() {
        getScrollingChildHelper().stopNestedScroll();
    }
    @Override
    public boolean hasNestedScrollingParent() {
        return getScrollingChildHelper().hasNestedScrollingParent();
    }
    @Override
    public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
        return getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
    }
    @Override
    public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
        return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
    }
    @Override
    public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
        return getScrollingChildHelper().dispatchNestedFling(velocityX, velocityY, consumed);
    }
    @Override
    public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
        return getScrollingChildHelper().dispatchNestedPreFling(velocityX, velocityY);
    }
}

定義Parent實現文字佈局置頂效果:

public class MyNestedScrollParent extends LinearLayout implements NestedScrollingParent {
    private ImageView img;
    private TextView tv;
    private MyNestedScrollChild nsc;
    private NestedScrollingParentHelper mParentHelper;
    private int imgHeight;
    private int tvHeight;
    public MyNestedScrollParent(Context context) {
        super(context);
        init();
    }
    public MyNestedScrollParent(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    private void init() {
        mParentHelper = new NestedScrollingParentHelper(this);
    }
    //獲取子view
    @Override
    protected void onFinishInflate() {
        img = (ImageView) getChildAt(0);
        tv = (TextView) getChildAt(1);
        nsc = (MyNestedScrollChild) getChildAt(2);
        img.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                if (imgHeight <= 0) {
                    imgHeight = img.getMeasuredHeight();
                }
            }
        });
        tv.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                if (tvHeight <= 0) {
                    tvHeight = tv.getMeasuredHeight();
                }
            }
        });
        super.onFinishInflate();
    }
    //在此可以判斷引數target是哪一個子view以及捲動的方向,然後決定是否要配合其進行巢狀捲動
    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
        if (target instanceof MyNestedScrollChild) {
            return true;
        }
        return false;
    }
    @Override
    public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
        mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
    }
    @Override
    public void onStopNestedScroll(View target) {
        mParentHelper.onStopNestedScroll(target);
    }
    //先於child捲動
    //前3個為輸入引數,最後一個是輸出引數
    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        if (showImg(dy) || hideImg(dy)) {//如果需要顯示或隱藏圖片,即需要自己(parent)捲動
            scrollBy(0, -dy);//捲動
            consumed[1] = dy;//告訴child我消費了多少
        }
    }
    //後於child捲動
    @Override
    public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
    }
    //返回值:是否消費了fling
    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        return false;
    }
    //返回值:是否消費了fling
    @Override
    public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
        return false;
    }
    @Override
    public int getNestedScrollAxes() {
        return mParentHelper.getNestedScrollAxes();
    }
    //--------------------------------------------------
    //下拉的時候是否要向下捲動以顯示圖片
    public boolean showImg(int dy) {
        if (dy > 0) {
            if (getScrollY() > 0 && nsc.getScrollY() == 0) {
                return true;
            }
        }
        return false;
    }
    //上拉的時候,是否要向上捲動,隱藏圖片
    public boolean hideImg(int dy) {
        if (dy < 0) {
            if (getScrollY() < imgHeight) {
                return true;
            }
        }
        return false;
    }
    //scrollBy內部會呼叫scrollTo
    //限制捲動範圍
    @Override
    public void scrollTo(int x, int y) {
        if (y < 0) {
            y = 0;
        }
        if (y > imgHeight) {
            y = imgHeight;
        }
        super.scrollTo(x, y);
    }
}

頁面的佈局如下:

<?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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/white">
    <com.guadou.lib_baselib.view.titlebar.EasyTitleBar
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:Easy_title="NestedParent/Child的捲動" />
    <com.guadou.kt_demo.demo.demo8_recyclerview.scroll8.MyNestedScrollParent
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="150dp"
            android:contentDescription="我是測試的圖片"
            android:src="@mipmap/ic_launcher" />
        <TextView
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:gravity="center"
            android:background="#ccc"
            android:text="我是測試的分割線" />
        <com.guadou.kt_demo.demo.demo8_recyclerview.scroll8.MyNestedScrollChild
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical">
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/scroll_content" />
        </com.guadou.kt_demo.demo.demo8_recyclerview.scroll8.MyNestedScrollChild>
    </com.guadou.kt_demo.demo.demo8_recyclerview.scroll8.MyNestedScrollParent>
</LinearLayout>

看看效果:

二、巢狀捲動 NestedScrollView

NestedScrollingParent/Child 的定義也太過複雜了吧,如果只是一些簡單的效果如 ScrollView 巢狀 LinearLayout 這樣的簡單效果,我們直接可以使用 NestedScrollView 來實現

因此,我們可以簡單的把 NestedScrollView 類比為 ScrollView,其作用就是作為控制元件父佈局,從而具備巢狀滑動功能。

<?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="NestedScrollView的捲動" />
    <androidx.core.widget.NestedScrollView
        android:layout_width="match_parent"
        android:layout_height="wrap_content">
        <LinearLayout
            android:orientation="vertical"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <ImageView
                android:layout_width="match_parent"
                android:layout_height="150dp"
                android:contentDescription="我是測試的圖片"
                android:src="@mipmap/ic_launcher" />
            <TextView
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:gravity="center"
                android:background="#ccc"
                android:text="我是測試的分割線" />
            <ScrollView
                android:layout_width="match_parent"
                android:layout_height="wrap_content">
                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:text="@string/scroll_content" />
            </ScrollView>
        </LinearLayout>
    </androidx.core.widget.NestedScrollView>
</LinearLayout>

效果:

三、巢狀捲動-自定義佈局

除了使用官方提供的方式,我們還能使用自定義View的方式,自己處理事件與監聽。

使用自定義ViewGroup的方式,新增全部的佈局,並測量與排版,並且對事件做攔截處理。內部是如LinearLayout的垂直佈局,實現了 ScrollingView 支援捲動,並處理捲動。有原始碼,大概2800行程式碼,這裡就不方便貼出來了。

如何使用:

<?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="自定義View實現的捲動" />
    <com.guadou.kt_demo.demo.demo8_recyclerview.scroll10.ConsecutiveScrollerLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical">
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="150dp"
            android:contentDescription="我是測試的圖片"
            android:src="@mipmap/ic_launcher" />
        <TextView
            app:layout_isSticky="true"   //可以實現吸頂效果
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:gravity="center"
            android:background="#ccc"
            android:text="我是測試的分割線" />
        <ScrollView
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
            <TextView
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:text="@string/scroll_content" />
        </ScrollView>
    </com.guadou.kt_demo.demo.demo8_recyclerview.scroll10.ConsecutiveScrollerLayout>
</LinearLayout>

效果:

總結

其實巢狀捲動要實現類似的效果,方式還有很多種,如自定義的ViewPager,自定義ListView,或者RecyclerView加上頭佈局也能實現類似的效果。這裡我只展示了基於 ScrollingView 自行卷動的方式。

巢狀的捲動主要方式就是這些,這些簡單的效果我們用協調卷動,如 CoordinatorLayout 也能實現同樣的效果。後面會講一些協調卷動的實現由幾種方式。

到此這篇關於Android巢狀捲動和協調卷動的多種實現方法的文章就介紹到這了,更多相關Android巢狀捲動與協調卷動內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com