查看原文
其他

自定义View实现一个日期选择器

jzman 躬行之 2022-08-26

沉迷于不解决实际问题的努力永远是伪学习。

通过自定义 View 来实现一个时期时间选择器,可以放在底部也可以放在中间位置弹出,先来一张效果图:

MDatePickerDialog.gif

下面简述一下实现过程:

  1. 基本思路

  2. Baseline计算

  3. 如何实现滚动

  4. 具体绘制

  5. MDatePickerDoialog的实现

  6. MDatePickerDoialog的设置

  7. MDatePickerDoialog的使用

基本思路

日期选择器的一个最基本元素都是一个可以随意设置数据的一个滚轮,这里也是自定义一个 MPickerView 作为日期和时间的选择容器,通过上下滚动来完成日期或时间的选择,根据需求使用 canvas 进行绘制,不管是日期还是时间都使用 MPickerView 来展示数据,最终的日期选择器使用 MPickerView 进行封装,使用 Calendar 组装日期时间数据,这里面最重要的就是 MPickerView 的实现了。

Baseline计算

文字基准线(Baseline)是文字绘制所参考的基准线,确定了文字的基准线,才可以更确切地将文字绘制到想要绘制的位置,所以,如果涉及到文字的绘制一定要按照 Baseline 来进行绘制,绘制文字时其左边原点在 Baseline 的左端,y 轴方向向上为负,向下为正,具体如下:

MDataPickerBaseline.PNG

因为最终选中的日期或时间要显示在所绘制 View 的中间位置,那么,在代码中如何计算呢?

1 //获取Baseline位置
2 Paint.FontMetricsInt metricsInt = paint.getFontMetricsInt();
3 float line = mHeight / 2.0f + (metricsInt.bottom - metricsInt.top) / 2.0f - metricsInt.descent;

如何实现滚动

MPickerView 中间位置绘制给定的一组数据的某个位置,这里绘制的位置总是数据大小 size/2 作为要绘制的数据的 index:

1public void setData(@NonNull List<String> data) {
2    if (mData != null) {
3        mData.clear();
4        mData.addAll(data);
5        //绘制中心位置的index
6        mSelectPosition = data.size() / 2;
7    }
8}

那么如何实现滚动效果呢,每次手指滑动一定距离,向上滑动则将最顶部的数据移动到底部,反之,向上滑动则将最底部的数据移动到顶部,以次来模拟数据的滚动,关键代码如下:

1@Override
2public boolean onTouchEvent(MotionEvent event) {
3    switch (event.getAction()) {
4        case MotionEvent.ACTION_DOWN:
5            mStartTouchY = event.getY();
6            break;
7        case MotionEvent.ACTION_MOVE:
8            mMoveDistance += (event.getY() - mStartTouchY);
9            if (mMoveDistance > RATE * mTextSizeNormal / 2) {//向下滑动
10                moveTailToHead();
11                mMoveDistance = mMoveDistance - RATE * mTextSizeNormal;
12            } else if (mMoveDistance < -RATE * mTextSizeNormal / 2) {//向上滑动
13                moveHeadToTail();
14                mMoveDistance = mMoveDistance + RATE * mTextSizeNormal;
15            }
16            mStartTouchY = event.getY();
17            invalidate();
18            break;
19        case MotionEvent.ACTION_UP:
20            //...
21    }
22    return true;
23}

具体绘制

MPickerView 的绘制主要是显示数据的绘制,可以分为上、中、下三个位置的数据的绘制。上面部分就是 index 在 mSelectPosition 前面的数据,中间位置就是 mSelectPosition 所指向的数据,下面部分则是 index 在 mSelectPosition 后面的数据,关键代码如下:

