你要知道的 ListView 5种滑动模式全在这里了
前段时间在使用 ListView 的过程中,需要对一个子 Item 优化横向 Bannar 的滑动体验,于是借此机会,深入了解了一下 ListView 滑动的一些知识,来探究一下,一个 View 滑动,究竟需要做哪些事情。
滑动模式基本介绍
ListView 的滑动模式使用变量 mTouchMode 来表示,分为以下几种模式:
mTouchMode | 注释 | 解析 | 备注 |
---|---|---|---|
TOUCH_MODE_REST | Indicates that we are not in the middle of a touch gesture | 标识当前未处于滑动手势中 | 用于重置当前滑动状态 |
TOUCH_MODE_DOWN | Indicates we just received the touch event and we are waiting to see if the it is a tap or a scroll gesture | 标识仅仅是接到了触摸事件,还需要进一步判断是点按事件还是滑动手势 | |
TOUCH_MODE_TAP | Indicates the touch has been recognized as a tap and we are now waiting to see if the touch is a longpress | 标识触摸事件已经被识别为点按事件,还需要进一步判断是不是长按事件 | |
TOUCH_MODE_DONE_WAITING | Indicates we have waited for everything we can wait for, but the user’s finger is still down | 标识我们已经等了很久,但是用户的手势还是处于down状态 | |
TOUCH_MODE_SCROLL | Indicates the touch gesture is a scroll | 标识触摸手势是滑动事件 | |
TOUCH_MODE_FLING | Indicates the view is in the process of being flung | 标识当前View是在“甩”的过程中 | |
TOUCH_MODE_OVERSCROLL | Indicates the touch gesture is an overscroll - a scroll beyond the beginning or end. | 标识手势是一个越界滑动越界滑动是指滑动超出了内容区域的首尾 | 这种状态下,AbsListView 会在 DOWN 事件时拦掉事件,交给自己的 onTouchEvent 处理,事件不会继续往子 View 传递 |
TOUCH_MODE_OVERFLING | Indicates the view is being flung outside of normal content bounds and will spring back. | 标识当前View被“甩”出了正常滑动区域,即将会弹回来 | 这种状态下,AbsListView 会在 DOWN 事件时拦掉事件,交给自己的 onTouchEvent 处理,事件不会继续往子 View 传递 |
ListView 滑动模式有这么多,其实都是基于控件自身设计和交互上的考虑。我们会发现上表中出现的滑动模式,前四个模式,标识 ListView 当前处于的状态,并没有”滑“起来,而后四个模式,却在实际中有对应的场景:
TOUCH_MODE_SCROLL
:最简单的场景,对应于 ListView 跟随我们的手指上下滑动TOUCH_MODE_FLING
:列表太长时,我们想要快速浏览,手指快速向下(上)滑动,手机离开屏幕伴有一定的加速度TOUCH_MODE_OVERSCROLL
:手机滑动列表已经超出内容的可滑动区域后,还继续滑动,表示 ListView 没有更多的内容了TOUCH_MODE_OVERFLING
:Fling 之后,滑动还没停下来的时候超出内容的滑动区域,继续滑动的状态,原生默认的效果是波纹动画,也是标示 ListView 没有更多可以滑动的内容了FAST_SCROLL
:除了以上的模式之外,还有一个模式 ,对应的场景是,我们拖动滑动标识器(scroll thumb)快速定位到 ListView 的某个位置,FAST_SCROLL 模式的事件处理在整个以上模式的最前边,是独立于这些模式的,我们后边单独分析
以上就是我们这次要讲得 5 种滑动模式
这里有个模式变化表,仅供参考,单次滑动屏幕,(中间不抬手指)
模式变化 | 说明 |
---|---|
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_OVERSCROLL | (不可能,无法直接从 DOWN->OVERSCROLL,因为 mScrollY 必须经过 onOverScrolled 之后才能有值) |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> 按下,长按,TAP | 普通点击,长按 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_REST | 普通的滑动模式 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL-> TOUCH_MODE_OVERFLING -> TOUCH_MODE_FLING -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> 越界 -> 越界FLING -> FLING -> 重置 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> 越界 -> 松手 -> 弹回重置 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_OVERSCROLL -> TOUCH_MODE_SCROLL -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> 越界 -> 往回滑动 -> 松手 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_FLING | (不可能,无法直接从 DOWN->FLING,因为 fling 需要加速度,必须要经过 move) |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_OVERFLING -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> 松手FLING -> 越界FLING -> 停止 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_OVERFLING -> TOUCH_MODE_FLING -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> 松手FLING -> 越界FLING -> FLING -> 停止 |
TOUCH_MODE_REST -> TOUCH_MODE_DOWN -> TOUCH_MODE_SCROLL -> TOUCH_MODE_FLING -> TOUCH_MODE_REST | 初始化 -> 触摸屏幕 -> 接触滑动 -> FLING -> 到达边缘(当前 overScrollMode 被禁用) |
从哪里开始
滑动是与用户连接非常密切的交互方式,而感知用户屏幕行为最重要的一个途径就是 MotionEvent 事件,所以要了解 ListView 的滑动模式,我们首先就需要把目光放到 ListView 处理屏幕触摸事件的两个方法
1 | android.widget.AbsListView#onInterceptTouchEvent() |
ListView 继承自 ViewGroup,所以事件分发的过程会经历以下三个方法(并不是每次事件流都必走,顺序也不是从上到下的)
1 | android.view.ViewGroup#dispatchTouchEvent() |
dispatchTouchEvent() 是 ViewGroup 专属,负责把控整个 View 树的整体事件分发,我们不再细说,具体可以看之前的一篇文章 触摸事件分析
触屏事件从底层传上来之后,会先经过父控件的 onInterceptTouchEvent() 方法,让父控件判断是否要拦截事件自用,要拦截的滑交给父控件自身的 onTouchEvent(),不拦截,那就向下传递,我们以这个为基本原则,详细来看
onInterceptTouchEvent
1 |
|
快速滑动的判定更早于其他模式,在 onInterceptTouchEvent() 一开始就处理过了,具体细节我们后续再说
在 ACTION_DOWN 中,OVERFLING,OVERFLING,OVERFLING 这三种情况下,会将接下来的事件直接转交给 ListView 自身,而其他情况下,对是否抵达这三种情况的判定其实是在 ACTION_MOVE 中,也就是说 ListView 并没有在事件一开始就接管,而是通过一段时间的滑动之后才做出了进入滑动模式的判定,从而基于这个判定来接管后续的事件
从 ACTION_DOWN 的代码中,我们可以总结两点:
在已经进入 OVERFLING,OVERFLING,OVERFLING 三个模式后,后续发生的事件流,都会先被 ListView 自身所接管处理,也就是说在 ListView 还在滑动的过程中的时候,轻点一下屏幕,并不会触发子 View 的点击事件,而是 ListView 自己去处理,这个并不难理解,触发滚动之后,后续的第一个事件自然应该是先停止滚动,然后在静止的情况下让用户准确的选中要处理的子 Item
在将要触发滑动的第一个事件流来的时候,ListView 并没有立刻接管这个事件流,而是“观察”了一段 ACTION_MOVE 事件之后,才开始接管,所以对于进入滑动的时机,是在 ACTION_MOVE 中判定的。
每组事件的开头 ACTION_DOWN 被父控件拦截之后,这个事件流的后续事件都会被父控件接管吗?子控件还有机会获得使用吗?
答:有机会,调用 requestDisallowInterceptTouchEvent() 就可以,但是因为 touch 事件已经被父 View 接管了,所以在触屏事件流的过程中,是没有机会的,只能通过其他时机触发
onTouchEvent
1 |
|
好了,定位到滑动的入口,接下来我们就简单介绍下这几个滑动模式的整体流程
事件首先会到达 android.widget.AbsListView#onInterceptTouchEvent
,我们先看拦截的机制
手指放到屏幕上,ACTION_DOWN ,mTouchMode 被初始化为 TOUCH_MODE_REST,标志着 touchMode 的重新计算,并重置了上次滑动的位置 mLastY
手指开始移动,ACTION_MOVE 事件到达,这里会时刻监控 startScrollIfNeeded() 的返回值,来决定要不要拦截事件,转交给自身的 onTouchEvent() 处理,
拦截的触发条件有两个:
- 是否超出 View 的边缘(mScrollY !=0),超过表示 overScroll() 模式
- 有没有超过 android 设定的误触值 mTouchSlop,超过并且不是 overScoll 模式,那么就是滑动模式 scroll 了,如果判定了滑动模式,要清除一些子 Item 的按下状态,还要阻止父控件拦截事件,全部交给自己来处理了(有点霸道。。),最后就根据滑动的值来改变 ListView 自身的状态了
我们假定,已经触发了滑动或者越界滑动的条件,实际滚动的过程需要区分这二者
所谓的超过 View 的边缘是什么意思?
答:View 的滚动可以分为两个部分,一个是内容的滚动,一个是 View 自身的滚动,ListView 中 item 的滚动就是属于内容的滚动,而越界的滚动属于 ListView 自身的滚动。
进入各个模式之前,我们需要明确一点,ListView 在每次滑动的时候,都会记录手指按下时候的位置 mMotionY,手指从上次按下位置开始到当前点滑动的距离 deltaY,以及手指自上次之后滑动的距离 incrementalDeltaY,以及当前 View 所滑动的距离 mScrollY(整个 View 滑动的距离,而不是 View 的内容)
这四个变量会在下边的四个滑动模式中被反复使用。
5种模式
SCROLL
滑动模式我们需要让 ListView 的内容滚动起来,这里 Android 是在 trackMotionScroll() 中处理的,这个处理的过程区分是否到达 ListView 的开头和末尾,如果满足条件的话,那么马上就要切换到越界滚动模式,反之,就要保证让 ListView 的内容滚动起来,因为 ListView 有 View 复用的逻辑,所以在旧的 View 滑出屏幕之后要加入回收池,新的 View 进入屏幕时要立刻绑定数据或者重新创建,
子 item 的滚动是通过对每个 item 使用 offsetChildrenTopAndBottom() 来实现的,这个函数之后会调用 invalidate() 来刷新 ListView 内容
那么问题又来了,屏幕滚动,一部分子 item 向上(下)滚动,那么 ListView 空出来的地方怎么把新的 View 填充上去,答案是 fillGap(),这个函数会根据向上还是向下滚动,来决定 fillUp() 还是 fillDown(),这里会根据一定的条件决定新建还是复用子 Item,如果是创建的话,还需要使用 setupChild() 初始化这个新的子 Item,来将 item 放到合适的位置上
SCROLL 模式的滑动模型是手指移动的距离就是 ListView 内容滚动的区域,所以在进入滑动模式之后,手指每次移动的距离 incrementalDeltaY 就是本次 listView 需要“消费”的距离,消费完成,ListView 就停止滚动
OVERSCOLL
越界滚动是在 ListView 滑动子 Item 到达顶部或者底部的时候触发,在之前的滚动模式中我们在判定 ListView 到达上下边缘时就看到了触发的条件,我们接着上边的分支看
判定满足了越界滚动模式的条件,那么就会通过 overScrollBy() 来梳理计算出越界滚动的一些数据(注意,这个 overScrollBy() 是 View 的能力),将数据通过交给使用者自己处理,我们可以在 onOverScrolled() 中拿到这些数据决定如何处理,ListView 这里只是设定了 mScrollY 的值,并通过回调通知使用者越界的具体参数,如果想实现弹性效果,就可以在复写 onOverScrolled() 来处理
这里插一句闲话,据说在 IOS 出现越界弹性效果之后,Android 也跟进了提供 onOverScrolled() 方式,但是却没有提供具体实现,可能是因为担心承担法律责任
最后,Android 默认会根据设定的 overScrollMode 来决定如何提示滚动越界了,android5.0 之后的越界会有波纹效果,就是从这里显示出来的,这里通过一个 EdgeEffect 的类来处理越界滚动的效果
至于 overScroll 模式如何停下来,我们在 fling 模式的后边细说,因为 overScroll 模式和 fling 模式的停止都使用了动画。
什么是未被消费的滚动距离
答:我们手指离开屏幕之后的加速度所计算得出的距离就是总的滚动距离,后续 View 自动滚动需要消费这些距离,直到消费完成
overScrollMode
先说明一下,overScroll 也有三种模式,用户可以设置的模式,根据这三种模式,越界滚动的效果也不一样
scrollMode | 注释 | 解析 | 备注 |
---|---|---|---|
OVER_SCROLL_ALWAYS | Always allow a user to over-scroll this view, provided it is a view that can scroll. | 总是允许越界滚动 | |
OVER_SCROLL_IF_CONTENT_SCROLLS | Allow a user to over-scroll this view only if the content is large enough to meaningfully scroll, provided it is a view that can scroll. | 只有在内容足够大时,来清晰的表示滑动状态时,才允许滑动 | |
OVER_SCROLL_NEVER | Never allow a user to over-scroll this view. | 总是禁止 |
看到这里不要忘记我们是在 onInterceptTouchEvent() 方法中,事件在这里拦截后,会转交给 onTouchEvent() 处理
所以在上文3的触发条件满足之后,我们后续的事件要接受 onTouchEvent() 的进一步处理
从我们在上边的分析中,在 ListView 从静止到触发滑动之前,事件并没有被 ListView 拦截掉,那么这些事件会被派发给子 Item,这时候子 Item 可以根据这个 Down 事件来处理按下的状态,
如果子 Item 这个时候在自己的事件处理中返回 true,接管了这个事件流的后续,那么后边是什么样的流程了?
答:那么事件将会直接交给子 item 处理,这个事件流周期内,ListView 自身是无法再接受到事件。
如果子 item 没有拦截这个事件,那么 listView 会在 onTouchDown() 中简单处理一下状态,并发送延迟消息来判定是否要触发长按事件。
我们接着来看滑动事件触发之后,事件会交给 ListView 自身处理,这时候 MOVE 事件会在 onTouchMove() 中处理,同样,这里也使用 scrollIfNeed() 来处理滚动和越界滚动的效果
这里为啥要多加一层,在onInterceptTouchEvent 中处理的不够吗?
答:因为 intercept 只是起到第一次拦截的作用,intercept 拦截之后,后续事件不会再次经过 intercept,只会直接交给 touchEvent,所以这里需要继续后边滑动的处理
FLING & OVER_FLING
在 ACTION_DOWN 和 ACTION_MOVE 方法处理中这两种模式并没有什么不同,不同之处在于松手之后,也就是 ACTION_UP 事件传过来,
ACTION_UP 事件传过来之后,我们会使用之前在 ACTION_DOWN 和 ACTION_MOVE 中监控的 velocityTracker 事件来分析手指离开屏幕的加速度,如果分析得到加速度大于 mMinimumVelocity ,那么会触发进入 TOUCH_MODE_FLING 模式,这个模式下的处理交给了 FlingRunnable,这个 Runnable 内置了一个 OverScroller 会按需处理 FLING,OverFling,SpringBack(回弹)这三种状态
举例来说,在列表足够长时,Fling 一段距离之后会停下来,而列表短时,可能会触发 OverFling,然后再触发 SpringBack 效果,这里不再细谈,只需要知道 Fling 和 OverFling 是在这个 FlingRunnable 中处理就 OK,后续需要的时候再回头细看即可
注:FlingRunnable 的运行模式
FlingRunnable 通过 postOnAnimation 把自身加入到 Choreographer 的回调队列中,在每次 VSYNC 事件的时候都会回调FlingRunnable 的 run 方法,在 run 方法中决定是否要继续监听下一次 Choreographer 的回调,来实现持续滑动的效果
VSYNC 相关内容可以参考以下文章:
为什么是VSYNC
Choreographer 可以参考以下文章:
invalidate 三部曲序
invalidate 三部曲之始于 invalidate
invalidate 三部曲之历经 Choreographer
FAST_SCROLL
说完 scroll,overScrol,fling,overFling,这四种模式之后,我们最后看一下 fastScroll 的一些内容
顾名思义,fastScroll,快速滚动,这个滚动不是通过滑动 listView 的内容区域来触发的,而是通过 fast scroll thumb 这个控件来触发的,事件处理也是独立于 ListView 的,单独放到了 FastScroller 中处理,我们可以通过设置来开关这个属性,很多人应该都用过,拖动 scrollbar,然后下拉,就可以达到这种效果
因为这个事件是在一个单独的控件中触发的,所以事件处理也是完全独立 ListView 的,感兴趣的翻看 FastScroller 代码
至此,我们对 ListView 的5个滑动模式就分析到这里,理清楚了大部分的流程之后,我们遇到 ListView 相关的问题,就可以迅速从某个入口介入。
你要知道的 ListView 5种滑动模式全在这里了
http://www.0xforee.top/2019/08/12/listview-scrollmode-parse/