手把手教你实现一个Android日期选择器

5,638 阅读4分钟

个人微信公众号躬行之。

最新更新 20210523。

  • 【适配】切换到AndroidX
  • 【新增】设置字体大小
  • 【新增】设置文字颜色
  • 【优化】微调文字绘制位置

自定义 View 实现一个好用的 Android 日期时间选择器,可以直接去Github查看,其依赖方式如下:

  1. 在项目根目录下的 build.gradle 文件中添加 jitpack 仓库,如下:
allprojects {
	repositories {
		// ...
		maven { url 'https://www.jitpack.io' }
	}
}
  1. 在 app 下面的 build.gradle 文件中引入 MDatePicker,如下:
implementation 'com.github.jzmanu:MDatePickerSample:v1.0.6'
  1. MDatePicker 的使用和普通的 Dialog 一样,参考如下:
MDatePicker.create(this)
    //附加设置(非必须,有默认值)
    .setCanceledTouchOutside(true)
    .setGravity(Gravity.BOTTOM)
    .setSupportTime(false)
    .setTwelveHour(true)
    //结果回调(必须)
    .setOnDateResultListener(new MDatePickerDialog.OnDateResultListener() {
        @Override
        public void onDateResult(long date) {
            // date
        }
    })
    .build()
    .show();

效果图如下:

MDatePickerDialog.gif

下面简述一下实现过程:

  1. 基本思路
  2. Baseline计算
  3. 如何实现滚动
  4. 具体绘制
  5. MDatePicker的实现
  6. MDatePicker的设置
  7. MDatePicker的使用

基本思路

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

Baseline计算

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

MDataPickerBaseline.PNG

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

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

如何实现滚动

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

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

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

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            mStartTouchY = event.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            mMoveDistance += (event.getY() - mStartTouchY);
            if (mMoveDistance > RATE * mTextSizeNormal / 2) {//向下滑动
                moveTailToHead();
                mMoveDistance = mMoveDistance - RATE * mTextSizeNormal;
            } else if (mMoveDistance < -RATE * mTextSizeNormal / 2) {//向上滑动
                moveHeadToTail();
                mMoveDistance = mMoveDistance + RATE * mTextSizeNormal;
            }
            mStartTouchY = event.getY();
            invalidate();
            break;
        case MotionEvent.ACTION_UP:
            //...
    }
    return true;
}

具体绘制

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

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    //绘制中间位置
    draw(canvas, 1, 0, mPaintSelect);
    //绘制上面数据
    for (int i = 1; i < mSelectPosition - 1; i++) {
        draw(canvas, -1, i, mPaintNormal);
    }
    //绘制下面数据
    for (int i = 1; (mSelectPosition + i) < mData.size(); i++) {
        draw(canvas, 1, i, mPaintNormal);
    }
    invalidate();
}

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

private void draw(Canvas canvas, int type, int position, Paint paint) {
    float space = RATE * mTextSizeNormal * position + type * mMoveDistance;
    float scale = parabola(mHeight / 4.0f, space);
    float size = (mTextSizeSelect - mTextSizeNormal) * scale + mTextSizeNormal;
    int alpha = (int) ((mTextAlphaSelect - mTextAlphaNormal) * scale + mTextAlphaNormal);
    paint.setTextSize(size);
    paint.setAlpha(alpha);

    float x = mWidth / 2.0f;
    float y = mHeight / 2.0f + type * space;
    Paint.FontMetricsInt fmi = paint.getFontMetricsInt();
    float baseline = y + (fmi.bottom - fmi.top) / 2.0f - fmi.descent;
    canvas.drawText(mData.get(mSelectPosition + type * position), x, baseline, paint);
}

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

