实现转轮的选择功能,效果见下图:
本项目是由这个项目修改而成,不过基本上除了原来的大体框架以外,内部的实现逻辑全都做了大量修改,各位看官可以对比参考,在此必须感谢原作者给我的启发。
先上源码:WheelView
实现一个自定义View最基本步骤有:
* 设计attribute属性
* 实现构造函数,在构造函数中读取attribute属性并使用
* 重写onMeasure方法
* 重写onDraw方法
这些基础的部分就不细说了,如果对这部分不了解的,可以看看我之前的一篇文章,也可以直接从源码找答案。本文重点聊聊这个View中的滚动的动画是如何设计、实现和调优的,以及在源代码中难以表现的一些思考,但是结合源码能更好的理解本文。
构思
参考前面的效果图,先让我们想想,我们应该能自定义这个View的哪些属性:
attr 属性 | 描述 |
---|---|
lineColor | 分割线颜色 |
lineHeight | 分割线高度 |
itemNumber | 此wheelView显示item的个数 |
noEmpty | 设置true则选中不能为空,否则可以是空 |
normalTextColor | 未选中文本颜色 |
normalTextSize | 未选中文本字体大小 |
selectedTextColor | 选中文本颜色 |
selectedTextSize | 选中文本字体大小 |
unitHeight | 每个item单元的高度 |
这样一个View应该具有什么功能,响应怎样的操作呢?
* 首先,起码要能滚动起来,特别是在手指快速滑过时,能继续滚动一段距离,这段距离应该跟手指滑动的力度有关
* 滚动的速度应该要先快后慢,减速停止
* 滚动的时候要能够判断哪一项应该被选中,也就是应该停在哪里
* 如果在滑动的过程中再次滑动,应该滑动更远
* 点击转轮的上部和下部的时候,应该产生单步选择的效果
* 滚轮被微小的扰动后应该能恢复原状
如何让画面动起来
这个问题有经验的童鞋都做过,简单的说就是:
1. 根据现有状态A0和输入的信息(从onTouchEvent中获得),计算出动画的终点状态An;
2. 在终点状态和当前状态之间,得出Am=f(Am-1),或者Am=g(Am),用于计算即将插入的有限个点A1,A2…An-1,先设i=1;
3. 计算Ai;
4. 调用invalidate()函数,使画面重绘;
5. 等待一段时间t,使i=i+1;
6. 重复3、 4、 5,直到i=n为止。
设计函数功能
现在我们知道,为了让画面动起来,我们应该在onTouchEvent函数中处理触摸事件。
@Override
public boolean onTouchEvent(MotionEvent event) {
if (!isEnable)
return true;
int y = (int) event.getY();
int move = Math.abs(y - downY);
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//防止被其他可滑动View抢占焦点,比如嵌套到ListView中使用时
getParent().requestDisallowInterceptTouchEvent(true);
if (isScrolling){
isGoOnMove=false;
if (moveHandler !=null) {
//清除当前快速滑动的动画,进入下一次滑动动作
moveHandler.removeMessages(GO_ON_MOVE_REFRESH);
moveHandler.sendEmptyMessage(GO_ON_MOVE_INTERRUPTED);
}
}
isScrolling = true;
downY = (int) event.getY();
downTime = System.currentTimeMillis();
break;
case MotionEvent.ACTION_MOVE:
isGoOnMove=false;
isScrolling = true;
actionMove(y - downY);
onSelectListener();
break;
case MotionEvent.ACTION_UP:
long time= System.currentTimeMillis()-downTime;
// 判断这段时间移动的距离
if (time < goonTime && move > goOnMinDistance) {
goonMove(time,y - downY);
} else {
//如果移动距离较小,则认为是点击事件,否则认为是小距离滑动
if (move<clickDistance){
if (downY<unitHeight*(itemNumber/2)&&downY>0){
//如果不先move再up,而是直接up,则无法产生点击时的滑动效果
//通过调整move和up的距离,可以调整点击的效果
actionMove((int) (unitHeight/2));
slowMove((int) unitHeight/4);
}else if (downY>controlHeight-unitHeight*(itemNumber/2)&&downY<controlHeight){
actionMove(-(int) (unitHeight/2));
slowMove(-(int) unitHeight/4);
}
}else {
slowMove(y - downY);
}
isScrolling = false;
}
break;
default:
break;
}
return true;
}
/**
* 处理MotionEvent.ACTION_MOVE中的移动
* @param move 移动的距离
*/
private void actionMove(int move)
/**
* 继续快速移动一段距离,连续滚动动画,滚动速度递减,速度减到SLOW_MOVE_SPEED之下后调用slowMove
* @param time 滑动的时间间隔
* @param move 滑动的距离
*/
void goonMove(long time, final long move)
/**
* 缓慢移动一段距离,移动速度为SLOW_MOVE_SPEED,
* 注意这个距离不是move参数,而是先将选项坐标移动move的距离以后,再判断当前应该选中的项目,然后将改项目移动到中间
* 移动完成后调用noEmpty
* @param move 立即设置的新坐标移动距离,不是缓慢移动的距离
*/
private void slowMove(final int move)
/**
* 不能为空,必须有选项 ,滑动动画结束时调用
* 判断当前应该被选中的项目,如果其不在屏幕中间,则将其移动到屏幕中间
* @param moveSymbol 移动的距离,实际上只需要其符号,用于判断当前滑动方向
*/
private void noEmpty(int moveSymbol)
为了防止本文淹没在代码中,actionMove、goonMove、slowMove、noEmpty函数只介绍了功能,具体实现可以移步源码查看。
需要注意的是,为了保证画面的流畅,应该将计算的部分放在其他线程中执行,计算完以后再进行绘制,常用方法就是在计算完成后发送消息给Handler,然后在Handler中调用invalidate(),或者也可以直接调用postInvalidate()方法来重绘。本项目中计算的部分在goonMove、slowMove和noEmpty三个函数中,这三个函数都是在子线程(moveHandler)中执行的,采用postInvalidate()方式刷新界面。
如何产生减速停止的效果
说到绘制动画时减速停止,很多人立刻就会想到Android提供给我们的插值器Interpolator。它有个实现类就是DecelerateInterpolator,从名字就可以看出是减速插值器。
结合到本项目的时候,有一个小trick,就是在goonMove中使用DecelerateInterpolator,来进行减速插值,当速度减慢到一定程度后(SLOW_MOVE_SPEED=3px),就改为调用slowMove来进行匀速滑动。结合slowMove的注释可以看出,如果在计算滑动的距离时,按照整数倍的unitHeight来滑动,则缓慢滑动的距离为0,没有效果,因此要多出一段距离,slowMove的滑动动画距离就会较长,可以得到一个更加平稳的缓慢停止效果。
如何候判断哪个备选项应该被选中
判断是否可以被选中,以及是否已经被选中是本项目最重要的功能。先看代码:
/**
* 判断是否在可以选择区域内,用于在没有刚好被选中项的时候判断备选项
* 考虑到文字的baseLine是其底部,而y+m的高度是文字的顶部的高度
* 因此判断为可选区域的标准是需要减去文字的部分的
* 也就是y+m在正中间和正中间上面一格的范围内,则判断为可选
*/
public synchronized boolean couldSelected() {
boolean isSelect=true;
if (y+move<=itemNumber/2*unitHeight-unitHeight||y+move>=itemNumber/2*unitHeight+unitHeight){
isSelect=false;
}
return isSelect;
}
/**
* 判断是否刚好在正中间的选择区域内,也就是选中状态
*/
public synchronized boolean selected() {
boolean isSelect=false;
if (textRect==null){
return false;
}
if ((y+move>=itemNumber/2*unitHeight-unitHeight/2+(float) textRect.height()/2)&&
(y+move<=itemNumber/2*unitHeight+unitHeight/2-(float)textRect.height()/2))
isSelect=true;
return isSelect;
}
这两个函数是每个item判断自己是否被选中的,其中y是这个item当前的坐标,move是这个item移动的距离,y+move就是这个item在画面中所处的位置的上顶边的值。上面的表达式经过简化,很难看出到底是怎么推倒出来的,下面的示意图能帮你更好理解。
上图所示是一个3格的滚轮,其中标示了几个重要的高度,从图中可以看出每一个待选项绘制位置是如何计算的。需要注意的是,y+m的起点并不是画面中的顶点,而是从第一个待选项的顶点算起的(也就是可能超出了绘制区域)。其中tH是根据normalTextSize和selectedTextSize和文字的内容计算出来的,具体计算步骤请看源码。
上图标示了如何计算couldSelected的结果,需要注意的是,N是int型的,因此N/2的结果其实是下取整的,故N/2*uH!=N*uH/2。如果不明白,去看看java的运算符优先级和隐式的类型转换吧。
从图中可以看出,couldSelected的范围其实刚好就是第一个待选项(含)和第三个待选项(含)之间的范围。而如果滚轮中不止3格,而是5格、7格,则couldSelected的范围 就是正中间那项的上下各一项的文字之间的范围。
上图标示了如何计算selected的结果,可以看出,selected的范围刚好是正中间那格的范围,文字的任何一部分进入这一格内的时候,这一项就被选中了。
现在你应该理解了这些数值的判断依据了,但你可能会问,如果有两个待选项都在这个范围内,selected怎么判断?那么使用时会使上方的那个item被选中,而事实上本项目在计算过程中已经基本排除了这种可能性了,结合前面介绍的slowMove和noEmpty函数的源码可以更好的理解couldSelected和selected的作用,以及整个选择和滚动的逻辑,具体实现还是请移步源码。
如何处理滑动的过程中的点击操作
系统的NumberPicker和一些其他的开源项目对滑动时的点击处理得不够理想。在滑动的过程中快速点击,很大的几率出现最终结果不居中的情况:
其实这就是我自己造轮子的原因。这种情况主要是以下两点设计上的缺陷导致的:
* 滚动动画本身的实现方式上有问题。在每次快速滑动的时候(goonMove的实现)新建一个Thread来进行计算,这样做有个好处在于,多次快速滚动的时候,可以通过多个线程同步计算,产生加速滚动的感觉。
* 没有在每一次滚动结束的时候,都进行一次让滚轮归位的操作。这些项目中,动画的实现方式,往往是在动画开始的时候就计算好了最终要滚动的距离,而由于滚动动画是在线程中迭代计算的,所以在计算的过程中再次进行微小的扰动,就会导致整个滚动产生偏差,形成上图中错位的结果。
于是我针对这两点做了对应的处理。
- 首先使用了HandlerThread和Handler来进行动画的计算,这样就使得同时只有一个线程进行滚动计算,也减少了频繁创建线程的开销。然后在onTouchEvent函数中做了打断当前滚动的判断,打断滚动很简单,就只是把当前动画的位置设置为新的动画的起点。这样在滚轮快速滚动过程中再次点击的时候,就相当于一次新的滚动,与上一次滚动就没有关系了。但是这就需要使用其他方法来产生加速滚动的效果,详见goonMove函数源码 。
- 通过使用HandlerThread,能保证在每次滚动的结束都调用slowMove函数和noEmpty函数(而且不会有同步问题),在这两个函数中,会再次计算当前滚轮的状态,从而确保在动画停止的时候肯定有一项被选中,且被选中项处于滚轮正中间的位置。说白了,就是通过重复计算的方式,确保最终效果。
如何调优性能
说实话,我对性能调优方面并没有深入研究,所以本项目的性能可能并不算好,但是性能优化的基本逻辑还是有的,也就是减少不必要的计算,本项目中有两处:
* 在绘制每个item的时候,需要先根据normalTextSize、selectedTextSize、文字内容和item的位置计算tH,但是如果normalTextSize和selectedTextSize相等的情况下,则每次计算的tH都一样,所以我设置了一个boolean来标示是否以及计算过了,计算过就无需反复计算了。
* 在绘制每个item之前,先调用isInView函数,判断当前item是否在显示区域内,如果不在,则直接跳过该item的计算和绘制,可以大幅提高动画的流畅度。注意下面代码中注释行和非注释行的区别。
/**
* 是否在可视界面内
* @return
*/
public synchronized boolean isInView() {
// if (y + move > controlHeight || ((float)y + (float)move + (float)unitHeight / 2 + (float)textRect.height() / 2f) < 0)
if (y + move > controlHeight || ((float)y + (float)move + (float)unitHeight ) < 0)//放宽判断的条件,否则就不能在onDraw的开头执行,而要到计算完tH以后才能判断了。
return false;
return true;
}
源码
源码会继续更新,博客可能会跟不上源码的进度,以源码为准。
tips:源码中比较核心的函数就是前面介绍过的onTouchEvent,goonMove,slowMove,noEmpty,couldSelected和selected,结合本文,基本上一看就明白了。