Android(Kotlin) MaterialDatePicker を日本語化して期間選択

2020/9/23

はじめに

過去記事DatePickerDialog に触れましたが、使いづらく、期間指定にも対応していませんでした。

そんな中、期間指定にも対応した MaterialDatePicker という新パッケージを知ったので試してみます。

実装内容

概要

  • アクティビティから MaterialDatePicker を期間選択モードで呼び出し
  • 呼び出し元アクティビティで選択した期間を取得
  • 日本語化
  • UTC ミリ秒ベースの API が使いづらいので、ローカル時間利用できるように拡張メソッドを用意

詳細

app/build.gradle

直接的に必要なパッケージは com.google.android.material:material ですが、 ローカル時間指定に java.time.* を使いたいので、前回記事と同様の差分も入れておきます。

@@ -22,6 +22,14 @@ android {
             proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
         }
     }
+
+    compileOptions {
+        // Flag to enable support for the new language APIs
+        coreLibraryDesugaringEnabled true
+        // Sets Java compatibility to Java 8
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
 }

 dependencies {
@@ -33,4 +41,6 @@ dependencies {
     androidTestImplementation 'androidx.test.ext:junit:1.1.1'
     androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'

+    implementation 'com.google.android.material:material:1.2.1'
+    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.10'
 }

app/src/main/java/com/example/myapplication/MainActivity.kt

選択済み期間指定には setSelection() メソッド、選択決定時における処理紐付けに addOnPositiveButtonClickListener() メソッドを利用するわけですが、両方とも UTC ミリ秒(Long 型)をデータ形式として採用しているようです。ただ、都度変換するのは面倒なので、それぞれ同名で LocalDate 型に対応した拡張メソッドを用意してみました。 (ついでに、UTC ミリ秒変換処理も拡張メソッド化しています)

あと、Pair が Kotlin のものではなく、androidx.core.util.Pair であることには注意です。

package com.example.myapplication

import androidx.appcompat.app.AppCompatActivity
import androidx.core.util.Pair
import android.os.Bundle
import android.widget.Button
import android.widget.TextView
import com.google.android.material.datepicker.MaterialDatePicker
import java.time.*

class MainActivity : AppCompatActivity(R.layout.activity_main) {
    private lateinit var textViewStartDate: TextView
    private lateinit var textViewEndDate: TextView

    private var selectingStartDate: LocalDate? = null
    private var selectingEndDate: LocalDate? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        textViewStartDate = findViewById(R.id.textViewStartDate)
        textViewEndDate = findViewById(R.id.textViewEndDate)

        findViewById<Button>(R.id.buttonMaterialDatePicker).apply {
            setOnClickListener {
                showDatePickerDialog(selectingStartDate, selectingEndDate)
            }
        }

    }

    private fun showDatePickerDialog(initialStartDate: LocalDate?, initialEndDate: LocalDate?) {
        val now = ZonedDateTime.now()
        val localZoneId = now.zone

        MaterialDatePicker.Builder.dateRangePicker()
            .setSelection(initialStartDate, initialEndDate)
            .build().apply {
                addOnPositiveButtonClickListener(localZoneId) { startDate, endDate ->
                    selectingStartDate = startDate
                    selectingEndDate = endDate
                    textViewStartDate.text = selectingStartDate!!.toString()
                    textViewEndDate.text = selectingEndDate!!.toString()
                }
            }.show(supportFragmentManager, "MaterialDatePicker")
    }

    private fun MaterialDatePicker.Builder<Pair<Long, Long>>.setSelection(startDate: LocalDate?, endDate: LocalDate?) : MaterialDatePicker.Builder<Pair<Long, Long>> {
        if (startDate != null && endDate != null) {
            val utcZoneId = ZoneId.of("UTC")
            val rangeDate = Pair(
                startDate.getTimeInMillis(utcZoneId),
                endDate.getTimeInMillis(utcZoneId)
            )
            setSelection(rangeDate)
        }
        return this
    }

    private fun MaterialDatePicker<Pair<Long, Long>>.addOnPositiveButtonClickListener(
        localZoneId: ZoneId, onPositiveButtonClick: (LocalDate, LocalDate) -> Unit
    ): Boolean {
        return addOnPositiveButtonClickListener { timeRange ->
            val startDateTime = timeRange.first!!.fromUnixTimeInMillis(localZoneId)
            val endDateTime = timeRange.second!!.fromUnixTimeInMillis(localZoneId)
            onPositiveButtonClick(startDateTime.toLocalDate(), endDateTime.toLocalDate())
        }
    }

    private fun Long.fromUnixTimeInMillis(zoneId: ZoneId): ZonedDateTime {
        val instant = Instant.ofEpochSecond(this / 1000)
        return ZonedDateTime.ofInstant(instant, zoneId)
    }

    private fun LocalDate.getTimeInMillis(zoneId: ZoneId): Long {
        return ZonedDateTime
            .of(this, LocalTime.of(0, 0, 0, 0), zoneId)
            .toInstant()
            .toEpochMilli()
    }
}

