Android嵌套滚动与协调滚动的几种实现方式

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 就收滑动回调并作出响应。

  1. 从上面的 Child 分析可知,滑动开始的调用 startNestedScroll(),Parent 收到 onStartNestedScroll() 回调,决定是否需要配合 Child 一起进行处理滑动,如果需要配合,还会回调 onNestedScrollAccepted()。

  2. 每次滑动前,Child 先询问 Parent 是否需要滑动,即 dispatchNestedPreScroll(),这就回调到 Parent 的 onNestedPreScroll(),Parent 可以在这个回调中“劫持”掉 Child 的滑动,也就是先于 Child 滑动。

  3. Child 滑动以后,会调用 onNestedScroll(),回调到 Parent 的 onNestedScroll(),这里就是 Child 滑动后,剩下的给 Parent 处理,也就是 后于 Child 滑动。

  4. 最后,滑动结束,调用 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>

看看效果:

[图片上传失败…(image-ea904b-1655281923803)]

二、嵌套滚动 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学习笔记+视频资料领取可扫描下方二维码👇

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值