Android 嵌套滑动设计思想

1. 背景

本文介绍 Android 嵌套滑动机制,不会过多的关注代码细节,而是聚焦于设计思想。通过本文,您可以了解到:

  1. 为什么需要 NestedScrolling 接口?
  2. 为什么需要 CoordinatorLayout?
  3. AppBarLayout.Behavior 复杂的继承关系。

2. 自定义View实现嵌套滑动的问题

2.1 需求:嵌套滑动

为了探究 NestedScrolling 与 CoordinatorLayout 的作用,我们先不使用它们,而是通过自定义View来实现嵌套滑动,看看会有什么问题。

如下图,我们期望滑动 RecyclerView 时,上方的 HeaderView 能够跟随滑动。

2.2 实现思路

  • 自定义 ViewGroup,取名为 CustomParent,继承自 LinearLayout。
  • 事件到来时,如果需要嵌套滑动,则让 CustomParent 拦截事件,并通过 scroll 将整体进行移动。
  • 如果不需要嵌套滑动,则不拦截事件,让 RecyclerView 滑动。

2.3 实现代码

kotlin 复制代码
class CustomParent @JvmOverloads constructor(mContext: Context, attributeSet: AttributeSet? = null, flag: Int = 0) : LinearLayout(mContext, attributeSet, flag) {
    private val touchSlop = 6
    private var startY = 0f
    
    private val headerViewHeight: Int
        get() = children.filterIsInstance<HeaderView>().first().measuredHeight

    override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
        var intercepted = false
        var dy = 0f

        when (ev?.action) {
            MotionEvent.ACTION_DOWN -> {
                startY = ev.rawY
            }
            MotionEvent.ACTION_MOVE -> {
                dy = startY - ev.rawY
                if (abs(dy) > touchSlop) {
                    // 向上移动且未完全折叠、向下移动且未完全展开,拦截事件
                    intercepted = (dy > 0 && !isTotalFold()) || (dy < 0 && !isTotalExpanded())
                }
                startY = ev.rawY
            }
        }
        return intercepted
    }

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                startY = event.rawY
            }
            MotionEvent.ACTION_MOVE -> {
                val dy = startY - event.rawY
                scrollInBounds(dy.toInt())
                startY = event.rawY
            }
        }
        return super.onTouchEvent(event)
    }

    // HeaderView完全折叠
    private fun isTotalFold(): Boolean {
        return scrollY >= headerViewHeight
    }

    // HeaderView完全展开
    private fun isTotalExpanded(): Boolean {
        return scrollY == 0
    }

    // 限制范围地滑动
    private fun scrollInBounds(y: Int): Int {
        val targetScrollY = (scrollY + y).coerceIn(0, headerViewHeight)
        val diffY = targetScrollY - scrollY
        
        super.scrollBy(0, diffY)
        return diffY
    }
}

2.4 问题:受限于 Android 事件机制

观察视频后半段我们会发现,HeaderView 完全折叠后,我们手势持续往上滑,RecyclerView 并没有继续向上滚动。需要先松手,再次上滑 RecyclerView 才能上滑。

原因是,在 Android 事件体系中:

  • Down/Move 事件 会确定一条 链表 ,这条链表就是 拦截事件的 View 及其祖先
  • 同一事件序列,后续所有事件 只能由 链表的上的View 接收,从而加快事件分发效率。

在向上滑动 RecyclerView 这个案例中:

  • Down 事件 由 RecyclerView 拦截,链表如下,链表上的所有 View 都可以拦截后续事件。
  • Move 事件 由 CustomParent 拦截,从而进行 scroll。拦截后链表状态如下,RecyclerView 已不在链表中,后续无法拦截事件,所以折叠后的向上滑动事件 RecyclerView 无法接收。

3. NestedScrolling 接口诞生

3.1 问题分析

由于事件机制的限制,要想实现理想的效果,不能让父View拦截事件 。我们可以:子View拦截事件,但消费前先让父View先消费

