(转载请注明作者:RubiTree,地址:blog.rubitree.com )
(图片加载速度稍慢,还请看官耐心等待~)
(博客搭得有点简陋,可以点击 >> RubiTree 的掘金专栏 << 获得更好的浏览体验哦~)
目录:
NestedScrolling 机制翻译过来叫嵌套滑动机制(本文将混用),它提供了一种优雅解决嵌套滑动问题的方案,具体是什么方案呢?我们从嵌套的同向滑动说起。
(文章有一点点长,但一定是字有所值的,把结尾的一句话搬到前面:如果你没有跳过地看完本文,关于 NestedScrolling 机制,我相信现在无论是原理、还是使用 (此处有彩蛋) 、甚至八卦历史,你都了解得一清二楚了,否则我只能怀疑你的我的语文老师表达水平了)
所谓嵌套同向滑动,就是指这样一种情况:两个可滑动的View内外嵌套,而且它们的滑动方向是相同的。
这种情况如果使用一般的处理方式,会出现交互问题,比如使用两个ScrollView
进行布局,你会发现,触摸着内部的ScrollView
进行滑动,它是滑不动的(不考虑后来 Google 给它加的NestedScroll
开关):
(温馨提示:本文涉及事件分发的内容比较多,建议对事件分发不太熟悉的同学先阅读另一篇透镜《看穿 > 触摸事件分发》)
如果你熟悉 Android 的触摸事件分发机制,那么原因很好理解:两个ScrollView
嵌套时,滑动距离终于达到滑动手势判定阈值(mTouchSlop
)的这个MOVE
事件,会先经过父 View 的onInterceptTouchEvent()
方法,父 View 于是直接把事件拦截,子 View 的onTouchEvent()
方法里虽然也会在判定滑动距离足够后调用requestDisallowInterceptTouchEvent(true)
,但始终要晚一步。
而这个效果显然是不符合用户直觉的
那用户希望看到什么效果呢?
ScrollView
进行滑动时,能先滑动内部的ScrollView
,只有当内部的ScrollView
滑动到尽头时,才滑动外部的ScrollView
这看上去非常自然,也跟触摸事件的处理方式一致,但相比触摸事件的处理,要在滑动时实现同样的效果却会困难很多
那能不能把事件拦截机制变成双向的呢?不是不行,但这显然违背了拦截机制的初衷,而且它很快会发展成无限递归的:双向的事件拦截机制本身是否也需要一个拦截机制呢?于是有了拦截的拦截,然后再有拦截的拦截的拦截...
换一个更直接的思路,如果我们的需求始终是内部滑动优先,那是否可以让外部 View「拦截滑动的判定条件」比内部 View「申请外部不拦截的判定条件」更严格,从而让滑动距离每次都先达到「申请外部不拦截的判定条件」,子 View 就能够在父 View 拦截事件前申请外部不拦截了。
能看到在ScrollView
中,「拦截滑动的判定条件」和「申请外部不拦截的判定条件」都是Math.abs(deltaY) > mTouchSlop
,我们只需要增大「拦截滑动的判定条件」时的mTouchSlop
就行了。
但实际上这样做并不好,因为mTouchSlop
到底应该增加多少,是件不确定的事情,手指滑动的快慢和屏幕的分辨率可能都会对它有影响。
所以可以换一种实现,那就是让第一次「拦截滑动的判定条件」成立时,先不进行拦截,如果内部没有申请外部不拦截,第二次条件成立时,再进行拦截,这样也同样实现了开始的思路。
于是继承 ScrollView
,覆写它的onInterceptTouchEvent()
:
class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
private var isFirstIntercept = true
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
isFirstIntercept = true
}
val result = super.onInterceptTouchEvent(ev)
if (result && isFirstIntercept) {
isFirstIntercept = false
return false
}
return result
}
}
它的效果是这样,能看到确实实现了让内部先获取事件:
但我们希望体验能更好一点,从上图能看到,内部即使在自己无法滑动的时候,也会对事件进行拦截,无法通过滑动内部来让外部滑动。其实内部应该在自己无法滑动的时候,直接在onTouchEvent()
返回false
,不触发「申请外部不拦截的判定条件」,就能让内外都有机会滑动。
这个要求非常通用而且合理,在SimpleNestedScrollView
基础上进行简单修改,加上以下代码:
private var isNeedRequestDisallowIntercept: Boolean? = null
override fun onTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) isNeedRequestDisallowIntercept = null
if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
if (isNeedRequestDisallowIntercept == false) return false
if (isNeedRequestDisallowIntercept == null) {
val offsetY = ev.y.toInt() - getInt("mLastMotionY")
if (Math.abs(offsetY) > getInt("mTouchSlop")) { // 滑动距离足够判断滑动方向是上还是下后
// 判断自己是否能在对应滑动方向上进行滑动(不能则返回false)
if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
isNeedRequestDisallowIntercept = false
return false
}
}
}
}
return super.onTouchEvent(ev)
}
private fun isScrollToTop() = scrollY == 0
private fun isScrollToBottom(): Boolean {
return scrollY + height - paddingTop - paddingBottom == getChildAt(0).height
}
getInt("mLastMotionY")
和getInt("mTouchSlop")
为反射代码,获取私有的mLastMotionY
和mTouchSlop
属性运行效果如下:
这样就完成了对嵌套滑动View最基本的需求:大家都能滑了。
后来我发现了一种更野的路子,不用小心翼翼地让改动尽量小,既然内部优先,完全可以让内部的ScrollView
在DOWN
事件的时候就申请外部不拦截,然后在滑动一段距离后,如果判断自己在该滑动方向无法滑动,再取消对外部的拦截限制,思路是类似的但代码更简单。
class SimpleNestedScrollView(context: Context, attrs: AttributeSet) : ScrollView(context, attrs) {
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) parent.requestDisallowInterceptTouchEvent(true)
if (ev.actionMasked == MotionEvent.ACTION_MOVE) {
val offsetY = ev.y.toInt() - getInt("mLastMotionY")
if (Math.abs(offsetY) > getInt("mTouchSlop")) {
if ((offsetY > 0 && isScrollToTop()) || (offsetY < 0 && isScrollToBottom())) {
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
return super.dispatchTouchEvent(ev)
}
}
运行的效果跟上面是一样的,不重复贴图了。
但这两种方式目前为止都没有实现最好的交互体验,最好的交互体验应该让内部不能滑动时,能接着滑动外部,甚至在你滑动过程中快速抬起时,接下来的惯性滑动也能在两个滑动View间传递。
由于滑动这个交互的特殊性,我们可以在外部对它进行操作,所以连续滑动的实现非常简单,只要重写scrollBy
就好了,所以在已有代码的基础上再加上下面的代码(上面的两种思路都是加一样的代码):
override fun scrollBy(x: Int, y: Int) {
if ((y > 0 && isScrollToTop()) || (y < 0 && isScrollToBottom())) {
(parent as View).scrollBy(x, y)
} else {
super.scrollBy(x, y)
}
}
效果如下:
而惯性滑动的实现就会相对复杂一点,得对computeScroll()
方法下手,要做的修改会多一些,这里暂时不去实现了,但做肯定是没问题的。
到这里我们对嵌套滑动交互的理解基本已经非常通透了,知道了让我们自己实现也就那么回事,主要需要解决下面几个问题:
这时就可以(终于可以了)来看看看系统提供的 NestedScrolling 机制是怎么完成嵌套滑动需求的,跟我们的实现相比,有什么区别,是更好还是更好?
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
与我们不同,我们只考虑了给ScrollView
增加支持嵌套滑动的特性,但系统开发者需要考虑给所有有滑动交互的 View 增加这个特性,所以一个直接的思路是在 View 里加入这个机制。
那么要怎么加,加哪些东西呢?
View
里是不能放其他View
的,它只能是内部的、主动的角色,而ViewGroup
既可以放在另一ViewGroup
里,它里边也可以放其他的View
,所以它可以是内部的也可以是外部的角色View
和ViewGroup
的继承关系,所以一个很自然的设计是:在View
中加入主动逻辑,在ViewGroup
中加入被动逻辑因为不是每个View
和ViewGroup
都能够滑动,滑动只是众多交互中的一种,View
和ViewGroup
不可能直接把所有事情都做了然后告诉你:Android 支持嵌套滑动了哦~ 所以 Google 加入的这些逻辑其实都是帮助方法,相关的View
需要选择在合适的时候进行调用,最后才能实现嵌套滑动的效果。
先不说加了哪些方法,先说 Google 希望能帮助你实现一个什么样的嵌套滑动效果:
ns child
和ns parent
,对应了上面的内部 View 和外部 View
nested scroll
的缩写;2)为什么叫逻辑上?因为实际上它允许你一个 View 同时扮演两个角色ns child
会在收到DOWN
事件时,找到自己祖上中最近的能与自己匹配的ns parent
,与它进行绑定并关闭它的事件拦截机制ns child
会在接下来的MOVE
事件中判定出用户触发了滑动手势,并把事件流拦截下来给自己消费MOVE
事件增加的滑动距离:
ns child
并不是直接自己消费,而是先把它交给ns parent
,让ns parent
可以在ns child
之前消费滑动ns parent
没有消费或是没有消费完,ns child
再自己消费剩下的滑动ns child
自己还是没有消费完这个滑动,会再把剩下的滑动交给ns parent
消费ns child
可以做最终的处理ns child
的computeScroll()
方法中,ns child
也会把自己因为用户fling
操作引发的滑动,与上一条中用户滑动屏幕触发的滑动一样,使用「parent -> child -> parent -> child」的顺序进行消费注:
- 以上过程参考当前最新的
androidx.core 1.1.0-alpha01
中的NestedScrollView
和androidx.recyclerView 1.1.0-alpha01
中的RecyclerView
实现,与之前的版本细节略有不同,后文会详述其中差异- 为了理解上的方便,有几处细节的描述做了简化:其实在
NestedScrollView
、RecyclerView
这类经典实现中:
- 在
ns child
滚动时,只要用户手指一按下,ns child
就会拦截事件流,不用等到判断出滑动手势(具体可以关注源码中的mIsBeingDragged
字段)
- 这个细节是合理的,会让用户体验更好
- (后文将不会对这个细节再做说明,而是直接用简化的描述,实现时如果要提高用户体验,需要注意这个细节)
- 按照 Android 的触摸事件分发规则,如果
ns child
内部没有要消费事件的 View,事件也将直接交给ns child
的onTouchEvent()
消费。这时在NestedScrollView
等ns child
的实现中,接下来在onTouchEvent()
里判断出用户是要滑动自己之前,就会把用户的滑动交给ns parent
进行消费(回到4.4)
- 这个设计我个人觉得不太合理,既然是传递滑动那就应该在判断出用户确实在滑动之后才开始传递,而不是这样直接传递,而且在后文的实践部分,你确实能看到这种设计带来的问题
- (后文的描述中如果没有特别说明,也是默认忽略这个细节)
- 描述中省略了关于直接传递 fling 的部分,因为这块的设计存在问题,而且最新版本这部分机制的作用已经非常小了,后面这点会详细讲
你会发现,这跟我们自己实现嵌套滑动的方式非常像,但它有这些地方做得更好(具体怎么实现的见后文)
ns child
使用更灵活的方式找到和绑定自己的ns parent
,而不是直接找自己的上一级结点ns child
在DOWN
事件时关闭ns parent
的事件拦截机制单独用了一个 Flag 进行关闭,这就不会关闭ns parent
对其他手势的拦截,也不会递归往上关闭祖上们的事件拦截机制。ns child
直到在MOVE
事件中确定自己要开始滑动后,才会调用requestDisallowInterceptTouchEvent(true)
递归关闭祖上们全部的事件拦截MOVE
事件传递来的滑动,都使用「parent -> child -> parent -> child」机制进行消费,让ns child
在消费滑动时与ns parent
配合更加细致、紧密和灵活fling
操作引发的滑动,与用户滑动屏幕触发的滑动使用同样的机制进行消费,实现了完美的惯性连续效果到这一步,我们再来看看 Google 给 View 和 ViewGroup 加了哪些方法?又希望我们什么时候怎么去调用它们?
加入的需要你关心的方法一共有这些(只注明了关键返回值和参数,参考当前最新的版本 androidx.core 1.1.0-alpha01
):
// 『View』
setNestedScrollingEnabled(true) // 调用
startNestedScroll() // 调用
dispatchNestedPreScroll(int delta, int[] consumed) // 调用
dispatchNestedScroll(int unconsumed, int[] consumed) // 调用
stopNestedScroll() // 调用
// 『ViewGroup』
boolean onStartNestedScroll() // 覆写
int getNestedScrollAxes() // 调用
onNestedPreScroll(int delta, int[] consumed) // 覆写
onNestedScroll(int unconsumed, int[] consumed) // 覆写
怎么调用这些方法取决于你要实现什么角色
ns child
角色时,你需要:
setNestedScrollingEnabled(true)
,启用嵌套滑动机制DOWN
事件时调用startNestedScroll()
方法,它会「找到自己祖上中最近的与自己匹配的ns parent
,进行绑定并关闭ns parent
的事件拦截机制」dispatchNestedPreScroll()
方法,传入用户的滑动距离,这个方法会「触发ns parent
对滑动的消费,并且把消费结果返回」ns child
可以开始自己消费剩下滑动ns child
自己消费完后调用dispatchNestedScroll()
方法,传入最后没消费完的滑动距离,这个方法会继续「触发ns parent
对剩下滑动的消费,并且把消费结果返回」ns child
拿到最后没有消费完的滑动,做最后的处理,比如显示 overscroll 效果,比如在 fling 的时候停止scroller
ns parent
,那么在View
的computeScroll()
方法中,对于每个scroller
计算到的滑动距离,与MOVE
事件中处理滑动一样,按照这个顺序进行消费:「dispatchNestedPreScroll()
-> 自己 -> dispatchNestedScroll()
-> 自己」UP
、CANCEL
事件中以及computeScroll()
方法中惯性滑动结束时,调用stopNestedScroll()
方法,这个方法会「打开ns parent
的事件拦截机制,并取消与它的绑定」ns parent
角色时,你需要:
boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)
,通过传入的参数,决定自己对这类嵌套滑动感兴趣,在感兴趣的情况中返回true
,ns child
就是通过遍历所有ns parent
的这个方法来找到与自己匹配的ns parent
getNestedScrollAxes()
,它会返回你某个方向的拦截机制是否已经被ns child
关闭了,如果被关闭,你就不应该拦截事件了onNestedPreScroll
和onNestedScroll
方法中耐心等待ns child
的消息,没错,它就对应了你在ns child
中调用的dispatchNestedPreScroll
和dispatchNestedScroll
方法,你可以在有必要的时候进行自己的滑动,并且把消耗掉的滑动距离通过参数中的数组返回这么实现的例子可以看 ScrollView
,只要打开它的setNestedScrollingEnabled(true)
开关,你就能看到嵌套滑动的效果:(实际上ScrollView
实现的不是完美的嵌套滑动,原因见下一节)
ns parent
还好,但ns child
的实现还会有大量的细节(包括实践部分会提到的「ns parent
偏移导致的 event
校正」等等),光是描述可能不够直接,为此我也为ns child
准备了一份参考模板:NestedScrollChildSample
注意
- 虽然模板在IDE里不会报错,但这不是可以运行的代码,这是剔除
NestedScrollView
中关于ns parent
的部分,得到的可以认为是官方推荐的ns child
实现- 同时,为了让主线逻辑更加清晰,删去了多点触控相关的逻辑,实际开发如果需要,可以直接参考
NestedScrollView
中的写法,不会麻烦太多(有空会写多点触控的透镜系列XD)- 其中的关键部分是在触摸和滚动时怎么调用
NestedScrollingChild
接口的方法,也就是onInterceptTouchEvent()
、onTouchEvent()
、computeScroll()
中大约 200 行的代码
另外,以上都说的是单一角色时的使用情况,有时候你会需要一个 View 扮演两个角色,就需要再多做一些事情,比如对于ns parent
,你要时刻注意你也是 ns child
,在来生意的时候也照顾一下自己的ns parent
,这些可以去看 NestedScrollView
的实现,不在这展开了。
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
但是有人就问了:(回到答案)
NestedScrollingParent
和NestedScrollingChild
这两个接口,然后利用上NestedScrollingParentHelper
和NestedScrollingChildHelper
这两个帮助类,才能实现一个支持嵌套滑动的自定义 View 啊,而且大家都称赞这是一种很棒的设计呢,怎么到你这就变成了直接加在View和 ViewGroup 里的方法了,这么普通的 DISCO 嘛?而且题图里也看到有这几个接口的啊,你难道是标题党吗?NestedScrollingParent
和NestedScrollingChild
这两个接口里放了那么多方法,你却只讲9个呢?NestedScrollingChild
,有NestedScrollingChild2
,工作不饱和的同学会发现最近 Google 还增加了NestedScrollingChild3
,这都是在干哈?改了些什么啊?别着急,要解释这些问题,还得先来了解下历史,翻翻sdk
和support library
家的老黄历:
(嫌弃太长也可以直接前往观看小结)
(事情要从五年前说起...)
在 Android 5.0 / API 21 (2014.9)
时, Google 第一次加入了 NestedScrolling 机制。
虽然在版本更新里完全没有提到,但是在View
和 ViewGroup
的源码里你已经能看到其中的嵌套滑动相关方法。
而且此时使用了这些方法实现了嵌套滑动效果的 View 其实已经有不少了,除了我们讲过的ScrollView
,还有AbsListView
、ActionBarOverlayLayout
等,而这些也基本是当时所有跟滑动有关的 View 了。
所以,如上文嵌套ScrollView
的例子所示,在Android 5.0
时大家其实就能通过setNestedScrollingEnabled(true)
开关启用 View 的嵌套滑动效果。
这是 NestedScrolling 机制的第一版实现。
因为第一个版本的 NestedScrolling 机制是加在 framework 层的 View 和 ViewGroup 中,所以能享受到嵌套滑动效果的只能是Android 5.0
的系统,也就是当时最新的系统。
大家都知道,这样的功能不会太受开发者待见,所以在当时 NestedScrolling 机制基本没有怎么被使用。(所以大家一说嵌套滑动就提后来才发布的NestedScrollView
而不不知道ScrollView
早就能嵌套滑动也是非常正常了)
Google 就觉得,这可不行啊,嵌套滑不动的Bug不能老留着啊 好东西得大家分享啊,于是一狠心,梳理了下功能,重构出来两个接口(NestedScrollingChild
、NestedScrollingParent
)两个 Helper (NestedScrollingChildHelper
、NestedScrollingParentHelper
)外加一个开箱即用的NestedScrollView
,在 Revision 22.1.0 (2015.4)
到来之际,把它们一块加入了v4 support library
豪华午餐。
这下大伙就开心了,奔走相告:嵌套滑动卡了吗,赶紧上NestedScrollView
吧,Android 1.6
也能用。
同时NestedScrollingChild
和NestedScrollingParent
也被大家知晓了,要自己整个嵌套滑动,那就实现这两接口吧。
随后,在下一个月 Revision 22.2.0 (2015.5)
时,Google又隆重推出了 Design Support library
,其中的杀手级控件CoordinatorLayout
更是把 NestedScrolling 机制玩得出神入化。
NestedScrolling 机制终于走上台前,一时风头无两。
但注意,我比较了一下,这时的 NestedScrolling 机制相比之前放在 View 和 ViewGroup 中的第一个版本,其实完全没有改动,只是把 View 和 ViewGroup 里的方法分成两部分放到接口和 Helper 里了,NestedScrollView
里跟嵌套滑动有关的部分也跟ScrollView
里的没什么区别,所以此时的 NestedScrolling 机制本质还是第一个版本,只是形式发生了变化。
而 NestedScrolling 机制形式的变化带来了什么影响呢?
isNestedScrollingEnabled()
、onNestedScrollAccepted()
),有的是设计别扭用得很少的(比如dispatchNestedFling()
),有的是需要特别优化细节才需要的(比如hasNestedScrollingParent()
),一开始开发者其实完全不用关心。Android 1.6
也用上了嵌套滑动,老奶奶开心得合不拢嘴。但大家用着用着,新鲜感过去之后,也开始不满足了起来,于是就有了第一版 NestedScrolling 机制的著名Bug:「惯性不连续」(回到小结)
什么是惯性不连续?如下图
简单说就是:你在滑动内部 View 时快速抬起手指,内部 View 会开始惯性滑动,当内部 View 惯性滑动到自己顶部时便停止了滑动,此时外部的可滑动 View 不会有任何反应,即使外部 View 可以滑动。
本来这个体验也没多大问题,但因为你手动滑动的时候,内部滑动到顶部时可以接着滑动外边的 View,这就形成了对比,有对比就有差距,有差距群众就不满意了,你不能在惯性滑动的时候也把里面的滑动传递到外面去吗?
所以这个问题也不能算是 Bug,只是体验没有做到那么好罢了。
其实 Google 不是没有考虑过惯性,其中关于 fling 的4个 API 更是存在感十足地告诉大家,我就是来处理你们说的这档子事的,但为什么还是有 Bug 呢,那就不得不提这4个 API 的奇葩设计和用法了。
这四个 API 长这样,看名字对应上 scroll 的4个 API 大概能知道是干什么的(但实际上有很大区别,见下文):
dispatchNestedPreFling
、dispatchNestedFling
onNestedPreFling
、onNestedFling
前面我在讲述的时候默认是让ns child
直接消费用户快速抬起时产生的惯性滑动,这没有什么问题,因为我们还在computeScroll
方法中把惯性引起的滑动也传递给了ns parent
,让父子配合进行惯性滑动。
但实际上此时的NestedScrollView
是这么写的:
public boolean onTouchEvent(MotionEvent ev) {
...
case MotionEvent.ACTION_UP:
if (mIsBeingDragged) {
...
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
flingWithNestedDispatch(-initialVelocity);
}
stopNestedScroll();
}
break;
...
}
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
if (canFling) fling(velocityY);
}
}
public void fling(int velocityY) {
if (getChildCount() > 0) {
...
mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0, Math.max(0, bottom - height), 0, height/2);
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
... // 没有关于把滑动分发给 ns parent 的逻辑
}
}
来读一下其中的逻辑
ns child
并快速抬起手指产生惯性的时候,看flingWithNestedDispatch()
方法,ns child
会先问ns parent
是否消费此速度
ns parent
不消费,那么将再次把速度交给ns parent
,并且告诉它自己是否有消费速度的条件(根据系统类库一贯的写法,如果ns child
消费这个速度,ns parent
都不会对这个速度做处理),同时自己在有消费速度的条件时,对速度进行消费mScroller
进行惯性滑动,但是在computeScroll()
中并没有把滑动分发给 ns parent
stopNestedScroll()
解除与ns parent
的绑定,宣告这次协同合作到此结束那么总结一下:
ns parent
有机会拦截处理惯性,它并不能在惯性滑动过程中让ns child
和ns parent
协同消费惯性引发的滑动,也就是实现不了前面人们期望的惯性连续效果,所以第一版的开发者想用直接传递惯性的方式实现惯性连续可能不是个好主意
ns child
无法进行滑动的时候起到一定的作用(虽然完全可以用滑动的协同消费机制替代),而在之后的版本中,这个作用基本也没有被用到,它确实被滑动的协同消费机制替代了ns child
进行惯性滑动时,把滑动传递出来,就可以了ns child
角色使用了嵌套滑动机制的系统控件,惯性相关的 API 和处理逻辑都可以保留,只要在computeScroll()
中把滑动用dispatchNestedPreScroll()
和dispatchNestedScroll()
方法分发给 ns parent
,再更改一下解除与ns parent
绑定的时机,放在 fling 结束之后ns child
View 可以直接改,但系统提供的NestedScrollView
、RecyclerView
等控件,你就只能提个 issue 等官方修复了,不过也可以拷贝一份出来自己改Google表示才不想搭理这些人,给你用就不错了哪来那么多事儿?我还要忙着搞AI呢 直到两年多后的2017年9月,Revision 26.1.0
才悄咪咪(更新日志里没有提,但是文档的添加记录里能看到,后来发现作者自己倒是写了篇博客说这事,说是Revision 26.0.0-beta2
时加的,跟文档里写的不一致,不过这不重要) 更新了一版NestedScrollingChild2
和NestedScrollingParent2
,并且处理了第一版中系统控件的Bug,这便是第二个版本的 NestedScrolling 机制了
来看看第二版是怎么处理第一版 Bug 的,大牛的救火思路果然比一般人要健壮。
首先看接口是怎么改的:
ns child
在computeScroll
中分发滑动给ns parent
没有问题(这是关键),但是我要区分开是用户手指移动触发的滑动还是由惯性触发的滑动(这是锦上添花)NestedScrollingChild
中滑动相关的(确切地说是除了「fling相关、滑动开关」外的) 5个方法、所有NestedScrollingParent
中滑动相关的(确切地说是除了「fling相关、获取滑动轴」外的) 5个方法,都增加了一个参数type
,type
有两个取值代表上述的两种滑动类型:TYPE_TOUCH
、TYPE_NON_TOUCH
type
参数,并且对旧的接口做了个兼容,让它们的type
是TYPE_TOUCH
改完了接口当然还要改代码了,Helper 类首先要改
NestedScrollingChildHelper
里边本来持有了一个ns parent
域 mNestedScrollingParentTouch
,作为绑定关系,第二版 又再加了一个ns parent
域 mNestedScrollingParentNonTouch
,为什么是两个而不是公用一个,大概是避免对两类滑动的生命周期有过于严格的要求,比如在 NestedScrollView
的实现里,就是先开启TYPE_NON_TOUCH
类型的滑动,然后关闭了 TYPE_TOUCH
类型的滑动,如果公用一个 ns parent
域,就做不到这样了NestedScrollingChildHelper
里边主要就做了这一点额外的改动,其他的改动都是增加参数后的常规变换,NestedScrollingParentHelper
里就更没有特别的变化了前面在分析第一版 Bug 的时候说过「第一版 NestedScrolling 机制本身是没有问题的,有问题的是那些系统控件使用这个机制的方式不对」,所以这次改动最大的还是那些使用了嵌套滑动机制的系统控件了,我们就以 NestedScrollView
为例来具体看看系统是怎么修复 Bug、建议大家现在应该怎么创建 ns child
角色的。
相同的部分不说了,在调用相关方法的时候要传入 type
也不细说了,主要的变化基本出现在预期的位置:
public boolean onTouchEvent(MotionEvent ev) {
...
case MotionEvent.ACTION_UP:
if (mIsBeingDragged) {
...
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
flingWithNestedDispatch(-initialVelocity);
}
stopNestedScroll(ViewCompat.TYPE_TOUCH);
}
break;
...
}
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0) && (scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
fling(velocityY); // 华点
}
}
public void fling(int velocityY) {
if (getChildCount() > 0) {
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, Integer.MIN_VALUE, Integer.MAX_VALUE, 0, 0);
mLastScrollerY = getScrollY();
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
final int x = mScroller.getCurrX();
final int y = mScroller.getCurrY();
int dy = y - mLastScrollerY;
// Dispatch up to parent
if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
dy -= mScrollConsumed[1];
}
if (dy != 0) {
final int range = getScrollRange();
final int oldScrollY = getScrollY();
overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledDeltaY = getScrollY() - oldScrollY;
final int unconsumedY = dy - scrolledDeltaY;
if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null, ViewCompat.TYPE_NON_TOUCH)) {
if (canOverscroll()) showOverScrollEdgeEffect();
}
}
ViewCompat.postInvalidateOnAnimation(this);
} else {
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
}
computeScroll()
方法的代码贴得比较多,因为它不仅是这次Bug修复的主要部分,它还是下一次Bug修复要改动的部分。
不过其实整个逻辑还是很简单的,符合预期,简单说明一下:
UP
时候做的事情没有变,还是在这解除了与ns parent
的绑定,但是注明了类型是TYPE_TOUCH
flingWithNestedDispatch()
这个方法先不说fling()
方法中,调用startNestedScroll()
开启了新一轮绑定,不过这时的类型变成了TYPE_NON_TOUCH
computeScroll()
方法中,但逻辑很清晰:对于每个dy
,都会经过「parent -> child -> parent -> child」这个消费流程,从而实现了惯性连续,解决了 Bug最后的效果是这样:
另外,从这版开始,View和 ViewGroup 里的 NestedScrolling 机制就没有更新过,一直维持着第一个版本的样子。
看上去第二个版本改得很漂亮对吧,但这次改动其实又引入了两个问题,至少有一个算是Bug,另一个可以说只是交互不够好,不过这个交互不够好的问题引入的原因却非常令人迷惑。
先说第一个问题:「二倍速」(回到小结)
NestedScrollView
中,RecyclerView
等类没有这个问题,我极度怀疑它的引入是因为手滑NestedScrollView
跟之前的对比,你会很容易发现flingWithNestedDispatch()
中(在我贴出来的代码里),fling(velocityY)
前的if (canFling)
离奇消失了然后是第二个问题:「空气马达」(回到小结)
flingWithNestedDispatch()
中的这段代码:其中的dispatchNestedPreFling()
大部分时候会返回false
,于是几乎所有的情况下,内部 View 都会通过fling()
方法启动自己mScroller
这个小马达computeScroll()
方法中,你会看到,(如果你不直接触摸内部View) 除非等到马达自己停止,否则没有外力能让它停下,于是它会一直向外输出dispatchNestedPreScroll()
和dispatchNestedScroll()
ns child
是主动的一方,ns parent
完全是被动的,ns parent
没法主动通知ns child
:啊我被摁住了,啊我撞墙了ns parent
并不是没办法告知ns child
信息,通过方法的返回值和引用类型的参数,ns child
仍然可以从ns parent
中获取信息ns child
询问ns parent
是否能够滑动,问题应该就解决了:如果ns parent
滑不动了,ns child
自己也滑不动,那就赶紧关闭马达吧,ns parent
是否能够滑动不是有现成的方法吗?dispatchNestedPreScroll()
会先让ns parent
在ns child
之前进行滑动,而且滑动的距离被记录在它的数组参数consumed
中,拿到数组中的值ns child
就能知道ns parent
是否在这时滑动了dispatchNestedScroll()
会让ns parent
在ns child
之后进行滑动,它有没有数组参数记录滑动距离,它只有一个返回值记录是否消费了滑动...不对,这个返回值不是记录是否消费滑动用的,它表示的是ns parent
是否能顺利联系上,如果能,就返回true
,并不关心它是否消费了滑动。在NestedScrollingChildHelper
中你也能看到这个逻辑的清晰实现,同时你也会看到在NestedScrollingParent2
中它对应的方法是void onNestedScroll()
,没有返回值(考虑过能不能通过dispatchNestedScroll()
中int[] offsetInWindow
没被使用的数组位置来传递信息,结果也因为 parent 中对应的方法不带这个参数而告终;而且ns parent
也无法主动解除自己与ns child
的绑定,这条路也不通)。总之,dispatchNestedScroll()
无法让ns child
得知ns parent
对事件的消费情况,此路不通dispatchNestedScroll()
的消费结果直接放在ns child
的 View 中,用这个后门解决了Bug,但这种方式使用的局限比较大,而且下面要介绍的最新的第三版已经修复了这个问题,我就不多写了)第二版的 Bug 虽然比第一版的严重,但好像没有太多人知道,可能这种使用场景还是没有那么多。
不过时隔一年多,Google 终于是意识到了这个问题,在最近也就是2018年11月5日androidx.core 1.1.0-alpha01
的更新中,给出了最新的修复——NestedScrollingChild3
和NestedScrollingParent3
,以及一系列系统组件也陆续进行了更新。
这就是第三个版本的 NestedScrolling 机制了,这个版本确实对上面两个 Bug 进行了处理,但可惜的是,第二个 Bug 并没有修理干净 (为 Google 大佬献上一首つづく,期待第四版)
(在本文快要完成的时候正好看到新一任消防员在18年12月3日发了条 twitter 说已经发布了第三版,结果评论区大家已经在欢乐地期待 NestedScrollingChild42
NestedScrollingChildX
NestedScrollingParentXSMax
NestedScrollingParentFinalFinalFinal
NestedScrollingParent2019
了 )
继续来看看在这个版本中,大佬是怎么救火的
照例先看接口,一看接口的改动你可能就笑了,真的是哪里不通改哪里
NestedScrollingChild3
中,没有增加方法,只是给dispatchNestedScroll
方法增加了一个参数int[] consumed
,并且把它的boolean
返回值改成了void
,有了能获取更详细信息的途径,自然就不需要这个boolean
了NestedScrollingParent3
同样只是改了一个方法,给onNestedScroll
增加了int[] consumed
参数(它返回值就是 void
,没变)下面是NestedScrollingChild3
中的对比:
// 2
boolean dispatchNestedScroll(
int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed,
@Nullable int[] offsetInWindow,
@NestedScrollType int type
);
// 3
void dispatchNestedScroll(
int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed,
@Nullable int[] offsetInWindow,
@NestedScrollType int type,
@NonNull int[] consumed // 这个
);
再看下 Helper ,NestedScrollingChildHelper
除了适配新的接口基本没有改动,NestedScrollingParentHelper
也只是增强了一点逻辑的严谨性(大概是被review了233)
最后看用法,还是通过我们的老朋友NestedScrollView
来看,改动部分跟预期基本一致:
@Override
public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
final int oldScrollY = getScrollY();
scrollBy(0, dyUnconsumed);
final int myConsumed = getScrollY() - oldScrollY;
if (consumed != null) consumed[1] += myConsumed; // 就加了这一句
final int myUnconsumed = dyUnconsumed - myConsumed;
mChildHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
// ---
// onTouchEvent 中逻辑没有变化
private void flingWithNestedDispatch(int velocityY) {
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, true);
fling(velocityY); // fling 中的逻辑没有变化
}
}
@Override
public void computeScroll() {
if (mScroller.isFinished()) return;
mScroller.computeScrollOffset();
final int y = mScroller.getCurrY();
int unconsumed = y - mLastScrollerY;
// Nested Scrolling Pre Pass
mScrollConsumed[1] = 0;
dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH);
unconsumed -= mScrollConsumed[1];
final int range = getScrollRange();
if (unconsumed != 0) {
// Internal Scroll
final int oldScrollY = getScrollY();
overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledByMe = getScrollY() - oldScrollY;
unconsumed -= scrolledByMe;
// Nested Scrolling Post Pass
mScrollConsumed[1] = 0;
dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset, ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
unconsumed -= mScrollConsumed[1];
}
// 处理最后还有 unconsumed 的情况
if (unconsumed != 0) {
if (canOverscroll()) showOverScrollEdgeEffect();
mScroller.abortAnimation(); // 关停小马达
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
if (!mScroller.isFinished()) ViewCompat.postInvalidateOnAnimation(this);
}
修改最多的还是computeScroll()
,不过其他地方也有些变化,简单说明一下:
onNestedScroll()
增加了记录距离消耗的参数,所以ns parent
就需要把这个数据记录上并且继续传递给自己的ns parent
flingWithNestedDispatch()
是之前有蜜汁 Bug 的方法,本来我的预期是恢复第一版的写法,也就是把fling(velocityY)
前的if (canFling)
加回来,结果这下倒好,连canFling
也不判断了,dispatchNestedFling(0, velocityY, true)
直接传true
,fling(velocityY)
始终调用。这意味着什么呢?需要结合大部分View的写法来看
API 28
的代码你就会看到:
onNestedPreFling()
方法,除了ResolverDrawerLayout
会在某些情况下消费fling并返回true
,以及CoordinatorLayout
会象征性地问一遍自己孩子们的Behavior
,其它的写法都是直接返回false
onNestedFling(boolean consumed)
方法,所有的写法都是,只要consumed
为true
,就什么都不会做,这种做法也非常自然computeScroll()
,它基本把我们在讨论怎么修复第二版中 Bug 时的思路实现了:因为能从dispatchNestedPreScroll()
和dispatchNestedScroll()
得知ns parent
消耗了多少这一次分发出去的滑动距离,同时也有自己消耗了多少,两者一合计,如果还有没消耗的滑动距离,那肯定无论内外都滑到头了,于是就该果断就把小马达关停现在的效果是这样的,能看到第二版中的Bug确实解决了
那么为什么我还说第二个 Bug 没有解决彻底呢?
DOWN
事件的处理相对第二版没有变化,它没有加入触摸外部 View 后关闭内部 View 马达的机制,更确切地说是没有加入「触摸外部 View 后阻止对内部 View 传递过来的滑动进行消费的机制」虽然现象与「空气马达」类似,但还是按照惯例给它也起个好听的新名字,就叫:...「摁不住」吧(回到小结)
实际体验跟分析结果一样这样,当通过滑动内部 View 触发外部 View 滑动时,你无法通过触摸外部 View 把它停下来,外部 View 比较长的时候容易复现,如下图(换了一个方向)
不过这个问题只有可以响应触摸的ns parent
需要考虑,可以响应触摸的ns parent
主要就是NestedScrollView
了,所以这个问题主要还是NestedScrollView
的问题。而且它也跟机制无关,只是NestedScrollView
的用法不对,所以前面说的会有第四版 NestedScrolling 机制可能性也不大,大概只会给NestedScrollView
上个普通的更新吧(顺手给 Google 大佬递了瓶 可乐)
而这个问题自己改也非常好改,只需要在DOWN
事件后能给ns child
反馈自己被摁住了就行,可以用反射,或是直接把NestedScrollView
挪出来改,关键代码如下
private boolean mIsBeingTouched = false;
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mIsBeingTouched = true;
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mIsBeingTouched = false;
break;
}
return super.onTouchEvent(ev);
}
private void onNestedScrollInternal(int dyUnconsumed, int type, @Nullable int[] consumed) {
final int oldScrollY = getScrollY();
if (!mIsBeingTouched) scrollBy(0, dyUnconsumed); // 只改了这一句
final int myConsumed = getScrollY() - oldScrollY;
if (consumed != null) {
consumed[1] += myConsumed;
}
final int myUnconsumed = dyUnconsumed - myConsumed;
childHelper.dispatchNestedScroll(0, myConsumed, 0, myUnconsumed, null, type, consumed);
}
我把用反射改好的放在这里了,你也可以直接使用
改完之后效果如下:
历史终于讲完了,小结一下(回去看详细历史)
Android 5.0( API 21)
中的 View 和 ViewGroup 中加入了第一个版本的 NestedScrolling 机制,此时能够通过启用嵌套滑动,让嵌套的ScrollView
不出现交互问题,但这个机制只有 API 21 以上才能使用NestedScrollingChild
、NestedScrollingParent
)和两个 Helper (NestedScrollingChildHelper
、NestedScrollingParentHelper
),并且用这套新的机制重写了一个默认启用嵌套滑动的NestedScrollView
,并把它们都放入了Revision 22.1.0
的v4 support library
,让低版本的系统也能使用嵌套滑动机制,不过此时的第一版机制有「惯性不连续」的 BugRevision 26.1.0
的v4 support library
中发布了第二个版本的 NestedScrolling 机制,增加了接口NestedScrollingChild2
、NestedScrollingParent2
,主要是给原本滑动相关的方法增加了一个参数type
,表示了两种滑动类型TYPE_TOUCH
、TYPE_NON_TOUCH
。并且使用新的机制重写了嵌套滑动相关的控件。这次更新解决了第一个版本中「惯性不连续」的Bug,但也引入了新的Bug:「二倍速」(仅NestedScrollView
)和「空气马达」AndroidX
家族的 NestedScrolling 机制更新了第三个版本,具体版本是androidx.core 1.1.0-alpha01
,增加了接口NestedScrollingChild3
、NestedScrollingParent3
,改动只是给原来的dispatchNestedScroll()
和onNestedScroll()
增加了int[] consumed
参数。并且后续把嵌套滑动相关的控件用新机制进行了重写。这次更新解决了第二个版本中 NestedScrollView
的「二倍速」Bug,同时期望解决「空气马达」Bug,但是没有解决彻底,还遗留了「摁不住」Bug所以前面的问题大家应该都有了答案:
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
第二节中其实已经讲过了实践,并且提供了实现 ns child
的模板。
这里我准备用刚发现的一个更有实际意义的例子来讲一下 ns parent
的实现,以及系统库中 ns child
的几个细节。
这个例子是「悬停布局」
你叫它粘性布局、悬浮布局、折叠布局都行,总之它理想的效果应该是这样:
用文字描述是这样:
在当前这个时间点(2019.1.13),这个例子还有不少实际意义,因为它虽然是比较常见的一个交互效果,但现在市场上的主流APP,居然是这样的...(饿了么v8.9.3)
这样的...(知乎v5.32.2)
这样的...(腾讯课堂v3.24.0.5)
这样的...(哔哩哔哩v5.36.0)
先不管它们是不是用 Native 实现的,只看实现的效果
其他还有一些千奇百怪的 Bug 就不举例了。
所以,就让我们来看看,这个功能实现起来是不是真有那么难。
如果内容区只有一个 Tab 页,一种简单直接的实现思路是:页面整个就是一个滑动控件,悬停区域会在滑动过程中不断调整自己的位置,实现悬停的效果。
它的实现非常简单,效果也完全符合要求,不举例了,可以自己试试。
但这里的需求是有多个 Tab 页,它用一整个滑动控件的思路是无法实现的,需要用多个滑动控件配合实现
在了解 NestedScrolling 机制之前,你可能觉得这个需求不太对劲,确实,从大的角度看,用户的一次触摸操作,却让多个 View 先后对其进行消费,它违背了事件分发的原则,也超出了 Android 触摸事件处理框架提供的功能:父 View 没用完的事件子 View 继续用,子 View 没用完的事件父 View 继续用
但具体到这个需求中
CoordinatorLayout
了,它就是用来帮助开发者去实现他们精心设计的多个 View 消费同一个事件流的效果的NestedScrolling
机制实现。另外CoordinatorLayout
让多个滑动控件配合对同一个事件流进行消费也是利用NestedScrolling
机制OK,既然需求提得没问题,而且我们也能实现,那下面就来看看具体要怎么实现。
可能有同学马上就举手了:我知道我知道,用CoordinatorLayout
!
对,当前这个效果最常见的实现方式就是使用基于CoordinatorLayout
的AppBarLayout
全家桶,这是它的自带效果,通过简单配置就能实现,而且还附送更多其他特效,非常酷炫,前面看到的效果比较好的哔哩哔哩视频详情页就是用它实现的。
而AppBarLayout
实现这个功能的方式其实是也使用了CoordinatorLayout
提供的NestedScrolling
机制(虽然实现的具体方法跟上面的分析有些区别,但并不重要,感兴趣的同学可以看AppBarLayout
的Behavior
),如果你嫌弃AppBarLayout
全家桶太重了,只想单独实现悬停功能,如前文所述,你也可以直接使用NestedScrolling
机制去实现。
这里就直接使用NestedScrolling
机制来实现出一个类似哔哩哔哩这样正常一些的悬停布局。
用NestedScrolling
机制一想,你会发现实现起来非常简单,上面的分析过程在机制中直接就有对应的接口,我们只要实现一个符合要求的 ns parent
就好了,NestedScrolling
机制会自动管理 ns parent
与 ns child
的绑定和 scroll 的传递,即使 ns child
与 ns parent
相隔好几层 View。
我把要实现的 ns parent
叫做 SuspendedLayout
,其中的关键代码如下,它剩下的代码以及布局和页面代码就不写出来了,可以在这里查看(简单把第一个 child view 作为 Header,第二个 child view 会自然悬停)。
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int, type: Int, consumed: IntArray) {
if (dyUnconsumed < 0) scrollDown(dyUnconsumed, consumed)
}
override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) {
if (dy > 0) scrollUp(dy, consumed)
}
/*-------------------------------------------------*/
private fun scrollDown(dyUnconsumed: Int, consumed: IntArray?) {
val oldScrollY = scrollY
scrollBy(0, dyUnconsumed)
val myConsumed = scrollY - oldScrollY
if (consumed != null) {
consumed[1] += myConsumed
}
}
private fun scrollUp(dy: Int, consumed: IntArray) {
val oldScrollY = scrollY
scrollBy(0, dy)
consumed[1] = scrollY - oldScrollY
}
override fun scrollTo(x: Int, y: Int) {
val validY = MathUtils.clamp(y, 0, headerHeight)
super.scrollTo(x, validY)
}
这么快就实现了,效果非常完美,与哔哩哔哩几乎一样:
但效果一样好也一样坏,哔哩哔哩的那个容易误操作的问题这里也有。
先看看为什么会出现这样的问题?
ViewPager
拦截了事件,也就是 ns child
没有及时「申请外部不拦截事件流」,于是到 NestScrollView
和 RecyclerView
中查看,问题其实就出在前面描述的ns child
在 onTouchEvent()
中的逻辑上 ns child
会在判断出用户在滑动后「申请外部不拦截事件流」,但 onTouchEvent()
中又在判断出用户在滑动前就把滑动用 dispatchNestedPreScroll()
方法传递给了 ns parent
,于是你就会看到,明明已经识别出我在上下滑动ns child
了,而且已经滑了一段距离,居然会忽然切换成滑动 ViewPager
所以这个问题要怎么修复呢?
NestScrollView
代码拷贝出来,并把其中的 dispatchNestedPreScroll()
方法放在判断出滑动之后进行调用,确实解决了问题parent.requestDisallowInterceptTouchEvent(true)
即可,完整代码见此,其中关键代码如下:private int downScreenOffset = 0;
private int[] offsetInWindow = new int[2];
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
downScreenOffset = getOffsetY();
}
if (ev.getActionMasked() == MotionEvent.ACTION_MOVE) {
final int activePointerIndex = ev.findPointerIndex(getInt("mActivePointerId"));
if (activePointerIndex != -1) {
final int y = (int) ev.getY(activePointerIndex);
int mLastMotionY = getInt("mLastMotionY");
int deltaY = mLastMotionY - y - (getOffsetY() - downScreenOffset);
if (!getBoolean("mIsBeingDragged") && Math.abs(deltaY) > getInt("mTouchSlop")) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
setBoolean("mIsBeingDragged", true);
}
}
}
return super.onTouchEvent(ev);
}
private int getOffsetY() {
getLocationInWindow(offsetInWindow);
return offsetInWindow[1];
}
这里有个细节值得一提:在计算deltaY
时不只是用mLastMotionY - y
,还减去了(getOffsetY() - downScreenOffset)
,这里的offsetInWindow
其实也出现在 NestedScrolling 机制里的dispatchNestedScroll()
等接口中
offsetInWindow
的作用非常关键,因为当 ns child
驱动 ns parent
滑动时,ns child
其实也在移动,此时ns child
中获取到的手指触发的motion event
中 x
和y
值是相对ns child
的,所以此时如果直接使用y
值,你会发现y
值几乎没有变化,这样算到的deltaY
也会没有变化,所以需要再获取ns child
相对窗口的偏移,把它算入deltaY
,才能得到你真正需要的deltaY
ViewPager
为什么会在竖直滑动那么远之后还能对横滑进行拦截,也是这个原因,它获取到的deltaY
其实很小改完之后的效果如下,能看到解决了问题:
RecyclerView
等其他的ns child
如果需要的话,也可以做类似的改动(不过这里的反射代码对性能有所影响,建议实现上做一些优化)
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
如果你没有跳过地看到这里,关于 NestedScrolling 机制,我相信现在无论是原理、还是使用、甚至八卦历史,你都了解得一清二楚了,否则我只能怀疑你的我的语文老师表达水平了。
而关于代码的设计,你大概也能学到一点,Google 工程师三入火场英勇救火的身影应该给你留下了深刻的印象。
最后关于使用多说两句:
NestedScrollView
,认为第三版的 Bug 也会影响到你宝贵而敏感的用户,那不如试试 implementation 我的项目 :D最后的最后,G 家的消防员都有顾不过来的时候,更何况是本菜鸡,本文内容肯定会有疏漏和不当之处,欢迎大家提 issue 啦~
]]>(转载请注明作者:RubiTree,地址:blog.rubitree.com )
目录:
事件分发,我想大部分人都能说几句,哦,三大方法,哦,那段经典伪代码,哦,责任链...
但如果要让你完完整整捋一遍,你可能就开始支支吾吾了,只能想到啥说啥
这块的东西确实麻烦,说出来不怕吓到你,事件流到底怎么流与这些因素都有关系:是什么事件类型(DOWN/MOVE/UP/CANCEL
)、在哪个视图层次(Activity/ViewGroup/View
)、在哪个回调方法(dispatch()/onIntercept()/onTouch()
)、回调方法给不同的返回值(true/false/super.xxx
),甚至对当前事件的不同处理还会对同一事件流中接下来的事件造成不同影响
比如我可以问:重写某个ViewGroup
里的dispatchTouchEvent
方法,对MOVE
事件返回false
,整个事件分发过程会是什么样的?
于是就有人对这些情况分门别类进行总结,得到了很多规律,也画出了纷繁复杂的事件分发流程图:
× N × N
甚至还有类似题图那样的动态流程图(是的,吸引你进来的题图居然是反面教材,我也很心疼啊,画了我半个下午,结果并没有太大的帮助)
这些规律和流程图确实是对的,而且某种意义上也是非常清晰的,能帮助你在调试 Bug 的时候找到一点方向。
你或许可以奋发图强,把这些流程图和规律背下来,也能在需要的时候一通叽里呱啦背完大家大眼瞪小眼。
但它们并不能让你真正理解事件分发是什么样子,你可能某一次花费了大量的时间去看懂它们,但是「每次都能看明白!过一段时间又忘了!」(某段有代表性的评论原话)
但讲道理,分发个触摸事件为什么会这么复杂呢?需要这么复杂吗?图啥呢?
于是,让我们回到起点,看看分发触摸事件到底是为了解决一个什么样的问题,有没有更简单的分发办法?然后看看当需求增加的时候,要怎么调整这个简单的分发策略?
看到最后你就会发现,原来一切是那么地自然。
所以,不用死记硬背,也不用急着去怼完整的事件分发流程,那么多复杂的逻辑和情况其实都是围绕着最根本的问题发展出来的,是随着需求的增加一步步变得复杂的,理解了演化过程,你自然会对其演化的结果了然于胸,想忘都忘不掉。
从根本问题出发,一切就会变得自然而然。
艾维巴蒂,黑喂狗!
下面我将从最简单的需求开始思考方案、编写代码,然后一步步增加需求、调整方案、继续编写代码,争取造出一个麻雀虽小五脏俱全的事件分发框架。
我们先实现一个最简单的需求:Activity 中有一堆层层嵌套的 View,有且只有最里边那个 View 会消费事件
(黄色高亮 View 代表可以消费事件,蓝色 View 代表不消费事件)
思考方案:
Activity
连接着根ViewDecorView
,它是通往外界的桥梁,能接收到屏幕硬件发送过来的触摸事件Activity
开始,经过一层一层 ViewGroup ,传到最里边的 ViewpassEvent(ev)
方法,父亲一层层往里调,能把事件传递过去,就完成了需求示意图
麻雀代码:
(本文代码使用Kotlin编写,核心代码也提供了Java版本)
open class MView {
open fun passEvent(ev: MotionEvent) {
// do sth
}
}
class MViewGroup(private val child: MView) : MView() {
override fun passEvent(ev: MotionEvent) {
child.passEvent(ev)
}
}
Activity
当成MViewGroup
处理也没有问题MViewGroup
继承MView
而不是反过来,因为 MView
是不需要 child
字段的然后我们增加一条需求,让情况复杂一点:Activity
中有一堆层层嵌套的View,有好几个叠着的View能处理事件
同时需要增加一条设计原则:用户的一次操作,只能被一个View真正处理(消费)
如果使用第一次试造的框架,要遵守这条原则,就需要在每一个可以处理事件的View层级,判断出自己要处理事件后,不继续调用child
的passEvent()
方法了,保证只有自己处理了事件。
但如果真这样实现了,在大部分场景下会显得怪怪的,因为处理事件的顺序不对:
所以实现新增需求的一个关键是:找到那个适合处理事件的View,而我们通过对业务场景进行分析,得到答案是:那个最里面的View适合处理事件
这就不能是等parent
不处理事件了才把事件传给child
,应该反过来,你需要事件的处理顺序是从里向外:里边的child
不要事件了,才调用parent
的passEvent()
方法把事件传出来。
于是得加一条向外的通道,只能在这条向外的通道上处理事件,前面向里的通道什么都不干,只管把事件往里传。
所以这时你有了两条通道,改个名字吧,向里传递事件是passIn()
方法,向外传递并处理事件是passOut()
方法。
示意图
麻雀代码:
open class MView {
var parent: MView? = null
open fun passIn(ev: MotionEvent) {
passOut(ev)
}
open fun passOut(ev: MotionEvent) {
parent?.passOut(ev)
}
}
class MViewGroup(private val child: MView) : MView() {
init {
child.parent = this // 示意写法
}
override fun passIn(ev: MotionEvent) {
child.passIn(ev)
}
}
这段代码没有问题,非常简单,但是它对需求意图的表达不够清晰,增加了框架的使用难度
passIn()
的时候只传递事件,希望在passOut()
的时候每个View决定是否要处理事件,并进行处理,而且在处理事件后,不再调用parent
的passOut()
方法把事件传出来于是我们用一个叫做dispatch()
的方法单独放事件传递的控制逻辑,用一个叫做onTouch()
的方法作为事件处理的钩子,而且钩子有一个返回值,表示钩子中是否处理了事件:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
override fun dispatch(ev: MotionEvent): Boolean {
var handled = child.dispatch(ev)
if (!handled) handled = onTouch(ev)
return handled
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
这样写完,整个行为其实没有变化,但你会发现:
dispatch()
中,一目了然onTouch()
单纯是一个钩子,框架使用者只需要关心这个钩子和它的返回值,不用太关心控制流程parent
也不需要了上文的实现看上去已经初具雏形了,但其实连开始提的那条原则都没实现完,因为原则要求一次操作只能有一个 View 进行处理,而我们实现的是一个触摸事件只能有一个View进行处理。
这里就涉及到一次触摸操作和一个触摸事件的区别:
DOWN/UP/ING
,其中ING
有点不够专业,改个名字叫MOVE
吧DOWN
事件开始、中间是多个MOVE
事件、最后结束于UP
事件的事件流组成于是设计原则更确切地说就是:一次触摸产生的事件流,只能被一个View消费
在上次试造的基础上把一个事件变成一个组事件流,其实非常简单:处理DOWN
事件时跟前面处理一个事件时一样,但需要同时记住DOWN
事件的消费对象,后续的MOVE/UP
事件直接交给它就行了
麻雀代码:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
private var isChildNeedEvent = false
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) handled = onTouch(ev)
} else {
if (isChildNeedEvent) handled = child.dispatch(ev)
if (!handled) handled = onTouch(ev)
}
if (ev.actionMasked == MotionEvent.ACTION_UP) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
代码好像增加了很多,其实只多做了两件事:
isChildNeedEvent
状态,对是子View是否处理了DOWN
事件进行记录,并在其他触摸事件时使用这个状态DOWN
事件的最开始和收到UP
事件的最后,重置状态此时框架使用者还是只需要关心onTouch()
钩子,在需要处理事件时进行处理并返回true
,其他事情框架都做好了。
上面的框架已经能完成基本的事件分发工作了,但下面这个需求,你尝试一下用现在框架能实现吗?
需求:在可滑动View中有一个可点击View,需要让用户即使按下的位置是可点击View,再进行滑动时,也可以滑动外面的的可滑动View。
这个需求其实非常常见,比如所有「条目可点击的滑动列表」就是这样的(微信/QQ聊天列表)。
假如使用上面的框架:
所以直接使用现在的模型去实现的「条目可点击的滑动列表」会永远滑动不了。
那怎么办呢?
dispatch()
方法在传入事件过程中的人设,让它不是只能往里传递事件,而是在自己能消费事件的时候把事件给自己
直接想实现觉得到处是矛盾,找不到突破口,那就从头开始吧,从什么样的触摸反馈是用户觉得自然的出发,看看这种符合直觉的反馈方案是否存在,找出来它是什么,再考虑我们要怎么实现:
DOWN
事件刚来的时候,能判断用户想干什么吗?很抱歉,不能DOWN
事件传过来的时候,判断出用户到底想做什么,于是两个View其实都不能确定自己是否要消费事件我*,这不傻*了吗,还搞什么GUI啊,大家都用命令行吧
等等,不要着急,GUI还是得搞的,不搞没饭吃的我跟你讲,所以你还是得想想,想尽办法去实现。
你先忘记前面说的原则,你想想,不考虑其他因素,也不是只能用DOWN
事件,只要你能判断用户的想法就行,你有什么办法?
DOWN
,然后MOVE
很小一段,也不会MOVE出这个子View,关键是比较短的时间就UP
DOWN
,然后开始MOVE
,这时候可能会MOVE出这个子View,也可能不,但关键是比较长的时间也没有在UP
,一直是在MOVE
DOWN
不行,还得看接下来的事件流,得走着瞧UP
,就是点击里边的ViewUP
,但没怎么MOVE
,就是长按里边的ViewMOVE
比较长的距离,就是滑动外面的View看上去这个目标 View 判定方案很不错,安排得明明白白,但我们现有的事件处理框架实现不了这样的判定方案,至少存在以下两个冲突:
DOWN
的时候判断当前事件流是不是该给自己,所以一开始它们都只能返回false
。但为了能对后续事件做判断,你希望事件继续流过它们,按照当前框架的逻辑,你又不能返回false
。所以要解决上述的冲突,就肯定要对上一版的事件处理框架进行修改,而且看上去一不小心就会大改
dispatch()
方法在传入事件过程中的人设,让它不是只传递事件了,还可以在往里传递事件前进行拦截,能够看情况拦截下事件并交给自己的 onTouch()
处理onTouch()
里对DOWN
事件返回true
,不管是否识别出当前属于自己的消费模式disptach
事件了,而是直接给自己的onTouch()
onIntercept()
在父 View 往里dispatch
事件前,开发者可以覆写这个方法,加入自己的事件模式分析代码,并且可以在确定要拦截的时候进行拦截
onTouch()
示意图:
于是使用思路二,在「三造」的基础上,修改得到以下代码:
open class MView {
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
class MViewGroup(private val child: MView) : MView() {
private var isChildNeedEvent = false
private var isSelfNeedEvent = false
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
if (onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) {
handled = onTouch(ev)
if (handled) isSelfNeedEvent = true
}
}
} else {
if (isSelfNeedEvent) {
handled = onTouch(ev)
} else if (isChildNeedEvent) {
if (onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
}
}
}
if (ev.actionMasked == MotionEvent.ACTION_UP) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
isSelfNeedEvent = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
open fun onIntercept(ev: MotionEvent): Boolean {
return false
}
}
写的过程中增加了一些对细节的处理:
DOWN
事件的dispatch()
前需要拦截,在后续事件中,也需要加入拦截,否则无法实现中途拦截的目标isSelfNeedEvent
记录自己是否拦截过事件,如果拦截过,后续事件直接就交给自己处理这一下代码是不是看上去瞬间复杂了,但其实只是增加了一个事件拦截机制,对比上一次试造的轮子,会更容易理解。(要是 Markdown 支持代码块内自定义着色就好了)
而且对于框架的使用者来说,关注点还是非常少
onIntercept()
方法,判断什么时候需要拦截事件,需要拦截时返回true
onTouch()
方法,如果处理了事件,返回true
上面的处理思路虽然实现了需求,但可能会导致一个问题:里边的子 View 接收了一半的事件,可能都已经开始处理并做了一些事情,父 View 忽然就不把后续事件给它了,会不会违背用户操作的直觉?甚至出现更奇怪的现象?
这个问题确实比较麻烦,分两类情况讨论
pressed
状态,如果你设置了对应的background
,你的 View 就会有高亮效果MOVE
事件了,这会有问题,就这个按下高亮的例子,如果你只是不传MOVE
事件了,那谁来告诉里边的子View取消高亮呢?所以你需要在中断的时候也传一个结束事件
UP
事件吗?也是不行的,因为这样就匹配了里边点击的模式了,会直接触发一个点击事件,这显然不是我们想要的CANCEL
onTouch()
的同时,另外生成一个新的事件发给自己的子View,事件类型是CANCEL
,它将是子View收到的最后一个事件ViewPager
里有三个page,page里是ScrollView
,ViewPager
可以横向滑动,page里的ScrollView
可以竖向滑动
ViewPager
把事件给里边ScrollView
之后,它也会偷偷观察,如果你一直是竖向滑动,那没话说,ViewPager
不会触发拦截事件ViewPager
就会开始紧张,想「组织终于决定是我了吗?真的假的,那我可就不客气了」,于是在你斜滑一定距离之后,忽然发现,你划不动ScrollView
了,而ViewPager
开始动ScrollView
的竖滑被取消了,ViewPager
把事件拦下来,开始横滑ScrollView
里有一些按钮,按钮有长按事件,长按再拖动就可以移动按钮
ScrollView
把事件拦下来呢?dispatch
方法返回一个特别的值给外边(之前只是true
和false
,现在要加一个)requestDisallowInterceptTouchEvent()
,子View调用它改变父View的一个状态,同时父View每次在准备拦截前都会判断这个状态(当然这个状态只对当前事件流有效)所以,连同上一次试造,总结一下
DOWN
事件时就确定,只能在后续的事件流中进一步判断CANCEL
事件给儿子就完了另外有几个值得一提的地方:
onTouch()
后,onTouch()
只会收到后半部分的事件,这样会不会有问题呢?
在「四造」的基础上,修改得到以下代码:
interface ViewParent {
fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean)
}
open class MView {
var parent: ViewParent? = null
open fun dispatch(ev: MotionEvent): Boolean {
return onTouch(ev)
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
open class MViewGroup(private val child: MView) : MView(), ViewParent {
private var isChildNeedEvent = false
private var isSelfNeedEvent = false
private var isDisallowIntercept = false
init {
child.parent = this
}
override fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
// add isDisallowIntercept
if (!isDisallowIntercept && onIntercept(ev)) {
isSelfNeedEvent = true
handled = onTouch(ev)
} else {
handled = child.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) {
handled = onTouch(ev)
if (handled) isSelfNeedEvent = true
}
}
} else {
if (isSelfNeedEvent) {
handled = onTouch(ev)
} else if (isChildNeedEvent) {
// add isDisallowIntercept
if (!isDisallowIntercept && onIntercept(ev)) {
isSelfNeedEvent = true
// add cancel
val cancel = MotionEvent.obtain(ev)
cancel.action = MotionEvent.ACTION_CANCEL
handled = child.dispatch(cancel)
cancel.recycle()
} else {
handled = child.dispatch(ev)
}
}
}
if (ev.actionMasked == MotionEvent.ACTION_UP
|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
isSelfNeedEvent = false
isDisallowIntercept = false
}
override fun onTouch(ev: MotionEvent): Boolean {
return false
}
open fun onIntercept(ev: MotionEvent): Boolean {
return false
}
override fun requestDisallowInterceptTouchEvent(isDisallowIntercept: Boolean) {
this.isDisallowIntercept = isDisallowIntercept
parent?.requestDisallowInterceptTouchEvent(isDisallowIntercept)
}
}
这次改动主要是增加了发出CANCEL
事件和requestDisallowInterceptTouchEvent
机制
CANCEL
事件时有一个细节:没有在给 child
分发CANCEL
事件的同时继续把原事件分发给自己的onTouch
requestDisallowInterceptTouchEvent
机制时,增加了ViewParent
接口
虽然目前整个框架的代码有点复杂,但对于使用者来说,依然非常简单,只是在上一版框架的基础上增加了:
requestDisallowInterceptTouchEvent()
方法onTouch
方法中对事件消费并且做了一些操作,需要注意在收到CANCEL
事件时,对操作进行取消到这里,事件分发的主要逻辑已经讲清楚了,不过还差一段 Activity 中的处理,其实它做的事情类似ViewGroup,只有这几个区别:
onTouch()
所以不多讲了,直接补上Activity的麻雀:
open class MActivity(private val childGroup: MViewGroup) {
private var isChildNeedEvent = false
private var isSelfNeedEvent = false
open fun dispatch(ev: MotionEvent): Boolean {
var handled = false
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
clearStatus()
handled = childGroup.dispatch(ev)
if (handled) isChildNeedEvent = true
if (!handled) {
handled = onTouch(ev)
if (handled) isSelfNeedEvent = true
}
} else {
if (isSelfNeedEvent) {
handled = onTouch(ev)
} else if (isChildNeedEvent) {
handled = childGroup.dispatch(ev)
}
if (!handled) handled = onTouch(ev)
}
if (ev.actionMasked == MotionEvent.ACTION_UP
|| ev.actionMasked == MotionEvent.ACTION_CANCEL) {
clearStatus()
}
return handled
}
private fun clearStatus() {
isChildNeedEvent = false
isSelfNeedEvent = false
}
open fun onTouch(ev: MotionEvent): Boolean {
return false
}
}
到这里,我们终于造好了一个粗糙但不劣质的轮子,源码的主要逻辑与它的区别不大,具体区别大概有:TouchTarget
机制、多点触控机制、NestedScrolling 机制、处理各种 listener、结合View的状态进行处理等,相比主要逻辑,它们就没有那么重要了,大家可以自行阅读源码,之后有空也会写关于多点触控和TouchTarget
的内容 (挖坑预警)
轮子的完整代码可以在在这里查看(Java版本)
这个轮子把源码中与事件分发相关的内容剥离了出来,能看到:
但轮子不是最重要的,最重要的是整个演化的过程。
所以回头看,你会发现事件分发其实很简单,它的关键不在于「不同的事件类型、不同的View种类、不同的回调方法、方法不同的返回值」对事件分发是怎么影响的。
关键在于「它要实现什么功能?对实现效果有什么要求?使用了什么解决方案?」,从这个角度,就能清晰而且简单地把事件分发整个流程梳理清楚。
事件分发要实现的功能是:对用户的触摸操作进行反馈,使之符合用户的直觉。
从用户的直觉出发能得到这么两个要求:
第二个要求是最难实现的,如果有多个View都可以消费触摸事件,怎么判定哪个View更适合消费,并且把事件交给它。
我们使用了一套简单但有效的先到先得策略,让内外的可消费事件的View拥有近乎平等的竞争消费者的资格:它们都能接收到事件,并在自己判定应该消费事件的时候去发起竞争申请,申请成功后事件就全部由它消费。
(转载请注明作者:RubiTree,地址:blog.rubitree.com )
可能有人会问,听你纸上谈兵了半天,你讲的真的跟源码一样吗,这要是不对我不是亏大了。
问的好,所以接下来我会使用一个测试事件分发的日志测试框架对这个小麻雀进行简单的测试,还会有实践部分真刀真枪地把上面讲过的东西练起来。
测试的思路是通过在每个事件分发的钩子中打印日志来跟踪事件分发的过程。
于是就需要在不同的 View 层级的不同钩子中,针对不同的触摸事件进行不同的操作,以制造各种事件分发的场景。
为了减少重复代码简单搭建了一个测试框架(所有代码都能在此处查看),包括一个可以代理 View 中这些的操作的接口IDispatchDelegate
及其实现类,和一个DispatchConfig
统一进行不同的场景的配置。
之后创建了使用统一配置和代理操作的 真实控件们SystemViews
和 我们自己实现的麻雀控件们SparrowViews
。
在DispatchConfig
中配置好事件分发的策略后,直接启动SystemViews
中的DelegatedActivity
,进行触摸,使用关键字TouchDojo
过滤,就能得到事件分发的跟踪日志。
同时,运行SparrowActivityTest
中的dispatch()
测试方法,也能得到麻雀控件的事件分发跟踪日志。
先配置策略,模拟View
和ViewGroup
都不消费事件的场景:
fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
return DispatchDelegate(layer)
}
fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
return DispatchDelegate(layer)
}
fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
return DispatchDelegate(layer)
}
能看到打印的事件分发跟踪日志:
[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept_BE |type:down
|layer:SViewGroup |on:Intercept_AF |result(super):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch_BE |type:down
|layer:SView |on:Touch_AF |result(super):false |type:down
|layer:SView |on:Dispatch_AF |result(super):false |type:down
|layer:SViewGroup |on:Touch_BE |type:down
|layer:SViewGroup |on:Touch_AF |result(super):false |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:down
|layer:SActivity |on:Touch_BE |type:down
|layer:SActivity |on:Touch_AF |result(super):false |type:down
|layer:SActivity |on:Dispatch_AF |result(super):false |type:down
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
[move]
...
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SActivity |on:Touch_BE |type:up
|layer:SActivity |on:Touch_AF |result(super):false |type:up
|layer:SActivity |on:Dispatch_AF |result(super):false |type:up
BE
代表 before
,表示该方法开始处理事件的时候,用AF
代表after
,表示该方法结束处理事件的时候,并且打印处理的结果View
和ViewGroup
都不消费DOWN
事件时,后续事件将不再传递给View
和ViewGroup
再配置策略,模拟View
和ViewGroup
都消费事件,同时ViewGroup
在第二个MOVE
事件时认为自己需要拦截事件的场景:
fun getActivityDispatchDelegate(layer: String = "Activity"): IDispatchDelegate {
return DispatchDelegate(layer)
}
fun getViewGroupDispatchDelegate(layer: String = "ViewGroup"): IDispatchDelegate {
return DispatchDelegate(
layer,
ALL_SUPER,
// 表示 onInterceptTouchEvent 方法中,DOWN 事件返回 false,第一个 MOVE 事件返回 false,第二个第三个 MOVE 事件返回 true
EventsReturnStrategy(T_FALSE, arrayOf(T_FALSE, T_TRUE, T_TRUE), T_SUPER),
ALL_TRUE
)
}
fun getViewDispatchDelegate(layer: String = "View"): IDispatchDelegate {
return DispatchDelegate(layer, ALL_SUPER, ALL_SUPER, ALL_TRUE)
}
能看到打印的事件分发跟踪日志:
[down]
|layer:SActivity |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Dispatch_BE |type:down
|layer:SViewGroup |on:Intercept |result(false):false |type:down
|layer:SView |on:Dispatch_BE |type:down
|layer:SView |on:Touch |result(true):true |type:down
|layer:SView |on:Dispatch_AF |result(super):true |type:down
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:down
|layer:SActivity |on:Dispatch_AF |result(super):true |type:down
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(false):false |type:move
|layer:SView |on:Dispatch_BE |type:move
|layer:SView |on:Touch |result(true):true |type:move
|layer:SView |on:Dispatch_AF |result(super):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Intercept |result(true):true |type:move
|layer:SView |on:Dispatch_BE |type:cancel
|layer:SView |on:Touch_BE |type:cancel
|layer:SView |on:Touch_AF |result(super):false |type:cancel
|layer:SView |on:Dispatch_AF |result(super):false |type:cancel
|layer:SViewGroup |on:Dispatch_AF |result(super):false |type:move
|layer:SActivity |on:Touch_BE |type:move
|layer:SActivity |on:Touch_AF |result(super):false |type:move
|layer:SActivity |on:Dispatch_AF |result(super):false |type:move
[move]
|layer:SActivity |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Dispatch_BE |type:move
|layer:SViewGroup |on:Touch |result(true):true |type:move
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:move
|layer:SActivity |on:Dispatch_AF |result(super):true |type:move
[up]
|layer:SActivity |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Dispatch_BE |type:up
|layer:SViewGroup |on:Touch |result(true):true |type:up
|layer:SViewGroup |on:Dispatch_AF |result(super):true |type:up
|layer:SActivity |on:Dispatch_AF |result(super):true |type:up
ViewGroup
拦截事件前后,事件是如何分发的除了以上场景外,我也模拟了其他复杂的场景,能看到系统控件和麻雀控件打印的日志一模一样,这就说明了麻雀控件中的事件分发逻辑,确实与系统源码是一致的。
而且从打印的日志中,能清晰地看到事件分发的轨迹,对理解事件分发过程也有很大的帮助。所以大家如果有需要,也可以直接使用这个框架像这样对触摸事件分发的各种情况进行调试。
实际上进行事件分发的实践时,会包括两方面内容:
GestureDetector
,它用起来非常方便时间关系,这部分暂时直接去看另一篇透镜《看穿 > NestedScrolling 机制》吧,它提供了过得去的实践场景。
4.1.事件分发经典伪代码
public boolean dispatchTouchEvent(MotionEvent event) {
boolean consume = false;
if (onInterceptTouchEvent(event)) {
consume = onTouchEvent(event);
} else {
consume = child.dispatchTouchEvent(event);
}
return consume;
}
]]>