1@Override
2protected void onDraw(Canvas canvas) {
3    super.onDraw(canvas);
4    //绘制中间位置
5    draw(canvas, 10, mPaintSelect);
6    //绘制上面数据
7    for (int i = 1; i < mSelectPosition - 1; i++) {
8        draw(canvas, -1, i, mPaintNormal);
9    }
10    //绘制下面数据
11    for (int i = 1; (mSelectPosition + i) < mData.size(); i++) {
12        draw(canvas, 1, i, mPaintNormal);
13    }
14    invalidate();
15}

下面来看一看 draw 方法的具体实现:

1private void draw(Canvas canvas, int type, int position, Paint paint) {
2    float space = RATE * mTextSizeNormal * position + type * mMoveDistance;
3    float scale = parabola(mHeight / 4.0f, space);
4    float size = (mTextSizeSelect - mTextSizeNormal) * scale + mTextSizeNormal;
5    int alpha = (int) ((mTextAlphaSelect - mTextAlphaNormal) * scale + mTextAlphaNormal);
6    paint.setTextSize(size);
7    paint.setAlpha(alpha);
8
9    float x = mWidth / 2.0f;
10    float y = mHeight / 2.0f + type * space;
11    Paint.FontMetricsInt fmi = paint.getFontMetricsInt();
12    float baseline = y + (fmi.bottom - fmi.top) / 2.0f - fmi.descent;
13    canvas.drawText(mData.get(mSelectPosition + type * position), x, baseline, paint);
14}

这样就完成了数据部分的绘制,此外就是一些额外效果的绘制,比如可以根据设计绘制分割线、绘制年、月、日、时、分等这些额外信息以及一些显示效果的调整,参考如下:

1//...
2if (position == 0) {
3    mPaintSelect.setTextSize(mTextSizeSelect);
4    float startX;
5
6    if (mData.get(mSelectPosition).length() == 4) {
7        //年份是四位数
8        startX = mPaintSelect.measureText("0000") / 2 + x;
9    } else {
10        //其他两位数
11        startX = mPaintSelect.measureText("00") / 2 + x;
12    }
13
14    //年、月、日、时、分绘制
15    Paint.FontMetricsInt anInt = mPaintText.getFontMetricsInt();
16    if (!TextUtils.isEmpty(mText))
17        canvas.drawText(mText, startX, mHeight / 2.0f + (anInt.bottom - anInt.top) / 2.0f - anInt.descent, mPaintText);
18    //分割线绘制
19    Paint.FontMetricsInt metricsInt = paint.getFontMetricsInt();
20    float line = mHeight / 2.0f + (metricsInt.bottom - metricsInt.top) / 2.0f - metricsInt.descent;
21    canvas.drawLine(0, line + metricsInt.ascent - 5, mWidth, line + metricsInt.ascent - 5, mPaintLine);
22    canvas.drawLine(0, line + metricsInt.descent + 5, mWidth, line + metricsInt.descent + 5, mPaintLine);
23    canvas.drawLine(0, dpToPx(mContext, 0.5f), mWidth, dpToPx(mContext, 0.5f), mPaintLine);
24    canvas.drawLine(0, mHeight - dpToPx(mContext, 0.5f), mWidth, mHeight - dpToPx(mContext, 0.5f), mPaintLine);
25}

上面代码相关坐标计算都与 Baseline 有关,具体代码实现参考文末阅读原文,MPickerView 实现效果如下:

MPickView.gif

MDatePickerDoialog的实现

MDatePickerDoialog 的实现非常简单就是自定义一个 Dialog,年、月、日、时、分等数据通过 Calendar 相关 API 获取对应数据,布局文件如下:

1<?xml version="1.0" encoding="utf-8"?>
2<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
3    xmlns:tools="http://schemas.android.com/tools"
4    android:layout_width="match_parent"
5    android:layout_height="wrap_content"
6    android:layout_gravity="center"
7    android:minWidth="300dp"
8    android:id="@+id/llDialog"
9    android:orientation="vertical">

10    <RelativeLayout
11        android:layout_width="match_parent"
12        android:layout_height="40dp">

