🕒

作りながら学ぶjetpack compose:カウントダウンタイマーの実装でStateや副作用を理解する

2023/03/25に公開

はじめに

この春、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の副作用の仕組み

GitHubで編集を提案

Discussion