用Jetpack Compose實作一款多功能義務役App Part1 (with Jetpack Compose DatePicker)

Alan Chen
11 min readFeb 11, 2024

開發背景

前陣子剛服完每個中華民國男性都需要服的兵役,在服兵役的過程中發現軍營中有很多事情如果能夠電子化作業的話會省下很多時間和人力,於是想到可以寫一個App把想到的功能實做出來。

功能

每一個領到徵集令的男生想到的第一件事肯定是上網查要當多久?哪一天退伍?Dcard上的軍旅版也常常出現這些問題,最後都被刪文。

所以,第一個實作出的功能就決定是退伍日期計算,及退伍日倒數。

目前實作出的Demo, title那句話是我們連長的名言

UI構想

役政司替代役訓練及管理中心提供的退伍日期計算機https://lurl.cc/TWFYqI

如果像上圖這款計算機的模式,日期的方面用下拉選單,天數又要用鍵盤輸入的方式,放在手機上的話可能會讓使用者輸入的體驗不太好(多用幾次就會直接躁起來😡

在這邊Material Design 3 提供的Date Picker Guideline提供了滿好參考選項,當中的Docked Date Picker 的樣式深得我心,很符合我心中想要的日期輸入框該有的樣子

Docked date picker https://m3.material.io/components/date-pickers/guidelines#2a91de43-c8a0-498e-9dd7-789145a08dcc

Docked date picker的模式可以大致上分成三個部分

輸入框的OutlinedTextField 和右側的IconButton以及按下IconButton後出現的date picker dialog。

OutlinedTextField & IconButton https://m3.material.io/components/date-pickers/accessibility#83faa6ed-e2a2-4959-bec6-bb4bb5f52747
date picker dialog https://m3.material.io/components/date-pickers/overview#2baa0c4d-ae6a-4111-abba-3264a87b1189

Ok,我們分析好需要的組件就可以開始用Jetpack Compose實做出來了

實作

@Composable
fun DateTextField (
modifier: Modifier = Modifier,
text: String,
trailingIcon: @Composable (() -> Unit)? = null, //要讓datepicker的IconButton放在這
onChange: (String) -> Unit,
imeAction: ImeAction = ImeAction.Next,
keyboardType: KeyboardType = KeyboardType.Number,
keyBoardActions: KeyboardActions = KeyboardActions(), //設定軟鍵盤
isEnabled: Boolean = true,
label: String,
supportingText: String = "yyyy/mm/dd",
isIllegalInput: Boolean //error state
) {
OutlinedTextField(
value = text,
onValueChange = onChange,
modifier = modifier.fillMaxWidth(),
textStyle = TextStyle(fontSize = 18.sp),
keyboardActions = keyBoardActions,
keyboardOptions = KeyboardOptions(
imeAction = imeAction,
keyboardType = keyboardType
),
enabled = isEnabled,
trailingIcon = trailingIcon,
label = {
Text(text = label, style = TextStyle(fontSize = 18.sp))
},
singleLine = true,
supportingText = {
Text(text = supportingText, style = TextStyle(fontSize = 18.sp))
},
isError = isIllegalInput
)
}

先從客製化我們的使用者輸入欄開始,主要的設定是想先將預設點擊Textfield後出現的softkeyboard設定在數字輸入,還有設定要從parent傳入的引數(其實現在來看好像可以直接拿去跟datepicker組合的時候再設定)

接下來是Date picker dialog

Jetpack Compose版的datepicker API在使用的當下還沒上穩定版,所以要先在build.gradle中修改dependencies (2/7號1.2.0上穩定版了)

dependencies {
////
implementation("androidx.compose.material3:material3:1.2.0-rc01")
}

DatePickerDialog

@ExperimentalMaterial3Api
@Composable
fun CustomDatePickerDialog (
state: DatePickerState,
confirmButtonText: String = "OK",
dismissButtonText: String = "Cancel",
onDismissRequest: () -> Unit,
onConfirmButtonClicked: (Long?) -> Unit
) {
DatePickerDialog(
onDismissRequest = onDismissRequest,
confirmButton = { //OK按鈕
TextButton(onClick = { onConfirmButtonClicked(state.selectedDateMillis) }) {
Text(text = confirmButtonText)
}
},
dismissButton = { //cancel按鈕
TextButton(onClick = onDismissRequest) {
Text(text = dismissButtonText)
}
},
content = {
DatePicker( //headline, title 切換模式的按鈕不需要
state = state,
showModeToggle = false,
headline = null,
title = null,
)
}
)
}

這裡設定的是OK 和Cancel 按鈕 還有顯示出來dialog的內容,還有傳入的狀態。

有了鍵盤輸入跟選取輸入這兩個輸入方式了,現在我們要將他組合起來!

@ExperimentalMaterial3Api
@Composable
fun CustomDatePicker (
label: String,
onInputDateChanged: (String) -> Unit, //onChange沒設定的話,顯示的文字不會隨著輸入改變
dateInputState: DateInputState = rememberDateInputState(""), //輸入的狀態
isIllegalInput: Boolean
) {
val isOpen = rememberSaveable { mutableStateOf(false) } //dialog的狀態
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = Instant.now().toEpochMilli()
) // get selected date
val form = DateTimeFormatter.ofPattern("yyyy/MM/dd") //日期格式
DateTextField(
text = dateInputState.dayInput,
label = label,
onChange = { onInputDateChanged(dateInputState.updateText(it)) } ,
trailingIcon = {
IconButton(onClick = { isOpen.value = true}) {
Icon(imageVector = Icons.Default.DateRange, contentDescription = "Date Picker")
if (isOpen.value) {
CustomDatePickerDialog(
state = datePickerState,
onConfirmButtonClicked = {
isOpen.value = false
if (it != null) {
dateInputState.datePickerInput = Instant
.ofEpochMilli(it)
.atZone(ZoneId.of("UTC+8"))
.toLocalDate().format(form)
onInputDateChanged(dateInputState
.updateText(dateInputState.datePickerInput))
}
},
onDismissRequest = { isOpen.value = false },
)
}
}
},
isIllegalInput = isIllegalInput
)
}

將兩個組件組合起來遇到最大的問題是,有兩個輸入源,但我們只有一個輸出顯示欄位要怎麼讓onChange讀到這兩個輸入確實要想一下,後來是看到在有一篇官方的codelab中,他們的方式是直接寫一個inputState 用狀態來控制輸入資料的流向。

示意圖by小畫家
class DateInputState (input: String, pickerInput: String) {

var dayInput by mutableStateOf(input)
private set

var datePickerInput by mutableStateOf(pickerInput)

fun updateText (day: String): String {
dayInput = day
return dayInput
}

companion object {
val Saver: Saver<DateInputState, *> = listSaver(
save = { listOf(it.dayInput, it.datePickerInput) },
restore = {
DateInputState(
input = it[0],
pickerInput = it[1]
)
}
)
}
}

@Composable
fun rememberDateInputState (input: String): DateInputState =
rememberSaveable (input, saver = DateInputState.Saver){
DateInputState(input, input)
}

另外一方面,因為會需要用到rememberSavable所以需要多寫一個saver和rememberDateInputState。(總不會希望輸入日期輸入到一半,手機拿歪輸入的資料就全都不見吧)

以上的步驟就成功地完成docked date picker啦

下一篇預計介紹 背後的business logic的部分

完整的程式碼

https://github.com/ylchen19/HateConscription

--

--

Alan Chen
0 Followers

Android Developer. Passionate about Apps development, and wanna become a better developer.