Androidアプリでミリ秒単位で時間を入力するダイアログ

作成: 2020年09月22日

更新: 2021年02月01日

概要

アンドロイドで好きな音楽のサビを流せるアラームアプリを作るで音楽の再生開始時間を設定する際にスライダーのほかに以下のようにダイアログ形式で設定したくなりました.

dialog.png

日付の設定にはDatePickerDialogが時刻の設定にはTimePickerDialogがあるが今回のようにミリ秒単位で設定したい場合はNumberPickerを使用してカスタムダイアログを作成する必要がある.

作成方法

まずダイアログとして表示するxmlファイル,number_picker_dialog.xmlをres/layoutディレクトリに作成する.

number_picker_dialog.xml<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/numberPickerDialogLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <NumberPicker
        android:id="@+id/minutesPicker"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/colon_between_minutes_and_seconds"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/colon_between_minutes_and_seconds"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:text=":"
        android:textSize="36sp"
        app:layout_constraintStart_toEndOf="@+id/minutesPicker"
        app:layout_constraintEnd_toStartOf="@+id/secondsPicker"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="@+id/minutesPicker"/>

    <NumberPicker
        android:id="@+id/secondsPicker"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@+id/colon_between_minutes_and_seconds"
        app:layout_constraintEnd_toStartOf="@+id/dot_between_seconds_and_millis"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/dot_between_seconds_and_millis"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:text="."
        android:textSize="36sp"
        app:layout_constraintStart_toEndOf="@+id/secondsPicker"
        app:layout_constraintEnd_toStartOf="@+id/millisPicker"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="@+id/secondsPicker"/>

    <NumberPicker
        android:id="@+id/millisPicker"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toEndOf="@+id/dot_between_seconds_and_millis"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

ContraintLayoutでNumberPickerとTextView(コロン:とドット.)を交互に置いてる.

次にnumber_picker_dialog.xmlをダイアログとして表示するためDialogFragmentを継承したNumberPickerDialogクラスを作成する.

