博客地址 http://blog.csdn.net/shulianghan/article/details/41520569

代码下载 :

-- GitHubhttps://github.com/han1202012/WheelViewDemo.git

-- CSDNhttp://download.csdn.net/detail/han1202012/8208997 ;

博客总结 :

博文内容 : 本文完整地分析了 WheelView 所有的源码, 包括其适配器类型, 两种回调接口 (选中条目改变回调, 和开始结束滚动回调), 以及详细的分析了 WheelView 主题源码, 其中 组件宽高测量, 手势监听器添加, 以及精准的绘图方法是主要目的, 花了将近1周时间, 感觉很值, 在这里分享给大家;

WheelView 使用方法 : 创建 WheelView 组件 --> 设置显示条目数 --> 设置循环 --> 设置适配器 --> 设置监听器 ;

自定义组件宽高获取策略 : MeasureSpec 最大模式 取 默认值 和 给定值中较小的那个, 未定义模式取默认值, 精准模式取 给定值;

自定义组件维护各种回调监听器策略 : 维护集合, 将监听器置于集合中, 回调接口时遍历集合元素, 回调每个元素的接口方法;

自定义组件手势监听器添加方法 : 创建手势监听器, 将手势监听器传入手势探测器, 在 onTouchEvent() 方法中回调手势监听器的 onTouchEvent()方法;

一. WheelView 简介

1. WheelView 效果

在 Android 中实现类似与 IOS 的 WheelView 控件 : 如图

2. WheelView 使用流程

(1) 基本流程简介

获取组件 --> 设置显示条目数 --> 设置循环 --> 设置适配器 --> 设置条目改变监听器 --> 设置滚动监听器

a. 创建 WheelView 组件 : 使用 构造方法 或者 从布局文件获取 WheelView 组件;

b. 设置显示条目数 : 调用 WheelView 组件对象的 setVisibleItems 方法 设置;

c. 设置是否循环 : 设置 WheelView 是否循环, 调用 setCyclic() 方法设置;

d. 设置适配器 : 调用 WheelView 组件的 setAdapter() 方法设置;

e. 设置条目改变监听器 : 调用 WheelView 组件对象的 addChangingListener() 方法设置;

f. 设置滚动监听器 : 调用 WheelView 组件对象的 addScrollingListener() 方法设置;

(2) 代码实例

a. 创建 WheelView 对象 :

        //创建 WheelView 组件
        final WheelView wheelLeft = new WheelView(context);

b. 设置 WheelView 显示条目数

        //设置 WheelView 组件最多显示 5 个元素
        wheelLeft.setVisibleItems(5);

c. 设置 WheelView 是否滚动循环

        //设置 WheelView 元素是否循环滚动
        wheelLeft.setCyclic(false);

d. 设置 WheelView 适配器

        //设置 WheelView 适配器
        wheelLeft.setAdapter(new ArrayWheelAdapter<String>(left));

e. 设置条目改变监听器

        //为左侧的 WheelView 设置条目改变监听器
        wheelLeft.addChangingListener(new OnWheelChangedListener() {
            @Override
            public void onChanged(WheelView wheel, int oldValue, int newValue) {
            	//设置右侧的 WheelView 的适配器
                wheelRight.setAdapter(new ArrayWheelAdapter<String>(right[newValue]));
                wheelRight.setCurrentItem(right[newValue].length / 2);
            }
        });

f. 设置滚动监听器

        wheelLeft.addScrollingListener(new OnWheelScrollListener() {

			@Override
			public void onScrollingStarted(WheelView wheel) {
				// TODO Auto-generated method stub

			}

			@Override
			public void onScrollingFinished(WheelView wheel) {
				// TODO Auto-generated method stub

			}
		});

二. WheelView  适配器 监听器 相关接口分析

1. 适配器 分析

这里定义了一个适配器接口, 以及两个适配器类, 一个用于任意类型的数据集适配, 一个用于数字适配;

适配器操作 : 在 WheelView.java 中通过 setAdapter(WheelAdapter adapter) 和 getAdapter() 方法设置 获取 适配器;

-- 适配器常用操作 : 在 WheelView 中定义了 getItem(), getItemsCount(), getMaxmiumLength() 方法获取 适配器的相关信息;

    /**
     * 获取该 WheelView 的适配器
     *
     * @return
     * 		返回适配器
     */
    public WheelAdapter getAdapter() {
        return adapter;
    }

    /**
     * 设置适配器
     *
     * @param adapter
     *            要设置的适配器
     */
    public void setAdapter(WheelAdapter adapter) {
        this.adapter = adapter;
        invalidateLayouts();
        invalidate();
    }

(1) 适配器接口 ( interface WheelAdapter )

适配器接口WheelAdapter;

-- 接口作用 : 该接口是所有适配器的接口, 适配器类都需要实现该接口;

接口抽象方法介绍 :

-- getItemsCount() : 获取适配器数据集合中元素个数;

    /**
     * 获取条目的个数
     *
     * @return
     * 		WheelView 的条目个数
     */
    public int getItemsCount();

-- getItem(int index) : 获取适配器集合的中指定索引元素;

    /**
     * 根据索引位置获取 WheelView 的条目
     *
     * @param index
     *            条目的索引
     * @return
     * 		WheelView 上显示的条目的值
     */
    public String getItem(int index);

-- getMaximumLength() : 获取 WheelView 在界面上的显示宽度;

    /**
     * 获取条目的最大长度. 用来定义 WheelView 的宽度. 如果返回 -1, 就会使用默认宽度
     *
     * @return
     * 		条目的最大宽度 或者 -1
     */
    public int getMaximumLength();

(2) 数组适配器 ( class ArrayWheelAdapter<T> implements WheelAdapter )

适配器作用 : 该适配器可以传入任何数据类型的数组, 可以是 字符串数组, 也可以是任何对象的数组, 传入的数组作为适配器的数据源;

成员变量分析 :

-- 数据源 :

    /** 适配器的数据源 */
    private T items[];

-- WheelView 最大宽度 :

    /** WheelView 的宽度 */
    private int length;

构造方法分析 :

-- ArrayWheelAdapter(T items[], int length) : 传入 T 类型 对象数组, 以及 WheelView 的宽度;

    /**
     * 构造方法
     *
     * @param items
     *            适配器数据源 集合 T 类型的数组
     * @param length
     *            适配器数据源 集合 T 数组长度
     */
    public ArrayWheelAdapter(T items[], int length) {
        this.items = items;
        this.length = length;
    }

-- ArrayWheelAdapter(T items[]) : 传入 T 类型对象数组, 宽度使用默认的宽度;

    /**
     * 构造方法
     *
     * @param items
     *            适配器数据源集合 T 类型数组
     */
    public ArrayWheelAdapter(T items[]) {
        this(items, DEFAULT_LENGTH);
    }

实现的父类方法分析 :

--  getItem(int index) : 根据索引获取数组中对应位置的对象的字符串类型;

    @Override
    public String getItem(int index) {
    	//如果这个索引值合法, 就返回 item 数组对应的元素的字符串形式
        if (index >= 0 && index < items.length) {
            return items[index].toString();
        }
        return null;
    }

-- getItemsCount() : 获取数据集广大小, 直接返回数组大小;

    @Override
    public int getItemsCount() {
    	//返回 item 数组的长度
        return items.length;
    }

-- getMaximumLength() : 获取 WheelView 的最大宽度;

    @Override
    public int getMaximumLength() {
    	//返回 item 元素的宽度
        return length;
    }

(3) 数字适配器 ( class NumericWheelAdapter implements WheelAdapter )

NumericWheelAdapter 适配器作用 : 数字作为 WheelView 适配器的显示值;

成员变量分析 :

-- 最小值 : WheelView 数值显示的最小值;

    /** 设置的最小值 */
    private int minValue;

-- 最大值 : WheelView 数值显示的最大值;

    /** 设置的最大值 */
    private int maxValue;

-- 格式化字符串 : 用于字符串的格式化;

    /** 格式化字符串, 用于格式化 货币, 科学计数, 十六进制 等格式 */
    private String format;

构造方法分析 :

-- NumericWheelAdapter() : 默认的构造方法, 使用默认的最大最小值;

    /**
     * 默认的构造方法, 使用默认的最大最小值
     */
    public NumericWheelAdapter() {
        this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE);
    }

-- NumericWheelAdapter(int minValue, int maxValue) : 传入一个最大最小值;

    /**
     * 构造方法
     *
     * @param minValue
     *            最小值
     * @param maxValue
     *            最大值
     */
    public NumericWheelAdapter(int minValue, int maxValue) {
        this(minValue, maxValue, null);
    }

-- NumericWheelAdapter(int minValue, int maxValue, String format) : 传入最大最小值, 以及数字格式化方式;

    /**
     * 构造方法
     *
     * @param minValue
     *            最小值
     * @param maxValue
     *            最大值
     * @param format
     *            格式化字符串
     */
    public NumericWheelAdapter(int minValue, int maxValue, String format) {
        this.minValue = minValue;
        this.maxValue = maxValue;
        this.format = format;
    }

实现的父类方法 :

-- 获取条目 : 如果需要格式化, 先进行格式化;

    @Override
    public String getItem(int index) {
    	String result = "";
        if (index >= 0 && index < getItemsCount()) {
            int value = minValue + index;
            //如果 format 不为 null, 那么格式化字符串, 如果为 null, 直接返回数字
            if(format != null){
            	result = String.format(format, value);
            }else{
            	result = Integer.toString(value);
            }
            return result;
        }
        return null;
    }

-- 获取元素个数

    @Override
    public int getItemsCount() {
    	//返回数字总个数
        return maxValue - minValue + 1;
    }

-- 获取 WheelView 最大宽度

    @Override
    public int getMaximumLength() {
    	//获取 最大值 和 最小值 中的 较大的数字
        int max = Math.max(Math.abs(maxValue), Math.abs(minValue));
        //获取这个数字 的 字符串形式的 字符串长度
        int maxLen = Integer.toString(max).length();
        if (minValue < 0) {
            maxLen++;
        }
        return maxLen;
    }

2. 监听器相关接口

(1) 条目改变监听器 ( interface OnWheelChangedListener )

监听器作用 : 在 WheelView 条目改变的时候, 回调该监听器的接口方法, 执行条目改变对应的操作;

接口方法介绍 :

-- onChanged(WheelView wheel, int oldValue, int newValue) : 传入 WheelView 组件对象, 以及 旧的 和 新的 条目值索引;

    /**
     * 当前条目改变时回调该方法
     *
     * @param wheel
     *            条目改变的 WheelView 对象
     * @param oldValue
     *            WheelView 旧的条目值
     * @param newValue
     *            WheelView 新的条目值
     */
    void onChanged(WheelView wheel, int oldValue, int newValue);

(2) 滚动监听器 ( interface OnWheelScrollListener )

滚动监听器作用 : 在 WheelView 滚动动作 开始 和 结束的时候回调对应的方法, 在对应方法中进行相应的操作;

接口方法介绍 :

-- 开始滚动方法 : 在滚动开始的时候回调该方法;

    /**
     * 在 WheelView 滚动开始的时候回调该接口
     *
     * @param wheel
     *            开始滚动的 WheelView 对象
     */
    void onScrollingStarted(WheelView wheel);

-- 停止滚动方法 : 在滚动结束的时候回调该方法;

    /**
     * 在 WheelView 滚动结束的时候回调该接口
     *
     * @param wheel
     *            结束滚动的 WheelView 对象
     */
    void onScrollingFinished(WheelView wheel);

三. WheelView 解析

1. 触摸 点击 手势 动作操作控制组件 模块

(1) 创建手势监听器

手势监听器创建及对应方法 :

-- onDown(MotionEvent e) : 在按下的时候回调该方法, e 参数是按下的事件;

-- onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) : 滚动的时候回调该方法, e1 滚动第一次按下事件, e2 当前滚动的触摸事件, X 上一次滚动到这一次滚动 x 轴距离, Y 上一次滚动到这一次滚动 y 轴距离;

-- onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) : 快速急冲滚动时回调的方法, e1 e2 与上面参数相同, velocityX 是手势在 x 轴的速度, velocityY 是手势在 y 轴的速度;

-- 代码示例 :

        /*
         * 手势监听器监听到 滚动操作后回调
         *
         * 参数解析 :
         * MotionEvent e1 : 触发滚动时第一次按下的事件
         * MotionEvent e2 : 触发当前滚动的移动事件
         * float distanceX : 自从上一次调用 该方法 到这一次 x 轴滚动的距离,
         * 				注意不是 e1 到 e2 的距离, e1 到 e2 的距离是从开始滚动到现在的滚动距离
         * float distanceY : 自从上一次回调该方法到这一次 y 轴滚动的距离
         *
         * 返回值 : 如果事件成功触发, 执行完了方法中的操作, 返回true, 否则返回 false
         * (non-Javadoc)
         * @see android.view.GestureDetector.SimpleOnGestureListener#onScroll(android.view.MotionEvent, android.view.MotionEvent, float, float)
         */
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        	//开始滚动, 并回调滚动监听器集合中监听器的 开始滚动方法
            startScrolling();
            doScroll((int) -distanceY);
            return true;
        }

        /*
         * 当一个急冲手势发生后 回调该方法, 会计算出该手势在 x 轴 y 轴的速率
         *
         * 参数解析 :
         * -- MotionEvent e1 : 急冲动作的第一次触摸事件;
         * -- MotionEvent e2 : 急冲动作的移动发生的时候的触摸事件;
         * -- float velocityX : x 轴的速率
         * -- float velocityY : y 轴的速率
         *
         * 返回值 : 如果执行完毕返回 true, 否则返回false, 这个就是自己定义的
         *
         * (non-Javadoc)
         * @see android.view.GestureDetector.SimpleOnGestureListener#onFling(android.view.MotionEvent, android.view.MotionEvent, float, float)
         */
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        	//计算上一次的 y 轴位置, 当前的条目高度 加上 剩余的 不够一行高度的那部分
            lastScrollY = currentItem * getItemHeight() + scrollingOffset;
            //如果可以循环最大值是无限大, 不能循环就是条目数的高度值
            int maxY = isCyclic ? 0x7FFFFFFF : adapter.getItemsCount() * getItemHeight();
            int minY = isCyclic ? -maxY : 0;
            /*
             * Scroll 开始根据一个急冲手势滚动, 滚动的距离与初速度有关
             * 参数介绍 :
             * -- int startX : 开始时的 X轴位置
             * -- int startY : 开始时的 y轴位置
             * -- int velocityX : 急冲手势的 x 轴的初速度, 单位 px/s
             * -- int velocityY : 急冲手势的 y 轴的初速度, 单位 px/s
             * -- int minX : x 轴滚动的最小值
             * -- int maxX : x 轴滚动的最大值
             * -- int minY : y 轴滚动的最小值
             * -- int maxY : y 轴滚动的最大值
             */
            scroller.fling(0, lastScrollY, 0, (int) -velocityY / 2, 0, 0, minY, maxY);
            setNextMessage(MESSAGE_SCROLL);
            return true;
        }
    };

(2) 创建手势探测器

手势探测器创建 : 调用 其构造函数, 传入 上下文对象 和 手势监听器对象;

-- 禁止长按操作 : 调用 setIsLongpressEnabled(false) 方法, 禁止长按操作, 因为 长按操作会屏蔽滚动事件;

    	//创建一个手势处理
        gestureDetector = new GestureDetector(context, gestureListener);
        /*
         * 是否允许长按操作,
         * 如果设置为 true 用户按下不松开, 会返回一个长按事件,
         * 如果设置为 false, 按下不松开滑动的话 会收到滚动事件.
         */
        gestureDetector.setIsLongpressEnabled(false);

(3) 将手势探测器 与 组件结合

关联手势探测器 与 组件 : 在组件的 onTouchEvent(MotionEvent event) 方法中, 调用手势探测器的 gestureDetector.onTouchEvent(event) 方法即可;

    /*
     * 继承自 View 的触摸事件, 当出现触摸事件的时候, 就会回调该方法
     * (non-Javadoc)
     * @see android.view.View#onTouchEvent(android.view.MotionEvent)
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    	//获取适配器
        WheelAdapter adapter = getAdapter();
        if (adapter == null) {
            return true;
        }

        /*
         * gestureDetector.onTouchEvent(event) : 分析给定的动作, 如果可用, 调用 手势检测器的 onTouchEvent 方法
         * -- 参数解析 : ev , 触摸事件
         * -- 返回值 : 如果手势监听器成功执行了该方法, 返回true, 如果执行出现意外 返回 false;
         */
        if (!gestureDetector.onTouchEvent(event) && event.getAction() == MotionEvent.ACTION_UP) {
            justify();
        }
        return true;
    }

2. Scroller 简介

(1) Scroller 简介

Scroller 通用作用 : Scroller 组件并不是一个布局组件, 该组件是运行在后台的, 通过一些方法设定 Scroller 对象 的操作 或者 动画, 然后让 Scroller 运行在后台中 用于模拟滚动操作, 在适当的时机 获取该对象的坐标信息, 这些信息是在后台运算出来的;

Scroller 在本 View 中作用 : Android 的这个自定义的 WheelView 组件, 可以平滑的滚动, 当我们做一个加速滑动时, 会根据速度计算出滑动的距离, 这些数据都是在 Scroller 中计算出来的;

(2) 设定 Scroller 对象的动作参数

终止滚动 :

-- 终止滚动 跳转到目标位置 : 终止平缓的动画, 直接跳转到最终的 x y 轴的坐标位置;

public void abortAnimation()

-- 终止滚动 停止在当前位置 : 强行结束 Scroll 的滚动;

public final void forceFinished(boolean finished)

设置滚动参数 :

-- 设置最终 x 轴坐标 :

public void setFinalX(int newX)

-- 设置最终 y 轴坐标 :

public void setFinalY(int newY)

-- 设置滚动摩擦力 :

public final void setFriction(float friction)

设置动作 :

-- 开始滚动 : 传入参数 开始 x 位置, 开始 y 位置, x 轴滚动距离, y 轴滚动距离;

public void startScroll(int startX, int startY, int dx, int dy)

-- 开始滚动 设定时间 : 最后一个参数是时间, 单位是 ms;

public void startScroll(int startX, int startY, int dx, int dy, int duration)

-- 急冲滚动 : 根据一个 急冲 手势进行滚动, 传入参数 : x轴开始位置, y轴开始位置, x 轴速度, y 轴速度, x 轴最小速度, x 轴最大速度, y 轴最小速度, y 轴最大速度;

public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY)

延长滚动时间 : 延长滚动的时间, 让滚动滚的更远一些;

public void extendDuration(int extend)

(3) 获取 Scroll 后台运行参数

获取当前数据 :

-- 获取当前 x 轴坐标 :

public final int getCurrX()

-- 获取当前 y 轴坐标 :

public final int getCurrY()

-- 获取当前速度 :

public float getCurrVelocity()

获取开始结束时的数据  :

-- 获取开始 x 轴坐标 :

public final int getStartX()

-- 获取开始 y 轴坐标 :

public final int getStartY()

-- 获取最终 x 轴坐标 : 该参数只在急冲滚动时有效;

public final int getFinalX()

-- 获取最终 y 轴坐标 : 该参数只在急冲滚动时有效;

public final int getFinalY()

查看是否滚动完毕 :

public final boolean isFinished()

获取从开始滚动到现在的时间 :

public int timePassed()

获取新位置 : 调用该方法可以获取新位置, 如果返回 true 说明动画还没执行完毕;

public boolean computeScrollOffset()

(4) Scroll 在 WheelView 中的运用

Scroller 创建 :

        //使用默认的 时间 和 插入器 创建一个滚动器
        scroller = new Scroller(context);

手势监听器 SimpleOnGestureListener 对象中的 onDown() 方法 : 如果滚动还在执行, 那么强行停止 Scroller 滚动;

    	//按下操作
        public boolean onDown(MotionEvent e) {
        	//如果滚动在执行
            if (isScrollingPerformed) {
            	//滚动强制停止, 按下的时候不能继续滚动
                scroller.forceFinished(true);
                //清理信息
                clearMessages();
                return true;
            }
            return false;
        }

当手势监听器 SimpleOnGestureListener 对象中有急冲动作时 onFling() 方法中 : 手势监听器监听到了 急冲动作, 那么 Scroller 也进行对应操作;

        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        	//计算上一次的 y 轴位置, 当前的条目高度 加上 剩余的 不够一行高度的那部分
            lastScrollY = currentItem * getItemHeight() + scrollingOffset;
            //如果可以循环最大值是无限大, 不能循环就是条目数的高度值
            int maxY = isCyclic ? 0x7FFFFFFF : adapter.getItemsCount() * getItemHeight();
            int minY = isCyclic ? -maxY : 0;
            /*
             * Scroll 开始根据一个急冲手势滚动, 滚动的距离与初速度有关
             * 参数介绍 :
             * -- int startX : 开始时的 X轴位置
             * -- int startY : 开始时的 y轴位置
             * -- int velocityX : 急冲手势的 x 轴的初速度, 单位 px/s
             * -- int velocityY : 急冲手势的 y 轴的初速度, 单位 px/s
             * -- int minX : x 轴滚动的最小值
             * -- int maxX : x 轴滚动的最大值
             * -- int minY : y 轴滚动的最小值
             * -- int maxY : y 轴滚动的最大值
             */
            scroller.fling(0, lastScrollY, 0, (int) -velocityY / 2, 0, 0, minY, maxY);
            setNextMessage(MESSAGE_SCROLL);
            return true;
        }

动画控制 Handler 中 :

-- 滚动 : 获取当前 Scroller 的 y 轴位置, 与上一次的 y 轴位置对比, 如果 间距 delta 不为0, 就滚动;

-- 查看是否停止 : 如果现在距离 到 最终距离 小于最小滚动距离, 强制停止;

-- 执行 msg.what 指令 : 如果需要停止, 强制停止, 否则调整坐标;

    /**
     * 动画控制器
     *  animation handler
     *
     *  可能会造成内存泄露 : 添加注解 HandlerLeak
     *  Handler 类应该应该为static类型,否则有可能造成泄露。
     *  在程序消息队列中排队的消息保持了对目标Handler类的应用。
     *  如果Handler是个内部类,那 么它也会保持它所在的外部类的引用。
     *  为了避免泄露这个外部类,应该将Handler声明为static嵌套类,并且使用对外部类的弱应用。
     */
    @SuppressLint("HandlerLeak")
	private Handler animationHandler = new Handler() {
        public void handleMessage(Message msg) {
        	//回调该方法获取当前位置, 如果返回true, 说明动画还没有执行完毕
            scroller.computeScrollOffset();
            //获取当前 y 位置
            int currY = scroller.getCurrY();
            //获取已经滚动了的位置, 使用上一次位置 减去 当前位置
            int delta = lastScrollY - currY;
            lastScrollY = currY;
            if (delta != 0) {
            	//改变值不为 0 , 继续滚动
                doScroll(delta);
            }

            /*
             * 如果滚动到了指定的位置, 滚动还没有停止
             * 这时需要强制停止
             */
            if (Math.abs(currY - scroller.getFinalY()) < MIN_DELTA_FOR_SCROLLING) {
                currY = scroller.getFinalY();
                scroller.forceFinished(true);
            }

            /*
             * 如果滚动没有停止
             * 再向 Handler 发送一个停止
             */
            if (!scroller.isFinished()) {
                animationHandler.sendEmptyMessage(msg.what);
            } else if (msg.what == MESSAGE_SCROLL) {
                justify();
            } else {
                finishScrolling();
            }
        }
    };

3. StaticLayout 布局容器

(1) StaticLayout 解析

StaticLayout 解析 : 该组件用于显示文本, 一旦该文本被显示后, 就不能再编辑, 如果想要修改文本, 使用 DynamicLayout 布局即可;