//...
if (position == 0) {
    mPaintSelect.setTextSize(mTextSizeSelect);
    float startX;
    
    if (mData.get(mSelectPosition).length() == 4) {
        //年份是四位数
        startX = mPaintSelect.measureText("0000") / 2 + x;
    } else {
        //其他两位数
        startX = mPaintSelect.measureText("00") / 2 + x;
    }

    //年、月、日、时、分绘制
    Paint.FontMetricsInt anInt = mPaintText.getFontMetricsInt();
    if (!TextUtils.isEmpty(mText))
        canvas.drawText(mText, startX, mHeight / 2.0f + (anInt.bottom - anInt.top) / 2.0f - anInt.descent, mPaintText);
    //分割线绘制
    Paint.FontMetricsInt metricsInt = paint.getFontMetricsInt();
    float line = mHeight / 2.0f + (metricsInt.bottom - metricsInt.top) / 2.0f - metricsInt.descent;
    canvas.drawLine(0, line + metricsInt.ascent - 5, mWidth, line + metricsInt.ascent - 5, mPaintLine);
    canvas.drawLine(0, line + metricsInt.descent + 5, mWidth, line + metricsInt.descent + 5, mPaintLine);
    canvas.drawLine(0, dpToPx(mContext, 0.5f), mWidth, dpToPx(mContext, 0.5f), mPaintLine);
    canvas.drawLine(0, mHeight - dpToPx(mContext, 0.5f), mWidth, mHeight - dpToPx(mContext, 0.5f), mPaintLine);
}

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

MPickView.gif

MDatePicker的实现

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

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:minWidth="300dp"
    android:id="@+id/llDialog"
    android:orientation="vertical">
    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="40dp">
        <TextView
            android:id="@+id/tvDialogTopCancel"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_marginStart="12dp"
            android:text="@string/strDateCancel"
            android:textColor="#cf1010"
            android:textSize="15sp" />
        <TextView
            android:id="@+id/tvDialogTitle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:text="@string/strDateSelect"
            android:textColor="#000000"
            android:textSize="16sp" />
        <TextView
            android:id="@+id/tvDialogTopConfirm"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentEnd="true"
            android:layout_centerVertical="true"
            android:layout_marginEnd="12dp"
            android:text="@string/strDateConfirm"
            android:textColor="#cf1010"
            android:textSize="15sp" />
    </RelativeLayout>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <com.manu.mdatepicker.MPickerView
            android:id="@+id/mpvDialogYear"
            android:layout_width="wrap_content"
            android:layout_height="160dp"
            android:layout_weight="1"
            tools:ignore="RtlSymmetry" />
        <com.manu.mdatepicker.MPickerView
            android:id="@+id/mpvDialogMonth"
            android:layout_width="0dp"
            android:layout_height="160dp"
            android:layout_weight="1" />
        <com.manu.mdatepicker.MPickerView
            android:id="@+id/mpvDialogDay"
            android:layout_width="0dp"
            android:layout_height="160dp"
            android:layout_weight="1" />
        <com.manu.mdatepicker.MPickerView
            android:id="@+id/mpvDialogHour"
            android:layout_width="0dp"
            android:layout_height="160dp"
            android:layout_weight="1" />
        <com.manu.mdatepicker.MPickerView
            android:id="@+id/mpvDialogMinute"
            android:layout_width="0dp"
            android:layout_height="160dp"
            android:layout_weight="1" />
    </LinearLayout>
    <LinearLayout
        android:id="@+id/llDialogBottom"
        android:layout_width="match_parent"
        android:layout_height="40dp"
        android:orientation="horizontal">
        <TextView
            android:id="@+id/tvDialogBottomConfirm"
            android:layout_width="0.0dp"
            android:layout_height="match_parent"
            android:layout_weight="1.0"
            android:gravity="center"
            android:text="@string/strDateConfirm"
            android:textColor="#cf1010"
            android:textSize="16sp" />
        <View
            android:layout_width="0.5dp"
            android:layout_height="match_parent"
            android:background="#dbdbdb" />
        <TextView
            android:id="@+id/tvDialogBottomCancel"
            android:layout_width="0.0dp"
            android:layout_height="match_parent"
            android:layout_weight="1.0"
            android:gravity="center"
            android:text="@string/strDateCancel"
            android:textColor="#cf1010"
            android:textSize="16sp" />
    </LinearLayout>
