[Android] 刻む「分」が指定できる拡張TimePickerを作る【解析編】

2013.11.02

TimePicker

Androidで時間指定をする時には、TimePickerを多く利用するかと思います。
device-2013-10-29-225900

簡単に時間の選択が出来て便利です。4系〜では、iOSのようなドラムのようなIFになっているので直感的で分かりやすいですね!

ちなみに2.3系以下だとこのような表示になります。うーん、イケてない・・・
device-2013-11-02-164300

困ったこと

先ほどのスクリーンショットを見れば分かるように、「分」の指定が1分刻みです。
これを「5分」「15分」のような刻みの選択肢にしたい場合どうしたら良いでしょうか?Androidでは標準でそのようなComponentは存在しないので、簡単にはいかないようです。
iOSでは、UIDatePickerというクラスを利用すれば、すぐに実現できるようです。このままではAndroidは負けてしまいます

出来ないならば作りましょう。自分で。
AndroidのComponentの実装情報は、オープンに公開されているので、調べれば何とかなりそうです

すぐに利用したい人はこちら

まずは、結論
とりあえずの要件として、最低限の機能を実装しました。すぐに利用したい方はgithubから、ソースコードをcloneしてプロジェクトに組み込めばすぐに利用できます。

com4dc / AndroidComponentLibrary

仰々しい名前がついてますが、まだ大したものはありません。多分今後増えます。ご自由にご利用ください

使い方

事前準備

  1. ライブラリプロジェクトとして作成しているので、そのままWorkspaceに配置します
  2. 対象のプロジェクトにライブラリプロジェクトして読み込む
    スクリーンショット 2013-10-29 23.14.05

実装コード

XML Layoutで利用する場合はこのように記述してください

<jp.classmethod.android.componentlibrary.widget.UITimePicker
	xmlns:app="http://schemas.android.com/apk/res-auto"
	android:id="@+id/uITimePicker1"
	app:unit="5"
	android:layout_width="wrap_content"
	android:layout_height="wrap_content"
	android:layout_marginLeft="10dip"
	/>

2行目で【ネームスペースを宣言】し4行目の【app:unit】で選択肢の刻む単位「分」を指定します。
この場合は【5分毎】の選択肢になります。
こちらの値は「60が割り切れる値」かつ「30以内」という制限がかかります。これに違反した場合は、例外がThrowされますのでご注意ください

動作を定義する場合は、Javaのコード内で下記のように記述します

UITimePicker tp = (UITimePicker)findViewById(R.id.uITimePicker1);
tp.setIs24HourView(true);
tp.setOnTimeChangedListener(new OnTimeChangedListener() {
	
	@Override
	public void onTimeChanged(TimePicker view, int hourOfDay, int minute) {
		String text = hourOfDay + ":" + minute;
		Toast.makeText(MainActivity.this, text, Toast.LENGTH_SHORT).show();
	}
});

出力結果

device-2013-10-29-232036

5分毎の選択肢になりました。TimePickerクラスを拡張しているので、使い方は通常のTimePickerクラスと変わりません。

以下、既存のTimePickerの実装解読

まずは実装の方針を立てるために、TimePickerがどのような実装になっているかを確認します。
よく知られていることですが、API Level 10前後でTimePicker、NumberPickerの実装が大きく異なるため、最低でも2種類の実装を確認する必要があります

TimePickerの実装を確認する(API Level 18)

TimePickerの構造は、「時」「分」の選択肢は主にNumberPickerによって構成されています
device-2013-10-29-230036

選択肢を変更したいので、ここの「分」の選択を行うNumberPickerに注目すれば良いようです。SDK内のソースを確認してみます

TimePicker(Android API Level 18)

まずはTimePickerから確認しましょう。API Level 18のソースコードはこちらを参考にします
{SDK_HOME}/sources/android-18/android/widget/TimePicker.java

まずは、「分」の選択肢を構成するUI Componentの宣言を探します

// ui components
private final NumberPicker mHourSpinner;

private final NumberPicker mMinuteSpinner;

