We recently updated Jetnews to enhance its behavior across all mobile devices, both big and small. We’d like to take you through our design and development process so that you can learn the philosophy and associated implementation steps for building an application optimized for all screens with Jetpack Compose, including how to build a list/detail layout. If you are new to Compose, take a look at the excellent guides on developer.android.com, as this post assumes a basic understanding of Compose as we improve an existing Compose app.
Prior to our work to optimize for all screens, Jetnews found itself in a situation that is likely shared by many apps today. From the initial designs to implementation and testing, the primary focus was building a great experience for traditional portrait phone displays. This is understandable, but the growing diversity of devices challenges that focus. Across tablets, foldables, and Chrome OS there are now over 250 million active large screen devices running Android.
From our partners:
Improve support for all screen sizes
Jetnews already had support for “traditional” mobile screens, so it was tempting to describe all of our changes as “adding large screen support.” While that is true, it misses the point of having adaptive UI. Let’s take a step back, and try to reframe the end result we want to create and maintain for our app.
A user’s device is their unique, personal portal into the digital world. As an app developer, we should let users run the app in the orientation and configuration they prefer. Concretely, the user gives our app a window: a specific portion of the screen where we can display interactive UI. Most often this is the entire device screen, but it doesn’t have to be. If the user wants to use their phone in landscape or portrait, or split-screen multiple apps, they should be able to.
There are many ways that an app’s window may change. To highlight just a few, split-screen support, foldable devices with an inner and outer display, and resizable windows on Chrome OS all impact your app’s window. It may be daunting to try to think about supporting each scenario individually, but there is a framing that simplifies the task significantly.
The common thread between all of these scenarios is the screen size available to your app, which is the most relevant piece of information for displaying your app’s UI in the space the user is giving you. This is the primary reason why methods
Display.getRealSize() were deprecated and replaced with
<a class="bv ig" href="https://developer.android.com/reference/androidx/window/layout/WindowMetricsCalculator#computeCurrentWindowMetrics(android.app.Activity)" target="_blank" rel="noopener ugc nofollow">WindowMetricsCalculator.computeCurrentWindowMetrics()</a>. For example, if your app is running in split screen mode on a tablet, it shouldn’t try to display “tablet UI” unless it actually has enough space for it.
This is the mindset we had while revamping Jetnews:
Given the amount of screen space available to us, how can we best display content describing our app’s state to the user?
Create state representing the screen size
To create a UI tailored to the size of the window, we want to perform logic based on the window metrics returned by
<a class="bv ig" href="https://developer.android.com/reference/androidx/window/layout/WindowMetricsCalculator#computeCurrentWindowMetrics(android.app.Activity)" target="_blank" rel="noopener ugc nofollow">WindowMetricsCalculator.computeCurrentWindowMetrics</a>. In addition, we need to adjust the UI anytime this computed value changes. Because a configuration change can update the window size, we will need to compute the current window metrics again whenever a configuration change occurs. To do this, we build
rememberWindowSize() (soon to be provided by a Compose material library itself!):
Whenever the configuration changes,
rememberWindowSize() will recompose and return the current
Size for the new window metrics. Every piece of UI that reads that size will automatically recompose and update.
This approach meshes perfectly with the declarative mental model of Compose. Instead of trying to use an
onWindowSizeChanged callback or exposing the changes as an observable
Flow, we reduce the size of the window into a simple piece of observable state. We can now use this state just like any other state in Compose, combining it with other states in our application to declaratively specify the UI displayed to the user.
Convert the window size into observable state
Now that we have the raw window size, we want to turn it into a meaningful value to base layout decisions on. Since the raw window size can take on any arbitrary combination of width and height, we will want to use breakpoints to group all
Size objects into a small set of buckets.
To do this, we will use the Material window size classes specification, which groups window sizes into 3 distinct classes based on the width of the screen: Compact, Medium and Expanded. These three classes allow thinking about window sizes naturally in terms of common scenarios, which can be referenced in our designs.
With all of this piping in place, deciding which elements to show is just a matter of checking the current window size class. For example, in Jetnews we want to replace the navigation drawer with a navigation rail when the window size is expanded.
We accomplish this by disabling the drawer when the window size is expanded, and adding in a navigation rail:
On the interests screen, we also want to change the style of the tab row depending on whether or not the screen was expanded, so we can again use
isExpandedScreen to switch between which content to display:
With Compose, it’s straightforward to swap out UI elements entirely based on the available space. With state hoisting, we can also preserve common state between these UI variants based on the screen size, which we will take a look at soon.
Prior to our changes, the article list and article detail list were always displayed separately, at separate navigation routes. This works well on smaller screen sizes, but with wider screens we can take advantage of the extra space by using a list detail view to display the article list and a selected article at the same time.
Because this impacts the design of the app at the screen level, this change will also have to impact the navigation structure of the app.
The approach that seems the most straightforward at first would be to navigate in response to the screen size changes. If we were on an article detail screen, and then rotated the device to get more horizontal space, then perhaps we could navigate to the list screen to show both the list and the detail.
This approach quickly shows some drawbacks, however. If we were to rotate our device back, the user would expect to be brought back to where they were before rotating the device at all. To accomplish that, we’d need to know that we were looking at the detail screen before rotating, and conditionally navigate back to the detail screen if this is the case, storing that state somewhere outside of our navigation graph.
Going down this path fights the idea of unidirectional data flow, where side effects from state are avoided where possible. We will likely see some visual artifacts (destinations displayed for a single frame), since we won’t be able to decide to navigate until we compose the screen for the first time causing a recomposition loop.
Avoid side effects (like calling navigate) upon size changes
A better approach is taking a step back and adjusting our navigation graph to avoid needing to navigate as a side effect of a size change. Previously, we had our list and detail screens at two different navigation routes. If we combine these to have both be displayed at the same route, we can swap out the entire screens with a conditional statement just as we already have done for the navigation rail and the tab row. That structure looks like the following:
Instead of a simple
if statement like for the taskbar, we now want 3 cases: we have enough space to show the list and the detail, or if not, we want to show either the list or the detail independently. In code, that looks like the following:
Traditionally, each navigation route is paired one-to-one with a specific screen, so each screen is always displayed at a different route, and each route displays a specific screen. However, here we’re breaking that normal pairing. Now, at the same home route, we can choose to display one out of three screens, instead of always displaying just one. To control that logic, we define
HomeRoute to just switch which full-size “screen” we display, based on normal, conditional logic for whether or not the screen is expanded, and if the article is currently open. Each of these Screen composables are still responsible for displaying UI that fills up all of the available space, and this layering avoids each screen needing to know exactly where it sits in the navigation graph.
To get our original navigation behavior when pressing the back button when just the detail screen is shown, we also include a
BackHandler that will set
false. This effectively provides a form of navigation without actually using the navigation library directly.
By controlling the article and detail navigation ourselves within a single route, we keep the backstack stable no matter how we fold, flip, resize, or rotate our device, and we keep complete control over the state of the screen at all times.
One consequence of combining two navigation routes into one was combining the
ViewModel from each route into one as well. That is okay! The combined
ViewModel would be bigger, but we also don’t have to put all logic into it. As explored in managing state in Compose, we can use a combination of different levels of state holders to avoid having a large object responsible for every single piece of state at each route.
Preserving the user’s state through screen size changes
Any time the screen size changes, whether due to an orientation change, folding or unfolding a device, or resizing a window, an
Activity can be recreated due to a configuration change. Even if you don’t change any UI for different screen sizes, ensuring that state is properly handled will avoid a bad experience where a user loses their work or their place in your app.
Preserve the user’s state and current task while rotating, resizing, and folding
Our two primary tools to save state through activity recreation and process death with Compose are
<a class="bv ig" href="https://developer.android.com/jetpack/compose/state#restore-ui-state" target="_blank" rel="noopener ugc nofollow">rememberSaveable</a> and the Architecture Components ViewModel with the SavedState module. Many built-in Compose components already make use of
rememberSaveable internally, like
Once we are switching components depending on the screen size, we also want to preserve state within those different components, even if they are not currently visible.
For example, we want to preserve the scroll state of the list and the articles, even if they might not always be visible at all times due to size changes. This preserves the user’s place, even as they fold or rotate their device.
By hoisting the list and detail scrolling states to the level of the
HomeRoute, the user’s scrolling state is preserved despite having multiple ways to display that state.
Add polishing touches
Building upon this new navigation foundation and taking advantage of hoisting state, we can more easily add in additional behavior to make the experience even better for users.
While the list and article screens are both visible, the user might be reading through an article, or they may be browsing the list for the next article to read. If they fold up their device, or otherwise cause the app to resize to a point where we only have space to show one or the other, we should remember which one the user was interacting with last.
We can create a modifier to notify us whenever the user interacts with a composable, allowing us to update some state as the user performs any interaction with the app:
Using this modifier, we can keep track of which pane the user interacted with last when both the list and details are showing, and update whether or not the article should be open if we only can show the list or the detail:
Embracing all screen sizes
With all of these changes, Jetnews is working better than ever on large screens, but also on small screens too. Ensuring the user’s state is preserved and intelligently displaying content based on the screen size available will lead to a better user experience when rotating between portrait and landscape on a traditional phone, while also setting up a solid foundation for differentiated experiences on foldables and large screen devices.
The philosophy of Compose is built around transforming state into UI, so treating the available screen size as a state opens up many exciting doors and unique experiences. As you migrate components, screens or entire apps to Compose, or you build a new app from the ground up, we hope you keep these principles in mind to help make it easier to support every user’s device.
You can download the sample and take a look at the improved Jetnews implementation on GitHub. You can find more design guidance for canonical layouts and large screens on the Material Design 3 website, and also see more information on responsive UIs for both Views and Compose on developers.android.com.
For enquiries, product placements, sponsorships, and collaborations, connect with us at [email protected]. We'd love to hear from you!
Our humans need coffee too! Your support is highly appreciated, thank you!