3.2 实现思路

  • 定义一个 [接口]
  • 父 View 实现这个接口,子 View 拦截事件后,找到实现了该接口的父 View,先让父 View 消费,剩余的滑动距离再由子 View 消费。

3.3 NestedScrollingParent 、NestedScrollingChild

  • Android 已经帮我们定义好了相关的接口。
  • 为了规范流程,参与嵌套滑动的 父View 和 子View 都有对应的接口,NestedScrollingParent、NestedScrollingChild
  • 接口中定义了许多方法,约束了嵌套滑动的流程

3.4 RecyclerView 实现了 NestedScrollingChild

RecyclerView 默认实现了 NestedScrollingChild,伪代码如下:

java 复制代码
public class RecyclerView extends ViewGroup implements ScrollingView,
        NestedScrollingChild2, NestedScrollingChild3 {
 
    @Override
    public boolean onTouchEvent(MotionEvent e) {
        switch (action) {
            case MotionEvent.ACTION_DOWN: {
                // 1.寻找是否有实现了 NestedScrollingParent 的父View
                startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
            }
            case MotionEvent.ACTION_MOVE: {
                // 2.如果有相关的父View,让它先消费
                if (dispatchNestedPreScroll(...)) {
                    dx -= mReusableIntPair[0]; // mReusableIntPair 中存储了父View消费的距离
                    dy -= mReusableIntPair[1];
                }
                
                scrollByInternal(dx, dy);
            }
        }
    }
    
    boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
        // 3.自身消费父View剩余的距离
        scrollStep(x, y, mReusableIntPair);
        // 4.如果还有剩,再让父View消费
        dispatchNestedScroll(...);
    }
}

3.5 CustomParent 实现 NestedScrollingParent

RecyclerView 已经实现了 NestedScrollingChild 接口,我们只需要让 CustomParent 实现 NestedScrollingParent3,即可解决折叠后无法滑动的问题。

kotlin 复制代码
class CustomParent: LinearLayout(...) , NestedScrollingParent3 {

    override fun onStartNestedScroll(child: View, target: View, axes: Int, type: Int): Boolean = true

    /**
    RecyclerView消费前,如果是 向上滑动、未完全折叠,我们先消费该距离
    */
    override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {         
        if (dy > 0 && !isTotalFold()) {             
            val consumedY = scrollInBounds(dy)
            consumed[1] = consumedY // 告诉子View消费了多少         
        }     
    }
    
    /**
    RecyclerView消费后,如果是 向下滑动、未完全展开,我们消费剩下的距离
    */     
    override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
        if (dyUnconsumed < 0 && !isTotalExpanded()) {
            val consumedY = scrollInBounds(dyUnconsumed)
            consumed[1] = consumedY // 告诉子View消费了多少
        }
    }
    
    // ...
}

3.6 效果

现在,HeaderView 折叠后,RecyclerView 也能继续滑动了。

3.7 问题:父View耦合子View的逻辑

当前实现下,子View想要嵌套滑动,是依托父View的能力。这导致父View需要 耦合 很多子View的逻辑,有没有更加低耦合的方案实现嵌套滑动呢?

4. CoordinatorLayout

4.1 具备哪些能力?

官方提供的 CoordinatorLayout 组件提供了 嵌套滑动、解耦 的能力。除此之外,它还具备 子View可相互依赖 的能力。下面我们依次来看一下。

4.2 实现了 NestedScrollingParent

CoordinatorLayout 实现了 NestedScrollingParent 接口,使其具备 嵌套滑动 的能力。

注:上一节描述的,子 View 滑动前会寻找实现 NestedScrollingParent 的接口,先让父 View 消费。

4.3 任务委托给 Behavior

4.3.1 原理

不仅仅是嵌套滑动,CoordinatorLayout 会先将大部分任务 委托 给子 View 的 Behavior 处理。如果没有子 View 处理,才由自身处理。从而进行 解耦