ありました。時、分共にNumberPickerのPrivate変数を持っています。
どこで選択肢を設定しているかを確認してみます

// minute
mMinuteSpinner = (NumberPicker) findViewById(R.id.minute);
mMinuteSpinner.setMinValue(0);
mMinuteSpinner.setMaxValue(59);
mMinuteSpinner.setOnLongPressUpdateInterval(100);
mMinuteSpinner.setFormatter(NumberPicker.getTwoDigitFormatter());
mMinuteSpinner.setOnValueChangedListener(new NumberPicker.OnValueChangeListener() {
    public void onValueChange(NumberPicker spinner, int oldVal, int newVal) {
        updateInputState();
        int minValue = mMinuteSpinner.getMinValue();
        int maxValue = mMinuteSpinner.getMaxValue();
        if (oldVal == maxValue && newVal == minValue) {
            int newHour = mHourSpinner.getValue() + 1;
            if (!is24HourView() && newHour == HOURS_IN_HALF_DAY) {
                mIsAm = !mIsAm;
                updateAmPmControl();
            }
            mHourSpinner.setValue(newHour);
        } else if (oldVal == minValue && newVal == maxValue) {
            int newHour = mHourSpinner.getValue() - 1;
            if (!is24HourView() && newHour == HOURS_IN_HALF_DAY - 1) {
                mIsAm = !mIsAm;
                updateAmPmControl();
            }
            mHourSpinner.setValue(newHour);
        }
        onTimeChanged();
    }
});
mMinuteSpinnerInput = (EditText) mMinuteSpinner.findViewById(R.id.numberpicker_input);
mMinuteSpinnerInput.setImeOptions(EditorInfo.IME_ACTION_NEXT);

172、173行目で数値の範囲を指定しています。
ここで指定した0〜59が選択肢になっているようです。他にValuesを設定している箇所がないようなので、ここの最小値、最大値から自動的に選択肢を生成しているようです。ここの選択肢を変更してやる必要があるのでチェックしておきます

続いて、NumberPickerクラスの実装を覗いてみます
API Level 18のソースコードはこちらを参考にします

{SDK_HOME}/sources/android-18/android/widget/NumberPicker.java

選択肢を設定するメソッドを探します

/**
 * Sets the values to be displayed.
 *
 * @param displayedValues The displayed values.
 *
 * <strong>Note:</strong> The length of the displayed values array
 * must be equal to the range of selectable numbers which is equal to
 * {@link #getMaxValue()} - {@link #getMinValue()} + 1.
 */