app/src/main/res/layout/activity_main.xml

特筆すべき点はありません。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textViewStartDate"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="未選択" />

    <TextView
        android:id="@+id/textViewEndDate"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="未選択" />

    <Button
        android:id="@+id/buttonMaterialDatePicker"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="ボタン" />
</LinearLayout>

app/src/main/res/values/strings.xml

日本語化対応です。既存文字列リソースを上書いています。

@@ -1,3 +1,16 @@
-<resources>
+<resources xmlns:tools="http://schemas.android.com/tools">
     <string name="app_name">My Application</string>
+
+    <string name="mtrl_picker_range_header_title" tools:override="true">期間を選択</string>
+    <string name="mtrl_picker_range_header_unselected" tools:override="true">開始日 – 終了日</string>
+    <string name="mtrl_picker_range_header_only_start_selected" tools:override="true">%1$s – 終了日</string>
+    <string name="mtrl_picker_range_header_only_end_selected" tools:override="true">開始日 – %1$s</string>
+    <string name="mtrl_picker_save" tools:override="true">OK</string>
+    <string name="mtrl_picker_text_input_date_range_start_hint" tools:override="true">開始日</string>
+    <string name="mtrl_picker_text_input_date_range_end_hint" tools:override="true">終了日</string>
+    <string name="mtrl_picker_invalid_range" tools:override="true">期間が正しくありません。</string>
+    <string name="mtrl_picker_out_of_range" tools:override="true">期間が正しくありません。</string>
+    <string name="mtrl_picker_invalid_format" tools:override="true">入力形式が正しくありません。</string>
+    <string name="mtrl_picker_invalid_format_use" tools:override="true">正しい形式: %1$s</string>
+    <string name="mtrl_picker_invalid_format_example" tools:override="true">例: %1$s</string>
 </resources>

app/src/main/res/values/styles.xml

java.lang.IllegalArgumentException: com.google.android.material.datepicker.MaterialDatePicker requires a value for the com.example.myapplication:attr/materialCalendarFullscreenTheme attribute to be set in your app theme. You can either set the attribute in your theme or update your theme to inherit from Theme.MaterialComponents (or a descendant).

特定のアトリビュートが足りないとエラーが出たので、対応しています。

@@ -1,6 +1,6 @@
 <resources>
     <!-- Base application theme. -->
-    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
         <!-- Customize your theme here. -->
         <item name="colorPrimary">@color/colorPrimary</item>
         <item name="colorPrimaryDark">@color/colorPrimaryDark</item>

おわりに

今回は MaterialDatePicker を使ってみました。全体的に DatePickerDialog を利用するよりもシンプルに記述できて良いですね。

情報受け渡しが UTC ミリ秒(Long 型)ベースになっていて、使いづらくなったとも言えますが、今回のように変換処理を共通化してあげれば大した問題ではないと思います。