</LinearLayout>

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

public static class Builder {
    private Context mContext;
    private String mTitle;
    private int mGravity;
    private boolean isCanceledTouchOutside;
    private boolean isSupportTime;
    private boolean isTwelveHour;
    private float mConfirmTextSize;
    private float mCancelTextSize;
    private int mConfirmTextColor;
    private int mCancelTextColor;
    private OnDateResultListener mOnDateResultListener;

    public Builder(Context mContext) {
        this.mContext = mContext;
    }

    public Builder setTitle(String mTitle) {
        this.mTitle = mTitle;
        return this;
    }

    public Builder setGravity(int mGravity) {
        this.mGravity = mGravity;
        return this;
    }

    public Builder setCanceledTouchOutside(boolean canceledTouchOutside) {
        isCanceledTouchOutside = canceledTouchOutside;
        return this;
    }

    public Builder setSupportTime(boolean supportTime) {
        isSupportTime = supportTime;
        return this;
    }

    public Builder setTwelveHour(boolean twelveHour) {
        isTwelveHour = twelveHour;
        return this;
    }

    public Builder setConfirmStatus(float textSize, int textColor) {
        this.mConfirmTextSize = textSize;
        this.mConfirmTextColor = textColor;
        return this;
    }

    public Builder setCancelStatus(float textSize, int textColor) {
        this.mCancelTextSize = textSize;
        this.mCancelTextColor = textColor;
        return this;
    }

    public Builder setOnDateResultListener(OnDateResultListener onDateResultListener) {
        this.mOnDateResultListener = onDateResultListener;
        return this;
    }

    private void applyConfig(MDatePicker dialog) {
        if (this.mGravity == 0) this.mGravity = Gravity.CENTER;
        dialog.mContext = this.mContext;
        dialog.mTitle = this.mTitle;
        dialog.mGravity = this.mGravity;
        dialog.isSupportTime = this.isSupportTime;
        dialog.isTwelveHour = this.isTwelveHour;
        dialog.mConfirmTextSize = this.mConfirmTextSize;
        dialog.mConfirmTextColor = this.mConfirmTextColor;
        dialog.mCancelTextSize = this.mCancelTextSize;
        dialog.mCancelTextColor = this.mCancelTextColor;
        dialog.isCanceledTouchOutside = this.isCanceledTouchOutside;
        dialog.mOnDateResultListener = this.mOnDateResultListener;
    }

    public MDatePicker build() {
        MDatePicker dialog = new MDatePicker(mContext);
        applyConfig(dialog);
        return dialog;
    }
}

MDatePicker的设置

MDatePicker 基本属性如下:

设置设置方法默认值
标题setTitle(String mTitle)日期选择
显示位置setGravity(int mGravity)Gravity.CENTER
时候支持点击外部区域取消setCanceledTouchOutside(boolean canceledTouchOutside)false
是否支持时间setSupportTime(boolean supportTime)false
是否支持12小时制setTwelveHour(boolean twelveHour)false
是否仅显示年月setOnlyYearMonth(boolean onlyYearMonth)false
设置年份默认值setYearValue(int yearValue)当前年份
设置月份默认值setMonthValue(int monthValue)当前月份
设置天默认值setDayValue(int dayValue)当前天数

MDatePicker的使用

MDatePicker 的使用非常简单,如下:

MDatePicker.create(this)
    //附加设置(非必须,有默认值)
    .setCanceledTouchOutside(true)
    .setGravity(Gravity.BOTTOM)
    .setSupportTime(false)
    .setTwelveHour(true)
    //结果回调(必须)
    .setOnDateResultListener(new MDatePickerDialog.OnDateResultListener() {
        @Override
        public void onDateResult(long date) {
            // date
        }
    })
    .build()
    .show();

具体细节参考如下链接或点击文末阅读原文,欢迎 star 一下!

更多内容见微信公众号 躬行之

1.png