PS:沉迷於不解決實際問題的努力永遠是偽學習。
最新更新 20210523。
- 【適配】切換到 AndroidX
- 【新增】設定字體大小
- 【新增】設定文字顏色
- 【優化】微調文字繪製位置
自定義 View 實現一個好用的 Android 日期時間選擇器,可以直接去Github 查看,其依賴方式如下:
- 在項目根目錄下的 build.gradle 文件中添加 jitpack 倉庫,如下:
allprojects {
repositories {
// ...
maven { url 'https://www.jitpack.io' }
}
}
- 在 app 下面的 build.gradle 文件中引入 MDatePicker,如下:
implementation 'com.github.jzmanu:MDatePickerSample:v1.0.6'
- 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();
效果圖如下:
下面簡述一下實現過程:
- 基本思路
- Baseline 計算
- 如何實現滾動
- 具體繪製
- MDatePicker 的實現
- MDatePicker 的設定
- MDatePicker 的使用
基本思路#
日期選擇器的一個最基本元素都是一個可以隨意設定數據的滾輪,這裡也是自定義一個 MPickerView 作為日期和時間的選擇容器,通過上下滾動來完成日期或時間的選擇,根據需求使用 canvas 進行繪製,不管是日期還是時間都使用 MPickerView 來展示數據,最終的日期選擇器使用 MPickerView 進行封裝,使用 Calendar 組裝日期時間數據,這裡面最重要的就是 MPickerView 的實現了。
Baseline 計算#
文字基準線(Baseline)是文字繪製所參考的基準線,確定了文字的基準線,才可以更確切地將文字繪製到想要繪製的位置,所以,如果涉及到文字的繪製一定要按照 Baseline 來進行繪製,繪製文字時其左邊原點在 Baseline 的左端,y 軸方向向上為負,向下為正,具體如下:
因為最終選中的日期或時間要顯示在所繪製 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 實現效果如下:
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 一下!