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();
効果の画像は以下の通りです:
以下に実装プロセスを簡単に説明します:
- 基本的な考え方
 - ベースラインの計算
 - スクロールの実現方法
 - 具体的な描画
 - MDatePicker の実装
 - MDatePicker の設定
 - MDatePicker の使用
 
基本的な考え方#
日付ピッカーの最も基本的な要素は、自由にデータを設定できるホイールです。ここでは、日付と時間の選択コンテナとしてカスタム MPickerView を作成し、上下にスクロールして日付または時間を選択します。必要に応じて canvas を使用して描画し、日付でも時間でも MPickerView を使用してデータを表示します。最終的な日付ピッカーは MPickerView でラップし、Calendar を使用して日付時間データを組み立てます。この中で最も重要なのは MPickerView の実装です。
ベースラインの計算#
文字のベースライン(Baseline)は、文字描画の基準となる線であり、文字のベースラインを決定することで、文字を描画したい位置に正確に描画できます。したがって、文字の描画に関わる場合は必ずベースラインに従って描画する必要があります。文字を描画する際、その左端の原点はベースラインの左端にあり、y 軸方向は上が負、下が正です。具体的には以下の通りです:
最終的に選択された日付または時間は描画された View の中央に表示される必要があるため、コード内でどのように計算するのでしょうか?
 //ベースライン位置を取得
 Paint.FontMetricsInt metricsInt = paint.getFontMetricsInt();
 float line = mHeight / 2.0f + (metricsInt.bottom - metricsInt.top) / 2.0f - metricsInt.descent;
スクロールの実現方法#
MPickerView の中央位置には、指定されたデータの特定の位置を描画します。ここで描画される位置は常にデータサイズ size/2 を描画するデータのインデックスとして使用します:
public void setData(@NonNull List<String> data) {
    if (mData != null) {
        mData.clear();
        mData.addAll(data);
        //中心位置のインデックスを描画
        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 の描画は主にデータの描画であり、上、中、下の 3 つの位置のデータの描画に分けられます。上部は mSelectPosition の前のデータ、中間位置は mSelectPosition が指し示すデータ、下部は 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) {
        //年は4桁
        startX = mPaintSelect.measureText("0000") / 2 + x;
    } else {
        //他は2桁
        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);
}
上記のコードに関連する座標計算はすべてベースラインに関係しており、具体的なコード実装は文末の原文を参照してください。MPickerView の実装効果は以下の通りです:
MDatePicker の実装#
MDatePickerDialog の実装は非常に簡単で、カスタム 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をお願いします!