X

Androidの標準Widgetを自作する(TimePicker) その(2)

本日は以前にTechBoosterで紹介した、Androidの標準Widgetを自作する(TimePicker)の後編をお届けします。

前回、その(1)で取り上げた通り、TimePickerはgetCurrentHourメソッドやgetCurrentMiniteメソッドを利用することで入力値を取得する事が出来ます。
ただし、入力値はFocusが変動しないと確定されないため、Focusがあたっている状態でgetCurrentHourメソッドやgetCurrentMiniteメソッドを呼び出しても表示されている値を取得することができません

本エントリでは、どのタイミングでも表示中の値を取得できるCustomTimePickerを作成し、
Androidのソースコードを参照しながらWidgetを自作する方法を以下順で紹介していきます。

1.作成するウィジェットの動作を規定
課題:通常のTimePickerでは、onFocus状態の設定値を取得できない
追加仕様:onFocus状態の取得できるAPIを追加したい

2.既存TimePickerを調査

3.TimePickerのソースコードをベースに、機能を追加する

※その(1)ではAndroidのソースコードが持つリソースファイルを参照し、見かけ上全く同様のWidget(TimePicker)を作成しました。

目的のおさらい

その(1)で実現したかった内容をおさらいしておきましょう。
通常のTimePickerでは、onFocus状態の設定値がgetCurrentHourメソッド等のgetメソッドで取得できず、編集前の値がとれる現象が発生する。

以下スクリーンショットでは、Hourの値をソフトキーボードで12に編集した状態(前状態は9)です。
Button押下でgetCurrentHourメソッドを呼び出し、TextViewに表示していますが、9から変化がありません。
仮に、ソフトキーボードでDoneを押下した後にもう一度取得しても、9から変化がありませんでした。

本エントリの目的は、上記問題を解決するため、「現在表示されている値を取得できるメソッドを作成する」ことです。

作成するWidgetは、以下の仕様を持つとします。
・通常のTimePickerと同様に、ソフトキーからの入力にも入力制限が設けられていること
(上限以上の値が入力できないこと)
・現在表示されている数値を取得できるメソッドを持つこと(onFocus状態でもとれる)
・上限下限が任意に設定可能であること

ただし、独自でWidgetを作成することにはメリットデメリットが存在します。
今回のデメリットは、見かけ上標準のコンポーネントと同等のものを作成してしまうため、
ユーザに混乱を招く恐れがあります。

前準備

まず始めに、TimePickerクラスを継承した独自クラスで問題が解決可能であるか確認します。
※Androidのソースコードを検索する為にココのサービスを利用させていただきました。
※Developer Collaboration Projectの皆様、OESF様便利なProjectを有難うございます。

■TimePicker.java

@Widget
public class TimePicker extends FrameLayout {
     // ... 省略 ...

     // state
     private int mCurrentHour = 0; // 0-23
     private int mCurrentMinute = 0; // 0-59
     private Boolean mIs24HourView = false;
     private boolean mIsAm;

     // ui components
     private final NumberPicker mHourPicker;
     private final NumberPicker mMinutePicker;
     private final Button mAmPmButton;
     private final String mAmText;
     private final String mPmText;

     // ... 省略 ...

上記ソースコードはTimePickerクラスのソースコードです。
5行目や11行目を見たところ、TimePickerクラスが保持するmCurrentHourもmHourPickerもprivateであり、簡単にアクセスができそうにありません。
また、TimePickerにはNumberPickerという数字の入力用クラスが使用されています。(@hideアノテーションにより、非公開APIです)
時間や分入力はこのNumberPickerクラスを利用して入力しているため、こちらにも簡単にアクセスできそうにありません。

今回は、直接の入力値にアクセスしたいため、NumberPickerクラスを参考に独自TimePicker-Widgetを作成してみましょう。
※ソースコード全体のURLはこちら

AndroidのNumberPickerクラスの調査

TimePickerのWidgetを独自に作成するとはいえ、同じ機構を開発することは非常に困難です。
Androidのソースコードの多くはApache2.0ライセンスの元オープンソースとして公開されているため、
適切なライセンス表示を行った上、独自Widgetに必要なモジュールは移植します。

今回は、EditTextのフィルタのコードをAndroidのソースコード(NumberPickerクラス)から移植しました。
EditTextには、InputTypeによる入力制限の他にInputFilterを用いた入力制限があります。
InputFilterを用いると、入力値を全て大文字にすることや、入力数値の最大値を指定することが出来ます。
Androidには標準で以下二つのInputFilterが用意されています。

[table “166” not found /]

例えば、上記表のInputFilter.AllCapsクラスを利用する場合は、以下の様になります。

