0%

自定义WheelView

儿童节快乐!!!

前言

公司项目中使用了时间选择器,原先使用的时间选择器控件无法满足我的需求,于是选择自己造(抄)个轮子。

国际惯例 - 效果展示(GIF可能耗时比较长,请耐心观看——23S)

效果图

接下来肯定是挂源码了:Github

正文

  1. 绘制目标View的大致草图
    草图.png

  2. 绘制自定义View

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    package com.yooking.tools.wheelview;

    import android.content.Context;
    import android.util.AttributeSet;
    import android.view.View;

    import androidx.annotation.Nullable;

    /**
    * 自定义WheelView
    * Created by yooking on 2020/6/1.
    * Copyright (c) 2020 yooking. All rights reserved.
    */
    public class WheelView extends View {
    public WheelView(Context context) {
    super(context);
    }

    public WheelView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    }

    public WheelView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    }
    }

    界面绘制有两种方式:

    • 根据用户定义的view宽高来确定每行数据的宽高
    • 根据用户定义的单行宽高来绘制view的宽高

    这里使用第二种方法 —— 因此需要重写onMeasure方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    private int measuredWidth;//View整体宽度
    private int measuredHeight;//View整体高度

    private float itemHeight = 50;//单项高度 - 默认50
    private int showNum = 5;//展示在界面中的项目数量 - 默认5

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    measuredWidth = getMeasuredWidth();
    if (measuredWidth != 0) {
    measuredHeight = (int) (itemHeight * showNum);
    setMeasuredDimension(measuredWidth, measuredHeight);
    }
    }

    绘制选中框 —— 重写omMeasure方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    private Paint linePaint;
    private int lineColor = getResources().getColor(R.color.color_line);
    private int lineWidth = 2;

    private float topLineY;//上线条高度
    private float bottomLineY;//下线条高度

    @Override
    protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //根据草图可知:绘制选区 选区可以用两条横线确定
    //这里先new 下画笔
    //onDraw会重复调用,画笔不需要重复new,且重复的new操作会造成内存抖动 - 严重可能造成内存溢出
    initPaint();
    drawLine(canvas);
    }

    private void initPaint() {
    if (linePaint == null) {
    linePaint = new Paint();
    linePaint.setColor(lineColor);//设置线条颜色
    linePaint.setStrokeWidth(lineWidth);//设置线宽
    }
    }

    private void drawLine(Canvas canvas) {
    //分析
    //计算线条所在位置 - 上线条为中央选中区域的最上方-线宽 下线条所在位置为中央选中区域的最下方
    //获取选中区域所在位置 - 如为奇数,选中区域为View正中心,如为偶数,选中区域为View中心上方
    int selectedIndex;//选中项
    // if (showNum % 2 == 1) {//取余法判断奇偶性
    // selectedIndex = showNum / 2;//计算选中项为第几项
    // } else {
    // selectedIndex = showNum / 2 - 1;
    // }
    //改写上述if else
    selectedIndex = showNum / 2 - 1 + showNum % 2;
    topLineY = defSelectedIndex * itemHeight - lineWidth / 2;//线条中间和选中框重叠
    bottomLineY = defSelectedIndex * itemHeight + itemHeight - lineWidth / 2;//线条中间和选中框重叠
    canvas.drawLine(0,topLineY,measuredWidth,topLineY,linePaint);//绘制上线条
    canvas.drawLine(0,bottomLineY,measuredWidth,bottomLineY,linePaint);//绘制上线条
    }

    引入布局文件中预览下效果:

    预览.png

    接下来就该绘制列表文字了:

    创建文本类:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    private class TextEntity {
    int position; //当前文字的行数
    String text; //文本内容
    //float y; //文本所在的y值
    public int getPosition() {
    return position;
    }

    public void setPosition(int position) {
    this.position = position;
    }

    public String getText() {
    return text;
    }

    public void setText(String text) {
    this.text = text;
    }

    public float getY() {//每个的原始y值和position相关
    return position * itemHeight + itemHeight / 2;
    }
    }