-- 使用场景 : 一般情况下不会使用该组件, 当想要自定义组件 或者 想要使用 Canvas 绘制文本时 才使用该布局;

常用方法解析 :

-- 获取底部 Padding : 获取底部 到最后一行文字的 间隔, 单位是 px;

public int getBottomPadding()

-- 获取顶部 Padding :

public int getTopPadding()

-- 获取省略个数 : 获取某一行需要省略的字符个数;

public int getEllipsisCount(int line)

-- 获取省略开始位置 : 获取某一行要省略的字符串的第一个位置索引;

public int getEllipsisStart(int line)

-- 获取省略的宽度 : 获取某一行省略字符串的宽度, 单位 px;

public int getEllipsisStart(int line)

-- 获取是否处理特殊符号

public boolean getLineContainsTab(int line)

-- 获取文字的行数

public int getLineCount()

-- 获取顶部位置 : 获取某一行顶部的位置;

public int getLineTop(int line)

-- 获取某一行底部位置

public int getLineDescent(int line)

-- 获取行的方向 : 字符串从左至右 还是从右至左;

public final Directions getLineDirections(int line)

-- 获取某行第一个字符索引 : 获取的是 某一行 第一个字符 在整个字符串的索引;

public int getLineStart(int line)

-- 获取该行段落方向 : 获取该行文字方向, 左至右 或者 右至左;

 public int getParagraphDirection(int line)

-- 获取某个垂直位置显示的行数

public int getLineForVertical(int vertical)

(2) 布局显示

布局创建 :

-- 三种布局 : WheelView 中涉及到了三种 StaticLayout 布局, 普通条目布局 itemLayout, 选中条目布局 valueLayout, 标签布局 labelLayout;

-- 创建时机 : 在 View 组件 每次 onMeasure() 和 onDraw() 方法中都要重新创建对应布局;

-- 创建布局源码 :

    /**
     * 创建布局
     *
     * @param widthItems
     *            布局条目宽度
     * @param widthLabel
     *            label 宽度
     */
    private void createLayouts(int widthItems, int widthLabel) {
    	/*
    	 * 创建普通条目布局
    	 * 如果 普通条目布局 为 null 或者 普通条目布局的宽度 大于 传入的宽度, 这时需要重新创建布局
    	 * 如果 普通条目布局存在, 并且其宽度小于传入的宽度, 此时需要将
    	 */
        if (itemsLayout == null || itemsLayout.getWidth() > widthItems) {

        	/*
        	 * android.text.StaticLayout.StaticLayout(
        	 * CharSequence source, TextPaint paint,
        	 * int width, Alignment align,
        	 * float spacingmult, float spacingadd, boolean includepad)
        	 * 传入参数介绍 :
        	 * CharSequence source : 需要分行显示的字符串
        	 * TextPaint paint : 绘制字符串的画笔
        	 * int width : 条目的宽度
        	 * Alignment align : Layout 的对齐方式, ALIGN_CENTER 居中对齐, ALIGN_NORMAL 左对齐, Alignment.ALIGN_OPPOSITE 右对齐
        	 * float spacingmult : 行间距, 1.5f 代表 1.5 倍字体高度
        	 * float spacingadd : 基础行距上增加多少 , 真实行间距 等于 spacingmult 和 spacingadd 的和
        	 * boolean includepad :
        	 */
            itemsLayout = new StaticLayout(buildText(isScrollingPerformed), itemsPaint, widthItems,
                    widthLabel > 0 ? Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_CENTER, 1,
                    ADDITIONAL_ITEM_HEIGHT, false);
        } else {
        	//调用 Layout 内置的方法 increaseWidthTo 将宽度提升到指定的宽度
            itemsLayout.increaseWidthTo(widthItems);
        }

        /*
         * 创建选中条目
         */
        if (!isScrollingPerformed && (valueLayout == null || valueLayout.getWidth() > widthItems)) {
            String text = getAdapter() != null ? getAdapter().getItem(currentItem) : null;
            valueLayout = new StaticLayout(text != null ? text : "", valuePaint, widthItems,
                    widthLabel > 0 ? Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_CENTER, 1,
                    ADDITIONAL_ITEM_HEIGHT, false);
        } else if (isScrollingPerformed) {
            valueLayout = null;
        } else {
            valueLayout.increaseWidthTo(widthItems);
        }

        /*
         * 创建标签条目
         */
        if (widthLabel > 0) {
            if (labelLayout == null || labelLayout.getWidth() > widthLabel) {
                labelLayout = new StaticLayout(label, valuePaint, widthLabel, Layout.Alignment.ALIGN_NORMAL, 1,
                        ADDITIONAL_ITEM_HEIGHT, false);
            } else {
                labelLayout.increaseWidthTo(widthLabel);
            }
        }
    }

4. 监听器管理

监听器集合维护 :

-- 定义监听器集合 : 在 View 组件中 定义一个 List 集合, 集合中存放 监听器元素;

    /** 条目改变监听器集合  封装了条目改变方法, 当条目改变时回调 */
    private List<OnWheelChangedListener> changingListeners = new LinkedList<OnWheelChangedListener>();
    /** 条目滚动监听器集合, 该监听器封装了 开始滚动方法, 结束滚动方法 */
    private List<OnWheelScrollListener> scrollingListeners = new LinkedList<OnWheelScrollListener>();

-- 提供对监听器集合的添加删除接口 : 提供 对集合 进行 添加 和 删除的接口;

    /**
     * 添加 WheelView 选择的元素改变监听器
     *
     * @param listener
     *            the listener
     */
    public void addChangingListener(OnWheelChangedListener listener) {
        changingListeners.add(listener);
    }

    /**
     * 移除 WheelView 元素改变监听器
     *
     * @param listener
     *            the listener
     */
    public void removeChangingListener(OnWheelChangedListener listener) {
        changingListeners.remove(listener);
    }

-- 调用监听器接口

    /**
     * 回调元素改变监听器集合的元素改变监听器元素的元素改变方法
     *
     * @param oldValue
     *            旧的 WheelView选中的值
     * @param newValue
     *            新的 WheelView选中的值
     */
    protected void notifyChangingListeners(int oldValue, int newValue) {
        for (OnWheelChangedListener listener : changingListeners) {
            listener.onChanged(this, oldValue, newValue);
        }
    }

5. 自定义 View 对象的宽高

(1) onMeasure 方法 MeasureSpec 模式解析

常规处理方法 : 组件的宽高有三种情况, widthMeasureSpec 有三种模式 最大模式, 精准模式, 未定义模式;

-- 最大模式 : 在 组件的宽或高 warp_content 属性时, 会使用最大模式;

-- 精准模式 : 当给组件宽 或者高 定义一个值 或者 使用 match_parent 时, 会使用精准模式;

处理宽高的常规代码 :

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);

		//获取宽度 和 高度的模式 和 大小
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        Log.i(TAG, "宽度 : widthMode : " + getMode(widthMode) + " , widthSize : " + widthSize + "\n"
        		+ "高度 : heightMode : " + getMode(heightMode) + " , heightSize : " + heightSize);

        int width = 0;
        int height = 0;
        /*
         * 精准模式
         * 		精准模式下 高度就是精确的高度
         */
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        //未定义模式 和 最大模式
        } else {
        	//未定义模式下 获取布局需要的高度
            height = 100;

            //最大模式下 获取 布局高度 和 布局所需高度的最小值
            if (heightMode == MeasureSpec.AT_MOST) {
                height = Math.min(height, heightSize);
            }
        }

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width = 100;
            if (heightMode == MeasureSpec.AT_MOST) {
                width = Math.min(width, widthSize);
            }
        }

        Log.i(TAG, "最终结果 : 宽度 : " + width + " , 高度 : " + height);

        setMeasuredDimension(width, height);

	}

	public String getMode(int mode) {
		String modeName = "";
		if(mode == MeasureSpec.EXACTLY){
			modeName = "精准模式";
		}else if(mode == MeasureSpec.AT_MOST){
			modeName = "最大模式";
		}else if(mode == MeasureSpec.UNSPECIFIED){
			modeName = "未定义模式";
		}

		return modeName;
	}

(2) 测试上述代码

使用下面的自定义组件测试 :

package cn.org.octopus.wheelview;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

public class MyView extends View {

	public static final String TAG = "octopus.my.view";

	public MyView(Context context, AttributeSet attrs) {
		super(context, attrs);
	}

	public MyView(Context context) {
		super(context);
	}

	public MyView(Context context, AttributeSet attrs, int defStyle) {
		super(context, attrs, defStyle);
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		super.onMeasure(widthMeasureSpec, heightMeasureSpec);

		//获取宽度 和 高度的模式 和 大小
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        Log.i(TAG, "宽度 : widthMode : " + getMode(widthMode) + " , widthSize : " + widthSize + "\n"
        		+ "高度 : heightMode : " + getMode(heightMode) + " , heightSize : " + heightSize);

        int width = 0;
        int height = 0;
        /*
         * 精准模式
         * 		精准模式下 高度就是精确的高度
         */
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        //未定义模式 和 最大模式
        } else {
        	//未定义模式下 获取布局需要的高度
            height = 100;

            //最大模式下 获取 布局高度 和 布局所需高度的最小值
            if (heightMode == MeasureSpec.AT_MOST) {
                height = Math.min(height, heightSize);
            }
        }

        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            width = 100;
            if (heightMode == MeasureSpec.AT_MOST) {
                width = Math.min(width, widthSize);
            }
        }

        Log.i(TAG, "最终结果 : 宽度 : " + width + " , 高度 : " + height);

        setMeasuredDimension(width, height);

	}

	public String getMode(int mode) {
		String modeName = "";
		if(mode == MeasureSpec.EXACTLY){
			modeName = "精准模式";
		}else if(mode == MeasureSpec.AT_MOST){
			modeName = "最大模式";
		}else if(mode == MeasureSpec.UNSPECIFIED){
			modeName = "未定义模式";
		}

		return modeName;
	}

	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);

		canvas.drawColor(Color.BLUE);
	}

}

给定具体值情况 :

-- 组件信息 :

    <cn.org.octopus.wheelview.MyView
        android:layout_width="300dip"
        android:layout_height="300dip"/>

-- 日志信息

11-30 01:40:24.304: I/octopus.my.view(2609): 宽度 : widthMode : 精准模式 , widthSize : 450
11-30 01:40:24.304: I/octopus.my.view(2609): 高度 : heightMode : 最大模式 , heightSize : 850
11-30 01:40:24.304: I/octopus.my.view(2609): 最终结果 : 宽度 : 450 , 高度 : 100
11-30 01:40:24.304: I/octopus.my.view(2609): 宽度 : widthMode : 精准模式 , widthSize : 450
11-30 01:40:24.304: I/octopus.my.view(2609): 高度 : heightMode : 精准模式 , heightSize : 450
11-30 01:40:24.304: I/octopus.my.view(2609): 最终结果 : 宽度 : 450 , 高度 : 450
11-30 01:40:24.335: I/octopus.my.view(2609): 宽度 : widthMode : 精准模式 , widthSize : 450
11-30 01:40:24.335: I/octopus.my.view(2609): 高度 : heightMode : 最大模式 , heightSize : 850
11-30 01:40:24.335: I/octopus.my.view(2609): 最终结果 : 宽度 : 450 , 高度 : 100
11-30 01:40:24.335: I/octopus.my.view(2609): 宽度 : widthMode : 精准模式 , widthSize : 450
11-30 01:40:24.335: I/octopus.my.view(2609): 高度 : heightMode : 精准模式 , heightSize : 450
11-30 01:40:24.335: I/octopus.my.view(2609): 最终结果 : 宽度 : 450 , 高度 : 450

warp_content 情况

-- 组件信息 :

    <cn.org.octopus.wheelview.MyView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

-- 日志信息