想要处理特定任务的子 View,可以以 插件化 的方式为自己 设置 Behavior,并重写相关方法。

4.3.2 源码分析:嵌套滑动分发

这里拿嵌套滑动任务的委托来体现源码,其他类型的任务是差不多的。

  • 让所有 Behavior 都消费嵌套滑动事件。
  • 返回消费的最多的 Behavior 的消费值。
java 复制代码
// CoordinatorLayout.java

@Override
public void onNestedPreScroll(View target , int dx , int dy , int[] consumed , int  type) {
    // ...
    int xConsumed = 0 ;
    int yConsumed = 0 ;
    
    final int childCount = getChildCount() ;
    for (int i = 0 ; i < childCount ; i++) {
        final View view = getChildAt(i) ;
        final LayoutParams lp = (LayoutParams) view.getLayoutParams() ;
        final Behavior viewBehavior = lp.getBehavior() ;
        
        if (viewBehavior != null) {
            viewBehavior.onNestedPreScroll(this , view , target , dx , dy , mBehaviorConsumed , type) ;

            xConsumed = dx > 0 ? Math.max(xConsumed , mBehaviorConsumed[0]) : Math.min(xConsumed , mBehaviorConsumed[0]) ;
            yConsumed = dy > 0 ? Math.max(yConsumed , mBehaviorConsumed[1]) : Math.min(yConsumed , mBehaviorConsumed[1]) ;
        }
    }

    consumed[0] = xConsumed ;
    consumed[1] = yConsumed ;

    // ...
}

4.4 子View可相互依赖

4.4.1 CoordinatorLayout 默认布局方式

CoordinatorLayout 的默认布局与 FrameLayout 一致,从左上方开始布局 ,子 View 会有 重叠 的效果,如下图。

如果想让子 View 依赖另一个子 View,可以继承 Behavior 的 layoutDependsOnonDependentViewChanged 方法。

4.4.2 ScrollingViewBehavior

AppBarLayout.ScrollingViewBehavior 是一个很好的例子来体现子 View 间的依赖关系。

4.4.2.1 使用场景:Rv 紧贴 Header
  • 现在想实现 RecyclerView 紧贴于 Header 下方。
  • 首先,在 Header 外头 包一层 AppBarLayout
  • 然后为 RecyclerView 设置 ScrollingViewBehavior
xml 复制代码
<androidx.coordinatorlayout.widget.CoordinatorLayout
    android:id="@+id/coordinatorLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    >

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appbar_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <HeaderView
            android:id="@+id/header_view"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            />

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior = "@string/appbar_scrolling_view_behavior"
        />

</androidx.coordinatorlayout.widget.CoordinatorLayout>
  • 即可实现 RecyclerView 紧贴 Header 下方,即使 Header 移动时,RecyclerView 也会跟着移动。
4.4.2.2 源码分析:ScrollingViewBehavior

我们来看看 ScrollingViewBehavior 的核心源码:

java 复制代码
public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {

    @Override
    public boolean layoutDependsOn(CoordinatorLayout parent , View child , View dependency) {
       // 1.依赖 AppBarLayout
      return dependency instanceof AppBarLayout ;
    }
    
    @Override
    public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent , @NonNull View child , @NonNull View dependency) {
       // 2.AppBarLayout变化时,调整自身布局位置,紧贴 AppBarLayout
      offsetChildAsNeeded(child , dependency) ;
    }
    
    private void offsetChildAsNeeded(@NonNull View child , @NonNull View dependency) {
        ViewCompat.offsetTopAndBottom(
            child, 
            dependency.getBottom() - child.getTop()  // ...
        );
    }
}

为什么 RecyclerView 的大小会超出屏幕?

答:因为 RecyclerView 的高度是 Match_Parent,也就是对其 CoordinatorLayout 的高度,虽然 ScrollingViewBehavior 改变了 RecyclerView 的布局位置,但并没有改变它的大小。

4.4.3 源码分析:依赖监听原理

