Support for different screen sizes enables access to your app by the widest variety of devices and greatest number of users.
To support as many screen sizes as possible, design your app layouts to be responsive and adaptive. Responsive/adaptive layouts provide an optimized user experience regardless of screen size, enabling your app to accommodate phones, tablets, foldables, ChromeOS devices, portrait and landscape orientations, and resizable configurations such as multi-window mode.
Responsive/adaptive layouts change based on available display space. Changes range from small layout adjustments that fill up space (responsive design) to completely replacing one layout with another so your app can best accommodate different display sizes (adaptive design).
As a declarative UI toolkit, Jetpack Compose is ideal for designing and implementing layouts that dynamically change to render content differently across a variety of display sizes.
Make large layout changes for screen-level composables explicit
When using Compose to lay out an entire application, app-level and screen-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.
On tablets, an app might be running in multi-window mode, which means the app may be splitting the screen with another app. On ChromeOS, 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 ChromeOS, 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 Use 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.
@Composable fun MyApp( windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass ) { // Perform logic on the size class to decide whether to show the top app bar. val showTopAppBar = windowSizeClass.windowHeightSizeClass != WindowHeightSizeClass.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 individual 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 individual, reusable composables should avoid implicitly depending on "global" size information.
Consider the following example: Imagine a nested composable that implements a list-detail layout, which may show one pane or two panes side-by-side.
We want this decision to be part of the overall layout for the app, so we pass down the decision from a screen-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?
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 screen-level composable, we also should not 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 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
- CanonicalLayouts is a repository of proven design patterns that provide an optimal user experience on large screen devices
- JetNews shows how to design an app that adapts its UI to make use of available space
- Reply is an adaptive sample for supporting mobile, tablets and foldables
- Now in Android is an app that uses adaptive layouts to support different screen sizes
Videos
Recommended for you
- Note: link text is displayed when JavaScript is off
- Mapping components to existing code
- Compose layout basics
- Jetpack Compose Phases