聊聊Android嵌套滑动
最近工作中遇到了需求是使用 Bottom-Sheet 交互的弹窗,使用了 design 包里面的 CoordinatorLayout
和 BottomSheetBehavior
,因为弹窗承载的页面相对来说还是比较复杂的页面,所以也踩了好几个坑。之前UI交互类的东西接触的也比较少,于是把Android里面嵌套滑动相关的内容也过了一遍,在这里做一些分享。
在嵌套滑动控件的场景中,可以在Android的事件分发机制本身做一些处理,外部拦截或者内部消化触摸事件。但是这样的解决方法有几个弊端:
- 代码复杂,难以维护
- 事件分发机制中子view消耗了事件没有办法通知父View,这样实现的效果非常的突兀,难以达到预期
于是 Android 在 5.0 之后除了一系列的嵌套滑动支持的组件。这些组件现在也都在 androidx
包下面。
嵌套滑动的机制
实现嵌套滑动需要实现以下几个接口:
NestedScrollingParent
NestedScrollingParent2
NestedScrollingParent3
这3个接口实际上是继承关系:
NestedScrollingParent3 extends NestedScrollingParent2 extends NestedScrollingParent
在可以滑动的view(例如 NestedScrollView
、 RecyclerView
) 中,开始嵌套滑动都依赖NestedScrollingChildHelper
这个对象。RecyclerView
为例:
嵌套滑动我们最先接触到的可能就是 NestedScrollView
这个控件了,那么它是怎么支持嵌套滑动的呢?我们仍然从它的touch事件处理流程开始看:
在它的 onInterceptTouchEvent
中,当手势是 MOVE
的时候, 如果是垂直方向滑动并且达到滑动定义的距离,就开始执行滑动:
当手势是 DOWN
的时候,开始嵌套滑动:
当手势是 MOVE
的时候,结束嵌套滑动:
最终,是否拦截触摸事件,都交由自己是否正在拖拽状态来觉得,如果是,就拦截。这样 NestedScrollView
里面的view 才可能完全跟着一起滑动。
if ((action == MotionEvent.ACTION_MOVE) && mIsBeingDragged) {
return true;
}
// ...
return mIsBeingDragged;
如果滑动的时候是移动手势的话,事件会被拦截下来交给自己去处理。如果是其他手势,滑动的时候拦截,不滑动的时候不拦截。如果滑动的时候不拦截的话,手势事件会交给子view去处理,如果子view是可以滚动的,这时候就会有冲突,所有滚动的时候事件要拦截下来交给自己处理。
接下来看下,如果拦截下来了, NestedScrollView
是如何处理触摸事件的:
DOWN
的时候直接触发嵌套滑动:
MOVE
的时候
在 mIsBegingDragged
的false但是距离还没到的时候,让父布局不要拦截事件,
当 mIsBegingDragged
为true的时候,分发嵌套预滚动事件。如果消费这个事件,相应的修改距离。
接着分发嵌套滚动事件,中间还有一些针对 Scroll mode的处理,我们这里不关心:
UP
的时候会根据距离判断是否需要消费快速滑动,如果不则会进行分发:
所以我们需要关注的就是:
startNestedScroll -> dispatchNestedPreScroll -> dispatchNestedScroll -> stopNestedScroll 的这个过程。
这些都是 NestedScrollView
里面的 NestedScrollingChildHelper
对象完成的。
开始嵌套滑动的时候,如果view是支持嵌套滑动的,则会view树一直往上寻找
while (p != null) {
// ... todo
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
todo的地方会调用ViewParentCompat
的 onStartNestedScroll,如果view的父布局同意view嵌套滑动,则返回true,如果不同意就继续询问父布局的父布局是否同意,如果到view树的最顶端还不支持,那么就返回false,无法进行嵌套滚动了。
那么这时候只要滑动方向是竖直方向,就可以认为是支持子View嵌套滑动了。
接下来看看 dispatchNestedPreScroll
:
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,@Nullable int[] offsetInWindow, @NestedScrollType int type) {
if (isNestedScrollingEnabled()) {
// todo
}
return false;
}
这里如果没有支持嵌套滑动,那么直接返回false,也就是父布局不去处理嵌套滑动事件。继续看正常支持时候的逻辑:
代码语言:javascript复制ViewParentCompat.onNestedPreScroll(parent, mView, dx, dy, consumed, type);
return consumed[0] != 0 || consumed[1] != 0;
这里只要父布局消费了距离,就会返回true。
onNestedPreScroll
的逻辑就和 onStartNestedScroll
非常类似了:
假设还是 NestedScrollView
外层套了NestedScrollView
:
dispatchNestedPreScroll(dx, dy, consumed, null, type);
会继续往父布局的父布局分发 pre-scroll
。
接下来会继续执行 dispatchNestedScroll
:
ViewParentCompat.onNestedScroll(parent,
mView,
dxConsumed,
dyConsumed,
dxUnconsumed,
dyUnconsumed,
type,
consumed);
onNestedScroll
执行逻辑也和之前2个方法类似:
在 NestedScrollView
里,仍然会带着最新的消费距离去继续分发嵌套滚动的事件:
这里父布局会接收到子view传来的 dyUnconsumed
,然后进行 scrollBy.这里正好也就能解释了为什么需要 pre-scroll
这个操作。因为有了一次 pre-scroll
操作,我们才可以让子view在第一次执行嵌套滑动分发的时候,带上自己没有消费的距离,也就是 unconsumedY
:
到这里 Android 的嵌套滑动机制就比较明了了,只要实现了这几个接口,再借助系统提供的这几个 Helper 类,我们就能很轻松的实现嵌套滑动效果。并且子 View 在消费了事件之后,还可以把剩下没有消费的事件交给父 View 继续处理,这样滑动事件就不会断的很突兀,非常的给力。
嵌套滚动方案的选择
有了这些接口之后,我们可以看到其实内置的Android 控件都支持了滑动嵌套,那么是否我们平时使用的方法都是正确的呢?不全是,最常见的比如 NestedScrollView
包裹 RecyclerView
,这时候 NestedScrollView
会把 UNSPECIFIED
传递给 RecyclerView
的 onMeasure
, 最终表现就是,不管你的列表有多少数据,都给你一次性加载出来。
具体细节这里推荐一篇文章,里面有讲解:https://juejin.im/post/6844903938395734024
于是有没有更好的选择方案呢?有,那就是使用 design 的 CoordinatorLayout
加 Behavior
。CoordinatorLayout
在布局上其实和我们常见的 FrameLayout
没有差别,但是它内部实现了嵌套滑动的接口来支持包裹一个可以支持嵌套滑动的Scroll 组件,并且把交互抽象到 Behavior
中进行处理。常见的有 AppBarLayout.Behavior
和 BottomSheetBehavior
, 前者是 appbar 的部分网上滑动之后固定在顶部,后者是从下网上弹出布局,这2种都是 MD 设计中常见的交互。
CoordinatorLayout
这里结合我最近使用到的 BottomSheetBehavior
来介绍一下 CoordinatorLayout
是怎么处理嵌套滑动的。关于bottomsheet的基础使用,我们可以参考官方文档或者网上的文章,这里找了一篇,没有使用过这个组件的可以先快速看一下:https://www.jianshu.com/p/0a7383e0ad0f
这里的 bottomsheet Dialog 的布局,其实是 design 包里面内置的,我们也可以自己实现这个dialog,布局是这样的:
这里需要让它第二个子view传入一个 behavior,这里是系统 BottomSheet 手势的behavior。
分别看下 CoordinatorLayout
和Behavior
:
public class CoordinatorLayout extends ViewGroup implements NestedScrollingParent2,
NestedScrollingParent3
CoordinatorLayout
实现了 NestedScrollingParent2
和NestedScrollingParent3
,是一个嵌套滚动的父控件。
Behavior则实现了一系列看起来和View
很像的方法:
- onInterceptTouchEvent
- onTouchEvent
- onMeasureChild
- onLayoutChild
- onStartNestedScroll
- onNestedScrollAccepted
- onStopNestedScroll
- onNestedScroll
- onNestedFling
还有几个比较重要的它自己的方法:
- layoutDependsOn 确定子view是否有其他布局作为依赖项,场景的appbar滚动固定的就会返回true
- onDependentViewChanged
- onDependentViewRemoved
看 CoordinatorLayout
的 onInterceptTouchEvent
方法:
这里会找到顶部的子view然后按照z轴来排序,然后遍历子view查看有没有 behavior,如果拦截到的事件不是 down的话,就触发一次 cancel 手势。然后再处理真正拦截到手势。也就是把拦截触摸事件的行为交给了自己的 Behavior
.
看下 Behavior
的拦截:
满足
- 当前有滑动的子view
- 手势是move
- 不忽略事件
- 状态不是正在滑动
- 手势触发的坐标不在滑动的子view内
- 达到了滑动定义的要求
这些同时满足的话,则说明子child不是可以滑动的,那么就直接拦截事件全部交给 CoordinatorLayout
自己处理。否则就不拦截,交给子View去处理。
同理,如果没有拦截事件的话, onTouchEvent
也会交给 Behavior
去处理:
在 BottomSheetBehavior
里面,则会根据距离来切换 STATE_EXPANDED
、 STATE_COLLAPSED
之类的状态来达到最终的效果。例如上图的,当dy大于0,说明是向上滑动,如果最新的top值比展开的状态坐标小,那么就把状态置为 STATE_EXPANDED
, 然后调用 offsetTopAndBottom
做距离上的变换。其他状态的处理也是类似。在这个方法里面,开始了真正的嵌套滑动。当距离到了最大的高度,为 STATE_EXPANDED
的时候,
拦截事件的条件:
state != STATE_DRAGGING
就成立了,这时候事件就被 CoordinatorLayout
拦截下来,内部的滑动控件就开始正常滑动。
总结
到这里,Android的嵌套滑动机制就介绍完了。不过 CoordinatorLayout
和 Behavior
虽然封装的很好,但是在很多场景下其实也还是有意想不到的坑,这个时候就需要具体情况具体分析,在这些关键的方法里面,一般也都可以找到答案。