NumberPickerDialog.ktclass NumberPickerDialog : DialogFragment() {
    private var initMinutes: Int? = null
    private var initSeconds: Int? = null
    private var initMillis: Int? = null
    lateinit var alarm: Alarm
    lateinit var viewContext: Context
    lateinit var listAdapter: AlarmRecyclerAdapter
    private var durationMilli: Int? = null

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        return activity?.let {
            val builder = AlertDialog.Builder(it)
            val inflater = requireActivity().layoutInflater

            val numberPickerDialog = inflater.inflate(R.layout.number_picker_dialog, null)
            val minutesPicker = numberPickerDialog.findViewById<NumberPicker>(R.id.minutesPicker)
            val secondsPicker = numberPickerDialog.findViewById<NumberPicker>(R.id.secondsPicker)
            val millisPicker = numberPickerDialog.findViewById<NumberPicker>(R.id.millisPicker)

            val dialogLimits = calcDialogLimits(initMinutes ?: 0, initSeconds ?: 0)
            minutesPicker.minValue = 0
            minutesPicker.maxValue = dialogLimits["maxMinutes"] ?: 99
            secondsPicker.minValue = 0
            secondsPicker.maxValue = dialogLimits["maxSeconds"] ?: 59
            millisPicker.minValue = 0
            millisPicker.maxValue = dialogLimits["maxMillis"] ?: 999

            minutesPicker.value = initMinutes ?: 0
            secondsPicker.value = initSeconds ?: 0
            millisPicker.value = initMillis ?: 0

            minutesPicker.setOnValueChangedListener { picker, oldValue, newValue ->
                val soundStartTimeMillis =
                    newValue * 60 * 1000 + secondsPicker.value * 1000 + millisPicker.value
                val soundStartTimeText = String.format(
                    "%02d:%02d.%03d",
                    newValue,
                    secondsPicker.value,
                    millisPicker.value
                )
                AlarmActionsCreator.changeSoundStartTime(
                    this.alarm.id,
                    soundStartTimeMillis,
                    soundStartTimeText,
                    this.viewContext
                )
                secondsPicker.maxValue =
                    calcDialogLimits(newValue, secondsPicker.value)["maxSeconds"] ?: 59
                this.listAdapter.notifyDataSetChanged()
            }
            secondsPicker.setOnValueChangedListener { picker, oldValue, newValue ->
                val soundStartTimeMillis =
                    minutesPicker.value * 60 * 1000 + newValue * 1000 + millisPicker.value
                val soundStartTimeText = String.format(
                    "%02d:%02d.%03d",
                    minutesPicker.value,
                    newValue,
                    millisPicker.value
                )
                AlarmActionsCreator.changeSoundStartTime(
                    this.alarm.id,
                    soundStartTimeMillis,
                    soundStartTimeText,
                    this.viewContext
                )
                millisPicker.maxValue =
                    calcDialogLimits(minutesPicker.value, newValue)["maxMillis"] ?: 999
                this.listAdapter.notifyDataSetChanged()
            }
            millisPicker.setOnValueChangedListener { picker, oldValue, newValue ->
                val soundStartTimeMillis =
                    minutesPicker.value * 60 * 1000 + secondsPicker.value * 1000 + newValue
                val soundStartTimeText = String.format(
                    "%02d:%02d.%03d",
                    minutesPicker.value,
                    secondsPicker.value,
                    newValue
                )
                AlarmActionsCreator.changeSoundStartTime(
                    this.alarm.id,
                    soundStartTimeMillis,
                    soundStartTimeText,
                    this.viewContext
                )
                this.listAdapter.notifyDataSetChanged()
            }

            builder.setView(numberPickerDialog)
            builder.create()
        } ?: throw IllegalStateException("Activity cannot be null")
    }

    private fun calcDialogLimits(minutes: Int, seconds: Int): Map<String, Int> {
        val maxMinutes = this.durationMilli?.div(60 * 1000) ?: 99
        val maxSeconds = if (minutes == maxMinutes) {
            this.durationMilli?.div(1000)?.rem(60) ?: 59
        } else {
            59
        }
        val maxMillis = if (minutes == maxMinutes && seconds == maxSeconds) {
            this.durationMilli?.rem(1000) ?: 999
        } else {
            999
        }
        return mapOf(
            "maxMinutes" to maxMinutes,
            "maxSeconds" to maxSeconds,
            "maxMillis" to maxMillis
        )
    }

    fun setDialogInit(
        alarm: Alarm,
        initMinutes: Int,
        initSeconds: Int,
        initMillis: Int,
        viewContext: Context,
        listAdapter: AlarmRecyclerAdapter
    ) {
        this.alarm = alarm
        this.initMinutes = initMinutes
        this.initSeconds = initSeconds
        this.initMillis = initMillis
        val retriever = MediaMetadataRetriever()
        if (alarm.isDefaultSound) {
            val assetFileDescriptor = viewContext.assets.openFd("default/${alarm.soundFileName}")
            retriever.setDataSource(
                assetFileDescriptor.fileDescriptor,
                assetFileDescriptor.startOffset,
                assetFileDescriptor.length
            )
        } else {
            retriever.setDataSource(
                viewContext,
                Uri.parse(alarm.soundFileUri)
            )
        }
        val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
        retriever.release()
        val durationMilli = Integer.parseInt(duration)
        this.durationMilli = durationMilli
        this.viewContext = viewContext
        this.listAdapter = listAdapter
    }
}

ダイアログを表示する際にonCreateDialogが呼ばれるのでそこにそれぞれのNumberPickerの値が変わったときの挙動をNumberPicker.setOnValueChangedListenerで記述している.今回はアラームアプリだったので音楽ファイルの読み込みなどを書いているので使用する場合は適宜変更が必要.

setOnValueChangedListenerで必要な要素,今回の場合はアラームの情報alarmやcontextなどはダイアログを表示する前にsetDialogInitを呼び出してこのクラスのフィールドに保存している.

ダイアログを表示するときはActivityのボタンなどから以下のように呼び出す.

button.setOnClickListener {
    val dialog = NumberPickerDialog()
    dialog.setDialogInit(/**NumberPickerの処理に必要な要素**/)
    dialog.show(supportFragmentManager, "タグ")
}