PS: Being obsessed with efforts that do not solve practical problems is always pseudo-learning.
Latest update 20210523.
- [Adaptation] Switched to AndroidX
- [New] Set font size
- [New] Set text color
- [Optimization] Fine-tune text drawing position
A custom View implements a user-friendly Android date and time picker, which can be viewed directly on Github, with the following dependency method:
- Add the jitpack repository in the build.gradle file at the root directory of the project, as follows:
allprojects {
repositories {
// ...
maven { url 'https://www.jitpack.io' }
}
}
- Introduce MDatePicker in the build.gradle file under app, as follows:
implementation 'com.github.jzmanu:MDatePickerSample:v1.0.6'
- The usage of MDatePicker is the same as a regular Dialog, refer to the following:
MDatePicker.create(this)
// Additional settings (optional, with default values)
.setCanceledTouchOutside(true)
.setGravity(Gravity.BOTTOM)
.setSupportTime(false)
.setTwelveHour(true)
// Result callback (mandatory)
.setOnDateResultListener(new MDatePickerDialog.OnDateResultListener() {
@Override
public void onDateResult(long date) {
// date
}
})
.build()
.show();
The effect is as follows:
Below is a brief description of the implementation process:
- Basic idea
- Baseline calculation
- How to achieve scrolling
- Specific drawing
- Implementation of MDatePicker
- Settings of MDatePicker
- Usage of MDatePicker
Basic Idea#
A fundamental element of a date picker is a wheel that can be freely set with data. Here, a custom MPickerView is used as a container for selecting dates and times, allowing selection through up and down scrolling. Canvas is used for drawing as needed, and both dates and times are displayed using MPickerView. The final date picker is encapsulated using MPickerView, with Calendar assembling date and time data. The most important part is the implementation of MPickerView.
Baseline Calculation#
The baseline of text is the reference line for text drawing. Once the baseline of the text is determined, the text can be accurately drawn at the desired position. Therefore, when drawing text, it must be done according to the baseline. When drawing text, its left origin is at the left end of the baseline, with the y-axis direction upwards being negative and downwards being positive, as follows:
Since the selected date or time needs to be displayed in the center of the drawn View, how is this calculated in the code?
// Get Baseline position
Paint.FontMetricsInt metricsInt = paint.getFontMetricsInt();
float line = mHeight / 2.0f + (metricsInt.bottom - metricsInt.top) / 2.0f - metricsInt.descent;
How to Achieve Scrolling#
MPickerView draws a given set of data at a certain position in the middle. The position drawn is always the index of the data size size/2:
public void setData(@NonNull List<String> data) {
if (mData != null) {
mData.clear();
mData.addAll(data);
// Index of the center position
mSelectPosition = data.size() / 2;
}
}
So how to achieve the scrolling effect? Each time the finger slides a certain distance, if sliding up, the topmost data moves to the bottom; conversely, if sliding down, the bottommost data moves to the top, simulating data scrolling. The key code is as follows:
@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) {// Sliding down
moveTailToHead();
mMoveDistance = mMoveDistance - RATE * mTextSizeNormal;
} else if (mMoveDistance < -RATE * mTextSizeNormal / 2) {// Sliding up
moveHeadToTail();
mMoveDistance = mMoveDistance + RATE * mTextSizeNormal;
}
mStartTouchY = event.getY();
invalidate();
break;
case MotionEvent.ACTION_UP:
//...
}
return true;
}
Specific Drawing#
The drawing of MPickerView mainly involves displaying the data, which can be divided into three positions: above, middle, and below. The upper part consists of data before the index at mSelectPosition, the middle position is the data pointed to by mSelectPosition, and the lower part consists of data after the index at mSelectPosition. The key code is as follows:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Draw middle position
draw(canvas, 1, 0, mPaintSelect);
// Draw upper data
for (int i = 1; i < mSelectPosition - 1; i++) {
draw(canvas, -1, i, mPaintNormal);
}
// Draw lower data
for (int i = 1; (mSelectPosition + i) < mData.size(); i++) {
draw(canvas, 1, i, mPaintNormal);
}
invalidate();
}
Now let's look at the specific implementation of the draw method:
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);
}
This completes the drawing of the data part. Additionally, there are some extra effects to draw, such as drawing dividing lines, years, months, days, hours, minutes, and adjusting some display effects, as follows:
//...
if (position == 0) {
mPaintSelect.setTextSize(mTextSizeSelect);
float startX;
if (mData.get(mSelectPosition).length() == 4) {
// Year is four digits
startX = mPaintSelect.measureText("0000") / 2 + x;
} else {
// Other two digits
startX = mPaintSelect.measureText("00") / 2 + x;
}
// Drawing year, month, day, hour, minute
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);
// Drawing dividing lines
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);
}
The coordinate calculations in the above code are related to the baseline. For specific code implementation, refer to the original text at the end. The effect of the MPickerView implementation is as follows:
Implementation of MDatePicker#
The implementation of MDatePickerDialog is very simple; it is a custom Dialog. The year, month, day, hour, minute, and other data are obtained through the Calendar-related API. The layout file is as follows:
<?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>
Based on the above layout file, encapsulate a Dialog that can pop up at the bottom and center of the screen. For specific implementation, refer to the original text link at the end. Let's see what functions can be set using MDatePicker. Here, settings are done through the Builder method, with some code as follows:
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;
}
}
Settings of MDatePicker#
The basic properties of MDatePicker are as follows:
Setting | Method | Default Value |
---|---|---|
Title | setTitle(String mTitle) | Date Selection |
Display Position | setGravity(int mGravity) | Gravity.CENTER |
Support for clicking outside to cancel | setCanceledTouchOutside(boolean canceledTouchOutside) | false |
Support for time | setSupportTime(boolean supportTime) | false |
Support for 12-hour format | setTwelveHour(boolean twelveHour) | false |
Only display year and month | setOnlyYearMonth(boolean onlyYearMonth) | false |
Set default year value | setYearValue(int yearValue) | Current year |
Set default month value | setMonthValue(int monthValue) | Current month |
Set default day value | setDayValue(int dayValue) | Current day |
Usage of MDatePicker#
Using MDatePicker is very simple, as follows:
MDatePicker.create(this)
// Additional settings (optional, with default values)
.setCanceledTouchOutside(true)
.setGravity(Gravity.BOTTOM)
.setSupportTime(false)
.setTwelveHour(true)
// Result callback (mandatory)
.setOnDateResultListener(new MDatePickerDialog.OnDateResultListener() {
@Override
public void onDateResult(long date) {
// date
}
})
.build()
.show();
For specific details, refer to the following link or click the original text at the end. Feel free to star it!