Sep 28

编写自定义view——小红点设计实录

Lrdcq , 2015/09/28 21:55 , 程序 , 閱讀(4561) , Via 本站原創
0.什么小红点?

小红点是值得qq和微信上那个可以拖动消失的提示红点:

點擊在新視窗中瀏覽此圖片

当然,小红点本身并不是疼迅发明的,而是我们大名鼎鼎的苹果(专利:http://www.google.com/patents/US8135392 )。当然,真的是不是苹果我们不得而知,不过这次腾讯做得很成功,为小红点添加了可拖动的功能后,几乎“重新定义了小红点”,之后的国内应用大部分做到小红点时,有消除需求的均会加上拖动消失的效果,以达到和腾讯小红点相似的用户体验。
当然,作为自己的产品,我们也有一定需求需要小红点这样的控件,浏览网络上github上各种个样的类似模仿控件,在我能找到的范围内并没有实现效果能够让我满意的开源代码,因此,这里我们尝试自己实现一个小红点。



1.小红点绘制

首先,小红点本体是一个圆,至少在两位数及以下它肯定是一个圆。而在拖动它离开原位后,本体跟随移动,原本所在的位置会生成一个小圆,并且在逐渐变小。然后,在大圆和小圆之间,我们用一定的算法创建图形,来实现“黏糊糊”的水滴效果。
首先,一开始的原型版本,中间是直接相连的,类似于梯形的样子,如下:

點擊在新視窗中瀏覽此圖片

其中A点是原位,也就是小圆的位置,B点是手指移动的位置,也就是大圆的位置,为了构成梯形,我们需要计算同时过两个圆的两条切线CD与EF的坐标位置。计算切线的方法很多,不过考虑到对于单侧,ABDC是一个直角梯形,且已经知道两个圆的半径长度,显然可以计算出直角三角形ABG中角ABG的大小,那么计算CD点的位置只要把AB方向上的半径向量稍微旋转一下就OK了。之后所有计算切线的方法都是用相同的道理,即计算直角三角形夹角就可以旋转出切点的坐标了。

显然,这只是一个基础模型,我们要的水滴状好歹中间不是直线,是凹进去的。说到画曲线,我们第一个当然想到是贝塞尔曲线。首先,安卓画布给我们的是一个二阶贝塞尔曲线的方法,那么我们只需要找一个关键点,就可以画出一条柔软的曲线了。考虑到大圆小圆的半径差异,我们取AB线段中AX:XB=RA:RB的点为关键点,因此如下图:

點擊在新視窗中瀏覽此圖片

这样看起来就靠谱多了,其中计算出X点后,以CXD和EXF绘制贝塞尔曲线,即可得到比较好的图像效果了。不过还不够,显然,这种情况下要圆和贝塞尔曲线平滑衔接(连续可微),CDEF点需要重新计算,而要结合贝塞尔曲线公式进行平滑计算的画就太复杂了,因此,我们经过设计采用一种折中的方式,以过X点的四个切点作为关键点,如下图:

點擊在新視窗中瀏覽此圖片

这样就可以满足看看起比较平滑的过度衔接,并且CDEF与X点的计算非常简单。
另外,在大圆远离原点的过程中,圆A是在慢慢变小的,并且在最远距离即设定的脱离距离的临界值,它达到极小值。经过多次测试与对比,还是小圆半径的二次线性变换比一次线性变换看起来更加自然。小圆的半径最终计算公式是:
float smallBallR = dp2px(1 + (1 - (1 - (dp2px(maxDistance) - dis) / dp2px(maxDistance)) * (1 - (dp2px(maxDistance) - dis) / dp2px(maxDistance))) * (ballR - 2));
//
CGFloat smallSize = 1 + (1 - (1 - (maxDistance - dis) / maxDistance) * (1 - (maxDistance - dis) / maxDistance)) * (ballSize - 2);
//

设定最小半径为1,最大半径为1+rate*rate*(大圆半径-2),保证了小圆的可见性,同时小圆半径的变化也牵涉到关键点X的变化率,同样也是以二次的速率从AB接近中点的位置靠近A点的位置,最终和A点合在一起趋向于一点。然而这里有一个问题,当A点无限小的时候,DFA几乎构成了一个巨大的三角形,如下:

點擊在新視窗中瀏覽此圖片

然后在下一个瞬间大圆脱离牵引范围,小圆即点A消失,同时冒出来这个三角形ADF也消失掉。但是仔细想想所谓“粘糊糊的”这个实际过程的画,最后牵引部分应该是化为一个看起来藕断丝连的细丝儿,再几乎细到无穷小的时候消失。因此,我们对DF关键点设定了衰减算法,让这两个点随着A的缩小迅速向中间靠拢,最后同A一起达到无穷小,中间就会形成藕断丝连的效果。计算夹角ABD同ABF,这个夹角经过初始化计算后叠加衰减公式:
double rate = 1 - (dp2px(maxDistance) - dis) / dp2px(maxDistance);
angleB *= (1 - rate * rate * rate * rate);
CGFloat rate = 1 - (maxDistance - dis) / maxDistance;
angleB *= (1 - rate * rate * rate * rate);

采用了一个四次方倍率的衰减来保证在最后即将拉伸到极限是有效的进行变化。经过实际测试,该衰减方法较为自然合理的实现了需求中的效果。

回弹效果。当用户把小圆点拖离原点,但并没有拖出判定范围,或者拖出去了转了好大一圈却有放回来了,总之用户不是在判定圈外释放手势的化,我们的圆有一个回弹性的动画。类似与这类的动画,我们第一反应的单词就是Tween,对,一般的Tween方法中肯定有实现类似效果的函数,我们选用了一般称为Elastic的算法的移出版本,此算法的简单版本和实际运动轨迹图如下图:
private float easeOut(float t, float b, float c, float d) {
        if (t == 0) return b;
        if ((t /= d) == 1) return b + c;
        float p = d * .3f;
        float a = c;
        float s = p / 4;
        return (a * (float) Math.pow(2, -10 * t) * (float) Math.sin((t * d - s) * (2 * (float) Math.PI) / p) + c + b);
}
CGFloat easeOut(CGFloat t, CGFloat b, CGFloat c, CGFloat d) {
    if (t == 0) return b;
    if ((t /= d) == 1) return b + c;
    CGFloat p = d * .3f;
    CGFloat a = c;
    CGFloat s = p / 4;
    return (a * pow(2, -10 * t) * sin((t * d - s) * (2 * PI) / p) + c + b);
}

點擊在新視窗中瀏覽此圖片

其中t是运动经过的时间,d是总的计算时间长度,b是要计算的数字的初始数值,c是这个计算要变化的数值。那么接下来就很简单了,在用户结束触摸的时候记录下最后的坐标数字,且在固定的频率与时间段内把初始坐标和回到原点的变化值依次丢进次算法中计算,就能得到一段回弹的动画了。实际使用中我们使用了这段轨迹前70%的效果。

消失效果。在判定范围外释放,也就是要让这个小红点消失了。和腾讯做的相同,我们使用了一个五帧的逐帧动画,和回弹动画类似的时间内在手释放的位置进行播放就可以了。不过对于几百毫秒的动画,消失动画是很难是被注意到的。

2.触控事件

显然,我们要监控我们自定义view的touch事件,并且整个控件的运作都依赖于这个事件。

Touch事件中,我们最主要的重写onTouch方法,而onTouch提供给我们的event里面包含了很多东西。熟悉,我们要判断这到底是什么操作,我们通过 event.getAction() & MotionEvent.ACTION_MASK 可以获得具体到底是啥操作,switch-case一下,我们可以区分为MotionEvent.ACTION_DOWN按下操作,MotionEvent.ACTION_MOVE移动中操作,和MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP离开操作。
同时,我们可以从event中获取坐标,event.getX/Y获取得到按下的位置相对于本控件原点的坐标,而event.getRawX/Y获取得到按下的位置相对与窗口的坐标,这两组坐标都是在运算过程中非常有用的数据。

触控事件在点击控件布局范围内的时候回触发,然后只要按下不释放,就可以在它的父级范围内到处移动了。不过比如在listview中,移动到当前这个item之外后,触控事件就被listview的滚动触控操作拦截住并被取消掉了。这是在设计中不能被接受的。因此,我们需要在MotionEvent.ACTION_DOWN按下之后,对父级以及以外层次的有可能会阻挡触控操作的属于viewgroup的控件执行方法requestDisallowInterceptTouchEvent(true)禁止他们接受触控事件,这样我们就可以拖着我们的小红点满界面晃悠了。

当然,还是要在MotionEvent.ACTION_CANCEL,MotionEvent.ACTION_UP的时候把requestDisallowInterceptTouchEvent设置回去以保证功能正常运行。

ios的触控处理类似。

3.控件遮挡

在整个制作过程中,最主要的问题就是控件遮挡了(主要问题集中在安卓上)。
首先,当我们非常直接的按需求把要绘制的东西画在画布上后,编译出来一看:wtf,控件只在这个控件的大小范围内,也就是在view的measure范围内:
點擊在新視窗中瀏覽此圖片

这显然是重绘范围太小了。重绘默认的区域是整个view的区域,当然也可以自定义一个重绘区域(包括超过view范围的区域,需要xml设定android:clipChildren="false"使其不被父级view主动裁剪),经过计算确定确实需要重绘的区域dirty,然后不断parent.invalidateChildInparent()进行相同的事情一直找到viewroot层。最后在一层一层上来最终确定的重绘范围,在调用performTraersal在重绘区域中计算大小排布并绘制。其中只有进行invalidate的view会调用draw进行绘制,其它的view是直接返回已经绘制完成的canvas给父级view进行重叠。

整个过程中我们涉及到三个矩形大小数据:
  - 首选是measure和layout定位获得的view大小矩形,这个矩形确定的是view在布局中占的矩形位置大小。
  - 其次是画布,默认和布局矩形是同一个范围,但是我们可以通过clipRect方法扣一个矩形来进行绘制,而这个区域可以是在原画布外的。。。。所以事实上画布矩形可以在屏幕任意位置
  - 再其次,是重绘矩形,是invalidate请求的重绘区域,这个区域默认也是布局矩形的区域,当然,也可以自定义他的范围。
对于绘画超出布局范围的view来说,重写invalidate()来扩大重绘范围是必须的,否则绘制的时候不会绘制范围之外的东西。

因此,我们需要手工计算重绘的矩形范围并且调用给入四个参数的invalidate对invalidate方法进行重写,使得每次刷新控件都在有意义的区域刷新。计算重绘区需要计算前一帧需要抹除的区域和新绘制的区域联合起来的最大矩形。这个计算…相当复杂,所以如果不是极限追求效率,我们就全屏重绘吧。
好,我们都全屏刷新了,这次应该正常渲染了吧。然而事实上,我们看到的是这样:
點擊在新視窗中瀏覽此圖片

是的,我们的渲染范围完全不能超过我们的父级groupview中。Why?嗯,安卓的渲染就这么设定的,groupview会主动裁剪超出自己范围的图像的。。不过还好,这个东西也可以设定的。在xml中有一个叫android:clipChildren的属性,如果把它设为false的话,这个groupview就不会主动裁剪超出的部分了。我们只要有耐心的把所有相关的父级layout都设定上这个属性,理论上就是ok的。

真是这样么?我们设定clipChildren之后,会很奇怪的发现,这个listview里面,上面的item可以画上去了,但是下面的item还是被压在下面的,还是像上图的那样。Why!why!其实是这样的,你想象中,listview的渲染层级是这样的:
點擊在新視窗中瀏覽此圖片

不觉得有什么不对么?嗯,就算是一个list的item也讲究一个先来后到啊。每个groupview都是按顺序渲染下去的,所以后渲染的一定压在先渲染的上面,像这样:
點擊在新視窗中瀏覽此圖片

所以说,就算上面的设定都是正确的,我们也只有保证我们的view是最后渲染的,才能从根本上解决控件被遮挡的问题。

所以我们需要做的是把正在接受touch的控件提到最顶上来:
點擊在新視窗中瀏覽此圖片

就再也没有人能够阻止它了。

因此我们考虑一下把这个控件提升到最顶层的方法。
结合第三方库的方案,我们曾经有四种种解决方案:
  - 第一种是,通过getParent()方法递归获取一直获取到最顶部到view,然后强行将自身塞入顶部的viewgroup中,定位后再渲染。这种方法最大的问题是随意变动布局树的内容可能会导致层次错乱,且view爬到最顶层的时候,顶层的viewgroup到底是哪种类型的layout是不可控的,我们需要对每个类型的layout分开处理,如果遇到第三方layout自定义layout之类的就没辙了。
  - 第二种,也是部分第三方库采用的方法,是直接将view注入system_alert层,也就是系统最高层中。当然,这样坑定不会有层次问题了,而且渲染的view也和下面activity中的布局树完全不相干。但是相应的,要实现这个功能我们的app需要android.permission.SYSTEM_ALERT_WINDOW权限,并且在system顶层的view可控性也是有问题的。另外听说android6.0之后系统对该权限会加以更严格的管理,那么这个功能很有可能就被cancel掉了。因此从长远上讲,这是最麻烦的。
  - 第三种,相对来说比第一种麻烦很多。既然顶部layout不可控,那好咯,我们自定义一个可以控制的layout,如果要使用小红点功能的话,请把需要使用的区域包在这个自定义的layout中,那么当我们去寻找上方的viewgroup时,就是可控的了,对于小红点和用户都可控。
  - 第四种,在我们根view的外边,还有很多系统的view的,其中第一层就是系统的ContentFrameLayout,诶,我们不正需要一个framelayout么,正好,我们把自定义的view给附加到它上面不就得了?是的,这样几乎可以完美解决问题,不过会导致绘制区域没有可控性。
因此,综合各个方案的优劣,我们最终采用了第四种方案做默认解决方案,再用第三种方案的思路可以自定义需要附加的layout。
具体的实现上,我们将原本的小红点拆成了两个view,其中一个就是本体,放在它本来应该在的位置,它只在没有移动的时候显示应该显示的东西。当它接受到触摸的时候,自己隐身,在外层layout中注入另一个view,这个view专门用来负责拖拽效果和其它特别效果的渲染,当触摸释放的动画完成后,删除自己。

这样,就可以保证我们的拖拽效果始终在最高层了。
logo