How are Android activities handled with Jetpack Compose and Compose Navigation?
I'm currently studying Jetpack Compose in an attempt to build a feature-rich application using modern Android architecture components. Traditionally, each screen (or navigation unit) in my application would be either an activity or a fragment, each with its own lifecycle bindings, but with Jetpack Compose and the Compose Navigation library, I would do something like this:
MainActivity.kt
:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
composable("main") { MainScreen(navController) }
// More composable calls
}
}
}
}
Where MainScreen
is just a composable. My questions are:
- What is the equivalent here of a "lifecycle" for this composable? Say I want to perform some action when the screen is loaded, when it is destroyed etc. This is perhaps more relevant to the case where I have more screens and navigation between them
- Is there some way to integrate between Compose and standard activities? That is, define activities for screens as you would, with each activity being a
ComponentActivity
and defining its own composable layout? Is this discouraged for some reason?
Solution 1:
The Compose application is designed to be used in a single-activity architecture with no fragments.
You can still have multiple activities or fragments and use setContent
in each of them, but in this case the transfer of data between activities falls on your shoulders. Use this approach if you're adding new Compose screens to an existing application built the old way.
But with Compose, it's much easier to do all the navigation within a single activity using Compose Navigation. Much less code, better performance due to no unnecessary code layers, easy to transfer data, etc.
To work with the view lifecycle, check out compose side-effects:
-
LaunchedEffect
can be used to execute an action when the view appears. It also runs on a coroutine context that is bound to the current composable: you can easily run suspend functions, and when the view disappears from view hierarchy - the coroutine will be canceled. -
DisposableEffect
can be used to subscribe to/unsubscribe from callbacks.
When you rotate the screen, all effects will restart no matter which key you passed.
@Composable
fun MainScreen(navController: NavController) {
LaunchedEffect(Unit) {
println("LaunchedEffect: entered main")
var i = 0
// Just an example of coroutines usage
// don't use this way to track screen disappearance
// DisposableEffect is better for this
try {
while (true) {
delay(1000)
println("LaunchedEffect: ${i++} sec passed")
}
} catch (cancel: CancellationException) {
println("LaunchedEffect: job cancelled")
}
}
DisposableEffect(Unit) {
println("DisposableEffect: entered main")
onDispose {
println("DisposableEffect: exited main")
}
}
}
Also note that in both cases, and in many other cases in compose, you pass key
to these functions. This helps compose understand when the value should be recomputed. In my example it is Unit
, which means that it won't change until the view is gone. But if you create a remember
value, use another dynamic value from the view model, or pass another argument to composable, you can pass it as a key
, this will cancel the current LaunchedEffect
job and call onDispose
for DisposableEffect
, and your job will be restarted with the updated key
value. You can pass as many keys as you want.
Read more about the state in Compose in documentation.