public void setDisplayedValues(String[] displayedValues) {
    if (mDisplayedValues == displayedValues) {
        return;
    }
    mDisplayedValues = displayedValues;
    if (mDisplayedValues != null) {
        // Allow text entry rather than strictly numeric entry.
        mInputText.setRawInputType(InputType.TYPE_CLASS_TEXT
                | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
    } else {
        mInputText.setRawInputType(InputType.TYPE_CLASS_NUMBER);
    }
    updateInputTextView();
    initializeSelectorWheelIndices();
    tryComputeMaxWidth();
}

見つけました。このメソッドを叩いてあげれば選択肢を変更できるようです

TimePickerの実装を確認する(API Level 10)

API Level 10以前の実装を確認します。
今回はAPI Level 10(Android 2.3.3相当)のソースコードを参考に、実装を確認します

TimePicker(Android API Level 10)

ソースコードはこちらを参考にします
android-2.3_r1.0/xref/frameworks/base/core/java/android/widget/TimePicker.java

UI Componentの宣言を探します

// ui components
private final NumberPicker mHourPicker;
private final NumberPicker mMinutePicker;

ありました。
微妙に変数名が変わっています。【mMinuteSpinner(API Level 18)】=> 【mMinutePicker(API Level 10)】

選択肢の設定がどこで行われているかを確認します

// digits of minute
mMinutePicker = (NumberPicker) findViewById(R.id.minute);
mMinutePicker.setRange(0, 59);
mMinutePicker.setSpeed(100);
mMinutePicker.setFormatter(NumberPicker.TWO_DIGIT_FORMATTER);
mMinutePicker.setOnChangeListener(new NumberPicker.OnChangedListener() {
    public void onChanged(NumberPicker spinner, int oldVal, int newVal) {
        mCurrentMinute = newVal;
        onTimeChanged();
    }
});

おっと。ここは大きく実装が異なります。見覚えのないメソッド名が散見されます。
ここで重要なのはsetRange(0, 59)でしょう。
API Level 18では【最小値】【最大値】の設定によって値の範囲が決定されているので、ここはNumberPickerの実装が大きく異なるのが原因のようです。

NumberPickerの実装も確認してみます
ソースコードはこちらを参照します

android-2.3_r1.0/xref/frameworks/base/core/java/android/widget/NumberPicker.java

/**
 * Set the range of numbers allowed for the number picker. The current
 * value will be automatically set to the start.
 *
 * @param start the start of the range (inclusive)
 * @param end the end of the range (inclusive)
 */
public void setRange(int start, int end) {
    setRange(start, end, null/*displayedValues*/);
}

/**
 * Set the range of numbers allowed for the number picker. The current
 * value will be automatically set to the start. Also provide a mapping
 * for values used to display to the user.
 *
 * @param start the start of the range (inclusive)
 * @param end the end of the range (inclusive)
 * @param displayedValues the values displayed to the user.
 */
public void setRange(int start, int end, String[] displayedValues) {
    mDisplayedValues = displayedValues;
    mStart = start;
    mEnd = end;
    mCurrent = start;
    updateView();

    if (displayedValues != null) {
        // Allow text entry rather than strictly numeric entry.
        mText.setRawInputType(InputType.TYPE_CLASS_TEXT |
                InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
    }
}

ありました。265行目のsetRange()が実体です。
選択肢を変更したい場合は、このdisplayValuesを指定してやればOKです。

概ねキーとなりそうな要素はそれぞれ発見出来ました。これらの情報を整理して拡張TimePickerの実装方針を立ててみます

改造の方針

調べた内容を整理してみます

  • TimePickerは主にNumberPickerで構築されている
  • TimePickerを構成するNumberPickerの変数はPrivate変数
  • 選択肢の設定は、setDisplayValues()
  • 分の選択肢は、今まで1分刻みであるため、選択肢のIndex=実際の【分】が成り立っている

TimePickerクラス内部で宣言されている、「分」の選択肢を司るNumberPickerの変数を操作をしてあげれば色々とうまくいきそうです。

動作を変更すべき対象は以下の3箇所になります

  • 刻み単位分の設定から、選択肢を生成する
  • 現在時刻を設定した際に、適切な選択肢を選択させる
  • 選択肢で設定した時刻を取得する際に、選択肢のIndexから適切な時間に変換する

Reflection

ここで問題になるのが、TimePicker内部で宣言されているNumberPickerの変数がprivateで宣言されていることです。通常自クラス以外からは不可視となります。
protectedフィールドであれば、継承した際にアクセス可能な変数となるため、特に問題はないのですが、今回はそうもいきません。

クラス外部からのprivateフィールドへのアクセスは通常はできませんが、よく知られていると思いますが、JavaではReflectionを利用することで、privateだろうとなんだろうとアクセス可能です。
カプセル化の概念を破壊するものなので推奨はされる手法ではありませんが、今回はこちらを存分に利用します。

※とても便利ですが、多用は禁物な黒魔術だと思ってます。個人的には

まとめ

今回は、拡張TimePickerを作成するためにSDK内部のTimePickerの実装方法を確認しました。
ここで収集した情報をもとに拡張TimePickerの設計、実装を行っていきます

Reflectionを使って若干黒魔術的な実装を行います。
カプセル化?そんなものは知ったことではないのです。なんとかするためには手段を選ば(べ)ないのです。というより、標準でサポートしてないのが悪い

次回は拡張TimePickerの設計・実装の解説を行います

参照