07月03, 2016

【造轮子系列】转轮选择工具——WheelView

实现转轮的选择功能,效果见下图:

效果图

本项目是由这个项目修改而成,不过基本上除了原来的大体框架以外,内部的实现逻辑全都做了大量修改,各位看官可以对比参考,在此必须感谢原作者给我的启发。

先上源码: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示意图

上图标示了如何计算couldSelected的结果,需要注意的是,N是int型的,因此N/2的结果其实是下取整的,故N/2*uH!=N*uH/2。如果不明白,去看看java的运算符优先级和隐式的类型转换吧。

从图中可以看出,couldSelected的范围其实刚好就是第一个待选项(含)和第三个待选项(含)之间的范围。而如果滚轮中不止3格,而是5格、7格,则couldSelected的范围 就是正中间那项的上下各一项的文字之间的范围。

selected示意图 上图标示了如何计算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;
}

更多性能调优请移步这篇:WheelView的改进

源码

WheelView 源码会继续更新,博客可能会跟不上源码的进度,以源码为准。

tips:源码中比较核心的函数就是前面介绍过的onTouchEvent,goonMove,slowMove,noEmpty,couldSelected和selected,结合本文,基本上一看就明白了。

转载注明出处:十个雨点

本文链接:http://www.siki.space/post/wheel_view_development.html

-- EOF --

Comments