作りながら学ぶjetpack compose:カウントダウンタイマーの実装でStateや副作用を理解する
はじめに
この春、Jetpack Composeを利用したモバイルアプリの開発に初挑戦しています。
そのアプリ内の一つのコンポーネントとして、カウントダウンタイマ-を実装したので、この記事でまとめようと思います。
つくりたいもの
少しドメイン依存の処理があるので、初めにアプリの全体像について説明します。
最終的に作りたいアプリ全体のデザインはこちら。この記事では画面全体の製作はしません。
これは、大学の通学バスの時刻表を見やすくするぞ!というアプリです。
この記事では、画面中央に配置されるカウントダウンタイマー部分を実装していきます。
一般的なカウントダウンタイマーと違うのは、
- カウントダウンが時刻依存
- カウント完了後、即座に次のカウントダウンを始めなくてはならない
- バスの発車時刻は時刻表から多少のズレることが予想されるため、時間の精度は最重要ではない
という点だと思います。
必要な知識
jetpack composeにおけるState
jetpack composeでは、composable関数によってUIを記述します。それらの関数を再度呼び出すこと(再コンポーズ)によってのみ、UIを変更します。
動的に状態を変更したい場合、MutableStateを活用します。
ComposeによってMutableStateの値は監視され、値が更新された場合は関連するcomposableを再コンポーズし、UIを更新します。
次の例では、numというMutableStateを定義し、ボタンが押された場合にそのvalueを更新します。MutableState.valueの更新がComposeによって検知されるのでMutableStateDemoが再コンポーズされ、UIが更新されます。
@Composable
fun MutableStateDemo() {
var num:MutableState<Int> = remember { mutableStateOf(0) }
Column{
Text(text = num.value.toString())
Button(onClick = { num.value += 1 }) {
Text(text = "tap to increment")
}
}
}
jetpack composeにおける副作用
上記の例では、onClickコールバックでStateを更新しました。しかし、今回カウントタイマーをつくるにあたっては、UIを毎秒変化させる必要があります。
今回は、LaunchedEffectを使ってコルーチンを起動し、コルーチン内でStateを毎秒更新するループを走らせることでこれを実現します。
LaunchedEffectでsuspend関数を実行する
suspend関数とは、delayのような、スレッドをブロッキングしない関数のことです。これを実行するには、suspend関数をLaunchedEffectにブロック引数として渡します。
次のコードで例を示します。
@Composable
fun CountDownDemo(){
var count = remember {
mutableStateOf(10)
}
LaunchedEffect(Unit){
while (true){
delay(1000)
if (count.value <= 0) break
else count.value -= 1
}
}
Text(text = "count: ${count.value.toString()}")
}
このコードでは、CountDownDemoコンポーザブルがコンポジションに入場したタイミングでコルーチンが起動し、suspend関数が実行されます。
LaunchedEffectのキー
また、LaunchedEffectには、引数にkeyを指定することができます。キーの値の変更を検出した場合、古い値で実行されているコルーチンはキャンセルされ、新しい値でループを開始します。
これも例を示します。
@Composable
fun LaunchedEffectKeyDemo(){
var flag:Boolean by remember {
mutableStateOf(true)
}
var count:Int by remember{ mutableStateOf(0)}
LaunchedEffect(key1 = flag){
count = 0
while (true){
delay(100)
count += 1
}
}
Column {
Text(text = count.toString())
Button(onClick = { flag = !flag }) {
Text(text = "push me")
}
}
}
この例では、LaunchedEffectにkeyとしてflagが渡されています。LaunchedEffectによってcountを更新し続けるループが走りますが、keyの変更を検知するとループを初めからやりなおします。
作成したCountDownTimer
実際に完成したコードがこちらです。
@RequiresApi(Build.VERSION_CODES.O)
@Composable
fun CountDownTimer(nextBusTime:LocalTime,onFinished: ()->Unit){
var countDownDuration by remember { mutableStateOf(Duration.between(LocalTime.now(), nextBusTime)) }
LaunchedEffect(nextBusTime){//nextBusTimeが変更されると起動中の処理はキャンセルされ、新しい値で再実行
countDownDuration = Duration.between(LocalTime.now(), nextBusTime)
while (true){
delay(1000)
if (countDownDuration.minusMillis(1000).isNegative){
onFinished()
}
countDownDuration = countDownDuration.minusMillis(1000)//ここで再コンポーズが走る
}
}
Column {
val minutes = countDownDuration.toMinutes()
val seconds = (countDownDuration.toMillis() / 1000 ) - minutes*60
Text(
text = "${if(minutes<10) 0 else ""}$minutes:${if(seconds<10) 0 else ""}$seconds",
style = MaterialTheme.typography.h1,
color = Color(0xff979494),
fontWeight = FontWeight.Medium
)
}
}
こうやって呼び出すことを想定しています。
@RequiresApi(Build.VERSION_CODES.O)
@Preview(showBackground = true)
@Composable
fun CountDownTimerPreview(){
var bus by remember {
mutableStateOf( LocalTime.now().plusSeconds(5))
}
CountDownTimer(
nextBusTime = bus,
onFinished = {bus = bus.plusMinutes(1)}//TODO 正確なバス発車時刻を渡す
)
}
nextBusTimeをLaunchedEffectのキーとして渡しています。キーの値の変更を検出した場合、古い値で実行されているコルーチンはキャンセルされ、新しい値で次のバスが発車されるまでの時間を計算しなおし、新たなカウントダウンを開始します。
@RequiresApi(Build.VERSION_CODES.O)
@Preview(showBackground = true)
@Composable
fun CountDownTimerPreview(){
var bus by remember {
mutableStateOf(LocalTime.now())
}
CountDownTimer(
nextBusTime = LocalTime.now().plusMinutes(1),
onFinished = {bus = bus.plusMinutes(1)}
)
}
AtomsとしてのCountDownTimerの実装をすることができました。
あとは、上位階層で適切にこれを呼び出してあげればよいです。
この記事ではここまで。
おわりに
Android開発自体が初めてだったこともあり、Jetpack Compose独特の記法や概念を理解するのに苦労しました。しかし、勉強をすすめてComposeの思想を理解するにつれて、簡潔に処理やUIを記述できる素晴らしいフレームワークだと実感しています。
LaunchedEffectをはじめとする副作用の利用については、Composableのライフサイクルについて知ることで、理解を深める助けになったと思います。
本記事内の記述について、よりよい書き方や、訂正の提案があればぜひ教えてください。
参考
コンポーザブルのライフサイクル
Compose における副作用
サンプルで理解するJetpack Composeの副作用の仕組み
Discussion