How to model complex screens in Jetpack Compose
This tutorial will teach you how to model complex screens in Jetpack Compose.
Composable functions can represent different things on a screen. A composable function might represent a entire screen or individual elements on a screen.
Let's start by looking at a top level screen composable:
Top level screen composable
@Composable
fun GalleryScreen() {
val viewModel = viewModel { GalleryScreenViewModel() }
val state by viewModel.state
when (val currentState = state) {
GalleryScreenState.Loading -> LoadingScreen()
is GalleryScreenState.Loaded -> LoadedScreen(
photos = currentState.photos,
onPhotoClick = { photo ->
viewModel.onPhotoSelected(photo)
})
GalleryScreenState.Error -> ErrorScreen()
}
}
In this type of composable function we hold a reference to our GalleryScreenViewModel
which emits GalleryScreenState
objects. The GalleryScreenState
is a Kotlin sealed class
which represents each different state the screen can have.
Here is what it might look like:
sealed class GalleryScreenState {
object Loading : GalleryScreenState()
data class Loaded(val photos: List<Photo>) : GalleryScreenState()
object Error : GalleryScreenState()
}
The ViewModel first emits a Loading
state and starts performing some long running operation (such as fetching data from the Internet). If there is an error, then a Error
state is emitted. If we were able to fetch data successfully the Loaded
state is emitted.
Depending on your scenario you might want to add more data to your state objects. The Loaded
state might hold a showBanner
boolean that when set to true, display some banner in your UI.
Overall there is no set rule how your state should be represented and the above example might be an overkill for a super simple screen. As long as you and your team agree on how the state should be modeled you are good to go.
Representing the state with a sealed class gives us the nice when syntax where you can directly see how each state object is mapped to which composable. You can use a simple CircularProgressIndicator()
and your error screen can be a simple Text
explaining what went wrong.
Now that you have an understanding how a screen
level composable looks like, let's jump to a state level composable the LoadedScreen()
:
State screen composable
This kind of composable represents a specific state emitted by your ViewModel
. Such composables are stateless meaning they do not hold any state. They accept the data they need to render in the parameters. They also accept lambdas that the nested composable functions can use as listeners in reaction to user events:
@Composable
private fun LoadedScreen(photos: List<PhotoData>, onPhotoClick: () -> Unit) {
LazyVerticalGrid(
columns = Fixed(2),
modifier = Modifier.fillMaxSize()
) {
items(photos) { photo ->
PhotoItem(photo, onPhotoClick)
}
}
}
Component based composables
Within the state screen composables I normally keep component level composables. Those are either custom composable functions that represent a specific element (such as a button or text) or one of the prebuilt composables that come with Jetpack Compose.
@Composable
fun PhotoItem(photo: PhotoData, onPhotoClick: () -> Unit) {
Surface(
onClick = onPhotoClick,
modifier = Modifier.padding(2.dp),
shape = RoundedCornerShape(4.dp)
) {
Box(Modifier.size(180.dp)) {
AsyncImage(
modifier = Modifier.fillMaxSize(),
model = ImageRequest.Builder(LocalContext.current)
.data(photo.url)
.crossfade(true)
.build(),
contentDescription = photo.contentDescription,
contentScale = ContentScale.Crop,
)
}
}
}
TL;DR: Data flows downwards, events bubbled upwards
In this article you learnt how to model complex screens in Jetpack Compose. A recap of what was mentioned:
- State is kept and remembered in the composable representing the entire screen.
- Depending on the type of state emited (loading, loaded, error) render a different composable and pass the data of the state to it.
- In each state level composable, use component based composables such as buttons and text to compose the screen for that state. Pass down data collected from the state and lambda functions.
- Component specific composables (such as buttons) accepts the data they need to render. They are the ones using the lambdas to bubble upwards to the parent composable functions (and later on the ViewModel of the screen) that the user interacted with the screen (ie pressed a button or swiped at a component)
🎁 EXTRA: How to organize your composable functions
There is no specific approach how one might organized the functions. You can keep as many number of composable functions as it makes sense in a given file.
An approach that works well for me is the following:
- Keep all functions about a specific screen in the same file. If a composable is shared with other screens or other composables in other parts of the app, extract them to a separate file.
- Keep variations of the same composable in the same file. I keep different types of the same composable within the same file, such as different versions of buttons.
Android Studio Tip: Press F6 while highlighting a composable function to move the function to its separate file. Use it to move your composables to their own file.
Recommended reading