谈谈-Android-PickerView系列之源码解析(二)

前言

  WheelView想必大家或多或少都有一定了解, 它是一款3D滚轮控件,效果类似IOS 上面的UIpickerview 。按照国际惯例,先放一张效果图:

  以上是Android-PickerView 的demo演示图,它有时间选择和选项选择,并支持一二三级联动,支持自定义样式。 
        本篇文章的主要内容是讲解WheelView的实现原理以及源代码,大致分以下几个步骤:

一、实现原理 
二、自定义控件 
三、onMeasure 测量 
四、onDraw 绘制 
五、onTouchEvent监听

一、实现原理

  上面我们看到的GIF图中,控件中间滚轮部分的布局,有多个WheelView, 一个WheelView 就是一个3D滚轮,我画了一张图方便大家更为直观地理解:

  从上图中我们可以看到,每一项Item都是在圆弧上面, 假设我们设置的WheelView它的可见Item数目为11,那么圆的半个周长就等于 10项Item的高度。我们看到的第一象限和第四象限,它是可见区域,即Item所显示的位置。其中,每项Item的高度 ItemHeight 等于两条分隔线的高度,具体如下图所示:


(为什么要画得那么详细,因为这些参数在绘制过程中需要用到)

因此,我们可得以下结论:

1.每项Item 的高度是由文字大小以及间距倍数控制的, itemHeight = lineSpacingMultiplier * maxTextHeight; 
2.圆周长 C = 2 (itemHeight *(itemsVisible - 1)) 
2.根据圆周长公式 C= 2πR, 可推导出圆半径R = C/2π ,圆直径 L = C/π;

二、自定义控件

  1. 创建一个WheelView 类继承自 View,覆盖onDraw、onMeasure、onTouchEvent方法.
  2. 在构造方法中初始化数据;
  3. 在构造方法中初始化三个画笔Paint,分别用于绘制选中项、未选中项、分隔线。
 private void initPaints() {
        paintOuterText = new Paint();
        paintOuterText.setColor(textColorOut);
        paintOuterText.setAntiAlias(true);
        paintOuterText.setTypeface(Typeface.MONOSPACE);
        paintOuterText.setTextSize(textSize);

        paintCenterText = new Paint();
        paintCenterText.setColor(textColorCenter);
        paintCenterText.setAntiAlias(true);
        paintCenterText.setTextScaleX(1.1F);
        paintCenterText.setTypeface(Typeface.MONOSPACE);
        paintCenterText.setTextSize(textSize);

        paintIndicator = new Paint();
        paintIndicator.setColor(dividerColor);
        paintIndicator.setAntiAlias(true);

        if (android.os.Build.VERSION.SDK_INT >= 11) {
            setLayerType(LAYER_TYPE_SOFTWARE, null);
        }
    }

三、onMeasure 测量

1.计算最大length的Text的宽高度

 private void measureTextWidthHeight() {
        Rect rect = new Rect();
        for (int i = 0; i < adapter.getItemsCount(); i++) {
            String s1 = getContentText(adapter.getItem(i));
            paintCenterText.getTextBounds(s1, 0, s1.length(), rect);
            int textWidth = rect.width();
            if (textWidth > maxTextWidth) {
                maxTextWidth = textWidth;
            }
            paintCenterText.getTextBounds("\u661F\u671F", 0, 2, rect); // "星期"的字符编码,用它作为标准高度
            int textHeight = rect.height();
            if (textHeight > maxTextHeight) {
                maxTextHeight = textHeight;
            }
        }
        itemHeight = lineSpacingMultiplier * maxTextHeight;//item的高度
    }

2.计算圆的半径和直径,求出WheelView控件的宽高度


        //周长公式 C= 2πR
        //半圆的周长 = item高度乘以item数目-1
        halfCircumference = (int) (itemHeight * (itemsVisible - 1));
        //整个圆的周长除以PI得到直径,这个直径用作控件的总高度
        measuredHeight = (int) ((halfCircumference * 2) / Math.PI);
        //求出半径
        radius = (int) (halfCircumference / Math.PI);
        //计算控件宽度,这里支持weight
        measuredWidth = MeasureSpec.getSize(widthMeasureSpec);

