[Android] DatePickerDialogのスピナーモードで月変更時に年がズレるバグの原因と対処法 ― カレンダーモードへの切り替え

誕生日の入力画面などで DatePickerDialog をスピナーモードで使っていたら、「月」を回しただけなのに「年」まで勝手に変わってしまった……という不思議な挙動に遭遇したことはありませんか?

ユーザーからすると「1月に戻したいだけなのに、なぜか年が1つ進んだ」という、かなり気持ち悪いバグです。しかもコードのロジック側は何も間違っていないので、原因が分からず頭を抱えがちなんですよね。

この記事では、この「年ズレ」がなぜ起こるのかを内部の仕組みから解説し、実務でいちばん現実的な対処法であるカレンダーモードへの切り替えを中心に、Kotlin のコード付きでまとめていきます。

スポンサーリンク

前提:DatePickerには2つのモードがある

まず整理しておくと、DatePicker には表示モードが2種類あります。

  • calendar モード:カレンダー全体を表示して日付をタップで選ぶ。API 21(Lollipop)以降、Materialテーマでのデフォルト
  • spinner モード:年・月・日をドラム式のホイール(NumberPicker)で個別に回して選ぶ。昔ながらのスタイル。

誕生日のように「何十年も前までスクロールしたい」ケースでは、カレンダーを延々めくるより spinner のほうが速いので、あえて spinner を選ぶ場面は今でもあります。XMLだとこう書きます。

<DatePicker
    android:id="@+id/birthDatePicker"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:datePickerMode="spinner"
    android:calendarViewShown="false" />

DatePickerDialog でダイアログとして出す場合は、テーマ側で spinner を指定するのが一般的です。

<!-- res/values/styles.xml -->
<style name="SpinnerDatePickerDialog"
    parent="Theme.AppCompat.Light.Dialog">
    <item name="android:datePickerStyle">@style/SpinnerDatePicker</item>
</style>
 
<style name="SpinnerDatePicker"
    parent="android:Widget.Material.Light.DatePicker">
    <item name="android:datePickerMode">spinner</item>
</style>

症状:月を変えると年がズレる

再現手順はシンプルです。spinner モードで、月のホイールを12月からさらに下(または1月からさらに上)へ回すと、ホイールがラップアラウンド(一周)します。このとき本来なら年が連動して増減するはずなのですが、環境や設定によっては年の値が意図しない方向にズレたり、選べないはずの日付が一瞬表示されたりします。

特に minDate / maxDate を設定していると挙動がおかしくなりやすく、「選択できるはずのない月・日がホイールに出てくる」といった副作用も起きます。

原因:スピナーの内部実装(DatePickerSpinnerDelegate)

この挙動の根っこは、spinner モードの内部実装 DatePickerSpinnerDelegate にあります。ざっくり言うと、次の2つが噛み合って起きています。

1. NumberPickerのラップアラウンド

月のホイールは NumberPicker で、wrapSelectorWheel(端まで行くと反対側に一周する挙動)が絡みます。12月の次を「1月」に戻すときに年を+1する、という連動処理が、境界の状態次第でうまく走らないことがあります。

2. min/maxの判定が「完全一致」でしか効かない

もう一つの厄介ポイントが min/max の扱いです。delegate 内部では、日や月のホイールに対する上限・下限の制限が、現在の日付が maxDate / minDate と「ミリ秒単位まで完全に一致」しているときにしか効かない作りになっています。

ところが maxDate をセットするとき、多くの人は Calendar の時刻フィールド(時・分・秒・ミリ秒)をクリアし忘れます。すると「日付は同じでも時刻がズレている」ために完全一致にならず、制限がすり抜けて、選べないはずの値がホイールに現れる → そこから月を回すと年の計算が狂う、という連鎖が起こるわけです。

そしてこの spinner 経路は、API 21 でカレンダーモードが Material の標準になって以降、ほぼメンテされていません。つまり「直る見込みが薄い塩漬けの実装」だと考えておくのが現実的です。

対処法1:カレンダーモードに切り替える(おすすめ)

いちばん確実で、実装もラクなのがカレンダーモードへの切り替えです。カレンダーモードはプラットフォームの標準で、年ズレのような不具合とは無縁です。誕生日入力でも、年の見出しをタップすれば年一覧から選べるので、実用上まず困りません。

やることは、spinner を指定していたテーマを外して素直に DatePickerDialog を出すだけです。

