稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Android View滑动处理大法

对于触控式操作来说,滑动是一个特别重要的手势操作,如何做到让应用程序的页面滑动起来如丝般顺滑,让用户感觉到手起刀落的流畅感,是开发人猿需要重点解决的问题,这对提升用户体验是最为重要的事情。本文就将探讨一下,Android中View的滑动相关知识,以及如何做到丝般顺滑。

如何让View滑动起来

View的滑动是GUI支持的一项基本特性,就像触摸事件一件,这是废话,平台如果不支持,你还搞个毛线。

View滑动的基本原理

我们先来看一下Android中实现View的滑动的基本原理。其实屏幕并没有动啊,一个View的可绘制区域,对于屏幕来说,对于view tree来说都是没有变化 的。父布局给某一个View的绘制区域是在layout之后就确定好了的,当View的真实高度或者宽度超过了这块可绘制区域,那么就需要滑动才可以把整个View做到用户可见。View内部通过两个关键成员变量mScrollX和mScrollY来记录滑动之后的坐标,View本身有mLeft和mTop来标识自己相对于父布局的坐标位置,那么当有滑动的时候,在此View当中具体要绘制的区域就变成了以mLeft+mScrollX和mTop+mScrollY为起点的区域了。由此View便滚动起来了。

如何实现View的滑动

对于开发人猿来说,实现View的滑动,需要关注三个重要的方法,也即是View#scrollBy),View#scrollTo)以及View#onScrollChanged),这是实现滑动的三个最为核心的方法。

scrollBy提供的参数是需要滑动的距离,而scrollTo则是需要传入要滑动到的目标坐标值,这两个方法都是要修改mScrollX和mScrollY的值,本质上是一样的。而onScrollChanged则是一个回调,用以通知更新了的滑动位置。

Scroll手势

要想让View滑动起来,离不开事件手势的支持。最简单也是最直接的手势就是onScroll手势,这个在GestureDetecor中可以识别出此手势,或者自己去直接处理touch event也可以得出此手势。这个并不复杂,就是直接通过touch 事件来计算滑动多少距离就好了,按照View预设计的可以滑动的方向,比如横向就计算不同时间点MotionEvent的坐标值,得到一个水平距离deltaX,然后调用scrollBy即可。垂直方向依此类推。

Scroll手势简单是因为它是直接来源于事件,且速度较慢,并不需要额外处理,所以整体逻辑处理流程并不复杂。

GestureDetector中的识别就是在ACTION_MOVE时,查看滑动过的距离,这个距离(由sqrt(dx x dx, dy x dy)如果大于touch slop,就会触发onScroll手势回调。

Fling手势

Fling也即是快速滑动,就是手指在屏幕上使劲的『挠』一下,手势的要点是手指在屏幕快速滑过一小段短距离,就像把一个小球弹出去的感觉一样。对于Fling手势来说,最重要的是速度,水平方向的速度和垂直方向的速度,可以理解为高中物理常讲到的平抛运动一样。

GestureDetector识别Fling的逻辑是,在ACTION_UP时,检查此次事件的速度,如果水平方向速度或者垂直方向速度超过了阈值,便会触发Fling手势回调。

注意:留意Scroll与Fling的区别,Scroll是慢的,不关心时间与速度,只关心滑动的距离,是在ACTION_MOVE时,手指并未有离开屏幕时就触发了,只要是ACTION_MOVE还在继续,就会继续触发onScroll,并且ACTION_UP时终止整个Scroll,而Fling只关心速度,不关心距离,是在ACTION_UP时,手指离开了屏幕了(此次事件流处理结了)才会触发。

VelocityTracker

Fling事件速度是决定性的,仔细看GestureDetector的处理过程会发现它使用了一个叫做VelocityTracker的对象,来帮忙处理一些关于速度的具体逻辑,那么有必要深入了解一下这个对象。

VelocityTracker使用起来并不复杂,获取它的一个对象后,只需要不断的把MotionEvent塞给它就可以了,然后在需要的时候让其计算两个方向上的速度,然后就没有然后了:

1
2
3
4
5
6
7
8
9
10
11
12
velocityTracker = VelocityTracker.obtain();

onTouchEvent(MotionEvent ev) {
    velocityTracker.addMovement(ev);

    if (want to know velocities) {
       velocityTracker.computeCurrentVelocity(100);
       vx = velocityTracker.getXVelocity();
       vy = veolocityTracker.getYVelocity();
       be happy with vx and vy.
    }
 }

这个类的实现,值得仔细看一下,它主要的实现都是用JNI去实现,可能是因为计算方式较复杂,所以computeCurrentVelocity)方法也说明了,让你真用的时候再调,这个不用去管细节实现。重点看一下这个类,里面有一个对象池,用以缓存对象,并且创建对象的方式并不是直接new,而是用其obtain)方法。这里用的是叫享元(Flyweight Pattern)的设计模式,也就是说VelocityTracker对象其实是共享的。