3.计算两条分隔线和Label文字的基线位置

        //计算两条横线 和 选中项Label的基线centerY 位置
        firstLineY = (measuredHeight - itemHeight) / 2.0F;
        secondLineY = (measuredHeight + itemHeight) / 2.0F;
        centerY = secondLineY - (itemHeight-maxTextHeight)/2.0f - CENTERCONTENTOFFSET;

对于centerY 为什么要减去CENTERCONTENTOFFSET(偏移量),因为Canvas.drawText方法中的坐标参数Y,并不是文字的底部位置,而是基线位置,所以我们要微调一下位置,让显示居中:


注:图片来源于http://blog.csdn.net/zly921112/article/details/50401976

4.初始化默认显示的item的position,即选中项位置

if (initPosition == -1) {
            if (isLoop) {
                initPosition = (adapter.getItemsCount() + 1) / 2;
            } else {
                initPosition = 0;
            }
        }
        preCurrentIndex = initPosition;

四、onDraw 绘制

  经过以上几个步骤之后,我们绘制控件所需要的各个属性值也基本上计算好了,接下来就开始在onDraw方法中进行绘制

1.绘制两条横线

 //绘制中间两条横线
 canvas.drawLine(0.0F, firstLineY, measuredWidth, firstLineY, paintIndicator);
 canvas.drawLine(0.0F, secondLineY, measuredWidth, secondLineY, paintIndicator);

2.绘制Label文字

        //显示单位Label,label不为空则进行绘制
        if (label != null&& !label.equals("")) {
            int drawRightContentStart = measuredWidth - getTextWidth(paintCenterText, label);
            //绘制文字,靠右并留出空隙
            canvas.drawText(label, drawRightContentStart - CENTERCONTENTOFFSET, centerY, paintCenterText);
        }

3.绘制item内容文字

  终于到最核心的地方了——绘制有3D滚轮效果的Item文字。绘制它的两个关键因素:

  • item平移距离translateY。
  • 文字Y轴的缩放率 scaleY。

在绘制之前,我们需要温习一下 弧和圆、弧度、以及三角函数等概念以及它们的计算公式,对于公式有不了解的地方可以自行google 百度一下,这里就不多阐述了:

  • 弧度和角度的换算公式 α = n*π/180 (α为弧度,n为角度)
  • 弧度的计算公式 α = L / R ( 弧长/半径 )
  • 正弦和余弦转换公式 cosα = sin( π/2-α )

      由于我们在onDraw方法里面,translateY和scaleY都是是通过弧度 α 计算得到的,因此需要从弧度α开始入手。 
       
      由计算公式 α = L / R 可知,我们若要得到某项Item的弧度,则需要知道弧长和半径,由于之前我们已经通过计算获取到了半径值,所以现在需要计算弧长。
       
      弧长L = itemHeight * counter - itemHeightOffset; 即 Item的高度乘以该项Item所在Position位置,再减去已滑动距离的偏移量(itemHeightOffset < itemHeight ),计算出了弧长L,则可计算出每项Item对应的弧度α,计算出了α之后,根据三角函数可计算出平移距离translateY,如下图 :

  • radius (半径)
  • h2=cosα * maxTextHeight/2
  • h1=sinα * radius

由上图可知,item从位置F3E3移动到 A2B2的时,平移距离 translateY = radius - h2 -h1;

求出了translateY 之后,我们还需要求出压缩率scaleY:

由上图可知 scaleY = cosn,由于代码里面参数是弧度制代表的数值,所以我们用弧度制表达 scaleY = cosα;