		EditText et = (EditText) findViewById(R.id.editText1);
		et.setFilters(new InputFilter[] { new InputFilter.AllCaps() });

Androidのソースコードでは、NumberPickerのEditTextは入力値の下限/上限を設定すると、それ以外の値が入力出来ない様に
フィルタがかけられています。
例えば、午前午後表示を採用している場合、12以上の値が入力出来ないよう1の入力後は3以上のボタンを押下しても入力をうけつけません。
InputFilterの拡張クラスは、NumberPickerInputFilterクラス(引用先 409行目)になります。
このNumberPickerInputFilterクラスと、関連するメソッドを移植します。
※関連する部分のソースコードを引用すると膨大な量になるため、割愛します。
※移植結果はエントリ最後のソースコードを参照ください。

コンストラクタでLayoutの設定を行う

その(1)で作成したLayoutファイルを利用し、Widgetの外見を整えると共にその他Viewの初期設定を行います。

■CustomTimePicker.java
	public CustomTimePicker(Context context, AttributeSet attrs) {
		super(context, attrs);

		// Layoutファイルのinflate
		LayoutInflater inflater = (LayoutInflater) context
				.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
		View parent = inflater.inflate(R.layout.ctime_picker, this, true);

		// +-のButtonのlistener設定
		((Button) parent.findViewById(R.id.increment)).setOnClickListener(this);
		((Button) parent.findViewById(R.id.decrement)).setOnClickListener(this);

		// 数値表示のTextViewへFilterの設定
		// androidのソースコードから移植したFilterを設定する
		ev = (EditText) parent.findViewById(R.id.timepicker_input);
		InputFilter inputFilter = new NumberPickerInputFilter();
		mNumberInputFilter = new NumberRangeKeyListener();

		ev.setFilters(new InputFilter[] {inputFilter});
	}

上記で紹介した、EditTextクラスへのInputFilterの設定を15〜17行目で行っています。

+-ボタンの処理

+-ボタンが押下された場合には、インクリメント/デクリメントを行うように
以下サンプルソースコードの通り処理を追加します。

■CustomTimePicker.java
	/**
	 * EditTextに表示中の数値から
	 * increment/decrementを行う
	 */
	@Override
	public void onClick(View v) {
		// 表示値の取得
		int current;
		try{
			current = Integer.valueOf(ev.getText().toString());
		}catch(NumberFormatException ne){
			current = mStart;
		}

		if(v.getId() == R.id.increment){
			if(current < mEnd){ 				current++; 				ev.setText(Integer.toString(current)); 			} 		} 		else if(v.getId() == R.id.decrement){ 			if(current > mStart){
				current--;
				ev.setText(Integer.toString(current));
			}
		}

		// Listener呼び出し
		changeVal(current);
	}

注意すべきポイントとして、EditTextに何も入力されていない状態でのボタンの押下も発生します。
EditTextに何も入力されていない場合には、getTextメソッド(Line 10)で数値以外の値がとれる為、
IntegerクラスのvaluOfメソッドはNumberFormatException(Line 11)を呼び出します。
これを利用し、何も入力されていない場合には下限値(mStart)を利用するようにしています。

EditTextへのアクセス

独自TimePickerとして作成しているWidgetの数値の入力箇所はEditTextであるため、
数値の取得はEditTextクラスのgetTextメソッドで行うことができます。

■CustomTimePicker.java
	/**
	 * 表示中の値を返す
	 * @return 表示中の値を返す
	 */
	public int getVal(){
		return  Integer.valueOf(ev.getText().toString());
	}

全体のソースコード

全体のソースコードは以下のとおりになります。
主に移植部分のソースコードに関して着目していただければよいと思います。

■CustomTimePicker.java
/*
 * Copyright (C) 2007 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

public class CustomTimePicker extends LinearLayout implements OnClickListener {

	private OnTimeChangedListener mOnTimeChangedListener;
	private EditText ev;
	private String[] mDisplayedValues;
	private final InputFilter mNumberInputFilter;

	private int mEnd;
	private int mStart;

	public interface OnTimeChangedListener {

		/**
		 * @param costumTimePicker
		 *            The view associated with this listener.
		 * @param hour
		 *            The current val.
		 */
		void onTimeChanged(CustomTimePicker costumTimePicker, int val);
	}

	public CustomTimePicker(Context context) {
		this(context, null);
	}

	public CustomTimePicker(Context context, AttributeSet attrs) {
		super(context, attrs);

		// Layoutファイルのinflate
		LayoutInflater inflater = (LayoutInflater) context
				.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
		View parent = inflater.inflate(R.layout.ctime_picker, this, true);

		// +-のButtonのlistener設定
		((Button) parent.findViewById(R.id.increment)).setOnClickListener(this);
		((Button) parent.findViewById(R.id.decrement)).setOnClickListener(this);

		// 数値表示のTextViewへFilterの設定
		// androidのソースコードから移植したFilterを設定する
		ev = (EditText) parent.findViewById(R.id.timepicker_input);
		InputFilter inputFilter = new NumberPickerInputFilter();
		mNumberInputFilter = new NumberRangeKeyListener();

		ev.setFilters(new InputFilter[] {inputFilter});
	}

	/**
	 * EditTextに表示中の数値から
	 * increment/decrementを行う
	 */
	@Override
	public void onClick(View v) {
		// 表示値の取得
		int current;
		try{
			current = Integer.valueOf(ev.getText().toString());
		}catch(NumberFormatException ne){
			current = mStart;
		}

		if(v.getId() == R.id.increment){
			if(current < mEnd){ 				current++; 				ev.setText(Integer.toString(current)); 			} 		} 		else if(v.getId() == R.id.decrement){ 			if(current > mStart){
				current--;
				ev.setText(Integer.toString(current));
			}
		}

		// Listener呼び出し
		changeVal(current);
	}

	/**
	 * 表示中の値を返す
	 * @return 表示中の値を返す
	 */
	public int getVal(){
		return  Integer.valueOf(ev.getText().toString());
	}

	/**
	 * Listenerを呼び出す
	 * @param cur
	 */
	private void changeVal(int cur){
		mOnTimeChangedListener.onTimeChanged(this, cur);
	}

	/**
	 * 下限/上限を設定する
	 * @param start
	 * @param end
	 */
	public void setRange(int start, int end) {
	        mStart = start;
        	mEnd = end;
    	}

	/**
	 * Listenerを登録する
	 * @param listener
	 */
	public void setOnChangeListener(OnTimeChangedListener listener) {
		mOnTimeChangedListener = listener;
    	}

	//--------------以降移植クラス/メソッド---------------
	private class NumberPickerInputFilter implements InputFilter {
		public CharSequence filter(CharSequence source, int start, int end,
				Spanned dest, int dstart, int dend) {
			if (mDisplayedValues == null) {
				return mNumberInputFilter.filter(source, start, end, dest,
						dstart, dend);
			}
			CharSequence filtered = String.valueOf(source.subSequence(start,
					end));
			String result = String.valueOf(dest.subSequence(0, dstart))
					+ filtered + dest.subSequence(dend, dest.length());
			String str = String.valueOf(result).toLowerCase();
			for (String val : mDisplayedValues) {
				val = val.toLowerCase();
				if (val.startsWith(str)) {
					return filtered;
				}
			}
			return "";
		}
	}

	private static final char[] DIGIT_CHARACTERS = new char[] { '0', '1', '2',
			'3', '4', '5', '6', '7', '8', '9' };

	private class NumberRangeKeyListener extends NumberKeyListener {

		// XXX This doesn't allow for range limits when controlled by a
		// soft input method!
		public int getInputType() {
			return InputType.TYPE_CLASS_NUMBER;
		}

		@Override
		protected char[] getAcceptedChars() {
			return DIGIT_CHARACTERS;
		}

		@Override
		public CharSequence filter(CharSequence source, int start, int end,
				Spanned dest, int dstart, int dend) {

			CharSequence filtered = super.filter(source, start, end, dest,
					dstart, dend);
			if (filtered == null) {
				filtered = source.subSequence(start, end);
			}

			String result = String.valueOf(dest.subSequence(0, dstart))
					+ filtered + dest.subSequence(dend, dest.length());

			if ("".equals(result)) {
				return result;
			}
			int val = getSelectedPos(result);

			/*
			 * Ensure the user can't type in a value greater than the max
			 * allowed. We have to allow less than min as the user might want to
			 * delete some numbers and then type a new number.
			 */
			if (val > mEnd) {
				return "";
			} else {
				return filtered;
			}
		}
	}

	private int getSelectedPos(String str) {
		if (mDisplayedValues == null) {
			try {
				return Integer.parseInt(str);
			} catch (NumberFormatException e) {
				/* Ignore as if it's not a number we don't care */
			}
		} else {
			for (int i = 0; i < mDisplayedValues.length; i++) {
				/* Don't force the user to type in jan when ja will do */
				str = str.toLowerCase();
				if (mDisplayedValues[i].toLowerCase().startsWith(str)) {
					return mStart + i;
				}
			}

			/*
			 * The user might have typed in a number into the month field i.e.
			 * 10 instead of OCT so support that too.
			 */
			try {
				return Integer.parseInt(str);
			} catch (NumberFormatException e) {

				/* Ignore as if it's not a number we don't care */
			}
		}
		return mStart;
	}

}

独自クラスを利用する

最後に、作成した独自クラスを利用したサンプルの実行結果を紹介します。
その(1)で作成したサンプルに、現状の値を取得するButtonを追加しました。
以下はボタン押下時のログ出力結果と、その時のスクリーンショットになります。

以上長々とお疲れ様でした。
---
サンプルソースコード
http://techbooster.googlecode.com/svn/trunk/TimePickerSample/

UpDown-G: