AndroidでSpinner形式のDatePickerDialogを表示する

AndroidでSpinner形式の `DatePickerDialog` を表示する方法を解説します。

![Spinner形式のDatePickerDialog](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67657/11c1264e-584b-4f0b-dc8a-aed3263a0bc4.jpeg)

本記事のサンプルコードについては [こちら](https://github.com/yhirano/SpinnerDatePickerDialogSampleAndroid) に置いてあります。

## コード

### テーマ

テーマを下記のように設定します。
`android:datePickerStyle` に `<item name="android:datePickerMode">spinner</item>` を子要素に持つスタイルを設定します。

```xml:themes.xml
<resources>

    <!-- 省略 -->

    <!-- DatePickerDialog -->
    <!--
      parentはアプリ全体のテーマに合わせて、
      "Theme.MaterialComponents.Light.Dialog" や "Theme.AppCompat.Light.Dialog"
      にする。
     -->
    <style name="DatePickerDialog" parent="Theme.MaterialComponents.Light.Dialog" />
    <style name="DatePickerDialog.Spinner">
        <item name="android:datePickerStyle">@style/DatePickerDialogSpinnerDatePickerStyle</item>
    </style>
    <style name="DatePickerDialogSpinnerDatePickerStyle">
        <item name="android:datePickerMode">spinner</item>
    </style>

</resources>
```

### DatePickerDialogの表示

DatePickerDialogの第2引数にさきほどのテーマを指定します。

```kotlin
val datePickerDialog = DatePickerDialog(
        context,
        R.style.DatePickerDialog_Spinner, // 上記のテーマを指定する
        { view, year, month, dayOfMonth ->
            // DatePickerDialogでOKを押した際の処理
        },
        2020, // 初期表示の年 2020年
        10,   // 初期表示の月 11月
        26    // 初期表示の日 26日
)
datePickerDialog.show()
```

## Android 7.0のバグの回避

Android 7.0にはバグがあり、上記のようにSpinner表示をしてもCalendar表示のDatePickerDialogが表示されてしまいます。
これを回避するために、Android標準の `android.app.DatePickerDialog` の代わりに、下記のDatePickerDialogを使用してください。

![Calendar形式のDatePickerDialog](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67657/7e6e1ac9-f4a5-7792-4fe2-222f5afc235b.jpeg)

```kotlin
package your.package.name // 適当なパッケージ名に書き換えてください

import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.widget.DatePicker
import java.lang.reflect.Field

/**
 * Android 7.0でspinner表示ができるDatePickerDialog
 */
class DatePickerDialog : android.app.DatePickerDialog {
    constructor(
        context: Context,
        callback: OnDateSetListener?,
        year: Int,
        monthOfYear: Int,
        dayOfMonth: Int
    ) : super(context, callback, year, monthOfYear, dayOfMonth) {
        fixSpinner(context, year, monthOfYear, dayOfMonth)
    }

    constructor(
        context: Context,
        theme: Int,
        callback: OnDateSetListener?,
        year: Int,
        monthOfYear: Int,
        dayOfMonth: Int
    ) : super(context, theme, callback, year, monthOfYear, dayOfMonth) {
        fixSpinner(context, year, monthOfYear, dayOfMonth)
    }

    @SuppressLint("PrivateApi", "DiscouragedPrivateApi")
    private fun fixSpinner(context: Context, year: Int, month: Int, dayOfMonth: Int) {
        if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N) {
            try {
                // android:datePickerModeを取得
                val modeSpinner = 2
                val styleableClass = Class.forName("com.android.internal.R\$styleable")
                val datePickerStyleableField = styleableClass.getField("DatePicker")
                val datePickerStyleable = datePickerStyleableField[null] as IntArray

                val a = context.obtainStyledAttributes(
                    null, datePickerStyleable, android.R.attr.datePickerStyle, 0
                )
                val datePickerModeStyleableField =
                    styleableClass.getField("DatePicker_datePickerMode")
                val datePickerModeStyleable = datePickerModeStyleableField.getInt(null)
                val mode = a.getInt(datePickerModeStyleable, modeSpinner)
                a.recycle()

                if (mode == modeSpinner) {
                    val datePicker = findField(
                        android.app.DatePickerDialog::class.java,
                        DatePicker::class.java,
                        "mDatePicker"
                    )!!.get(this) as DatePicker
                    val delegateClass = Class.forName("android.widget.DatePickerSpinnerDelegate")
                    val delegateField = findField(
                        DatePicker::class.java, delegateClass, "mDelegate"
                    )!!
                    var delegate = delegateField[datePicker]
                    val spinnerDelegateClass =
                        Class.forName("android.widget.DatePickerSpinnerDelegate")

                    // Android 7.0ではdatePickerModeがなぜか無視され、デリゲートはDatePickerClockDelegate
                    // となってしまっている
                    if (delegate.javaClass != spinnerDelegateClass) {
                        delegateField[datePicker] = null // DatePickerClockDelegateを削除
                        datePicker.removeAllViews() // DatePickerClockDelegate viewを削除
                        val createSpinnerUIDelegate = DatePicker::class.java.getDeclaredMethod(
                            "createSpinnerUIDelegate",
                            Context::class.java,
                            AttributeSet::class.java,
                            Int::class.javaPrimitiveType,
                            Int::class.javaPrimitiveType
                        )
                        createSpinnerUIDelegate.isAccessible = true

                        // createSpinnerUIDelegateメソッドを通してDatePickerSpinnerDelegateを生成する
                        delegate = createSpinnerUIDelegate.invoke(
                            datePicker,
                            context,
                            null,
                            android.R.attr.datePickerStyle,
                            0
                        )
                        delegateField[datePicker] = delegate // DatePicker.mDelegateをspinnerに設定
                        datePicker.calendarViewShown = false
                        // DatePickerデリゲートを再度生成
                        datePicker.init(year, month, dayOfMonth, this)
                    }
                }
            } catch (e: Exception) {
                throw RuntimeException(e)
            }
        }
    }

    companion object {
        private fun findField(
            objectClass: Class<*>,
            fieldClass: Class<*>,
            expectedName: String
        ): Field? {
            try {
                val field = objectClass.getDeclaredField(expectedName)
                field.isAccessible = true
                return field
            } catch (e: NoSuchFieldException) {
                // nop
            }
            // 見つからない場合は検索
            for (searchField in objectClass.declaredFields) {
                if (searchField.type == fieldClass) {
                    searchField.isAccessible = true
                    return searchField
                }
            }
            return null
        }
    }
}
```

## 参考文献
* [react-native/DismissableDatePickerDialog.java at dd4298a3770eee7f66846ef0cc4c41a628b7bf01 · facebook/react-native](https://github.com/facebook/react-native/blob/dd4298a3770eee7f66846ef0cc4c41a628b7bf01/ReactAndroid/src/main/java/com/facebook/react/modules/datepicker/DismissableDatePickerDialog.java)