【造轮子系列】转轮选择工具——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的结果,需要注意的是,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;
}

源码

WheelView

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

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

时间: 2024-10-13 02:21:45

【造轮子系列】转轮选择工具——WheelView的相关文章

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

在[造轮子系列]转轮选择工具--WheelView中,我详细记录了这个自定义控件的设计思路和相关数据的计算.由于本人能力有限,当时还留下了一些不足的地方,主要包括: 滑动的性能和流畅性有待提高,特别是快速滑动时的效果 没有实现循环滚动的效果 经过这一段时间的不断改进,现在基本上已经比较完美了,接近ios闹钟的滚轮时间选择器的效果了.下面结合代码,对比之前的版本,记录一下我做的这些改进. 效果图 源码 WheelView 核心计算思想的转变 性能优化说白了就是在得到相同结果的前提下进行最少的计算.

重复造轮子系列——基于FastReport设计打印模板实现桌面端WPF套打和商超POS高度自适应小票打印

重复造轮子系列——基于FastReport设计打印模板实现桌面端WPF套打和商超POS高度自适应小票打印 一.引言 桌面端系统经常需要对接各种硬件设备,比如扫描器.读卡器.打印机等. 这里介绍下桌面端系统打印经常使用的场景. 1.一种是类似票务方面的系统需要打印固定格式的票据.比如景点门票.车票.电影票. 这种基本是根据模板调整位置套打. 2.还有一种是交易小票,比如商超POS小票,打印长度会随着内容的大小自动伸缩. 这种就不仅仅是固定格式的套打了,还得计算数据行以适应不同的打印长度. 打印方式

重复造轮子系列--桶排序

理解了基数排序,也就理解了桶排序. 桶排序就是基数排序的一种优化,从MSD开始,即取最高位来排一次序,如果最高位没有重复(意味着没有冲突需要处理),是算法的最佳状态,O(n). 如果有冲突,就将冲突的元素存放到对应的桶里(代码就是一个链表或者数组或者stl容器),然后对每个桶进行一次插入排序,平均情况的话冲突很小的,桶里的元素的数量就不多,速度很快, 如果冲突集中在几个桶甚至一个桶里,那么就出现了算法的最差情形,也就是O(n^2)的复杂度. 下面是例子代码实现: 1 template<typen

重复造轮子系列--计数,基数排序

计数,基数的中文读音都一样,这翻译的人还嫌我们计算机不够乱,真的想吐槽. 不管了,毕竟代码还是不一样的. 1.计数排序(counter sort): 通过一个上限来统计集合里的数值(或者其他非数值类型映射的数值),并累计比小于自己(包括)的数值的统计的个数,从而形成排序的索引(也就是前面有多少个小于我的,我的位置就确定了). 普通计数排序代码:(仍需优化,valuetype默认是整数类型) 1 template<typename _InIt> 2 void counter_sort(_InIt

重复造轮子系列--插入排序和归并排序

囧,道理很简单,实践起来却不容易. 因为编程语言跟算法描述数据结构并不能完全一致,所以理论到实践还是有些出入的. 下面的例子是没有哨兵位置的实现: 1 #include <iostream> 2 #include <vector> 3 #include <algorithm> 4 #include <cassert> 5 6 using namespace std; 7 8 template<typename _InIt, typename _Func

重复造轮子系列--内存池(C语言)

mem_pool.h 1 #ifndef MEM_POOL_H_ 2 #define MEM_POOL_H_ 3 4 typedef struct MemBlock { 5 struct MemBlock* next; 6 int size; 7 void *ptr; 8 } MemBlock; 9 10 typedef unsigned char byte; 11 12 // 8 16 32 64 128 256 512 1024 2048 4096 13 // 1 2 4 8 16 32 6

重复造轮子系列--dijkstra算法

spf.h 1 #ifndef SPF_H_ 2 #define SPF_H_ 3 4 5 typedef struct { 6 int length; 7 char src; 8 char dst; 9 char prev_hop; 10 } dijkstra; 11 12 #define MAX 1024 13 #define NODE_NUM 5 14 #define TRUE 1 15 #define FALSE 0 16 17 #endif spf.c 1 #include <stdi

重复造轮子系列--字符串常用操作(C语言)

xstring.h 1 #ifndef XSTRING 2 #define XSTRING 3 4 typedef struct xstring { 5 char *str; 6 struct xstring *next; 7 } xstring; 8 9 10 ////////////////////////////////////////////////////////////////////////// 11 void* allocate(size_t size); 12 13 #ifde

重复造轮子,编写一个轻量级的异步写日志的实用工具类(LogAsyncWriter)

一说到写日志,大家可能推荐一堆的开源日志框架,如:Log4Net.NLog,这些日志框架确实也不错,比较强大也比较灵活,但也正因为又强大又灵活,导致我们使用他们时需要引用一些DLL,同时还要学习各种用法及配置文件,这对于有些小工具.小程序.小网站来说,有点“杀鸡焉俺用牛刀”的感觉,而且如果对这些日志框架不了解,可能输出来的日志性能或效果未毕是与自己所想的,鉴于这几个原因,我自己重复造轮子,编写了一个轻量级的异步写日志的实用工具类(LogAsyncWriter),这个类还是比较简单的,实现思路也很