13        <TextView
14            android:id="@+id/tvDialogTopCancel"
15            android:layout_width="wrap_content"
16            android:layout_height="wrap_content"
17            android:layout_centerVertical="true"
18            android:layout_marginStart="12dp"
19            android:text="@string/strDateCancel"
20            android:textColor="#cf1010"
21            android:textSize="15sp" />

22        <TextView
23            android:id="@+id/tvDialogTitle"
24            android:layout_width="wrap_content"
25            android:layout_height="wrap_content"
26            android:layout_centerInParent="true"
27            android:text="@string/strDateSelect"
28            android:textColor="#000000"
29            android:textSize="16sp" />

30        <TextView
31            android:id="@+id/tvDialogTopConfirm"
32            android:layout_width="wrap_content"
33            android:layout_height="wrap_content"
34            android:layout_alignParentEnd="true"
35            android:layout_centerVertical="true"
36            android:layout_marginEnd="12dp"
37            android:text="@string/strDateConfirm"
38            android:textColor="#cf1010"
39            android:textSize="15sp" />

40    </RelativeLayout>
41    <LinearLayout
42        android:layout_width="match_parent"
43        android:layout_height="wrap_content"
44        android:orientation="horizontal">

45        <com.manu.mdatepicker.MPickerView
46            android:id="@+id/mpvDialogYear"
47            android:layout_width="wrap_content"
48            android:layout_height="160dp"
49            android:layout_weight="1"
50            tools:ignore="RtlSymmetry" />

51        <com.manu.mdatepicker.MPickerView
52            android:id="@+id/mpvDialogMonth"
53            android:layout_width="0dp"
54            android:layout_height="160dp"
55            android:layout_weight="1" />

56        <com.manu.mdatepicker.MPickerView
57            android:id="@+id/mpvDialogDay"
58            android:layout_width="0dp"
59            android:layout_height="160dp"
60            android:layout_weight="1" />

61        <com.manu.mdatepicker.MPickerView
62            android:id="@+id/mpvDialogHour"
63            android:layout_width="0dp"
64            android:layout_height="160dp"
65            android:layout_weight="1" />

66        <com.manu.mdatepicker.MPickerView
67            android:id="@+id/mpvDialogMinute"
68            android:layout_width="0dp"
69            android:layout_height="160dp"
70            android:layout_weight="1" />

71    </LinearLayout>
72    <LinearLayout
73        android:id="@+id/llDialogBottom"
74        android:layout_width="match_parent"
75        android:layout_height="40dp"
76        android:orientation="horizontal">

77        <TextView
78            android:id="@+id/tvDialogBottomConfirm"
79            android:layout_width="0.0dp"
80            android:layout_height="match_parent"
81            android:layout_weight="1.0"
82            android:gravity="center"
83            android:text="@string/strDateConfirm"
84            android:textColor="#cf1010"
85            android:textSize="16sp" />

86        <View
87            android:layout_width="0.5dp"
88            android:layout_height="match_parent"
89            android:background="#dbdbdb" />

90        <TextView
91            android:id="@+id/tvDialogBottomCancel"
92            android:layout_width="0.0dp"
93            android:layout_height="match_parent"
94            android:layout_weight="1.0"
95            android:gravity="center"
96            android:text="@string/strDateCancel"
97            android:textColor="#cf1010"
98            android:textSize="16sp" />

99    </LinearLayout>
100</LinearLayout>

以上面的布局文件为基础封装一个可以在屏幕底部和中间位置弹出的 Dialog 即可,具体实现参考文末原文链接,来看一下使用 MDatePickerDoialog 可以设置那些功能,这里通过 Builder 的方式进行设置,部分代码如下:

