State of Compose 2023 results are in! Click here to learn more
Published on

How to model complex screens in Jetpack Compose

Authors

Featured in Android Weekly #550

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:

  1. State is kept and remembered in the composable representing the entire screen.
  2. Depending on the type of state emited (loading, loaded, error) render a different composable and pass the data of the state to it.
  3. 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.
  4. 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.

Move function

Recommended reading

Everything you need to know about state in Jetpack Compose