Build adaptive layouts

The UI for your app should be responsive to account for different screen sizes, orientations and form factors. An adaptive layout changes based on the screen space available to it. These changes range from simple layout adjustments to fill up space, to changing layouts completely to make use of additional room.

As a declarative UI toolkit, Jetpack Compose is well suited for designing and implementing layouts that adjust themselves to render content differently across a variety of sizes. This document contains some guidelines for how you can use Compose to make your UI responsive.

Make large layout changes for root composables explicit

When using Compose to lay out an entire application, root-level composables occupy all of the space your app is given to render. At this level in your design, it might make sense to change the overall layout of a screen to take advantage of larger screens.

Avoid using physical, hardware values for making layout decisions. It might be tempting to make decisions based on a fixed tangible value (Is the device a tablet? Does the physical screen have a certain aspect ratio?), but the answers to these questions may not be useful for determining the space your UI can work with.

A diagram showing several different device form factors -- a phone, a foldable, a tablet, and a laptop

On tablets, an app might be running in multi-window mode, which means the app may be splitting the screen with another app. On Chrome OS, an app might be in a resizable window. There might even be more than one physical screen, such as with a foldable device. In all of these cases, the physical screen size isn’t relevant for deciding how to display content.

Instead, you should make decisions based on the actual portion of the screen that is allocated to your app, such as the current window metrics provided by the Jetpack WindowManager library. To see how to use WindowManager in a Compose app, check out the JetNews sample.

Following this approach makes your app more flexible, as it will behave well in all of the scenarios above. Making your layouts adaptive to the screen space available to them also reduces the amount of special handling to support platforms like Chrome OS, and form factors like tablets and foldables.

Once you are observing the relevant space available for your app, it is helpful to convert the raw size into a meaningful size class, as described in Window Size Classes. This groups sizes into standard size buckets, which are breakpoints that are designed to balance simplicity with the flexibility to optimize your app for most unique cases. These size classes refer to the overall window of your app, so use these classes for layout decisions that affect your overall screen layout. You can pass these size classes down as state, or you can perform additional logic to create derived state to pass down to nested composables.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the top app bar.
    val showTopAppBar = windowSizeClass != WindowSizeClass.Compact

    // MyScreen knows nothing about window sizes, and performs logic
    // based on a Boolean flag.
    MyScreen(
        showTopAppBar = showTopAppBar,
        /* ... */
    )
}

This layered approach confines screen size logic to a single location, instead of scattering it across your app in many places that need to be kept in sync. This single location produces state, which can be explicitly passed down to other composables just like you would for any other app state. Explicitly passing state simplifies non-root composables, since they will just be normal composable functions that take the size class or specified configuration along with other data.

Flexible nested composables are reusable

Composables are more reusable when they can be placed in a wide variety of places. If a composable assumes that it will always be placed in a certain location with a specific size, then it will be harder to reuse it elsewhere in a different location, or with a different amount of space available. This also means that non-root, reusable composables should avoid implicitly depending on “global” size information.

Let’s look at an example: Imagine a nested composable that implements a list-detail layout, which may show one pane or two panes side-by-side.

Screenshot of an app showing two panes side-by-side

Screenshot of an app showing a typical list/detail layout. 1 is the list area, 2 is the detail area.

We want this decision to be part of the overall layout for the app, so we pass down the decision from a root-level composable as we saw above:

@Composable
fun AdaptivePane(
    showOnePane: Boolean,
    /* ... */
) {
    if (showOnePane) {
        OnePane(/* ... */)
    } else {
        TwoPane(/* ... */)
    }
}

What if we instead want a composable to independently change its layout based on the space available? For instance, a card that wants to show additional details if space allows. We want to perform some logic based on some available size, but which size specifically?

Examples of two different cards: a narrow card showing just an icon and title, and a wider card showing the icon, title, and short description

As we saw above, we should avoid trying to use the size of the device’s actual screen. This won’t be accurate for multiple screens, and also won’t be accurate if the app isn’t fullscreen.

Because the composable is not a root-level composable, we also shouldn’t use the current window metrics directly, in order to maximize reusability. If the component is being placed with padding (such as for insets), or if there are components like navigation rails or app bars, the amount of space available to the composable may differ significantly from the overall space available to the app.

Therefore, we should use the width that the composable is actually given to render itself. We have two options to get that width:

If you want to change where or how content is displayed, you can use a collection of modifiers or a custom layout to make the layout responsive. This could be as simple as having some child fill all of the available space, or laying out children with multiple columns if there is enough room.

If you want to change what you show, you can use BoxWithConstraints as a more powerful alternative. This composable provides measurement constraints that you can use to call different composables based on the space that is available. However, this comes at some expense, as BoxWithConstraints defers composition until the Layout phase, when these constraints are known, causing more work to be performed during layout.

@Composable
fun Card(/* ... */) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(/* ... */)
                Title(/* ... */)
            }
        } else {
            Row {
                Column {
                    Title(/* ... */)
                    Description(/* ... */)
                }
                Image(/* ... */)
            }
        }
    }
}

Ensure all data is available for different sizes

When taking advantage of additional screen space, on a large screen you might have room to show more content to the user than on a small screen. When implementing a composable with this behavior, it might be tempting to be efficient, and load data as a side effect of the current size.

However, this goes against the principles of unidirectional data flow, where data can be hoisted and simply provided to composables to render appropriately. Enough data should be provided to the composable so that the composable always has what it needs to display across any size, even if some portion of the data might not always be used.

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(description)
                }
                Image(imageUrl)
            }
        }
    }
}

Building on the Card example, note that we always pass the description to the Card. Even though the description is only used when the width permits displaying it, Card always requires it, regardless of the available width.

Always passing data makes adaptive layouts simpler by making them less stateful, and avoids triggering side-effects when switching between sizes (which may occur due to a window resize, orientation change, or folding and unfolding a device).

This principle also allows preserving state across layout changes. By hoisting information that may not be used at all sizes, we can preserve the user’s state as the layout size changes. For example, we can hoist a showMore Boolean flag so that the user’s state is preserved when resizes cause the layout to switch between hiding and showing the description:

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    var showMore by remember { mutableStateOf(false) }

    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(
                        description = description,
                        showMore = showMore,
                        onShowMoreToggled = { newValue ->
                            showMore = newValue
                        }
                    )
                }
                Image(imageUrl)
            }
        }
    }
}

Learn more

To learn more about custom layouts in Compose, consult the following additional resources.

Sample apps

  • JetNews. shows how to design an app that adapts its UI to make use of available space

Videos