initPaint中增加文字画笔:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void initPaint() {
...
//绘制各区域文字
//同样先设置下画笔
//选中项和非选中项的色彩应该是不同的,所以这里要根据不同选项使用不同色彩的画笔
if (textPaintS == null) {//S = selected
textPaintS = new TextPaint();
textPaintS.setTextSize(selectedSize);
textPaintS.setColor(selectedColor);
}

if (textPaintU == null) {//U = unSelected
textPaintU = new TextPaint();
textPaintU.setTextSize(unSelectedSize);
textPaintU.setColor(unSelectedColor);
}
}

制造假数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
private List<TextEntity> textList;//展示用的数据
// ------------------------------假数据------------------------------ //
private void dataFalsification() {
if (textList == null) {
textList = new ArrayList<>();
}
for (int i = 0; i < 20; i++) {
TextEntity entity = new TextEntity();
entity.setPosition(i);
entity.setText("选项:" + i);
textList.add(entity);
}
}

绘制文本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private void drawTextList(Canvas canvas) {
if (textList == null) {
dataFalsification();
}
//遍历数据 并绘制
for (TextEntity item : textList) {
//根据选中状态使用不同的画笔
drawText(canvas, measuredWidth / 2, item, isSelected(item) ? textPaintS : textPaintU);
}
}