上一小节中体现了 CoordinatorLayout 支持子 View 相互依赖的能力,这一小节看看它是如何实现的。

绘制后 ,判断 被依赖View绘制区域 是否改变,如果改变了,通知依赖View。

java 复制代码
class CoordinatorLayout {
    private OnPreDrawListener mOnPreDrawListener ;
    
    @Override
    public void onAttachedToWindow() {
        ...
        final ViewTreeObserver vto = getViewTreeObserver() ;
        vto.addOnPreDrawListener(mOnPreDrawListener) ;
    }

    class OnPreDrawListener implements ViewTreeObserver.OnPreDrawListener {
        @Override
        public boolean onPreDraw() {
            onChildViewsChanged(EVENT_PRE_DRAW) ;
            return true ;
        }
    }
        
    final void onChildViewsChanged(...) {
        ...
        final int childCount = mDependencySortedChildren.size() ;
        
         // 1.遍历 被依赖View
        for (int i = 0 ; i < childCount ; i++) {
            final View child = mDependencySortedChildren.get(i) ;
            
             // 2.判断绘制区域是否改变
            getChildRect(child , true , drawRect) ;
            getLastChildRect(child , lastDrawRect) ;
            if (lastDrawRect.equals(drawRect)) {
                continue;
            }
            
             // 3.如果变了,通知依赖的 View
            for (int j = i + 1 ; j < childCount ; j++) {
                final View checkChild = mDependencySortedChildren.get(j) ;
                final Behavior b = checkLp.getBehavior() ;
                
                if (b != null && b.layoutDependsOn(this , checkChild , child)) {
                    ...
                    b.onDependentViewChanged(this , checkChild , child) ;
                }
            }
        }
    }
}

5. AppBarLayout

5.1 具备哪些能力?

我们先按下面的方式实现 xml,看看效果。

xml 复制代码
<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">

        <TextView
            ...
            android:text="ViewInAppbar - scroll"
            app:layout_scrollFlags= "scroll" 
            />

        <TextView
            ...
            android:text="ViewInAppbar - snap"
            app:layout_scrollFlags= "snap" 
            />

    </com.google.android.material.appbar.AppBarLayout>

    <!--下面的 Behavior 就是上节讲到的 ScrollingViewBehavior -->
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior= "@string/appbar_scrolling_view_behavior" 
        />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

从视频中可以看出,AppBarLayout 包裹的内容具备以下能力:

  1. 随手势滑动
  2. Fling
  3. 部分吸顶
  4. 响应嵌套滑动(下拉 RecyclerView 时 AppbarLayout 也跟着下移)

5.2 实现思路

AppBarLayout 默认为自己设置了 AppbarLayout.Behavoir,从而实现上述功能。

注:上一节描述的,CoordinatorLayout 的子 View 可以通过 Behavior 来代理 CoordinatorLayout 的任务。这样做耦合度更低。

其继承关系如下:

可见还是比较复杂的,我们依次看看每个 Behavior 的职责。

5.3 ViewOffsetBehavior - 支持偏移、记住偏移量

假如一个 View 监听了事件,在 Move 事件 时,通过 offsetTopAndBottom 移动随手势移动自身。

但当 requestLayout View 会恢复正常的布局,因为 offsetTopAndBottom 并不会改变父 View 对其的布局方式。

ViewOffsetBehavior 为子 View 提供了 记住偏移量 的能力:

  • 如下图,ViewOffsetHelper 存储了偏移量 offsetTop
  • 每次 setTopAndBottomOffset 时,都会更新 offsetTop。
  • ViewOffsetBehavior 代理了自身的布局逻辑,会在自身布局后,再偏移 offsetTop。

为子 View 设置 ViewOffsetBehavior 后,调用 offsetTopAndBottom 后再刷新,也不会恢复原位。

5.4 HeaderBehavior - 支持手势、Fling