11-30 01:37:47.351: I/octopus.my.view(1803): 宽度 : widthMode : 最大模式 , widthSize : 492
11-30 01:37:47.351: I/octopus.my.view(1803): 高度 : heightMode : 最大模式 , heightSize : 850
11-30 01:37:47.351: I/octopus.my.view(1803): 最终结果 : 宽度 : 100 , 高度 : 100
11-30 01:37:47.351: I/octopus.my.view(1803): 宽度 : widthMode : 精准模式 , widthSize : 100
11-30 01:37:47.351: I/octopus.my.view(1803): 高度 : heightMode : 最大模式 , heightSize : 802
11-30 01:37:47.351: I/octopus.my.view(1803): 最终结果 : 宽度 : 100 , 高度 : 100
11-30 01:37:47.390: I/octopus.my.view(1803): 宽度 : widthMode : 最大模式 , widthSize : 492
11-30 01:37:47.390: I/octopus.my.view(1803): 高度 : heightMode : 最大模式 , heightSize : 850
11-30 01:37:47.390: I/octopus.my.view(1803): 最终结果 : 宽度 : 100 , 高度 : 100
11-30 01:37:47.390: I/octopus.my.view(1803): 宽度 : widthMode : 精准模式 , widthSize : 100
11-30 01:37:47.390: I/octopus.my.view(1803): 高度 : heightMode : 最大模式 , heightSize : 802
11-30 01:37:47.390: I/octopus.my.view(1803): 最终结果 : 宽度 : 100 , 高度 : 100

match_parent 情况 :

-- 组件信息 :

    <cn.org.octopus.wheelview.MyView
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

-- 日志信息 :

11-30 01:39:08.296: I/octopus.my.view(2249): 宽度 : widthMode : 精准模式 , widthSize : 492
11-30 01:39:08.296: I/octopus.my.view(2249): 高度 : heightMode : 精准模式 , heightSize : 850
11-30 01:39:08.296: I/octopus.my.view(2249): 最终结果 : 宽度 : 492 , 高度 : 850
11-30 01:39:08.296: I/octopus.my.view(2249): 宽度 : widthMode : 精准模式 , widthSize : 492
11-30 01:39:08.296: I/octopus.my.view(2249): 高度 : heightMode : 精准模式 , heightSize : 802
11-30 01:39:08.296: I/octopus.my.view(2249): 最终结果 : 宽度 : 492 , 高度 : 802
11-30 01:39:08.328: I/octopus.my.view(2249): 宽度 : widthMode : 精准模式 , widthSize : 492
11-30 01:39:08.328: I/octopus.my.view(2249): 高度 : heightMode : 精准模式 , heightSize : 850
11-30 01:39:08.328: I/octopus.my.view(2249): 最终结果 : 宽度 : 492 , 高度 : 850
11-30 01:39:08.328: I/octopus.my.view(2249): 宽度 : widthMode : 精准模式 , widthSize : 492
11-30 01:39:08.328: I/octopus.my.view(2249): 高度 : heightMode : 精准模式 , heightSize : 802
11-30 01:39:08.328: I/octopus.my.view(2249): 最终结果 : 宽度 : 492 , 高度 : 802

博客地址 http://blog.csdn.net/shulianghan/article/details/41520569#t17

代码下载 :

-- GitHub : https://github.com/han1202012/WheelViewDemo.git

-- CSDN : http://download.csdn.net/detail/han1202012/8208997 ;

四. 详细代码

1. WheelAdapter

package cn.org.octopus.wheelview.widget;

/**
 * WheelView 适配器接口
 * @author han_shuliang(octopus_truth@163.com)
 *
 */
public interface WheelAdapter {
    /**
     * 获取条目的个数
     *
     * @return
     * 		WheelView 的条目个数
     */
    public int getItemsCount();

    /**
     * 根据索引位置获取 WheelView 的条目
     *
     * @param index
     *            条目的索引
     * @return
     * 		WheelView 上显示的条目的值
     */
    public String getItem(int index);

    /**
     * 获取条目的最大长度. 用来定义 WheelView 的宽度. 如果返回 -1, 就会使用默认宽度
     *
     * @return
     * 		条目的最大宽度 或者 -1
     */
    public int getMaximumLength();
}

2. ArrayWheelAdapter

package cn.org.octopus.wheelview.widget;

/**
 * WheelView 的适配器类
 *
 * @param <T>
 *            元素类型
 */
public class ArrayWheelAdapter<T> implements WheelAdapter {

    /** 适配器的 元素集合(数据源) 默认长度为 -1 */
    public static final int DEFAULT_LENGTH = -1;

    /** 适配器的数据源 */
    private T items[];
    /** WheelView 的宽度 */
    private int length;

    /**
     * 构造方法
     *
     * @param items
     *            适配器数据源 集合 T 类型的数组
     * @param length
     *            适配器数据源 集合 T 数组长度
     */
    public ArrayWheelAdapter(T items[], int length) {
        this.items = items;
        this.length = length;
    }

    /**
     * 构造方法
     *
     * @param items
     *            适配器数据源集合 T 类型数组
     */
    public ArrayWheelAdapter(T items[]) {
        this(items, DEFAULT_LENGTH);
    }

    @Override
    public String getItem(int index) {
    	//如果这个索引值合法, 就返回 item 数组对应的元素的字符串形式
        if (index >= 0 && index < items.length) {
            return items[index].toString();
        }
        return null;
    }

    @Override
    public int getItemsCount() {
    	//返回 item 数组的长度
        return items.length;
    }

    @Override
    public int getMaximumLength() {
    	//返回 item 元素的宽度
        return length;
    }

}

3. NumericWheelAdapter

package cn.org.octopus.wheelview.widget;

/**
 * 显示数字的 WheelAdapter
 */
public class NumericWheelAdapter implements WheelAdapter {

    /** 默认最小值 */
    public static final int DEFAULT_MAX_VALUE = 9;

    /** 默认最大值 */
    private static final int DEFAULT_MIN_VALUE = 0;

    /** 设置的最小值 */
    private int minValue;
    /** 设置的最大值 */
    private int maxValue;

    /** 格式化字符串, 用于格式化 货币, 科学计数, 十六进制 等格式 */
    private String format;

    /**
     * 默认的构造方法, 使用默认的最大最小值
     */
    public NumericWheelAdapter() {
        this(DEFAULT_MIN_VALUE, DEFAULT_MAX_VALUE);
    }

    /**
     * 构造方法
     *
     * @param minValue
     *            最小值
     * @param maxValue
     *            最大值
     */
    public NumericWheelAdapter(int minValue, int maxValue) {
        this(minValue, maxValue, null);
    }

    /**
     * 构造方法
     *
     * @param minValue
     *            最小值
     * @param maxValue
     *            最大值
     * @param format
     *            格式化字符串
     */
    public NumericWheelAdapter(int minValue, int maxValue, String format) {
        this.minValue = minValue;
        this.maxValue = maxValue;
        this.format = format;
    }

    @Override
    public String getItem(int index) {
    	String result = "";
        if (index >= 0 && index < getItemsCount()) {
            int value = minValue + index;
            //如果 format 不为 null, 那么格式化字符串, 如果为 null, 直接返回数字
            if(format != null){
            	result = String.format(format, value);
            }else{
            	result = Integer.toString(value);
            }
            return result;
        }
        return null;
    }

    @Override
    public int getItemsCount() {
    	//返回数字总个数
        return maxValue - minValue + 1;
    }

    @Override
    public int getMaximumLength() {
    	//获取 最大值 和 最小值 中的 较大的数字
        int max = Math.max(Math.abs(maxValue), Math.abs(minValue));
        //获取这个数字 的 字符串形式的 字符串长度
        int maxLen = Integer.toString(max).length();
        if (minValue < 0) {
            maxLen++;
        }
        return maxLen;
    }
}

4. OnWheelChangedListener

package cn.org.octopus.wheelview.widget;

/**
 * 条目改变监听器
 */
public interface OnWheelChangedListener {
    /**
     * 当前条目改变时回调该方法
     *
     * @param wheel
     *            条目改变的 WheelView 对象
     * @param oldValue
     *            WheelView 旧的条目值
     * @param newValue
     *            WheelView 新的条目值
     */
    void onChanged(WheelView wheel, int oldValue, int newValue);
}

5. OnWheelScrollListener

package cn.org.octopus.wheelview.widget;

/**
 * WheelView 滚动监听器
 */
public interface OnWheelScrollListener {
    /**
     * 在 WheelView 滚动开始的时候回调该接口
     *
     * @param wheel
     *            开始滚动的 WheelView 对象
     */
    void onScrollingStarted(WheelView wheel);

    /**
     * 在 WheelView 滚动结束的时候回调该接口
     *
     * @param wheel
     *            结束滚动的 WheelView 对象
     */
    void onScrollingFinished(WheelView wheel);
}

6. WheelView

package cn.org.octopus.wheelview.widget;

import java.util.LinkedList;
import java.util.List;

import cn.org.octopus.wheelview.R;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.graphics.drawable.GradientDrawable.Orientation;
import android.os.Handler;
import android.os.Message;
import android.text.Layout;
import android.text.StaticLayout;
import android.text.TextPaint;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Interpolator;
import android.widget.Scroller;

/**
 * WheelView 主对象
 */
public class WheelView extends View {
    /** 滚动花费时间 Scrolling duration */
    private static final int SCROLLING_DURATION = 400;

    /** 最小的滚动值, 每次最少滚动一个单位 */
    private static final int MIN_DELTA_FOR_SCROLLING = 1;

    /** 当前条目中的文字颜色 */
    private static final int VALUE_TEXT_COLOR = 0xF0FF6347;

    /** 非当前条目的文字颜色 */
    private static final int ITEMS_TEXT_COLOR = 0xFF000000;

    /** 顶部和底部的阴影颜色 */
    //private static final int[] SHADOWS_COLORS = new int[] { 0xFF5436EE, 0x0012CEAE, 0x0012CEAE };
    private static final int[] SHADOWS_COLORS = new int[] { 0xFF111111, 0x00AAAAAA, 0x00AAAAAA };

    /** 额外的条目高度 Additional items height (is added to standard text item height) */
    private static final int ADDITIONAL_ITEM_HEIGHT = 15;

    /** 字体大小 */
    private static final int TEXT_SIZE = 24;

    /** 顶部 和 底部 条目的隐藏大小,
     * 如果是正数 会隐藏一部份,
     * 0 顶部 和 底部的字正好紧贴 边缘,
     * 负数时 顶部和底部 与 字有一定间距 */
    private static final int ITEM_OFFSET = TEXT_SIZE / 5;

    /** Additional width for items layout */
    private static final int ADDITIONAL_ITEMS_SPACE = 10;

    /** Label offset */
    private static final int LABEL_OFFSET = 8;

    /** Left and right padding value */
    private static final int PADDING = 10;

    /** 默认的可显示的条目数 */
    private static final int DEF_VISIBLE_ITEMS = 5;

    /** WheelView 适配器 */
    private WheelAdapter adapter = null;
    /** 当前显示的条目索引 */
    private int currentItem = 0;

    /** 条目宽度 */
    private int itemsWidth = 0;
    /** 标签宽度 */
    private int labelWidth = 0;

    /** 可见的条目数 */
    private int visibleItems = DEF_VISIBLE_ITEMS;

    /** 条目高度 */
    private int itemHeight = 0;

    /** 绘制普通条目画笔 */
    private TextPaint itemsPaint;
    /** 绘制选中条目画笔 */
    private TextPaint valuePaint;

    /** 普通条目布局
     * StaticLayout 布局用于控制 TextView 组件, 一般情况下不会直接使用该组件,
     * 除非你自定义一个组件 或者 想要直接调用  Canvas.drawText() 方法
     *  */
    private StaticLayout itemsLayout;
    private StaticLayout labelLayout;
    /** 选中条目布局 */
    private StaticLayout valueLayout;

    /** 标签 在选中条目的右边出现 */
    private String label;
    /** 选中条目的背景图片 */
    private Drawable centerDrawable;