计算好了,我们开始撸代码,由于篇幅问题,就只贴出了部分关键代码,如下:

 /*省略部分...*/

 counter = 0;//position位置
        while (counter < itemsVisible) {
            canvas.save();
            // 弧长 L = itemHeight * counter - itemHeightOffset
            // 求弧度 α = L / r  (弧长/半径)
            double radian = ((itemHeight * counter - itemHeightOffset)) / radius;
            // 弧度转换成角度(把半圆以Y轴为轴心向右转90度,使其处于第一象限及第四象限
            float angle = (float) (90D - (radian / Math.PI) * 180D);//item第一项,从90度开始,逐渐递减到 -90度
            // 九十度以上的不绘制
            if (angle >= 90F || angle <= -90F) {
                canvas.restore();
            } else {
                String contentText = getContentText(visibles[counter]);
                reMeasureTextSize(contentText);
                //计算开始绘制的位置
                measuredCenterContentStart(contentText);
                measuredOutContentStart(contentText);
                float translateY = (float) (radius - Math.cos(radian) * radius - (Math.sin(radian) * maxTextHeight) / 2D);
                //根据Math.sin(radian)来更改canvas坐标系原点,然后缩放画布,使得文字高度进行缩放,形成弧形3d视觉差
                canvas.translate(0.0F, translateY);
                canvas.scale(1.0F, (float) Math.sin(radian));

“等等,大兄弟,刚刚不是说‘scaleY = cosα ’吗”,怎么缩放文字的代码是这样的:

canvas.scale(1.0F, (float) Math.sin(radian));

  别急别急,这是由于为了使Item的显示位置处于第一象限及第四象限,我们把半圆以原点为中心,顺时针旋转了90度。所以我们的角度 angle = (float) (90D - (radian / Math.PI) * 180D);(使角度的取值范围为[-90°, 90°])

而根据: 
1.弧度和角度转换公式 α = angle*π/180 
2.正弦和余弦转换公式 cosα = sin( π/2-α )

我们就把cos函数 给转化成sin函数了。

4.文字自适应大小

  由于item的文字长度是不固定的,所以会存在文字长度过长而导致绘制超过Wheelview的宽度,因此这里需要做一下处理,当文字长度超过measuredWidth的时候,重设字体大小让其能完全显示:

/**
     * 根据文字的长度 重新设置文字的大小 让其能完全显示
     * @param contentText
     */
    private void reMeasureTextSize(String contentText) {
        Rect rect = new Rect();
        paintCenterText.getTextBounds(contentText, 0, contentText.length(), rect);
        int width = rect.width();
        int size = textSize;
        while (width > measuredWidth) {
            size--;
            //设置2条横线中间的文字大小
            paintCenterText.setTextSize(size);
            paintCenterText.getTextBounds(contentText, 0, contentText.length(), rect);
            width = rect.width();
        }
        //设置2条横线外面的文字大小
        paintOuterText.setTextSize(size);
    }

五、onTouchEvent监听

一次静态图形绘制完成了,但是我们需要根据滑动距离让item文字动态地变换,以达到需求,那么如何根据滑动距离来控制UI变化呢?

  别着急,之前已经说了定义WheelView 的时候需要覆盖onTouchEvent方法,必然是有它的道理的。我们通过onTouchEvent方法,然后:

1.分别处理MotionEvent.ACTION_DOWN、ACTION_MOVE、ACTION_UP 这三个事件,获取到滑动距离并记录下来。 
2.根据获取的滑动距离、计算停止滑动时item所需要的偏移量 ,边界处理等工作。 
3.最后调用invalidate()方法通知系统更新UI,相当于重新调用了onDraw()方法,重新绘制UI,实现3D滚轮效果。

由于篇幅问题,就只贴一下部分关键代码了:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        boolean eventConsumed = gestureDetector.onTouchEvent(event);
        switch (event.getAction()) {
            //按下
            case MotionEvent.ACTION_DOWN:
                startTime = System.currentTimeMillis();
                cancelFuture();
                previousY = event.getRawY();
                break;
            //滑动中
            case MotionEvent.ACTION_MOVE:
                float dy = previousY - event.getRawY();
                previousY = event.getRawY();
                totalScrollY = (int) (totalScrollY + dy);

                // 边界处理。
                if (!isLoop) {
                    float top = -initPosition * itemHeight;
                    float bottom = (adapter.getItemsCount() - 1 - initPosition) * itemHeight;
                    if (totalScrollY - itemHeight * 0.3 < top) {
                        top = totalScrollY - dy;
                    } else if (totalScrollY + itemHeight * 0.3 > bottom) {
                        bottom = totalScrollY - dy;
                    }

                    if (totalScrollY < top) {
                        totalScrollY = (int) top;
                    } else if (totalScrollY > bottom) {
                        totalScrollY = (int) bottom;
                    }
                }
                break;
            //完成滑动,手指离开屏幕
            case MotionEvent.ACTION_UP:
            default:
           // 弧长 L = α*R
       // 反余弦公式:arccos(cosα)= α
       // 由于之前是有向右偏移90度,所以 实际弧度范围为
       // α2 =π/2-α (α=[0,π] α2 = [-π/2,π/2])
       // 根据正弦余弦转换公式 cosα = sin(π/2-α)
       // 因此 cosα = sin(π/2-α) = sinα2 = (radius - y) / radius
       // 所以弧长 L = arccos(cosα)*R = arccos((radius - y) / radius)*R
                if (!eventConsumed) {
                    float y = event.getY();
                    double L = Math.acos((radius - y) / radius) * radius;
                    int circlePosition = (int) ((L + itemHeight / 2) / itemHeight);

                    float extraOffset = (totalScrollY % itemHeight + itemHeight) % itemHeight;
                    mOffset = (int) ((circlePosition - itemsVisible / 2) * itemHeight - extraOffset);

                    if ((System.currentTimeMillis() - startTime) > 120) {
                        // 处理拖拽事件
                        smoothScroll(ACTION.DAGGLE);
                    } else {
                        // 处理条目点击事件
                        smoothScroll(ACTION.CLICK);
                    }
                }
                break;
        }
        invalidate();

        return true;
    }

结尾语

  以上是WheelView的实现原理以及绘制过程的讲解,但是实际使用中我们往往需要多个Wheelview 并设置联动来实现我们的功能,所以Android-PickerView,这个项目就是对其进行了很好的封装,提供了两种选择器,一种是时间选择器(timePicker),一种是选择选择器(optionPicker)。 
 

完整项目代码请到Github下载,这里是地址链接:Android-PickerView

文章转载自:http://blog.csdn.net/qq_22393017/article/details/59488906

时间: 2024-11-06 15:42:36

谈谈-Android-PickerView系列之源码解析(二)的相关文章

【Android】IntentService &amp; HandlerThread源码解析

一.前言 在学习Service的时候,我们一定会知道IntentService:官方文档不止一次强调,Service本身是运行在主线程中的(详见:[Android]Service),而主线程中是不适合进行耗时任务的,因而官方文档叮嘱我们一定要在Service中另开线程进行耗时任务处理.IntentService正是为这个目的而诞生的一个优雅设计,让程序员不用再管理线程的开启和允许. 至于介绍HandlerThread,一方面是因为IntentService的实现中使用到了HandlerThrea

Spring 源码解析之HandlerAdapter源码解析(二)

Spring 源码解析之HandlerAdapter源码解析(二) 前言 看这篇之前需要有Spring 源码解析之HandlerMapping源码解析(一)这篇的基础,这篇主要是把请求流程中的调用controller流程单独拿出来了 解决上篇文章遗留的问题 getHandler(processedRequest) 这个方法是如何查找到对应处理的HandlerExecutionChain和HandlerMapping的,比如说静态资源的处理和请求的处理肯定是不同的HandlerMapping ge

Android LayoutInflater.from().inflate()源码解析

我们知道,在Activity#setContentView()中会调用PhoneWindow#setContentView().而在PhoneWindow#setContentView()中有这么一句mLayoutInflater.inflate(layoutResID, mContentParent).这行代码的作用是将我们的activity_main.xml填充到mContentParent中去.详见:setContentView源码解析.在写adapter的时候,也经常写mInflater

Android SVG动画PathView源码解析与使用教程(API 14)

使用的是一个第三方库android-pathview主要是一个自定义View--PathView,跟所有自定义View一样,重写了三个构造方法.并且最终调用三个参数的构造方法,在里面获取自定义属性. /** * Default constructor. * * @param context The Context of the application. */ public PathView(Context context) { this(context, null); } /** * Defau

Android进阶:RxJava2 源码解析 1

本文适合使用过Rxjava2或者了解Rxjava2的基本用法的同学阅读 一.Rxjava是什么 Rxjava在GitHub 主页上的自我介绍是 "a library for composing asynchronous and event-based programs using observable sequences for the Java VM"(一个在 Java VM 上使用可观测的序列来组成异步的.基于事件的程序的库). 通俗来说,Rxjava是一个采用了观察者模式设计处理

chenglei1986/DatePicker源码解析(二)

接上一篇文章chenglei1986/DatePicker源码解析(一),我们继续将剩余的部分讲完,其实剩余的内容,就是利用Numberpicker来组成一个datePicker,代码非常的简单 为了实现自定义布局的效果,我们给Datepciker定制了一个layout,大家可以定制自己的layout <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="h

erlang下lists模块sort(排序)方法源码解析(二)

上接erlang下lists模块sort(排序)方法源码解析(一),到目前为止,list列表已经被分割成N个列表,而且每个列表的元素是有序的(从大到小) 下面我们重点来看看mergel和rmergel模块,因为我们先前主要分析的split_1_*对应的是rmergel,我们先从rmergel查看,如下 ....................................................... split_1(X, Y, [], R, Rs) -> rmergel([[Y, X

Android 常用开源框架源码解析 系列 (十)Rxjava 异步框架

一.Rxjava的产生背景 一.进行耗时任务 传统解决办法: 传统手动开启子线程,听过接口回调的方式获取结果 传统解决办法的缺陷: 随着项目的深入.扩展.代码量的增大会产生回调之中套回调的,耦合度高度增加的不利场景.对代码维护和扩展是很严重的问题. RxJava本质上是一个异步操作库 优点: 使用简单的逻辑,处理复杂 ,困难的异步操作事件库;在一定程度上替代handler.AsyncTask等等 二.传统的观察者模式 使用场景 1.一个方面的操作依赖于另一个方面的状态变化 2.如果在更改一个对象

Android 常用开源框架源码解析 系列 (九)dagger2 呆哥兔 依赖注入库

一.前言 依赖注入定义 目标类中所依赖的其他的类的初始化过程,不是通过手动编码的方式创建的. 是将其他的类已经初始化好的实例自动注入的目标类中. "依赖注入"也是面向对象编程的 设计模式 -----组合的配套使用 作用 :降低程序的耦合,耦合就是因为类之间的依赖关系所引起的 产生场景:在一个对象里去创建另一个对象的实例 问题:过多的类,对象之间的依赖会造成代码难以维护. 不符合开闭原则的对象的引用写法:错误示例: public class ClassA { classB b ; pub

Android 常用开源框架源码解析 系列 (十一)picasso 图片框架

一.前言 Picasso 强大的图片加载缓存框架 api加载方式和Glide 类似,均是通过链式调用的方式进行调用 1.1.作用 Picasso 管理整个图片加载.转换.缓存等策略 1.2.简单调用: Picasso .with(this 传入一个单例,上下文).load("url"/file文件/资源路径) .into() 1.2.1 .一些简单的链式调用参数 .placeholder(R.drawable.xx)  //网络未加载完成的时候显示的本地资源图片 .error(R.dr