视频中可以看见,AppbarLayout 的内容会随手势移动,同时支持Fling。这个两个功能是在 HeaderBehavior 中实现的。从类图可以看出其实现:

  • 代理 CoordinatorLayout 的 onInterceptTouchEvent,子 View 还能滑动时拦截事件。
  • 代理 CoordinatorLayout 的 onTouchEvent。
    • Move 事件时,利用 ViewOffsetBehavior 的能力偏移子 View。
    • Up 事件时,OverScroller、VelocityTracker 的能力进行 fling。

5.5 AppBarLayout.BaseBehavior - 嵌套滑动、部分吸顶

BaseBehavior 则实现了剩余的两个能力:嵌套滑动、部分内容吸顶。我们分别介绍一下:

嵌套滑动:

代理 CoordinatorLayout 的 onNestedPreScroll/onNestedScroll。嵌套滑动事件到来时,利用 ViewOffsetBehavior 的能力偏移自身。

部分吸顶:

通过 限制滑动范围 来实现。在滑动前,会调用 AppBarLayout.getDownNestedScrollRange 方法,获取可滑动的范围,进行约束。

java 复制代码
// BaseBehavior.java

@Override
public void onNestedScroll(...) {
    // ...
    consumed[1] = 
        scroll(coordinatorLayout , child , dyUnconsumed , -child.getDownNestedScrollRange() , 0) ;
}

AppBarLayout.getDownNestedScrollRange 的原理是将 第一个不可滑动的View 之前的所有 View 的高度,当作是滑动范围进行约束。

6. 常见问题

6.1 NestedScrollingParent、NestedScrollingParent2、NestedScrollingParent3 的区别?

在 3.3 节提到,接口的作用,除了能让子 View 找到参与嵌套滑动的父 View 外,还能约束嵌套滑动的流程。

接口的 版本演进 是为了增强功能,从而 规范流程

例如,NestedScrollingParent2 在 NestedScrollingParent 的基础上,增加了 滑动类型 的概念。

约束了子 View 发起嵌套滑动时,需要告诉父 View 这是手势滑动还是 fling。

java 复制代码
public class ViewCompat {
    @IntDef({TYPE_TOUCH, TYPE_NON_TOUCH}) // TYPE_TOUCH:手势滑动,TYPE_NON_TOUCH:fling
    public @interface NestedScrollType {}
}

public interface NestedScrollingParent {
    boolean onStartNestedScroll(
        @NonNull View child, 
        @NonNull View target, 
        @ScrollAxis int axes
    );
}

public interface NestedScrollingParent2 extends NestedScrollingParent {
    boolean onStartNestedScroll(
        @NonNull View child, 
        @NonNull View target, 
        @ScrollAxis int axes,
        @NestedScrollType int type // 与1.0版本的区别在此
    );
}

在没有 NestedScrollingParent2 的时期,在需要判断滑动类型的场景,开发者可能不想使用 NestedScrollingParent,而是自己定义一套接口,这会造成不同组件的 兼容性问题

所以后来官方就提供了 NestedScrollingParent 的 2.0 版本。

6.2 滑动冲突处理

AppBarLayout fling 的同时,手势滑动 RecyclerView,可能导致 嵌套滑动事件fling 的滑动冲突。

我们可以继承 AppBarLayout.Behavior,重写 onStartNestedScroll 方法。嵌套滑动开始时,反射拿到 scroller,停止 AppBarLayout 的 fling。

相关推荐
阿巴斯甜3 小时前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker3 小时前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95274 小时前
Andorid Google 登录接入文档
android
黄林晴6 小时前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab18 小时前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿21 小时前
Android MediaPlayer 笔记
android
Jony_21 小时前
Android 启动优化方案
android
阿巴斯甜21 小时前
Android studio 报错:Cause: error=86, Bad CPU type in executable
android
张小潇21 小时前
AOSP15 Input专题InputReader源码分析
android
_小马快跑_1 天前
Kotlin | 协程调度器选择:何时用CoroutineScope配置,何时用launch指定?
android