    /** 顶部阴影图片 */
    private GradientDrawable topShadow;
    /** 底部阴影图片 */
    private GradientDrawable bottomShadow;

    /** 是否在滚动 */
    private boolean isScrollingPerformed;
    /** 滚动的位置 */
    private int scrollingOffset;

    /** 手势检测器 */
    private GestureDetector gestureDetector;
    /**
     * Scroll 类封装了滚动动作.
     * 开发者可以使用 Scroll 或者 Scroll 实现类 去收集产生一个滚动动画所需要的数据, 返回一个急冲滑动的手势.
     * 该对象可以追踪随着时间推移滚动的偏移量, 但是这些对象不会自动向 View 对象提供这些位置.
     * 如果想要使滚动动画看起来比较平滑, 开发者需要在适当的时机  获取 和 使用新的坐标;
     *  */
    private Scroller scroller;
    /** 之前所在的 y 轴位置 */
    private int lastScrollY;

    /** 是否循环 */
    boolean isCyclic = false;

    /** 条目改变监听器集合  封装了条目改变方法, 当条目改变时回调 */
    private List<OnWheelChangedListener> changingListeners = new LinkedList<OnWheelChangedListener>();
    /** 条目滚动监听器集合, 该监听器封装了 开始滚动方法, 结束滚动方法 */
    private List<OnWheelScrollListener> scrollingListeners = new LinkedList<OnWheelScrollListener>();