1public static class Builder {
2    private Context mContext;
3    private String mTitle;
4    private int mGravity;
5    private boolean isCanceledTouchOutside;
6    private boolean isSupportTime;
7    private boolean isTwelveHour;
8    private float mConfirmTextSize;
9    private float mCancelTextSize;
10    private int mConfirmTextColor;
11    private int mCancelTextColor;
12    private OnDateResultListener mOnDateResultListener;
13
14    public Builder(Context mContext) {
15        this.mContext = mContext;
16    }
17
18    public Builder setTitle(String mTitle) {
19        this.mTitle = mTitle;
20        return this;
21    }
22
23    public Builder setGravity(int mGravity) {
24        this.mGravity = mGravity;
25        return this;
26    }
27
28    public Builder setCanceledTouchOutside(boolean canceledTouchOutside) {
29        isCanceledTouchOutside = canceledTouchOutside;
30        return this;
31    }
32
33    public Builder setSupportTime(boolean supportTime) {
34        isSupportTime = supportTime;
35        return this;
36    }
37
38    public Builder setTwelveHour(boolean twelveHour) {
39        isTwelveHour = twelveHour;
40        return this;
41    }
42
43    public Builder setConfirmStatus(float textSize, int textColor) {
44        this.mConfirmTextSize = textSize;
45        this.mConfirmTextColor = textColor;
46        return this;
47    }
48
49    public Builder setCancelStatus(float textSize, int textColor) {
50        this.mCancelTextSize = textSize;
51        this.mCancelTextColor = textColor;
52        return this;
53    }
54
55    public Builder setOnDateResultListener(OnDateResultListener onDateResultListener) {
56        this.mOnDateResultListener = onDateResultListener;
57        return this;
58    }
59
60    private void applyConfig(MDatePickerDialog dialog) {
61        if (this.mGravity == 0this.mGravity = Gravity.CENTER;
62        dialog.mContext = this.mContext;
63        dialog.mTitle = this.mTitle;
64        dialog.mGravity = this.mGravity;
65        dialog.isSupportTime = this.isSupportTime;
66        dialog.isTwelveHour = this.isTwelveHour;
67        dialog.mConfirmTextSize = this.mConfirmTextSize;
68        dialog.mConfirmTextColor = this.mConfirmTextColor;
69        dialog.mCancelTextSize = this.mCancelTextSize;
70        dialog.mCancelTextColor = this.mCancelTextColor;
71        dialog.isCanceledTouchOutside = this.isCanceledTouchOutside;
72        dialog.mOnDateResultListener = this.mOnDateResultListener;
73    }
74
75    public MDatePickerDialog build() {
76        MDatePickerDialog dialog = new MDatePickerDialog(mContext);
77        applyConfig(dialog);
78        return dialog;
79    }
80}

MDatePickerDoialog的设置

MDatePickerDialog 常用设置如下:

MDateDoc.PNG

MDatePickerDoialog的使用

MDatePickerDoialog 的使用非常简单,和普通的 Dialog 使用方式一致,当然下面是比较完整的设置

1public void btnClickDateBottom(View view) {
2    MDatePickerDialog dialog = new MDatePickerDialog.Builder(this)
3            //附加设置(非必须,有默认值)
4            .setCanceledTouchOutside(true)
5            .setGravity(Gravity.BOTTOM)
6            .setSupportTime(false)
7            .setTwelveHour(true)
8            .setCanceledTouchOutside(false)
9            //结果回调(必须)
10            .setOnDateResultListener(new MDatePickerDialog.OnDateResultListener() {
11                @Override
12                public void onDateResult(long date) {
13                    Calendar calendar = Calendar.getInstance();
14                    calendar.setTimeInMillis(date);
15                    SimpleDateFormat dateFormat = (SimpleDateFormat) SimpleDateFormat.getDateInstance();
16                    dateFormat.applyPattern("yyyy-MM-dd HH:mm");
17                    Toast.makeText(MainActivity.this, dateFormat.format(new Date(date)), Toast.LENGTH_SHORT).show();
18                }
19            })
20            .build();
21    dialog.show();
22}

具体细节参考如下链接或点击文末阅读原文,欢迎 star 一下!https://github.com/jzmanu/MDatePickerSample,可在公众号回复关键字【加群】邀你进Android交流群。

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存