2日かけて、AndroidのDatePickerDialogのまがい物を作った。名前被らないように、DateSelectDialogって名前にした。
何で作ろうと思ったかというと、標準のDatePickerDialogでは「年だけ選択させる」とか「年と月だけ選択させる」っていうのができないから。それを実現しようと思うと、
この方のような方法をとることになる。昔のUIだったらこれでいけたんだけど、最近のDatePickerDialogは下の画像のようにすごい複雑化してるので、ただ単に「日付選択のViewだけ非表示にする」っていうのではうまくいかなさそう。
今では非推奨となってしまったHOLOテーマを指定する手もあるけど、マテリアルデザインが好きなので戻りたくない…。そこで、もう自分でまがい物を作ればいいんちゃうかなって思った。
現在の年選択ダイアログが
こんなん。この上半分を消して、年選択の右側に月選択と日選択のスピナーを表示させたらいいんかな?
年選択はListViewで実装されてるっぽいので、それに倣ってやってみる。
完成図は下のような感じ。
ダイアログの中身
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="horizontal" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="@dimen/dialog_padding_top" android:paddingBottom="@dimen/dialog_padding_bottom" android:paddingStart="@dimen/dialog_horizontal_padding" android:paddingEnd="@dimen/dialog_horizontal_padding" android:baselineAligned="false"> <ListView android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:divider="@null" android:id="@+id/year"/> <ListView android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:divider="@null" android:id="@+id/month"/> <ListView android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:divider="@null" android:id="@+id/date"/> </LinearLayout>
weightを1にして、均等に並べる。
ListViewの中身
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="@dimen/date_select_dialog_item_height"> <ImageView android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_dateselect_circle" android:layout_centerInParent="true" android:tint="@color/colorAccent" android:alpha=".38" android:id="@+id/image"/> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:gravity="center" android:textSize="13sp" android:id="@+id/text"/> </RelativeLayout>
ImageViewは背景の丸で、TextViewは「2016」とかの文字。
Dialogのコード
このダイアログを実際に使うときには
DateSelectDialog dialog = new DateSelectDialog(); Bundle args = new Bundle(); args.putInt("mode", Calendar.DATE); args.putInt("default", CalendarManager.toEpochtime(date, Calendar.DATE)); dialog.setArguments(args); dialog.show(getActivity().getSupportFragmentManager(), "dialog");
このように、"mode"にCalendar.DATE or Calendar.MONTH or Calendar.YEARを指定することでどこまで選択させるかを決め、"default"で初期値を入れることにする。なので、そういう情報はgetArguments()から取ってくる。
ダイアログクラスのonCreateDialogは下のようになった。
@Override public Dialog onCreateDialog(Bundle savedInstanceState) { inflater = LayoutInflater.from(getContext()); final View contentView = inflater.inflate(R.layout.date_select_dialog, null); mode = getArguments().getInt("mode"); selected = CalendarManager.fromEpochtime(getArguments().getInt("default"), mode); ListView yearList = (ListView) contentView.findViewById(R.id.year); // monthとdateも同様 // ダイアログ作る Dialog dialog = new AlertDialog.Builder(getContext()) .setView(contentView) .setPositiveButton("OK", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { // あとで書く } }) .setNegativeButton("Cancel", null) .create(); // modeに応じてListViewを消す if (mode == Calendar.MONTH || mode == Calendar.YEAR) { dateList.setVisibility(View.GONE); if (mode == Calendar.YEAR) { monthList.setVisibility(View.GONE); } } // リストの中身を作成(数を+2してるのは、端っこに空白を作るため) List<Integer> yearObjects = new ArrayList<>(); for (int i = 0; i < YEAR_LAST-YEAR_FIRST + 1 + 2; i++) { yearObjects.add(i); } List<Integer> monthObjects = new ArrayList<>(); for (int i = 0; i < MONTH_COUNT + 2; i++) { monthObjects.add(i); } List<Integer> dateObjects = new ArrayList<>(); for (int i = 0; i < CalendarManager.getEndOfMonth(selected) + 2; i++) { dateObjects.add(i); } // アダプタをセット yearList.setAdapter(new DateSelectDialogListAdapter(getContext(), 0, yearObjects, Calendar.YEAR)); // monthとdateも同様 // 端をフェードさせる yearList.setVerticalFadingEdgeEnabled(true); // monthとdateも同様 updateTitle(dialog); // 現在選択されてる日時をタイトルにセットする return dialog; }
Dialogの中身のListViewのアダプタ
まず、日付を変換する必要があったので、いろいろメソッドを用意した。この辺で使う数字には3タイプある。
リストでのポジション(0インデックス)
普通に1から始まる(年は1970からだけど)表示用の数字
カレンダーにセットするとき用の数字(日付・年は表示用のままでいいのに、月だけは0インデックス)
それらを互いに変換しようと思うと、下のようになった。もうちょっと効率のいい方法あったかも…。
// positionから表示用の数字へ static int pos2Displayed(int mode, int position) { switch (mode) { case Calendar.DATE: return position; case Calendar.MONTH: return position; case Calendar.YEAR: default: return position + YEAR_FIRST - 1; } } // positionからCalendar用の数字へ static int pos2Calendar(int mode, int position) { switch (mode) { case Calendar.DATE: return position; case Calendar.MONTH: return position - 1; case Calendar.YEAR: default: return position + YEAR_FIRST - 1; } } // Calendar用の数字からpositionへ static int calendar2Pos(int mode, Calendar calendar) { switch (mode) { case Calendar.DATE: return calendar.get(mode); case Calendar.MONTH: return calendar.get(mode) + 1; case Calendar.YEAR: default: return calendar.get(mode) - YEAR_FIRST + 1; } }
ここからが一番ややこしかった。
特に、リストの一番上と一番下に空白を追加するところが厄介だった。
空白を入れないと、
こんな感じで、フォーカスされてる日付を真ん中に持ってこようとしても限界がある。だから、空白を加えて下のようにする。
その空白の高さ(ここではoffsetって名前にしてる)は「(ListView全体の高さ - 一行の高さ) / 2」なんだけど、ListView全体の高さっていうのがgetViewに来てからでないと求まらんかった。DialogFragmentの方のonCreateDialogとかonCreateViewとかでは、0が返ってきたりListView自体がnullだったりする。しかもよくわからないことに、ログ出力してみたらgetViewがすごい回数呼ばれてて、初めのうちはListViewの高さは0なのに、途中からちゃんとした数字になっていくみたいだった。
class DateSelectDialogListAdapter extends ArrayAdapter<Integer> { int mode; List<Integer> objects; int itemHeight; int offset = 0; public DateSelectDialogListAdapter(Context context, int resource, List<Integer> objects, int mode) { super(context, resource, objects); this.mode = mode; this.objects = objects; itemHeight = getResources().getDimensionPixelSize(R.dimen.date_select_dialog_item_height); } @Override public View getView(int position, View convertView, ViewGroup parent) { // 空白の分のViewかどうか boolean end = position == 0 || position == objects.size() - 1; // dateListの中身を月に合わせる if (mode == Calendar.DATE && objects.size() - 2 != CalendarManager.getEndOfMonth(selected)) { int excess = objects.size() - 2 - CalendarManager.getEndOfMonth(selected); for (int i = 0; i < Math.abs(excess); i++) { if (excess > 0) { objects.remove(objects.size() - 2); } else { objects.add(objects.size() - 1, objects.size() - 1); } } notifyDataSetChanged(); } if (convertView == null) { convertView = inflater.inflate(R.layout.date_select_dialog_list_item, parent, false); } if (parent.getHeight() != 0 && offset == 0) { offset = (parent.getHeight() - itemHeight) / 2; // 選択中のアイテムが真ん中に来るようスクロール ((ListView) parent).setSelectionFromTop(calendar2Pos(selected), offset); convertView.getLayoutParams().height = end ? offset : itemHeight; } // TextViewの設定 // end == trueならVisibilityをView.GONEにしたり、そうじゃなかったら数字を表示したり色を変えたり // 選択されている日であれば背景を表示 convertView.findViewById(R.id.image).setVisibility( selected.get(mode) == pos2Calendar(position) ? View.VISIBLE : View.GONE ); // クリックリスナー final int finalPosition = position; convertView.setOnClickListener(end ? null : new View.OnClickListener() { @Override public void onClick(View v) { v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); // 日付の変更 selected.set(mode, pos2Calendar(finalPosition)); updateTitle(getDialog()); DateSelectDialogListAdapter.this.notifyDataSetChanged(); } }); return convertView; } }
setSelectionFromTopやりすぎって思ったけど、一回セットしただけでは元に戻ってしまったので何回もやってる。
クリックリスナーでは、標準のDatePickerDialogと同じように、クリックされた際に短くバイブさせる。それがperformHapticFeedbackっていうメソッドなんだけど、ここでは引数であるfeedbackConstantにHapticFeedbackConstants.LONG_PRESSを指定してる。本当はCLOCK_TICKがよかったんだけど、APIレベルが足りないので仕方なく…。CLOCK_TICKが「ビッ」って感じだとすると、LONG_PRESSは「ズォンッ」って勢いでバイブレートしよる。
クリックが遅れて反応する問題
たまに、リストの中身をクリックしたら反応が遅れることがあった。
ここの、LayoutParamsについて語ってる人の回答を見て解決!
convertViewに毎回新しく作ったLayoutParamsをセットしてたのが原因だった。
convertView.getLayoutParams().height = ○;
こうやって設定すればよかったみたい。
ダイアログの選択が完了したときのリスナー
インターフェースの中にメソッド入れといて、
public interface OnDateSelectedListener { void onSelect(Calendar calendar); }
それをフィールドとしておいて、
OnDateSelectedListener onDateSelectedListener;
setOnDateSelectedListenerが呼び出されたときに登録する。
public void setOnDateSelectedListener(OnDateSelectedListener l) { this.onDateSelectedListener = l; }
そしたら、さっき「あとで書く」って書いたOKボタンのリスナーには下のように書ける。
.setPositiveButton("OK", new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { if (onDateSelectedListener != null) { onDateSelectedListener.onSelect(今選択されてる日付のCalendarオブジェクト); } } })
ダイアログを呼び出す側はこうなる↓
dialog.setOnDateSelectedListener(new DateSelectDialog.OnDateSelectedListener() { @Override public void onSelect(Calendar calendar) { // calendarを使って好きなことをする } });
いろいろと効率悪いところもあるけど、これで上に貼った完成図のようなダイアログができた(*‘▽’)
横向けてもちゃんと表示されてる。