    /**
     * 构造方法
     */
    public WheelView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initData(context);
    }

    /**
     * 构造方法
     */
    public WheelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initData(context);
    }

    /**
     * 构造方法
     */
    public WheelView(Context context) {
        super(context);
        initData(context);
    }

    /**
     * 初始化数据
     *
     * @param context
     *            上下文对象
     */
    private void initData(Context context) {
    	//创建一个手势处理
        gestureDetector = new GestureDetector(context, gestureListener);
        /*
         * 是否允许长按操作,
         * 如果设置为 true 用户按下不松开, 会返回一个长按事件,
         * 如果设置为 false, 按下不松开滑动的话 会收到滚动事件.
         */
        gestureDetector.setIsLongpressEnabled(false);

        //使用默认的 时间 和 插入器 创建一个滚动器
        scroller = new Scroller(context);
    }

    /**
     * 获取该 WheelView 的适配器
     *
     * @return
     * 		返回适配器
     */
    public WheelAdapter getAdapter() {
        return adapter;
    }

    /**
     * 设置适配器
     *
     * @param adapter
     *            要设置的适配器
     */
    public void setAdapter(WheelAdapter adapter) {
        this.adapter = adapter;
        invalidateLayouts();
        invalidate();
    }

    /**
     * 设置 Scroll 的插入器
     *
     * @param interpolator
     *            the interpolator
     */
    public void setInterpolator(Interpolator interpolator) {
    	//强制停止滚动
        scroller.forceFinished(true);
        //创建一个 Scroll 对象
        scroller = new Scroller(getContext(), interpolator);
    }

    /**
     * 获取课件条目数
     *
     * @return the count of visible items
     */
    public int getVisibleItems() {
        return visibleItems;
    }

    /**
     * 设置可见条目数
     *
     * @param count
     *            the new count
     */
    public void setVisibleItems(int count) {
        visibleItems = count;
        invalidate();
    }

    /**
     * 获取标签
     *
     * @return the label
     */
    public String getLabel() {
        return label;
    }

    /**
     * 设置标签
     *
     * @param newLabel
     *            the label to set
     */
    public void setLabel(String newLabel) {
        if (label == null || !label.equals(newLabel)) {
            label = newLabel;
            labelLayout = null;
            invalidate();
        }
    }

    /**
     * 添加 WheelView 选择的元素改变监听器
     *
     * @param listener
     *            the listener
     */
    public void addChangingListener(OnWheelChangedListener listener) {
        changingListeners.add(listener);
    }

    /**
     * 移除 WheelView 元素改变监听器
     *
     * @param listener
     *            the listener
     */
    public void removeChangingListener(OnWheelChangedListener listener) {
        changingListeners.remove(listener);
    }

    /**
     * 回调元素改变监听器集合的元素改变监听器元素的元素改变方法
     *
     * @param oldValue
     *            旧的 WheelView选中的值
     * @param newValue
     *            新的 WheelView选中的值
     */
    protected void notifyChangingListeners(int oldValue, int newValue) {
        for (OnWheelChangedListener listener : changingListeners) {
            listener.onChanged(this, oldValue, newValue);
        }
    }

    /**
     * 添加 WheelView 滚动监听器
     *
     * @param listener
     *            the listener
     */
    public void addScrollingListener(OnWheelScrollListener listener) {
        scrollingListeners.add(listener);
    }

    /**
     * 移除 WheelView 滚动监听器
     *
     * @param listener
     *            the listener
     */
    public void removeScrollingListener(OnWheelScrollListener listener) {
        scrollingListeners.remove(listener);
    }

    /**
     * 通知监听器开始滚动
     */
    protected void notifyScrollingListenersAboutStart() {
        for (OnWheelScrollListener listener : scrollingListeners) {
        	//回调开始滚动方法
            listener.onScrollingStarted(this);
        }
    }

    /**
     * 通知监听器结束滚动
     */
    protected void notifyScrollingListenersAboutEnd() {
        for (OnWheelScrollListener listener : scrollingListeners) {
        	//回调滚动结束方法
            listener.onScrollingFinished(this);
        }
    }

    /**
     * 获取当前选中元素的索引
     *
     * @return
     * 		当前元素索引
     */
    public int getCurrentItem() {
        return currentItem;
    }

    /**
     * 设置当前元素的位置, 如果索引是错误的 不进行任何操作
     * -- 需要考虑该 WheelView 是否能循环
     * -- 根据是否需要滚动动画来确定是 ①滚动到目的位置 还是 ②晴空所有条目然后重绘
     *
     * @param index
     *            要设置的元素索引值
     * @param animated
     *            动画标志位
     */
    public void setCurrentItem(int index, boolean animated) {
    	//如果没有适配器或者元素个数为0 直接返回
        if (adapter == null || adapter.getItemsCount() == 0) {
            return; // throw?
        }
        //目标索引小于 0 或者大于 元素索引最大值(个数 -1)
        if (index < 0 || index >= adapter.getItemsCount()) {
        	//入股WheelView 可循环, 修正索引值, 如果不可循环直接返回
            if (isCyclic) {
                while (index < 0) {
                    index += adapter.getItemsCount();
                }
                index %= adapter.getItemsCount();
            } else {
                return; // throw?
            }
        }

        //如果当前的索引不是传入的 索引
        if (index != currentItem) {

        	/*
        	 * 如果需要动画, 就滚动到目标位置
        	 * 如果不需要动画, 重新设置布局
        	 */
            if (animated) {
            	/*
            	 * 开始滚动, 每个元素滚动间隔 400 ms, 滚动次数是 目标索引值 减去 当前索引值, 这是滚动的真实方法
            	 */
                scroll(index - currentItem, SCROLLING_DURATION);
            } else {
            	//所有布局设置为 null, 滚动位置设置为 0
                invalidateLayouts();

                int old = currentItem;
                currentItem = index;

                //便利回调元素改变监听器集合中的监听器元素中的元素改变方法
                notifyChangingListeners(old, currentItem);

                //重绘
                invalidate();
            }
        }
    }

    /**
     * 设置当前选中的条目, 没有动画, 当索引出错不做任何操作
     *
     * @param index
     *            要设置的索引
     */
    public void setCurrentItem(int index) {
        setCurrentItem(index, false);
    }

    /**
     * 获取 WheelView 是否可以循环
     * -- 如果可循环 : 第一个之前是最后一个, 最后一个之后是第一个;
     * -- 如果不可循环 : 到第一个就不能上翻, 最后一个不能下翻
     *
     * @return
     */
    public boolean isCyclic() {
        return isCyclic;
    }

    /**
     * 设置 WheelView 循环标志
     *
     * @param isCyclic
     *            the flag to set
     */
    public void setCyclic(boolean isCyclic) {
        this.isCyclic = isCyclic;

        invalidate();
        invalidateLayouts();
    }

    /**
     * 使布局无效
     * 将 选中条目 和 普通条目设置为 null, 滚动位置设置为0
     */
    private void invalidateLayouts() {
        itemsLayout = null;
        valueLayout = null;
        scrollingOffset = 0;
    }

    /**
     * 初始化资源
     */
    private void initResourcesIfNecessary() {
    	/*
    	 * 设置绘制普通条目的画笔, 允许抗拒齿, 允许 fake-bold
    	 * 设置文字大小为 24
    	 */
        if (itemsPaint == null) {
            itemsPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FAKE_BOLD_TEXT_FLAG);
            itemsPaint.setTextSize(TEXT_SIZE);
        }

        /*
         * 设置绘制选中条目的画笔
         * 设置文字大小 24
         */
        if (valuePaint == null) {
            valuePaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.FAKE_BOLD_TEXT_FLAG | Paint.DITHER_FLAG);
            valuePaint.setTextSize(TEXT_SIZE);
            valuePaint.setShadowLayer(0.1f, 0, 0.1f, 0xFFC0C0C0);
        }

        //选中的条目背景
        if (centerDrawable == null) {
            centerDrawable = getContext().getResources().getDrawable(R.drawable.wheel_val);
        }

        //创建顶部阴影图片
        if (topShadow == null) {
        	/*
        	 * 构造方法中传入颜色渐变方向
        	 * 阴影颜色
        	 */
            topShadow = new GradientDrawable(Orientation.TOP_BOTTOM, SHADOWS_COLORS);
        }

        //创建底部阴影图片
        if (bottomShadow == null) {
            bottomShadow = new GradientDrawable(Orientation.BOTTOM_TOP, SHADOWS_COLORS);
        }

        /*
         * 设置 View 组件的背景
         */
        setBackgroundResource(R.drawable.wheel_bg);
    }

    /**
     * 计算布局期望的高度
     *
     * @param layout
     *      组件的布局的
     * @return
     * 		布局需要的高度
     */
    private int getDesiredHeight(Layout layout) {
        if (layout == null) {
            return 0;
        }

        /*
         * 布局需要的高度是 条目个数 * 可见条目数 减去 顶部和底部隐藏的一部份 减去 额外的条目高度
         */
        int desired = getItemHeight() * visibleItems - ITEM_OFFSET * 2 - ADDITIONAL_ITEM_HEIGHT;

        // 将计算的布局高度 与 最小高度比较, 取最大值
        desired = Math.max(desired, getSuggestedMinimumHeight());

        return desired;
    }

    /**
     * 根据条目获取字符串
     *
     * @param index
     *            条目索引
     * @return
     * 		条目显示的字符串
     */
    private String getTextItem(int index) {
        if (adapter == null || adapter.getItemsCount() == 0) {
            return null;
        }
        //适配器显示的字符串个数
        int count = adapter.getItemsCount();

        //考虑 index 小于 0 的情况
        if ((index < 0 || index >= count) && !isCyclic) {
            return null;
        } else {
            while (index < 0) {
                index = count + index;
            }
        }

        //index 大于 0
        index %= count;
        return adapter.getItem(index);
    }

    /**
     * 根据当前值创建 字符串
     *
     * @param useCurrentValue
     * 		是否在滚动
     * @return the text
     * 		生成的字符串
     */
    private String buildText(boolean useCurrentValue) {
    	//创建字符串容器
        StringBuilder itemsText = new StringBuilder();
        //计算出显示的条目相对位置, 例如显示 5个, 第 3 个是正中见选中的布局
        int addItems = visibleItems / 2 + 1;

        /*
         * 遍历显示的条目
         * 获取当前显示条目 上下 各 addItems 个文本, 将该文本添加到显示文本中去
         * 如果不是最后一个 都加上回车
         */
        for (int i = currentItem - addItems; i <= currentItem + addItems; i++) {
        	//如果在滚动
            if (useCurrentValue || i != currentItem) {
                String text = getTextItem(i);
                if (text != null) {
                    itemsText.append(text);
                }
            }
            if (i < currentItem + addItems) {
                itemsText.append("\n");
            }
        }

        return itemsText.toString();
    }

    /**
     * 返回 条目的字符串
     *
     * @return
     * 		条目最大宽度
     */
    private int getMaxTextLength() {
        WheelAdapter adapter = getAdapter();
        if (adapter == null) {
            return 0;
        }

        //如果获取的最大条目宽度不为 -1, 可以直接返回该条目宽度
        int adapterLength = adapter.getMaximumLength();
        if (adapterLength > 0) {
            return adapterLength;
        }

        String maxText = null;
        int addItems = visibleItems / 2;
        /*
         * 遍历当前显示的条目, 获取字符串长度最长的那个, 返回这个最长的字符串长度
         */
        for (int i = Math.max(currentItem - addItems, 0); i < Math.min(currentItem + visibleItems,
                adapter.getItemsCount()); i++) {
            String text = adapter.getItem(i);
            if (text != null && (maxText == null || maxText.length() < text.length())) {
                maxText = text;
            }
        }

        return maxText != null ? maxText.length() : 0;
    }

    /**
     * 获取每个条目的高度
     *
     * @return
     * 		条目的高度
     */
    private int getItemHeight() {
    	//如果条目高度不为 0, 直接返回
        if (itemHeight != 0) {
            return itemHeight;
        //如果条目的高度为 0, 并且普通条目布局不为null, 条目个数大于 2
        } else if (itemsLayout != null && itemsLayout.getLineCount() > 2) {
        	/*
        	 * itemsLayout.getLineTop(2) : 获取顶部第二行上面的垂直(y轴)位置, 如果行数等于
        	 */
            itemHeight = itemsLayout.getLineTop(2) - itemsLayout.getLineTop(1);
            return itemHeight;
        }

        //如果上面都不符合, 使用整体高度处以 显示条目数
        return getHeight() / visibleItems;
    }

    /**
     * 计算宽度并创建文字布局
     *
     * @param widthSize
     *            输入的布局宽度
     * @param mode
     *            布局模式
     * @return
     * 		计算的宽度
     */
    private int calculateLayoutWidth(int widthSize, int mode) {
        initResourcesIfNecessary();

        int width = widthSize;

        //获取最长的条目显示字符串字符个数
        int maxLength = getMaxTextLength();

        if (maxLength > 0) {
        	/*
        	 * 使用方法 FloatMath.ceil() 方法有以下警告
        	 * Use java.lang.Math#ceil instead of android.util.FloatMath#ceil() since it is faster as of API 8
        	 */
            //float textWidth = FloatMath.ceil(Layout.getDesiredWidth("0", itemsPaint));
        	//向上取整  计算一个字符串宽度
        	float textWidth = (float) Math.ceil(Layout.getDesiredWidth("0", itemsPaint));

        	//获取字符串总的宽度
            itemsWidth = (int) (maxLength * textWidth);
        } else {
            itemsWidth = 0;
        }

        //总宽度加上一些间距
        itemsWidth += ADDITIONAL_ITEMS_SPACE; // make it some more

        //计算 label 的长度
        labelWidth = 0;
        if (label != null && label.length() > 0) {
        	labelWidth = (int) Math.ceil(Layout.getDesiredWidth(label, valuePaint));
            //labelWidth = (int) FloatMath.ceil(Layout.getDesiredWidth(label, valuePaint));
        }

        boolean recalculate = false;
        //精准模式
        if (mode == MeasureSpec.EXACTLY) {
        	//精准模式下, 宽度就是给定的宽度
            width = widthSize;
            recalculate = true;
        } else {
        	//未定义模式
            width = itemsWidth + labelWidth + 2 * PADDING;
            if (labelWidth > 0) {
                width += LABEL_OFFSET;
            }

            // 获取 ( 计算出来的宽度 与 最小宽度的 ) 最大值
            width = Math.max(width, getSuggestedMinimumWidth());

            //最大模式 如果 给定的宽度 小于 计算出来的宽度, 那么使用最小的宽度 ( 给定宽度 | 计算出来的宽度 )
            if (mode == MeasureSpec.AT_MOST && widthSize < width) {
                width = widthSize;
                recalculate = true;
            }
        }

        /*
         * 重新计算宽度 , 如果宽度是给定的宽度, 不是我们计算出来的宽度, 需要重新进行计算
         * 重新计算的宽度是用于
         *
         * 计算 itemsWidth , 这个与返回的 宽度无关, 与创建布局有关
         */
        if (recalculate) {
            int pureWidth = width - LABEL_OFFSET - 2 * PADDING;
            if (pureWidth <= 0) {
                itemsWidth = labelWidth = 0;
            }
            if (labelWidth > 0) {
                double newWidthItems = (double) itemsWidth * pureWidth / (itemsWidth + labelWidth);
                itemsWidth = (int) newWidthItems;
                labelWidth = pureWidth - itemsWidth;
            } else {
                itemsWidth = pureWidth + LABEL_OFFSET; // no label
            }
        }

        if (itemsWidth > 0) {
        	//创建布局
            createLayouts(itemsWidth, labelWidth);
        }

        return width;
    }

    /**
     * 创建布局
     *
     * @param widthItems
     *            布局条目宽度
     * @param widthLabel
     *            label 宽度
     */
    private void createLayouts(int widthItems, int widthLabel) {
    	/*
    	 * 创建普通条目布局
    	 * 如果 普通条目布局 为 null 或者 普通条目布局的宽度 大于 传入的宽度, 这时需要重新创建布局
    	 * 如果 普通条目布局存在, 并且其宽度小于传入的宽度, 此时需要将
    	 */
        if (itemsLayout == null || itemsLayout.getWidth() > widthItems) {

        	/*
        	 * android.text.StaticLayout.StaticLayout(
        	 * CharSequence source, TextPaint paint,
        	 * int width, Alignment align,
        	 * float spacingmult, float spacingadd, boolean includepad)
        	 * 传入参数介绍 :
        	 * CharSequence source : 需要分行显示的字符串
        	 * TextPaint paint : 绘制字符串的画笔
        	 * int width : 条目的宽度
        	 * Alignment align : Layout 的对齐方式, ALIGN_CENTER 居中对齐, ALIGN_NORMAL 左对齐, Alignment.ALIGN_OPPOSITE 右对齐
        	 * float spacingmult : 行间距, 1.5f 代表 1.5 倍字体高度
        	 * float spacingadd : 基础行距上增加多少 , 真实行间距 等于 spacingmult 和 spacingadd 的和
        	 * boolean includepad :
        	 */
            itemsLayout = new StaticLayout(buildText(isScrollingPerformed), itemsPaint, widthItems,
                    widthLabel > 0 ? Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_CENTER, 1,
                    ADDITIONAL_ITEM_HEIGHT, false);
        } else {
        	//调用 Layout 内置的方法 increaseWidthTo 将宽度提升到指定的宽度
            itemsLayout.increaseWidthTo(widthItems);
        }

        /*
         * 创建选中条目
         */
        if (!isScrollingPerformed && (valueLayout == null || valueLayout.getWidth() > widthItems)) {
            String text = getAdapter() != null ? getAdapter().getItem(currentItem) : null;
            valueLayout = new StaticLayout(text != null ? text : "", valuePaint, widthItems,
                    widthLabel > 0 ? Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_CENTER, 1,
                    ADDITIONAL_ITEM_HEIGHT, false);
        } else if (isScrollingPerformed) {
            valueLayout = null;
        } else {
            valueLayout.increaseWidthTo(widthItems);
        }

        /*
         * 创建标签条目
         */
        if (widthLabel > 0) {
            if (labelLayout == null || labelLayout.getWidth() > widthLabel) {
                labelLayout = new StaticLayout(label, valuePaint, widthLabel, Layout.Alignment.ALIGN_NORMAL, 1,
                        ADDITIONAL_ITEM_HEIGHT, false);
            } else {
                labelLayout.increaseWidthTo(widthLabel);
            }
        }
    }

    /*
     * 测量组件大小
     * (non-Javadoc)
     * @see android.view.View#onMeasure(int, int)
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    	//获取宽度 和 高度的模式 和 大小
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        //宽度就是 计算的布局的宽度
        int width = calculateLayoutWidth(widthSize, widthMode);

        int height;
        /*
         * 精准模式
         * 		精准模式下 高度就是精确的高度
         */
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;

        //未定义模式 和 最大模式
        } else {
        	//未定义模式下 获取布局需要的高度
            height = getDesiredHeight(itemsLayout);

            //最大模式下 获取 布局高度 和 布局所需高度的最小值
            if (heightMode == MeasureSpec.AT_MOST) {
                height = Math.min(height, heightSize);
            }
        }

        //设置组件的宽和高
        setMeasuredDimension(width, height);
    }

    /*
     * 绘制组件
     * (non-Javadoc)
     * @see android.view.View#onDraw(android.graphics.Canvas)
     */
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        //如果条目布局为 null, 就创建该布局
        if (itemsLayout == null) {
        	/*
        	 * 如果 条目宽度为0, 说明该宽度没有计算, 先计算, 计算完之后会创建布局
        	 * 如果 条目宽度 大于 0, 说明已经计算过宽度了, 直接创建布局
        	 */
            if (itemsWidth == 0) {
                calculateLayoutWidth(getWidth(), MeasureSpec.EXACTLY);
            } else {
            	//创建普通条目布局, 选中条目布局, 标签条目布局
                createLayouts(itemsWidth, labelWidth);
            }
        }

        //如果条目宽度大于0
        if (itemsWidth > 0) {
            canvas.save();
            // 使用平移方法忽略 填充的空间 和 顶部底部隐藏的一部份条目
            canvas.translate(PADDING, -ITEM_OFFSET);
            //绘制普通条目
            drawItems(canvas);
            //绘制选中条目
            drawValue(canvas);
            canvas.restore();
        }

        //在中心位置绘制
        drawCenterRect(canvas);
        //绘制阴影
        drawShadows(canvas);
    }

    /**
     * Draws shadows on top and bottom of control
     *
     * @param canvas
     *            the canvas for drawing
     */
    private void drawShadows(Canvas canvas) {
        topShadow.setBounds(0, 0, getWidth(), getHeight() / visibleItems);
        topShadow.draw(canvas);

        bottomShadow.setBounds(0, getHeight() - getHeight() / visibleItems, getWidth(), getHeight());
        bottomShadow.draw(canvas);
    }

    /**
     * 绘制选中条目
     *
     * @param canvas
     *            画布
     */
    private void drawValue(Canvas canvas) {
        valuePaint.setColor(VALUE_TEXT_COLOR);

        //将当前 View 状态属性值 转为整型集合, 赋值给 普通条目布局的绘制属性
        valuePaint.drawableState = getDrawableState();

        Rect bounds = new Rect();
        //获取选中条目布局的边界
        itemsLayout.getLineBounds(visibleItems / 2, bounds);

        // 绘制标签
        if (labelLayout != null) {
            canvas.save();
            canvas.translate(itemsLayout.getWidth() + LABEL_OFFSET, bounds.top);
            labelLayout.draw(canvas);
            canvas.restore();
        }

        // 绘制选中条目
        if (valueLayout != null) {
            canvas.save();
            canvas.translate(0, bounds.top + scrollingOffset);
            valueLayout.draw(canvas);
            canvas.restore();
        }
    }

    /**
     * 绘制普通条目
     *
     * @param canvas
     *            画布
     */
    private void drawItems(Canvas canvas) {
        canvas.save();

        //获取 y 轴 定点高度
        int top = itemsLayout.getLineTop(1);
        canvas.translate(0, -top + scrollingOffset);

        //设置画笔颜色
        itemsPaint.setColor(ITEMS_TEXT_COLOR);
        //将当前 View 状态属性值 转为整型集合, 赋值给 普通条目布局的绘制属性
        itemsPaint.drawableState = getDrawableState();
        //将布局绘制到画布上
        itemsLayout.draw(canvas);

        canvas.restore();
    }

    /**
     * 绘制当前选中条目的背景图片
     *
     * @param canvas
     *            画布
     */
    private void drawCenterRect(Canvas canvas) {
        int center = getHeight() / 2;
        int offset = getItemHeight() / 2;
        centerDrawable.setBounds(0, center - offset, getWidth(), center + offset);
        centerDrawable.draw(canvas);
    }

    /*
     * 继承自 View 的触摸事件, 当出现触摸事件的时候, 就会回调该方法
     * (non-Javadoc)
     * @see android.view.View#onTouchEvent(android.view.MotionEvent)
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    	//获取适配器
        WheelAdapter adapter = getAdapter();
        if (adapter == null) {
            return true;
        }

        /*
         * gestureDetector.onTouchEvent(event) : 分析给定的动作, 如果可用, 调用 手势检测器的 onTouchEvent 方法
         * -- 参数解析 : ev , 触摸事件
         * -- 返回值 : 如果手势监听器成功执行了该方法, 返回true, 如果执行出现意外 返回 false;
         */
        if (!gestureDetector.onTouchEvent(event) && event.getAction() == MotionEvent.ACTION_UP) {
            justify();
        }
        return true;
    }

    /**
     * 滚动 WheelView
     *
     * @param delta
     *            滚动的值
     */
    private void doScroll(int delta) {
        scrollingOffset += delta;

        //计算滚动的条目数, 使用滚动的值 处于 单个条目高度, 注意计算整数值
        int count = scrollingOffset / getItemHeight();
        /*
         * pos 是滚动后的目标元素索引
         * 计算当前位置, 当前条目数 减去 滚动的条目数
         * 注意 滚动条目数可正 可负
         */
        int pos = currentItem - count;
        //如果是可循环的, 并且条目数大于0
        if (isCyclic && adapter.getItemsCount() > 0) {
            //设置循环, 如果位置小于0, 那么该位置就显示最后一个元素
            while (pos < 0) {
                pos += adapter.getItemsCount();
            }
            //如果位置正无限大, 模条目数 取余
            pos %= adapter.getItemsCount();

        // (前提 : 不可循环  条目数大于0, 可循环 条目数小于0, 条目数小于0, 不可循环) , 如果滚动在执行
        } else if (isScrollingPerformed) {
            //位置一旦小于0, 计算的位置就赋值为 0, 条目滚动数为0
            if (pos < 0) {
                count = currentItem;
                pos = 0;

            //位置大于条目数的时候, 当前位置等于(条目数 - 1), 条目滚动数等于 当前位置 减去 (条目数 - 1)
            } else if (pos >= adapter.getItemsCount()) {
                count = currentItem - adapter.getItemsCount() + 1;
                pos = adapter.getItemsCount() - 1;
            }

        } else {
            // fix position
            pos = Math.max(pos, 0);
            pos = Math.min(pos, adapter.getItemsCount() - 1);
        }

        //滚动的高度
        int offset = scrollingOffset;

        /*
         * 如果当前位置不是滚动后的目标位置, 就将当前位置设置为目标位置
         * 否则就重绘组件
         */
        if (pos != currentItem) {
            setCurrentItem(pos, false);
        } else {
        	//重绘组件
            invalidate();
        }

        // 将滚动后剩余的小数部分保存
        scrollingOffset = offset - count * getItemHeight();
        if (scrollingOffset > getHeight()) {
            scrollingOffset = scrollingOffset % getHeight() + getHeight();
        }
    }

    /**
     * 手势监听器
     */
    private SimpleOnGestureListener gestureListener = new SimpleOnGestureListener() {

    	//按下操作
        public boolean onDown(MotionEvent e) {
        	//如果滚动在执行
            if (isScrollingPerformed) {
            	//滚动强制停止, 按下的时候不能继续滚动
                scroller.forceFinished(true);
                //清理信息
                clearMessages();
                return true;
            }
            return false;
        }

        /*
         * 手势监听器监听到 滚动操作后回调
         *
         * 参数解析 :
         * MotionEvent e1 : 触发滚动时第一次按下的事件
         * MotionEvent e2 : 触发当前滚动的移动事件
         * float distanceX : 自从上一次调用 该方法 到这一次 x 轴滚动的距离,
         * 				注意不是 e1 到 e2 的距离, e1 到 e2 的距离是从开始滚动到现在的滚动距离
         * float distanceY : 自从上一次回调该方法到这一次 y 轴滚动的距离
         *
         * 返回值 : 如果事件成功触发, 执行完了方法中的操作, 返回true, 否则返回 false
         * (non-Javadoc)
         * @see android.view.GestureDetector.SimpleOnGestureListener#onScroll(android.view.MotionEvent, android.view.MotionEvent, float, float)
         */
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        	//开始滚动, 并回调滚动监听器集合中监听器的 开始滚动方法
            startScrolling();
            doScroll((int) -distanceY);
            return true;
        }

        /*
         * 当一个急冲手势发生后 回调该方法, 会计算出该手势在 x 轴 y 轴的速率
         *
         * 参数解析 :
         * -- MotionEvent e1 : 急冲动作的第一次触摸事件;
         * -- MotionEvent e2 : 急冲动作的移动发生的时候的触摸事件;
         * -- float velocityX : x 轴的速率
         * -- float velocityY : y 轴的速率
         *
         * 返回值 : 如果执行完毕返回 true, 否则返回false, 这个就是自己定义的
         *
         * (non-Javadoc)
         * @see android.view.GestureDetector.SimpleOnGestureListener#onFling(android.view.MotionEvent, android.view.MotionEvent, float, float)
         */
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        	//计算上一次的 y 轴位置, 当前的条目高度 加上 剩余的 不够一行高度的那部分
            lastScrollY = currentItem * getItemHeight() + scrollingOffset;
            //如果可以循环最大值是无限大, 不能循环就是条目数的高度值
            int maxY = isCyclic ? 0x7FFFFFFF : adapter.getItemsCount() * getItemHeight();
            int minY = isCyclic ? -maxY : 0;
            /*
             * Scroll 开始根据一个急冲手势滚动, 滚动的距离与初速度有关
             * 参数介绍 :
             * -- int startX : 开始时的 X轴位置
             * -- int startY : 开始时的 y轴位置
             * -- int velocityX : 急冲手势的 x 轴的初速度, 单位 px/s
             * -- int velocityY : 急冲手势的 y 轴的初速度, 单位 px/s
             * -- int minX : x 轴滚动的最小值
             * -- int maxX : x 轴滚动的最大值
             * -- int minY : y 轴滚动的最小值
             * -- int maxY : y 轴滚动的最大值
             */
            scroller.fling(0, lastScrollY, 0, (int) -velocityY / 2, 0, 0, minY, maxY);
            setNextMessage(MESSAGE_SCROLL);
            return true;
        }
    };

    // Handler 中的  Message 信息
    /** 滚动信息 */
    private final int MESSAGE_SCROLL = 0;
    /** 调整信息 */
    private final int MESSAGE_JUSTIFY = 1;

    /**
     * 清空之前的 Handler 队列, 发送下一个消息到 Handler 中
     *
     * @param message
     *            要发送的消息
     */
    private void setNextMessage(int message) {
    	//清空 Handler 队列中的  what 消息
        clearMessages();
        //发送消息到 Handler 中
        animationHandler.sendEmptyMessage(message);
    }

    /**
     * 清空队列中的信息
     */
    private void clearMessages() {
    	//删除 Handler 执行队列中的滚动操作
        animationHandler.removeMessages(MESSAGE_SCROLL);
        animationHandler.removeMessages(MESSAGE_JUSTIFY);
    }

    /**
     * 动画控制器
     *  animation handler
     *
     *  可能会造成内存泄露 : 添加注解 HandlerLeak
     *  Handler 类应该应该为static类型,否则有可能造成泄露。
     *  在程序消息队列中排队的消息保持了对目标Handler类的应用。
     *  如果Handler是个内部类,那 么它也会保持它所在的外部类的引用。
     *  为了避免泄露这个外部类,应该将Handler声明为static嵌套类,并且使用对外部类的弱应用。
     */
    @SuppressLint("HandlerLeak")
	private Handler animationHandler = new Handler() {
        public void handleMessage(Message msg) {
        	//回调该方法获取当前位置, 如果返回true, 说明动画还没有执行完毕
            scroller.computeScrollOffset();
            //获取当前 y 位置
            int currY = scroller.getCurrY();
            //获取已经滚动了的位置, 使用上一次位置 减去 当前位置
            int delta = lastScrollY - currY;
            lastScrollY = currY;
            if (delta != 0) {
            	//改变值不为 0 , 继续滚动
                doScroll(delta);
            }

            /*
             * 如果滚动到了指定的位置, 滚动还没有停止
             * 这时需要强制停止
             */
            if (Math.abs(currY - scroller.getFinalY()) < MIN_DELTA_FOR_SCROLLING) {
                currY = scroller.getFinalY();
                scroller.forceFinished(true);
            }

            /*
             * 如果滚动没有停止
             * 再向 Handler 发送一个停止
             */
            if (!scroller.isFinished()) {
                animationHandler.sendEmptyMessage(msg.what);
            } else if (msg.what == MESSAGE_SCROLL) {
                justify();
            } else {
                finishScrolling();
            }
        }
    };

    /**
     * 调整 WheelView
     */
    private void justify() {
        if (adapter == null) {
            return;
        }
        //上一次的 y 轴的位置为 0
        lastScrollY = 0;
        int offset = scrollingOffset;
        int itemHeight = getItemHeight();
        /*
         * 当滚动补偿 大于 0, 说明还有没有滚动的部分,  needToIncrease 是 当前条目是否小于条目数
         * 如果 滚动补偿不大于 0,  needToIncrease 是当前条目是否大于 0
         */
        boolean needToIncrease = offset > 0 ? currentItem < adapter.getItemsCount() : currentItem > 0;
        if ((isCyclic || needToIncrease) && Math.abs((float) offset) > (float) itemHeight / 2) {
            if (offset < 0)
                offset += itemHeight + MIN_DELTA_FOR_SCROLLING;
            else
                offset -= itemHeight + MIN_DELTA_FOR_SCROLLING;
        }
        if (Math.abs(offset) > MIN_DELTA_FOR_SCROLLING) {
            scroller.startScroll(0, 0, 0, offset, SCROLLING_DURATION);
            setNextMessage(MESSAGE_JUSTIFY);
        } else {
            finishScrolling();
        }
    }

    /**
     * WheelView 开始滚动
     */
    private void startScrolling() {
    	//如果没有滚动, 将滚动状态 isScrollingPerformed 设为 true
        if (!isScrollingPerformed) {
            isScrollingPerformed = true;
            //通知监听器开始滚动 回调所有的 滚动监听集合中 的 开始滚动方法
            notifyScrollingListenersAboutStart();
        }
    }

    /**
     * 结束滚动
     * 	设置滚动状态为 false, 回调滚动监听器的停止滚动方法
     */
    void finishScrolling() {
        if (isScrollingPerformed) {
            notifyScrollingListenersAboutEnd();
            isScrollingPerformed = false;
        }
        //设置布局无效
        invalidateLayouts();
        //重绘布局
        invalidate();
    }

    /**
     * 滚动 WheelView
     *
     * @param itemsToSkip
     *            滚动的元素个数
     * @param time
     *            每次滚动的间隔
     */
    public void scroll(int itemsToScroll, int time) {
    	//如果有滚动强制停止
        scroller.forceFinished(true);

        lastScrollY = scrollingOffset;
        int offset = itemsToScroll * getItemHeight();

        /*
         * 给定 一个开始点, 滚动距离, 滚动间隔, 开始滚动
         *
         * 参数解析 :
         * 1. 开始的 x 轴位置
         * 2. 开始的 y 轴位置
         * 3. 要滚动 x 轴距离
         * 4. 要滚动 y 轴距离
         * 5. 滚动花费的时间
         */
        scroller.startScroll(0, lastScrollY, 0, offset - lastScrollY, time);
        setNextMessage(MESSAGE_SCROLL);

        //设置开始滚动状态, 并回调滚动监听器方法
        startScrolling();
    }

}

7. Activity 主界面

package cn.org.octopus.wheelview;

import android.app.Activity;
import android.app.AlertDialog;
import android.app.Fragment;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.widget.Button;
import android.widget.LinearLayout;
import cn.org.octopus.wheelview.widget.ArrayWheelAdapter;
import cn.org.octopus.wheelview.widget.OnWheelChangedListener;
import cn.org.octopus.wheelview.widget.OnWheelScrollListener;
import cn.org.octopus.wheelview.widget.WheelView;

public class MainActivity extends Activity{

	public static final String TAG = "octopus.activity";

	private static Button bt_click;

	public String province[] = new String[] { "  河北省  ", "  山西省  ", "  内蒙古  ", "  辽宁省  ", "  吉林省  ", "  黑龙江  ", "  江苏省  " };

    public String city[][] = new String[][] {
            new String[] {"  石家庄  ", "唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口", "承德", "沧州", "廊坊", "衡水"},
            new String[] {"太原", "大同", "阳泉", "长治", "晋城", "朔州", "晋中", "运城", "忻州", "临汾", "吕梁"},
            new String[] {"呼和浩特", "包头", "乌海", "赤峰", "通辽", "鄂尔多斯", "呼伦贝尔", "巴彦淖尔", "乌兰察布", "兴安", "锡林郭勒", "阿拉善"},
            new String[] {"沈阳", "大连", "鞍山", "抚顺", "本溪", "丹东", "锦州", "营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛"},
            new String[] {"长春", "吉林", "四平", "辽源", "通化", "白山", "松原", "白城", "延边"},
            new String[] {"哈尔滨", "齐齐哈尔", "鸡西", "鹤岗", "双鸭山", "大庆", "伊春", "佳木斯", "七台河", "牡丹江", "黑河", "绥化", "大兴安岭"},
            new String[] {"南京", "无锡", "徐州", "常州", "苏州", "南通", "连云港", "淮安", "盐城", "扬州", "镇江", "泰州", "宿迁"} };

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);

		if (savedInstanceState == null) {
			getFragmentManager().beginTransaction()
					.add(R.id.container, new PlaceholderFragment()).commit();
		}
	}

	/*
	 * 点击事件
	 */
	public void onClick(View view) {
		showSelectDialog(this, "选择地点", province, city);
	}

	private void showSelectDialog(Context context, String title, final String[] left, final String[][] right) {
    	//创建对话框
        AlertDialog dialog = new AlertDialog.Builder(context).create();
        //为对话框设置标题
        dialog.setTitle(title);
        //创建对话框内容, 创建一个 LinearLayout
        LinearLayout llContent = new LinearLayout(context);
        //将创建的 LinearLayout 设置成横向的
        llContent.setOrientation(LinearLayout.HORIZONTAL);
        //创建 WheelView 组件
        final WheelView wheelLeft = new WheelView(context);
        //设置 WheelView 组件最多显示 5 个元素
        wheelLeft.setVisibleItems(5);
        //设置 WheelView 元素是否循环滚动
        wheelLeft.setCyclic(false);
        //设置 WheelView 适配器
        wheelLeft.setAdapter(new ArrayWheelAdapter<String>(left));
        //设置右侧的 WheelView
        final WheelView wheelRight = new WheelView(context);
        //设置右侧 WheelView 显示个数
        wheelRight.setVisibleItems(5);
        //设置右侧 WheelView 元素是否循环滚动
        wheelRight.setCyclic(true);
        //设置右侧 WheelView 的元素适配器
        wheelRight.setAdapter(new ArrayWheelAdapter<String>(right[0]));
        //设置 LinearLayout 的布局参数
        LinearLayout.LayoutParams paramsLeft = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT, 4);
        paramsLeft.gravity = Gravity.LEFT;
        LinearLayout.LayoutParams paramsRight = new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT, 6);
        paramsRight.gravity = Gravity.RIGHT;
        //将 WheelView 对象放到左侧 LinearLayout 中
        llContent.addView(wheelLeft, paramsLeft);
        //将 WheelView 对象放到 右侧 LinearLayout 中
        llContent.addView(wheelRight, paramsRight);

        //为左侧的 WheelView 设置条目改变监听器
        wheelLeft.addChangingListener(new OnWheelChangedListener() {
            @Override
            public void onChanged(WheelView wheel, int oldValue, int newValue) {
            	//设置右侧的 WheelView 的适配器
                wheelRight.setAdapter(new ArrayWheelAdapter<String>(right[newValue]));
                wheelRight.setCurrentItem(right[newValue].length / 2);
            }
        });

        wheelLeft.addScrollingListener(new OnWheelScrollListener() {

			@Override
			public void onScrollingStarted(WheelView wheel) {
				// TODO Auto-generated method stub

			}

			@Override
			public void onScrollingFinished(WheelView wheel) {
				// TODO Auto-generated method stub

			}
		});

        //设置对话框点击事件 积极
        dialog.setButton(AlertDialog.BUTTON_POSITIVE, "确定", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                int leftPosition = wheelLeft.getCurrentItem();
                String vLeft = left[leftPosition];
                String vRight = right[leftPosition][wheelRight.getCurrentItem()];
                bt_click.setText(vLeft + "-" + vRight);
                dialog.dismiss();
            }
        });

        //设置对话框点击事件 消极
        dialog.setButton(AlertDialog.BUTTON_NEGATIVE, "取消", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                dialog.dismiss();
            }
        });
        //将 LinearLayout 设置到 对话框中
        dialog.setView(llContent);
        //显示对话框
        if (!dialog.isShowing()) {
            dialog.show();
        }
    }

	@Override
	public boolean onCreateOptionsMenu(Menu menu) {

		// Inflate the menu; this adds items to the action bar if it is present.
		getMenuInflater().inflate(R.menu.main, menu);
		return true;
	}

	@Override
	public boolean onOptionsItemSelected(MenuItem item) {
		// Handle action bar item clicks here. The action bar will
		// automatically handle clicks on the Home/Up button, so long
		// as you specify a parent activity in AndroidManifest.xml.
		int id = item.getItemId();
		if (id == R.id.action_settings) {
			return true;
		}
		return super.onOptionsItemSelected(item);
	}

	/**
	 * A placeholder fragment containing a simple view.
	 */
	public static class PlaceholderFragment extends Fragment {

		public PlaceholderFragment() {
		}

		@Override
		public View onCreateView(LayoutInflater inflater, ViewGroup container,
				Bundle savedInstanceState) {
			View rootView = inflater.inflate(R.layout.fragment_main, container,
					false);
			bt_click = (Button)rootView.findViewById(R.id.bt_click);
			return rootView;
		}
	}

}