顺滑如丝

前面提到了,让View滑动,只需要调用scrollBy或者scrollTo即可,但这个吧,是直接修改了mScrollX,mScrollY,然后invalidate,View下次draw时就直接在把目标区域内容绘制出来了,换句话说这两个方法滑动是瞬间跳格式的。

一般来说,这也没有问题,就像onScroll手势,ACTION_MOVE时,不断的scrollBy刚刚滑过的距离,都还okay,没有什么问题。

但是对于Fling事件就不行了,Fling事件,也即快速滑动,要求短时间内进行大距离滑动,或者像有跳转的需求时,也是短时间内要滑动大距离。如果直接scrollBy或者scrollTo一步到位了,会显得 相当的突兀,体验相当不好,卡顿感特别强。如果能像做动画那样,在一定时间内,让其平滑的滑动,就会如丝般顺滑,体验好很多。Scroller就是专门用来解决此问题的。

Scroller

Scroller是对滑动的封装,并不是View的子类,其实它跟View一点关系也没有,也不能操作View,实际上它与属性动画类似,它仅是一个滚动位置的计算器,告诉它起始位置和要滚动的距离,然后它就会告诉你位置随时间变化的值。其实这是一个中学物理题,也即给定初始位置,给定要滚动的距离,以一定的方式来计算每个时间点的位置。具体的计算方式由mInterpolater成员来控制,默认是ViscousFluid,是按自然指数为减速度来计算的,具体的可以查看Scroller的源码。如果不喜欢默认的计算方式,可以自己实现个Interpolator,然后在构造时传进去。

Scroller的作用在于实现平稳滑动,不让View的滚动出现跳跃,比如滑动一下ListView,开始滑动时的位置是x0,y0(ActionDown的位置),要向下滑动比如500个像素,不平稳的意思是,从x0,一下跳到x0+500的位置。要平稳,就要不断的一点点的改变x的值然后invalidate,这也就是Scroller的典型使用场景:

1
2
Scroller scroller = new Scroller(getContext());
scroller.startScroll(x0, y0, 500, 0);

然后在computeScroll时:

1
2
3
4
5
if (scroller.computeScrollOffset()) {
   int currX = scroller.getCurrX();
   int currY = scroller.getCurrY();
   invalidate(); // with currX and currY
}

computeScrollOffset在滚动没结束时返回true,也就是说你需要继续刷新view。返回false时表明滚动结束了,当然也就没有必要再刷新view(当然如果你乐意也可以继续刷,但是位置啥的都不变了,所以刷了也白刷)。

滑动冲突处理

关于View的滑动,最难搞的问题便是手势冲突处理,特别是当页面的结构变得复杂了以后。一般来讲,滑动手势,是让某一个View沿着某一个方向『平移』一段距离,如果某一个页面中只有一个View是可以滑动的,或者页面中不同的View的可滑动方向是垂直正交的,那么就不会有冲突的问题。

所谓滑动冲突,是指父View和子View都接受滑动手势,并且方向又是一样的,这时就产生了滑动冲突,常见就是ScrollView中套着ListView(这个通常是垂直Y方向上面有滑动冲突),或者ViewPager中套着ScrollView(这个是水平X方向上有滑动冲突)。

要想解决好滑动冲突问题,需要先确实好整体的设计方案,有了大的原则后,就容易用技术方案找到解法。最理想的方案,也是目前用的最多的方案就是在子View的边界设定一个margin区域,当ACTION_DOWN在margin区域以外,认定滑动手势归父View处理,否则交由子View处理。像一些全局手势也是要用如此的方案,当点击距离屏幕一定范围内(margin区域)认定此事件归当前页面处理,否则就认定为全局手势,就好比从屏幕左边向右滑动,很多应该将此识别为BACK到上一页,但如果离左边较远时滑动,就会是页面内部的滑动事件(假如它有可滑动的组件的话,事件手势会被其滑消耗掉)。

参考资料

Comments