private void drawText(Canvas canvas, int centerX, TextEntity item, TextPaint textPaint) {
//文字度量
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
//得到基线的位置
float baselineY = item.getY() + move + (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom;

if (baselineY < 0 || baselineY > measuredHeight) {//当文字超出界面时不绘制
return;
}

float textWidth = textPaint.measureText(item.getText());//粗略计算文本宽度
//文字应该绘制在正中央
canvas.drawText(item.getText(), centerX - textWidth / 2, baselineY, textPaint);
}

private boolean isSelected(TextEntity item) {
//根据Y值是否在上线条和下线条之间可以确定是否为选中项目
float realY = item.getY() + move;
return realY > topLineY && realY < bottomLineY;
}

看看效果
预览2.png

效果还是基本符合预期的

  1. 为自定义View添加滚动事件(手势监听)
    重写onTouchEvent

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    private static final int MOVE_INERTIA = 10086;//惯性运动
    private static final int MOVE_ACTION = 10010;//匀速运动
    private static final int MOVE_BACK = -1;//回弹
    private float vInertia;//惯性滚动的移动速度
    private float vAction;//点击滚动的移动速度

    private float lastY = 0;//按下的位置
    //move 偏移量 上方已定义
    private float lastMove = 0;//记录手势抬起时的偏移量
    private float nowMove = 0;//本次移动的值
    private long lastDownTime = 0L;//上次按下的时间戳
    private static final float MIN_MOVE = 10;//最小偏移量 当偏移量小于这个值时视为点击事件

    @SuppressLint("ClickableViewAccessibility")
    @Override
    public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
    //按下事件
    nowMove = 0;//将本次移动距离置零
    if (handler != null) {//如果handler存在 按下时阻止继续滚动
    handler.removeMessages(MOVE_INERTIA);
    handler.removeMessages(MOVE_ACTION);
    handler.removeMessages(MOVE_INERTIA);
    }
    lastDownTime = new Date().getTime();
    lastY = event.getRawY();
    break;
    case MotionEvent.ACTION_MOVE:
    //移动事件
    nowMove = event.getRawY() - lastY;
    move = nowMove + lastMove;
    invalidate();//刷新布局
    break;
    case MotionEvent.ACTION_UP:
    long moveTime = new Date().getTime() - lastDownTime;//本次移动的时间间隔
    float lastAbsMove = Math.abs(nowMove);
    if (lastAbsMove <= MIN_MOVE) {//当移动距离小于最小移动距离,视为点击事件,跳转到目标位置
    int clickNum = (int) (event.getY() / itemHeight);
    int moveNum = clickNum - defSelectedIndex;//移动坐标数 每个坐标单位为itemHeight
    vAction = itemHeight * moveNum / 20;//100毫秒内走完 移动单位为5毫秒
    //匀速滚动
    handler.sendEmptyMessage(MOVE_ACTION);
    } else {
    vInertia = nowMove * 5 / moveTime;//单位 5毫秒 初速度
    //惯性滚动
    handler.sendEmptyMessage(MOVE_INERTIA);
    }
    break;
    }
    return event.getAction() == MotionEvent.ACTION_DOWN ||
    event.getAction() == MotionEvent.ACTION_MOVE ||
    event.getAction() == MotionEvent.ACTION_UP;
    }

    UI线程中添加Handler

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    private Handler handler = new Handler(Looper.getMainLooper()) {
    private float a = 1;//减速度 单位 5毫秒
    private int duration = 100;//actionMove时间为300毫秒

    @Override
    public void handleMessage(@NonNull Message msg) {
    super.handleMessage(msg);
    switch (msg.what) {
    case MOVE_INERTIA:
    inertiaMove();
    break;
    case MOVE_ACTION:
    actionMove();
    break;
    case MOVE_BACK:
    springBack();
    break;
    }
    }

    private void inertiaMove() {
    //惯性运动
    //设定阻力 匀减速运动 减速度 a
    vInertia = (Math.abs(vInertia) - a) * (vInertia > 0 ? 1 : -1);
    float absV = Math.abs(vInertia);
    if (absV >= a) {//当速度大于加速度时
    move += vInertia;
    lastMove = move;
    invalidate();
    handler.sendEmptyMessageDelayed(MOVE_INERTIA, 5);
    } else {//当惯性速度小于加速度时停止
    handler.sendEmptyMessageDelayed(MOVE_BACK, 5);
    }
    }

    private void actionMove() {
    //匀速运动
    move -= vAction;
    lastMove = move;
    invalidate();
    duration -= 5;
    if (duration > 0) {
    handler.sendEmptyMessageDelayed(MOVE_ACTION, 5);
    } else {
    duration = 100;
    handler.sendEmptyMessageDelayed(MOVE_BACK, 5);
    }
    }

    private void springBack() {
    //回弹
    //当文字不在选中项正中间时,移动到正中间
    float deviation;
    if (move > 0) {
    //计算偏差值
    deviation = move % itemHeight;
    if (2 * deviation <= itemHeight) //偏差值 小于 itemHeight的一半,复原 否则移动到下一项
    move -= deviation;
    else
    move = move - deviation + itemHeight;
    } else {
    deviation = -move % itemHeight;
    if (2 * deviation <= itemHeight) //偏差值 小于 itemHeight的一半,复原 否则移动到下一项
    move += deviation;
    else
    move = move + deviation - itemHeight;
    }

    //当选择器超出文字时,回弹
    //defSelectedIndex 默认选中位置 即选中区所在的位置
    float max = itemHeight * (textList.size() - 1 - defSelectedIndex);
    if (move > defSelectedIndex * itemHeight) move = defSelectedIndex * itemHeight;
    else if (-move > max) move = -max;

    lastMove = move;
    invalidate();
    }
    };
  2. attrs.xml中自定义参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <?xml version="1.0" encoding="utf-8"?>
    <resources>
    <declare-styleable name="WheelView">
    <!--基本信息-->
    <attr name="itemHeight" format="dimension" />
    <attr name="showNum" format="integer" />
    <!--选中线条相关-->
    <attr name="lineColor" format="color" />
    <attr name="lineWidth" format="dimension" />
    <!--选项分割线相关-->
    <attr name="isShowDivider" format="boolean" />
    <attr name="dividerColor" format="color" />
    <attr name="dividerWidth" format="dimension" />
    <!--选中项相关-->
    <attr name="selectedColor" format="color" />
    <attr name="selectedSize" format="dimension" />
    <!--非选相关-->
    <attr name="unSelectedColor" format="color" />
    <attr name="unSelectedSize" format="dimension" />
    </declare-styleable>
    </resources>

    自定义View中添加init(Context context, @Nullable AttributeSet attrs)方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    private void init(Context context, @Nullable AttributeSet attrs) {
    TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.WheelView);

    //基本信息
    itemHeight = typedArray.getDimension(R.styleable.WheelView_itemHeight, itemHeight);
    showNum = typedArray.getInteger(R.styleable.WheelView_showNum, showNum);

    //选中线条相关
    lineColor = typedArray.getColor(R.styleable.WheelView_lineColor, lineColor);
    lineWidth = typedArray.getDimension(R.styleable.WheelView_lineWidth, lineWidth);

    //分割线相关
    isShowDivider = typedArray.getBoolean(R.styleable.WheelView_isShowDivider, isShowDivider);
    dividerColor = typedArray.getColor(R.styleable.WheelView_dividerColor, dividerColor);
    dividerWidth = typedArray.getDimension(R.styleable.WheelView_dividerWidth, dividerWidth);

    //选中项相关
    selectedColor = typedArray.getColor(R.styleable.WheelView_selectedColor, selectedColor);
    selectedSize = typedArray.getDimension(R.styleable.WheelView_selectedSize, selectedSize);

    //非选相关
    unSelectedColor = typedArray.getColor(R.styleable.WheelView_unSelectedColor, unSelectedColor);
    unSelectedSize = typedArray.getDimension(R.styleable.WheelView_unSelectedSize, unSelectedSize);
    typedArray.recycle();
    }

    调用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public WheelView(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    init(context, attrs);
    }

    public WheelView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(context, attrs);
    }

    布局中使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <com.yooking.tools.wheelview.WheelView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/white"
    app:itemHeight="@dimen/dp_50"
    app:showNum="6"

    app:selectedSize="@dimen/sp_30"
    app:selectedColor="#e24223"

    app:unSelectedSize="@dimen/sp_25"
    app:unSelectedColor="#999999"

    app:isShowDivider="false"
    app:dividerColor="#0000ff"
    app:dividerWidth="@dimen/dp_1"

    app:lineWidth="@dimen/dp_1"
    app:lineColor="@color/color_red_e24234"
    />
    <!-- isShowDivider 丑,慎用-->
  3. 使用代码控制布局

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    private void initDefault() {//initDefault在实例化过程中加载
    defSelectedIndex = showNum / 2 - 1 + showNum % 2;
    index = defSelectedIndex;
    }

    public WheelView<T> setItemHeight(float itemHeight) {
    this.itemHeight = dp2px(itemHeight);
    measuredHeight = (int) (itemHeight * showNum);
    return this;
    }

    public WheelView<T> setShowNum(int showNum) {
    this.showNum = showNum;
    measuredHeight = (int) (itemHeight * showNum);
    initDefault();
    return this;
    }

    public WheelView<T> setLineColor(@ColorInt int lineColor) {
    this.lineColor = lineColor;
    return this;
    }

    public WheelView<T> setLineWidth(float lineWidth) {
    this.lineWidth = dp2px(lineWidth);
    return this;
    }

    public WheelView<T> setShowDivider(boolean showDivider) {
    isShowDivider = showDivider;
    return this;
    }

    public WheelView<T> setDividerColor(@ColorInt int dividerColor) {
    this.dividerColor = dividerColor;
    return this;
    }

    public WheelView<T> setDividerWidth(float dividerWidth) {
    this.dividerWidth = dp2px(dividerWidth);
    return this;
    }

    public WheelView<T> setSelectedColor(@ColorInt int selectedColor) {
    this.selectedColor = selectedColor;
    return this;
    }

    public WheelView<T> setSelectedSize(float selectedSize) {
    this.selectedSize = sp2px(selectedSize);
    return this;
    }

    public WheelView<T> setUnSelectedColor(@ColorInt int unSelectedColor) {
    this.unSelectedColor = unSelectedColor;
    return this;
    }

    public WheelView<T> setUnSelectedSize(float unSelectedSize) {
    this.unSelectedSize = sp2px(unSelectedSize);
    return this;
    }

    /**
    * 插入数据
    *
    * @param data 数据
    */
    public WheelView<T> setData(List<T> data) {
    this.data = data;
    if (textList == null) {
    textList = new ArrayList<>();
    } else {
    textList.clear();
    }
    for (int i = 0; i < data.size(); i++) {
    TextEntity entity = new TextEntity();
    entity.setPosition(i);
    entity.setText(data.get(i).getName());
    textList.add(entity);
    }
    return this;
    }

    /**
    * 移动指针
    *
    * @param position 选中项
    */
    public void change2Index(int position) {
    //由于 defSelectedIndex 在界面未加载完成时为0,因此将defSelectedIndex提到initDefault中
    this.index = position;//index为当前选中项
    move = (defSelectedIndex - position) * itemHeight;
    lastMove = move;
    }

    /**
    * 滚动到指定位置
    *
    * @param position 选中项
    */
    public void scroll2Index(int position) {
    int moveNum = position - defSelectedIndex;//移动坐标数 每个坐标单位为itemHeight

    vAction = itemHeight * moveNum / 20;//100毫秒内走完 移动单位为5毫秒
    handler.sendEmptyMessage(MOVE_ACTION);
    }

    其中T继承于BaseEntity

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    package com.yooking.tools.wheelview;

    /**
    * WheelView数据类
    * Created by yooking on 2020/6/2.
    * Copyright (c) 2020 yooking. All rights reserved.
    */
    public class BaseEntity {

    private String id;
    private String name;

    public String getId() {
    return id;
    }

    public void setId(String id) {
    this.id = id;
    }

    public String getName() {
    return name;
    }

    public void setName(String name) {
    this.name = name;
    }
    }
  4. 回调监听

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    private int index;//当前选中项

    private OnScrollingListener onScrollingListener;

    /**
    * @param onScrollingListener 监听滚动事件
    */
    public WheelView<T> setOnScrollingListener(OnScrollingListener onScrollingListener) {
    this.onScrollingListener = onScrollingListener;
    return this;
    }

    private OnSelectedListener<T> onSelectedListener;

    /**
    * @param onSelectedListener 监听选中事件
    */
    public WheelView<T> setOnSelectedListener(OnSelectedListener<T> onSelectedListener) {
    this.onSelectedListener = onSelectedListener;
    return this;
    }

    public interface OnScrollingListener {
    void onScrolling();
    }

    public interface OnSelectedListener<T> {
    void onSelected(int index, T data);
    }

    /**
    * 根据传入值获取目标项目
    *
    * @param index 传入的值
    */
    public T getData(int index) {
    if (data != null) {
    return data.get(index);
    }
    return null;
    }

    /**
    * 获取当前选中的项目
    */
    public T getSelectedData() {
    return getData(index);
    }
  5. 代码中调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    WheelView<BaseEntity> wheelView = findViewById(R.id.wv_test);
    List<BaseEntity> entityList = new ArrayList<>();
    for (int i = 0; i < 20; i++) {
    BaseEntity entity = new BaseEntity();
    entity.setId("");
    entity.setName("hhhhhh" + i);
    entityList.add(entity);
    }
    wheelView.setData(entityList)
    .setItemHeight(30)
    .setShowNum(8)
    .setLineColor(getResources().getColor(R.color.color_red_e24234))
    .setLineWidth(1)
    .setShowDivider(true)
    .setDividerColor(getResources().getColor(R.color.color_line))
    .setDividerWidth(1)
    .setSelectedColor(getResources().getColor(R.color.text_3))
    .setSelectedSize(30)
    .setUnSelectedColor(getResources().getColor(R.color.text_9))
    .setUnSelectedSize(28)
    .change2Index(6);

    wheelView.setOnScrollingListener(() -> Log.i("Test", "onScrolling: "))
    .setOnSelectedListener((index, data) ->
    Log.i("Test", "onSelected: " + index + "data:" + data.getName())
    );

    Log.i("Test", "onCreate: " + wheelView.getSelectedData().getName());
------------本文结束感谢您的阅读------------

Thank you for your accept!