Jetpack Composeを使ってシンプルなドラムロール型DatePickerを作ってみる

本投稿は TECOTEC Advent Calendar 2023 の16日目の記事です。

はじめに

こんにちは。証券フロンティア事業部の割子田です。普段の業務では主にAndroidアプリの開発に従事しています。

Jetpack Composeのバージョン 1.0(初の安定版)がリリースされてから2年以上が経過したこともあり、ネイティブのAndroidアプリ開発において、Jetpack Composeを採用するプロジェクトは多くなってきているのではないかと思います。

またマテリアル3に対応したCompose Material 3の開発も活発に行われており、v1.1.0からはDatePickerに対応しました。

m3.material.io

しかしCompose Material 3で実装されているDatePickerはカレンダー形式のものであり、ドラムロール型(iOSのUIKitではUIDatePickerのWheelsに該当する方)で日付や時刻を選択することはできないようです。

個人的には、年や月を頻繁に移動する際には、カレンダー型よりドラムロール型の方がストレスなく操作できて使いやすいと感じています。

AndroidViewではDatePickerDialogのdatePickerModespinnerにすれば実現できるので、それをそのまま使うことも選択肢のひとつなのですが、せっかくなので今回Jetpack Composeでそれを実現するコードを書いてみました。

完成イメージ

DatePickerの完成イメージ

環境

実装した際の環境は次の通りです。

    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
    implementation("androidx.activity:activity-compose:1.8.1")
    implementation(platform("androidx.compose:compose-bom:2023.10.01"))
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")

実装コードと簡単な解説

まずはダイアログの表示状態を制御する部分です。

選択された日付をあとから表示するためselectedDateTextにrememberを使用しています。

TextFieldではenabled = trueのとき、クリックをうまく検知できないため、Boxを重ねて問題を回避しています(本来の使い方と少し違う)。

Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
) {
    val context = LocalContext.current
    var isShowDialog by remember { mutableStateOf(false) }
    var selectedDateText by remember { mutableStateOf("日付を選択") }
    // TextFieldだとクリックを検知できないので透明なBoxを重ねる
    Box {
        TextField(
            value = selectedDateText,
            onValueChange = {},
            readOnly = true,
        )
        Box(
            modifier = Modifier
                .matchParentSize()
                .clickable { isShowDialog = true }
        )
    }
    if (isShowDialog) {
        WheelsDatePickerDialog(
            label = "日付を選択",
            onSelectedDateChange = { newDate ->
                // Toastの表示
                Toast.makeText(context, newDate, Toast.LENGTH_SHORT).show()
                selectedDateText = newDate
            },
            onDismissRequest = {
                isShowDialog = false
            }
        )
    }
}

次にダイアログを構築する部分です。

「年」「月」「日」をそれぞれ管理します。 「年」や「月」はあらかじめ数を決めることができますが、「日」は年や月の組み合わせによって数が異なるため計算する必要があります。

そのためdaysInMonthdaysListは年と月の組み合わせから作成している訳ですが、rememberでラップすることで、同じ年と月の組み合わせであれば再計算を避けるようにしています。

val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val currentDay = Calendar.getInstance().get(Calendar.DAY_OF_MONTH)
val currentMonth = Calendar.getInstance().get(Calendar.MONTH)

@Composable
fun WheelsDatePickerDialog(
    label: String,
    onSelectedDateChange: (String) -> Unit,
    onDismissRequest: () -> Unit
) {
    Dialog(
        onDismissRequest = { onDismissRequest() }
    ) {
        DatePickerUI(
            label = label,
            onSelectedDateChange = onSelectedDateChange,
            onDismissRequest = onDismissRequest
        )
    }
}

@Composable
fun DatePickerUI(
    label: String,
    onSelectedDateChange: (String) -> Unit,
    onDismissRequest: () -> Unit
) {
    Card(
        shape = RoundedCornerShape(10.dp),
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .fillMaxWidth()
                .padding(vertical = 10.dp, horizontal = 5.dp)
        ) {
            // ダイアログのラベル
            Text(
                text = label,
                style = MaterialTheme.typography.headlineLarge,
                modifier = Modifier.fillMaxWidth(),
                textAlign = TextAlign.Center
            )

            Spacer(modifier = Modifier.height(30.dp))

            val chosenYear = remember { mutableStateOf(currentYear) }
            val chosenMonth = remember { mutableStateOf(currentMonth) }
            val chosenDay = remember { mutableStateOf(currentDay) }

            val daysInMonth = remember(chosenYear.value, chosenMonth.value) {
                val calendar = Calendar.getInstance()
                calendar.set(Calendar.YEAR, chosenYear.value)
                calendar.set(Calendar.MONTH, chosenMonth.value - 1) // Calendarクラスでは月が0から始まるので1を引く
                calendar.getActualMaximum(Calendar.DAY_OF_MONTH)
            }
            val daysList = remember(chosenYear.value, chosenMonth.value) {
                (1..daysInMonth).map { it.toString() }
            }

            // 日付選択領域
            DateSelectionSection(
                onYearSelected = { chosenYear.value = it.toInt() },
                onMonthSelected = { chosenMonth.value = it.toInt() },
                days = daysList,
                onDaySelected = { chosenDay.value = it.toInt() },
            )

            Spacer(modifier = Modifier.height(30.dp))

            // 確定ボタン
            Button(
                shape = RoundedCornerShape(5.dp),
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(horizontal = 10.dp),
                onClick = {
                    val dateText =
                        "${chosenYear.value}-${chosenMonth.value}-${chosenDay.value}"
                    onSelectedDateChange(dateText)
                    onDismissRequest()
                }
            ) {
                Text(
                    text = "確定",
                    style = MaterialTheme.typography.labelLarge,
                    modifier = Modifier.fillMaxWidth(),
                    textAlign = TextAlign.Center
                )
            }
        }
    }
}