private fun showBirthDatePicker() {
    val today = Calendar.getInstance()
 
    val dialog = DatePickerDialog(
        this,
        { _, year, month, dayOfMonth ->
            // month は 0 始まりなので表示時は +1 する
            onBirthDateSelected(year, month + 1, dayOfMonth)
        },
        today.get(Calendar.YEAR) - 20, // 初期表示を20歳あたりに
        today.get(Calendar.MONTH),
        today.get(Calendar.DAY_OF_MONTH)
    )
 
    // 未来日は選べないようにする(時刻はクリアしておく)
    dialog.datePicker.maxDate = today.clearTime().timeInMillis
    dialog.show()
}

ポイントは maxDate に渡す前に時刻をクリアしておくことです。ここが揃っていないと、カレンダーモードでも min/max の境界が1日ぶんズレることがあります。時刻クリアは拡張関数にしておくと使い回せて便利です。

// Calendar の時・分・秒・ミリ秒を 0 にする拡張関数
private fun Calendar.clearTime(): Calendar = apply {
    set(Calendar.HOUR_OF_DAY, 0)
    set(Calendar.MINUTE, 0)
    set(Calendar.SECOND, 0)
    set(Calendar.MILLISECOND, 0)
}

対処法2:どうしてもスピナーが必要なとき

デザイン要件などで spinner を残さざるを得ない場合は、min/max の時刻を必ず揃えるのが最低限の防御になります。前述のとおり、delegate の制限は完全一致でしか効かないためです。

private fun showSpinnerDatePicker() {
    val today = Calendar.getInstance()
 
    // spinner 用テーマを指定してダイアログを生成
    val dialog = DatePickerDialog(
        this, R.style.SpinnerDatePickerDialog,
        { _, year, month, day -> onBirthDateSelected(year, month + 1, day) },
        1990, 0, 1
    )
 
    // min/max は時刻をクリアして「日付の境界」を明確にする
    val minDate = Calendar.getInstance().apply { set(1900, 0, 1) }.clearTime()
    dialog.datePicker.minDate = minDate.timeInMillis
    dialog.datePicker.maxDate = today.clearTime().timeInMillis
 
    dialog.show()
}

ただし、これでも端末やOSバージョンによっては挙動のブレが残ります。特に古い端末では spinner 指定を無視してカレンダー表示に落ちるケースも報告されているので、spinner に強くこだわるほど検証コストが跳ね上がる点は覚悟しておきましょう。個人的には、よほどの理由がなければ対処法1(カレンダーモード)を推します。

モダンな選択肢:MaterialDatePicker

これから新しく作るなら、Material Components の MaterialDatePicker も候補です。見た目がモダンで、カレンダー表示とテキスト入力を切り替えられます。

val picker = MaterialDatePicker.Builder.datePicker()
    .setTitleText("誕生日を選択")
    .setSelection(MaterialDatePicker.todayInUtcMilliseconds())
    .build()
 
picker.addOnPositiveButtonClickListener { selectionMillis ->
    // 選択結果はUTCミリ秒で返る点に注意
    onBirthDateSelectedFromUtc(selectionMillis)
}
picker.show(supportFragmentManager, "birth_date_picker")

ただし1点だけ注意があります。MaterialDatePicker にはスピナーモードがありません。「年・月・日をホイールで回したい」という要件がある場合は、結局のところ従来の DatePickerDialog(=この記事のバグと付き合う)か、独自のカスタムピッカーを作るかの二択になります。要件と相談して選んでください。

まとめ

  • spinner モードの「年ズレ」は、内部の DatePickerSpinnerDelegate のラップアラウンド処理と、min/max が完全一致でしか効かない仕様が原因
  • maxDate / minDate時刻をクリアしてからセットするのが鉄則
  • いちばん確実なのはカレンダーモードへの切り替え。プラットフォーム標準で不具合とも無縁
  • 新規なら MaterialDatePicker も良いが、スピナーモードは非対応

「ロジックは正しいのに表示だけおかしい」系のバグは、原因が自分のコードの外(フレームワーク側)にあることが多くて厄介ですよね。今回のように「そもそも塩漬けの実装を避ける」という判断も、立派な解決策のひとつだと思います。似たようなUI部品のハマりどころは他にもあるので、また少しずつ書いていければと思います。

コメント

タイトルとURLをコピーしました