banner
jzman

jzman

Coding、思考、自觉。
github

手取り足取りAndroidの日付選択器を実現する方法

PS:実際の問題を解決しない努力に夢中になることは、永遠に偽の学びです。

最新の更新 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. ベースラインの計算
  3. スクロールの実現方法
  4. 具体的な描画
  5. MDatePicker の実装
  6. MDatePicker の設定
  7. MDatePicker の使用

基本的な考え方#

日付ピッカーの最も基本的な要素は、自由にデータを設定できるホイールです。ここでは、日付と時間の選択コンテナとしてカスタム MPickerView を作成し、上下にスクロールして日付または時間を選択します。必要に応じて canvas を使用して描画し、日付でも時間でも MPickerView を使用してデータを表示します。最終的な日付ピッカーは MPickerView でラップし、Calendar を使用して日付時間データを組み立てます。この中で最も重要なのは MPickerView の実装です。

ベースラインの計算#

文字のベースライン(Baseline)は、文字描画の基準となる線であり、文字のベースラインを決定することで、文字を描画したい位置に正確に描画できます。したがって、文字の描画に関わる場合は必ずベースラインに従って描画する必要があります。文字を描画する際、その左端の原点はベースラインの左端にあり、y 軸方向は上が負、下が正です。具体的には以下の通りです:

image

最終的に選択された日付または時間は描画された 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 の実装効果は以下の通りです:

MPickView.gif

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をお願いします!

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。