続いて日付選択領域です。

「年」「月」「日」をRowで横に並べています。 yearsmonthsはあらかじめ決めておき、daysは計算で求めたものです。

この実装ではスクロールするときの余白用として各リストの先頭と後尾に要素を1つずつ追加しています。

@Composable
fun DateSelectionSection(
    onYearSelected: (String) -> Unit,
    onMonthSelected: (String) -> Unit,
    days: List<String>,
    onDaySelected: (String) -> Unit,
) {
    Row(
        horizontalArrangement = Arrangement.SpaceAround,
        modifier = Modifier
            .fillMaxWidth()
            .height(120.dp)
    ) {
        // 年
        val years = (1950..2050).map { it.toString() }
        val modifiedYears = listOf("top") + years + "bottom" // 余白用にtopとbottomをリストに追加する
        ItemsPicker(
            items = modifiedYears,
            firstIndex = currentYear - 1950,
            onItemSelected = onYearSelected
        )
        // 月
        val months = (1..12).map { it.toString() }
        val modifiedMonths = listOf("top") + months + "bottom" // 余白用にtopとbottomをリストに追加する
        ItemsPicker(
            items = modifiedMonths,
            firstIndex = currentMonth,
            onItemSelected = onMonthSelected
        )
        // 日
        val modifiedDays = listOf("top") + days + "bottom" // 余白用にtopとbottomをリストに追加する
        ItemsPicker(
            items = modifiedDays,
            firstIndex = currentDay - 1,
            onItemSelected = onDaySelected
        )
    }
}

最後に最も重要なドラムロールの部分です。

LazyColumnを使ってスクロールを実現しています。

今回は記事用の簡易的な実装ですが、LazyColumnやLaunchedEffectを使用する際はパフォーマンスに注意する必要があります。

また今回の実装方法は、Spacerや各Modifierの高さに依存したものになっています。現状はこれらの高さを変えると想定通りに機能しなくなるため、必要に応じて汎用性を高める設計を検討する必要があります。

@Composable
fun ItemsPicker(
    items: List<String>,
    firstIndex: Int,
    onItemSelected: (String) -> Unit,
) {
    val listState = rememberLazyListState(firstIndex)
    val currentValue = remember { mutableStateOf(items.getOrElse(firstIndex) { "" }) }

    LaunchedEffect(key1 = listState.isScrollInProgress) {
        val firstVisibleItemIndex = listState.firstVisibleItemIndex
        currentValue.value = items.getOrElse(firstVisibleItemIndex + 1) { "" }
        onItemSelected(currentValue.value)
        // 選択した数字を上下中央に自動スクロールさせる
        listState.animateScrollToItem(index = firstVisibleItemIndex)
    }

    Box(modifier = Modifier.height(106.dp)) {
        LazyColumn(
            horizontalAlignment = Alignment.CenterHorizontally,
            state = listState,
            content = {
                items(count = items.size) { index ->
                    // 状態が変更されたときのみ透明度を再計算するためderivedStateOfを使う
                    val alpha by remember {
                        derivedStateOf {
                            when (index) {
                                0, items.lastIndex -> 0f // 余白用に追加したtopとbottom
                                listState.firstVisibleItemIndex + 1 -> 1f // 選択中
                                else -> 0.3f
                            }
                        }
                    }
                    Text(
                        text = items[index],
                        modifier = Modifier.alpha(alpha),
                        style = MaterialTheme.typography.bodyLarge,
                        textAlign = TextAlign.Center,
                    )

                    Spacer(modifier = Modifier.height(16.dp))
                }
            }
        )
    }
}

まとめ

今回はJetpack Composeでシンプルなドラムロール型DatePickerを作ってみました。

Spacerや各Modifierの高さに依存してしまっている点以外にも、海外の日付表記に対応していなかったり、無限スクロールが出来なかったり等、まだまだ改良の余地はありそうですね。

今回掲載したコードは記事用のものであるため、実際のアプリに組み込む際はクラッシュ対策はもちろん、ViewModel等を用いて状態を管理したり、パフォーマンスの最適化を図ったりする必要がある点に注意して下さい。

テコテックの採用活動について

テコテックでは新卒採用、中途採用共に積極的に募集をしています。
採用サイトにて会社の雰囲気や福利厚生、募集内容をご確認いただけます。
ご興味を持っていただけましたら是非ご覧ください。 tecotec.co.jp