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。

相关推荐
你过来啊你26 分钟前
Android用户鉴权实现方案深度分析
android·鉴权
恣艺4 小时前
LeetCode 854:相似度为 K 的字符串
android·算法·leetcode
阿华的代码王国4 小时前
【Android】相对布局应用-登录界面
android·xml·java
用户207038619495 小时前
StateFlow与SharedFlow如何取舍?
android
QmDeve5 小时前
原生Android Java调用系统指纹识别方法
android
淹没5 小时前
🚀 告别复杂的HTTP模拟!HttpHook让Dart应用测试变得超简单
android·flutter·dart
HX4366 小时前
MP - List (not just list)
android·ios·全栈