博客地址 http://blog.csdn.net/shulianghan/article/details/41520569#t17

代码下载 :

-- GitHub : https://github.com/han1202012/WheelViewDemo.git

-- CSDN : http://download.csdn.net/detail/han1202012/8208997 ;

【Android 应用开发】 自定义组件 宽高适配方法, 手势监听器操作组件, 回调接口维护策略, 绘制方法分析 -- 基于 WheelView 组件分析自定义组件的更多相关文章

  1. Activity启动过程中获取组件宽高的五种方式

    第一种:(重写Activity的onWindowFocusChanged方法) /** * 重写Acitivty的onWindowFocusChanged方法 */ @Override public ...

  2. android下载网络图片,设置宽高,等比缩放

    使用Picasso组件去下载图片会发现图片宽高会变形不受等比缩放控制,即使设置了图片的 scaleType,可能是对Picasso的api没有用对, Picasso.with(this.activit ...

  3. Android实战开发租赁管理软件(适配UI,数据的存储,多线程下载)课程分享

    亲爱的网友,我这里有套课程想和大家分享,假设对这个课程有兴趣的,能够加我的QQ2059055336和我联系. 课程内容简单介绍 我们软件是基于移动设备的.所以我们必定的选择了安卓作为我们的开发工具.课 ...

  4. 微信小程序宽高适配

    小程序的宽任何机型都是750rpx,但是画布canvas的默认单位是px,可能会出现需要怪异的样式,我们可以用到 wx.getSystemInfoSync().windowWidth和 wx.getS ...

  5. 让自定义view宽高成比例显示

    有时候我们自定义一个View,比如ImageView,我们需要让它宽高按照一定的比例显示,例如在ImageView在GridView中显示,GridView设置了3列,由于ImageVIew的宽度会根 ...

  6. Android笔记之获取显示器宽高

    原先的Display.getWidth().Display.getHeight()已废弃 推荐的获取Display宽高的方法如下 DisplayMetrics metrics = new Displa ...

  7. Android NDK开发篇:Java与原生代码通信(数据操作)

    虽然说使用NDK可以提高Android程序的执行效率,但是调用起来还是稍微有点麻烦.NDK可以直接使用Java的原生数据类型,而引用类型,因为Java的引用类型的实现在NDK被屏蔽了,所以在NDK使用 ...

  8. android onCreate中获取view宽高为0的解决方法

    view.post(runnable) 通过post可以将一个runnable投递到消息队列的尾部,然后等待UI线程Looper调用此runnable的时候,view也已经初始化好了. view.po ...

  9. web开发中各种宽高

    Gosper 曲线:https://www.cnblogs.com/tgzhu/p/8286616.html

随机推荐

  1. jvm(四):垃圾回收

    垃圾回收我们主要从以下三个方面进行描述 垃圾对象的判断 目前判断对象为垃圾对象有两种方法:引用计数法,可达性分析法,目前普遍是的是可达性分析法 可达性分析法的实现原理: 定义gcroot一直往下找,如 ...

  2. 48. Rotate Image(中等)

    You are given an n x n 2D matrix representing an image. Rotate the image by 90 degrees (clockwise). ...

  3. li标签中list-style-image如何居中

    使用list-style-image设置了一个列表项的小图标时,一直不能让图标居中的显示. 解决办法是:使用ul li的backgrou-image(背景图片)来设置. 代码如下: ul li{ he ...

  4. Linux服务器搭建相关教程链接整理

    Linux: Linux 教程 | 菜鸟教程 linux下如何添加一个用户并且让用户获得root权限 - !canfly - 博客园 Git: 在 Linux 下搭建 Git 服务器 - 黄棣-dee ...

  5. JavaC命令不能被执行尴尬问题解决

    安装和配置环境变量都按着流程在,但在最后的检验时,发现Java   Java -version 都能运行,唯独Javac 报"不能识别命令"错误信息,下面列出我遇到一个尴尬问题 在 ...

  6. Node.js Smalloc

    稳定性: 1 - 试验 类: smalloc 由简单内存分配器(处理扩展原始内存的分配)支持的缓存.Smalloc 有以下函数: smalloc.alloc(length[, receiver][, ...

  7. MongoDB 连接

    启动 MongoDB服务 在前面的教程中,我们已经讨论了如何启动MongoDB服务,你只需要在MongoDB安装目录的bin目录下执行'mongod'即可. 执行启动操作后,mongodb在输出一些必 ...

  8. linux下删除目录及其子目录下某种类型文件

    Linux下,如果想要删除目录及其子目录下某种类型文件,比如说所有的txt文件,则可以使用下面的命令: find . -name "*.txt" -type f -print -e ...

  9. EBS业务学习之库存管理

    库存管理业务流程 企业结构 库存结构 库存结构定义 指定每个子库存的特性: •子库存的数量跟踪 •资产类子库存 •保留子库存 •净值子库存 •包含在有效承诺中Include in ATP •子库存级库 ...

  10. 为什么选择C++

    为什么选择C++,怎么不选其它语言呢? 为什么不选择C? 因为C++比C简单点~ 为什么不选择C#? 因为C++可以在所有操作系统上使用. 为什么不选择JAVA? 因为C++的性能好一点~ 还有其他的 ...