Other posts in this series:
Part I. Jetpack Compose Side-Effects — LaunchedEffect
Part III. Jetpack Compose Side-Effects — rememberUpdatedState
In the previous part, we talked about how LaunchedEffect
can be used to launch coroutines from composable and not worry about leaking the task. LaunchedEffect
launches the coroutine as soon as the composition starts and is cleaned up when the composition exits automatically.
This works well for cases when you want to start a coroutine from a composable as soon as it enters composition and stops on exit, but has a few limitations which make unusable in other cases
LaunchedEffect
is a composable itself. This basically means it can only be started from another composable function. You cannot start LaunchedEffect from a callback (on a click of a button, for example)- With
LaunchedEffect
, you cannot control the lifecycle of the coroutine. The coroutine starts and ends based on the Composable lifecycle and has no way to manually cancel it (in cases like cancelling an animation).
For such cases, we have rememberCoroutineScope
rememberCoroutineScope
is a composable function which returns a scope. This coroutine scope is tied to the composable from where it is called and will automatically be cancelled when this composable leaves composition.
Using this scope, we can safely launch coroutines from any composable or from callbacks without worrying about the lifecycle of the coroutine.
@Composable | |
fun TimerScreen() { | |
val scope = rememberCoroutineScope() | |
Column { | |
Button(onClick = { | |
println("Timer started") | |
scope.launch { | |
try { | |
startTimer(5000) { | |
println("Timer ended") | |
} | |
} catch (ex: Exception) { | |
println("Timer cancelled") | |
} | |
} | |
}) { | |
Text("Start Timer") | |
} | |
} | |
} |
In the example above, we get access to scope by calling rememberCoroutineScope
. Since we called this function in the TimerScreen
composable, the scope is tied to the lifecycle of this composable. If any coroutine is running when this composable exits composition, it will automatically be cancelled.
Now, once we have access to this scope, we can use it to launch coroutines. Here, we are launching a coroutine on click of the button which starts a timer for 5 seconds.
When a user clicks the button, the timer starts by printing “Timer started” on the console. After 5 seconds, the timer ends by printing “Timer ended”.
Now, let’s say the user clicks the button and then before the timer could end, rotates the screen. This screen rotation would cause the activity to restart and composable to exit the composition. Since the scope is tied to this composable’s lifecycle, the timer would automatically cancel printing “Timer cancelled” on the console.
Manual Cancellation
Besides the composable controlling the lifecycle of the coroutine, we can manually cancel it as well. Since we have access to the scope, we can call scope.cancel()
or job.cancel()
to cancel coroutines manually.
Cancellation using job
@Composable | |
fun TimerScreen() { | |
val scope = rememberCoroutineScope() | |
var job: Job? by remember { | |
mutableStateOf(null) | |
} | |
Column { | |
Button(onClick = { | |
job = scope.launch { | |
try { | |
println("Timer started") | |
startTimer(5000) { | |
println("Timer ended") | |
} | |
} catch (ex: Exception) { | |
println("timer cancelled") | |
} | |
} | |
}) { | |
Text("Start Timer") | |
} | |
Spacer(Modifier.height(20.dp)) | |
Button(onClick = { | |
println("Cancelling timer") | |
job?.cancel() | |
}) { | |
Text("Cancel Timer") | |
} | |
} | |
} |
Job Offers
To cancel coroutine, we need to save the job returned by the launch function call in a variable. Invoking job.cancel()
would then cancel this specific coroutine as shown above. Here, we have a job as a state variable so that we have a reference to it even after the recomposition of the composable. We assign to it the value returned by the launch
block. Now this job can be used to cancel this launch
block as done in the onClick
listener of Cancel button.
Now consider a case where the user clicks Start Timer button 3 times. On each click, a new coroutine would spin up starting a new timer. Since we are only storing the reference to the last job that was launched, calling job.cancel()
would only cancel the most recently launched coroutine while the other two will go on to completion unless of course the entire scope is cancelled on, like in the case of screen rotation.
In the above example, to limit the number of timers to 1, we could have cancelled the already running job before starting a new one.
Cancellation using scope
@Composable | |
fun TimerScreen() { | |
val scope = rememberCoroutineScope() | |
Column { | |
Button(onClick = { | |
scope.launch { | |
try { | |
println("Timer started") | |
startTimer(5000) { | |
println("Timer ended") | |
} | |
} catch (ex: Exception) { | |
println("Timer cancelled") | |
} | |
} | |
}) { | |
Text("Start Timer") | |
} | |
Spacer(Modifier.height(20.dp)) | |
Button(onClick = { | |
println("Cancelling timer") | |
scope.cancel() | |
}) { | |
Text("Cancel Timer") | |
} | |
} | |
} |
In the above example, we are calling cancel on scope instead of job. This will cancel all coroutines which are running using this scope at once. One important thing to note here is that once you call cancel on the scope, it can no longer be used to launch more coroutines. For example, if you press the start timer button twice, two coroutines would spin up with two timers. Then press cancel, both the timers would stop. Post this, clicking on Start Timer button will not start any more coroutines because the scope it is attached to, has already been cancelled.
Thank you for reading! Hopefully, the above examples have made usage of rememberCoroutineScope
clearer to you. Do leave a clap as a token of appreciation 😀. In the coming posts, we will continue looking at other ways to launch side effects in compose.
Till then, keep composing and don’t forget to follow for more parts to come.