Building Jetcaster on all Form Factors
Written by: Chris Arriola, Chiara Chiappini, and Chiko Shimizu
Did you know that you can not only build mobile apps with Jetpack Compose but also TV apps, Wear apps, and also Widgets? We recommend that you use Compose to build UI for all these form factors.
In this blog post, we’ll go over the work that we did to update one of our samples — Jetcaster — to support additional form factors. We’ll discuss the approach we took to add support for other form factors in a way that promotes code sharing and reusability so that you can do the same for your application.
Jetcaster
Jetcaster is a sample podcast app which allows you to view podcasts, subscribe to a few, and play an episode from a podcast. Like many of our other Compose samples, the primary goal of Jetcaster is to demonstrate the capabilities of building UI with Compose. So, anything non-UI related, such as playing audio, is simply mocked.
Jetcaster was selected out of the 10 Compose sample apps on GitHub to extend to additional form factors as media apps tend to be useful not just on phones, but on TV and Wear, too.
Compose on all form factors
You can use Jetpack Compose to build apps on mobile, TV, and Wear. You can also use Jetpack Glance to build widgets with Compose-like syntax.
When building for a specific form factor, it’s important to distinguish which exact Compose dependencies are necessary. Use the list below as a guide to common dependencies you need for a given form factor:
Mobile (Phones, Large Screens, Foldables, and Chrome OS)
androidx.compose.material3:material3
— Dependency providing Material Design 3 componentsandroidx.navigation:navigation-compose
— Dependency for handling navigation between screen-level composables (also can be used by TV)androidx.compose.ui:ui-tooling
— Dependency for Compose previews (also can be used by TV)androidx.compose.material3.adaptive:adaptive
— Dependency providing layouts for creating adaptive UIs
TV
androidx.tv:tv-material
— TV dependency providing Material Design 3 components. This should be used instead of the mobile dependencyandroidx.tv:tv-foundation
— TV dependency providing extensions to the Foundation library. This should be used in addition to the Compose Foundation library
Wear OS
androidx.wear.compose:compose-material
— Wear OS dependency providing Material Design 2.5 components. This should be used instead of the mobile dependencyandroidx.wear.compose:compose-navigation
— Wear OS dependency for navigation. This should be used instead of the mobile Navigation Compose libraryandroidx.wear.compose:compose-foundation
— Wear OS dependency providing extensions to the Foundation library. This should be used in addition to the Compose Foundation libraryandroidx.wear.compose:compose-ui-tooling
— Wear OS dependency for previews
Widgets (Glance)
androidx.glance:glance-appwidget
— Dependency providing core Glance composables
Jetpack Compose consists of a number of layers, providing different functionality. For example, UI components tailored to different form factors are provided by the Material libraries for Mobile, TV, and Wear OS. These Material libraries share the Compose foundation layer and layers beneath. Compose foundation provides the building blocks for constructing more opinionated UI components found in the Material Compose libraries. On the other hand, Glance depends on a lower layer, the runtime layer, and provides a completely different set of components.
Understanding how these dependencies are related is crucial for knowing the Compose code you can share across different form factors and what should be specific to a single form factor. Next, let’s look into the project and module architecture of Jetcaster.
Preparing Jetcaster to support more form factors
Jetcaster originally had a single-module architecture that only targeted mobile devices (a side note: Jetcaster was optimized for foldables and even had custom logic for tabletop mode, but more on that later). The first step was to break this single-module architecture into multiple modules to promote code sharing (before and after).
Jetcaster was modularized so that core parts of the mobile app such as the domain, data, and design system layer, can be shared. Doing so avoids any duplication and also enables consistency across form factors.
Ultimately, we decided to create 3 library modules that are shared by the application (:mobile
, :tv
and :wear
) modules:
:core:data
— this module represents the data layer containing repositories and networking and local persistence data sources:core:domain
— this module represents the domain layer containing use cases and domain objects:core:designsystem
— this module contains the design system including colors, typographies, shapes, and shared composables
As well as 2 testing-specific modules:
:core:data-testing
— this module provides mock implementations for interfaces defined in the data layer:core:domain-testing
— this module provides mock data for domain models which were handy to use in composable previews
The dependency graph for Jetcaster looks something like this:
Adding new app modules
Once we had the library and testing modules set up, adding app modules such as TV and Wear OS were simply a matter of:
- Creating a new module,
- Adding the form factor-specific dependencies as mentioned above, and
- Creating the necessary screens and flows for that specific form factor.
With this structure, Jetcaster can reuse the data layer and common design elements which made it fast to support an additional form factor. You refer to the pull requests that added Wear OS support, and TV support, to dive deeper.
Form factor-specific nuances
While reusing as much code as possible is desirable, most composables in the UI layer were not shared. Instead, each form factor may provide a form-factor specific component which was used instead. Taking lazy lists as an example, mobile uses LazyColumn
/LazyRow
, whereas Wear uses ScalingLazyColumn
(no equivalent lazy row is available on Wear). Similarly, TV also provides a handful of TV-specific components in the tv-material
library.
One question you might then ask is this: why was it necessary to provide a different composable instead of using the same composable all throughout?
The reason is because each form factor has specific UX and UI nuances.
For example, on mobile UI interactions are commonly done through taps and gestures. On the other hand, TV interactions are commonly done with a controller (using a directional pad or arrow keys). Due to these input differences, the scrolling behavior when navigating with gestures versus using a controller is also different (i.e. fast scrolling vs. center-focused scrolling). To account for this, TV offers TV-specific Material components that are optimized for controller-driven navigation.
Similarly, Wear offers ScalingLazyColumn
to increase the visibility of items on round screens by scaling and fading as items enter/exit the screen. Furthermore, Wear supports rotary input which can happen from a different source depending on the device: a rotating side button, a physical bezel, or a touch bezel.
It’s for this precise reason that different form factors may have a different composable implementation of a UI concept.
There are, however, some instances where UI components can be shared. For Jetcaster, the components that are shared across mobile, TV and Wear have to do with loading images over the network and handling HTML formatted text. In these use cases, the implementation is exactly the same for all form factors and thus could be shared. Additionally, the components needed to implement these use cases only depend on androidx.compose.foundation
, and layers beneath it, which all form factor-specific Compose APIs already depend on.
Making Jetcaster adapt across window sizes
Jetcaster optimizes for large screens in the following ways:
- By using a lazy grid instead of a lazy column when displaying a list of items, like in the home and podcast details screen,
- By adopting the supporting pane canonical layout to display 2 panes (primary and secondary) when running Jetcaster in expanded layouts.
Note that :mobile
supports large screens.
Using grids
Displaying a grid instead of a column is ideal for expanded screens. Doing so takes advantage of the additional screen real estate allowing users to view more information and reducing the number of interactions required to use the app.
To update Jetcaster to use grids instead of columns, we replaced the LazyColumn
implementation with a LazyVerticalGrid
that adapts its number of columns based on the width constraints available. Some items, however, had to still occupy the full width (like the subscribed podcast carousel and the ‘Your library’/’Discover’ tabs) which was done by providing a GridItemSpan
in the item
function (see fullWidthItem).
Displaying multiple panes
In addition to showing a grid of episodes, Jetcaster optimizes for large screens by using the new Compose APIs for building adaptive layouts. Specifically, Jetcaster uses SupportingPaneScaffold to implement a supporting pane so that two panes are displayed on the home screen when the space permits it. That is, landscape mode, unfolded screen on a foldable, and so on.
SupportingPaneScaffold
also handles whether the app should display a single pane. That would be the case when viewing the home screen from a phone in portrait mode. Without using these APIs, you would have to handle this logic explicitly. However, with SupportingPaneScaffold
, it handles when and how to show the right number of panes.
Note, however, that using SupportingPaneScaffold
required changing the top-level navigation graph as the podcast detail screen is now a supporting pane within the scaffold rather than it being a top-level screen destination (PR).
Adding support for Wear OS on Jetcaster
Wear OS is a great platform for Android users to engage with a variety of audio content while on the go. Wear OS by Google lets you write apps for a variety of categories, including audio content, that help users stay connected, stay healthy, and express themselves.
Jetcaster on WearOS is built using the Media Toolkit which is an open source project part of Horologist to ease the development of media apps on Wear OS with Compose for Wear.
Common use cases for building a media app on Wear OS are to download and stream media content. This sample showcases how to build UI in Compose for Wear OS for selecting and streaming podcast content, with the plan to add downloaded content in next iterations. You can refer to the MediaToolkit sample to learn how to build a fully functioning Media app on Wear OS.
This example showcases a 2-screen pager which allows navigation between the Player and the Library:
From the Library, users can access the latest episodes from subscribed podcasts and the queue. Since the sample is not retrieving data from the phone, when you start the app the first time you will be requested to select some podcasts to follow, we suggest selecting Now In Android to get a good catalog to test the app.
From the podcast, users can access episode details and add episodes to the queue.
From the Player screen, users can access a volume screen and control the playback speed. Horologist provides a VolumeScreen
that can be reused by media apps to conveniently control volume either by interacting with the rotating side button(RSB)/Bezel or by using the provided buttons.
Implementation
On Wear it is crucial to make sure no content is clipped on different screen sizes to satisfy the core app quality requirements. In order to do that across the screens we are using:
- ResponsiveListHeader which positions the header of a list providing enough padding so that the text does not clip with different screen sizes.
- rememberResponsiveColumnState which ensures the appropriate padding is used for the first and last item of a list based on the component that is used.
- The Horologist ScalingLazyColumn which takes care of the horizontal and vertical padding for a list, so there is no need to specify those to follow Wear OS UX guidance.
Additionally, we are using AppScaffold and ScreenScaffold to smoothly transition the TimeText between screens and make sure every scrollable screen shows a PositionIndicator.
Adding support for TV on Jetcaster
Efficient, predictable, and intuitive d-pad navigation
One of the big differences between mobile apps and apps on TV is navigation. Users can tap on any interactive element on a screen on mobile devices. In contrast, users rely on the d-pad (up, down, left, right, select buttons on the remote) to interact with components by moving d-pad focus. D-pad focus can only move to its neighboring components making predictable and intuitive d-pad navigation a must for apps on TV.
We optimized navigation and screen layout so that users can understand where to move the d-pad focus to accomplish their tasks. For example, the subscribed podcasts are displayed on the home screen in addition to the podcasts in the selected category and the latest episodes. The TV app has a dedicated screen to browse the subscribed podcasts and provides the navigation between the home screen to the subscribed podcasts screen with a navigation drawer.
Logical initial focus
The initial focus on the interface should be logical and support the user’s primary goal or journey.
For example, the player screen has several interactive components, such as buttons to control playback, a button to see more details of the episode, and so on. By default, no interactive component has the d-pad focus when the screen is displayed. Users move the d-pad focus to the playback control by pushing the down button several times.
We can manage the d-pad focus in the same manner as keyboard focus. You can request d-pad focus to move an interactive component with FocusRequester. For example, to bring focus to the pause button when the playback screen is displayed, the button is associated with a FocusRequester
object with the focusRequester modifier. The FocusRequester’s requestFocus method is called when the screen is displayed, and the d-pad focus moves to the pause button. This is accomplished by adding a LaunchedEffect
to be executed when the screen composable is first composed:
Focus is controlled in various places in the Jetcaster sample to improve the UX in this fashion.
Adding support for Widgets with Jetpack Glance
Widgets are a great way to enhance the user experience of your app by providing a convenient way to access your app’s content and features. Widgets are commonly rendered on a device’s home screen but can also appear on other screens.
In the past, writing a widget required working directly with RemoteView
. However, with Jetpack Glance, you can build your widget in Kotlin using the same declarative syntax as Compose because Glance is built on top of the Compose runtime.
Glance enables creating responsive widgets that can fill the entire bounds of the UI. Using Glance’s declarative syntax enables creating modular composables that can be assembled to build the final UI.
To support widgets on Jetcaster, we created a new module called :glancewidget
containing all widget-related code for Jetcaster. We created 2 size buckets to to create 2 distinct UIs depending on the desired size of the widget.
To learn more, you can check out the code directly in the glancewidget module.
Conclusion
Using Jetpack Compose makes it easy to build apps not just for phones, but also for the variety of screen sizes that Android supports. You can dive deeper into our documentation to learn more about supporting multiple form factors:
- Get started with Jetpack Compose
- Use Jetpack Compose on Wear OS
- Use Jetpack Compose on Android TV
- Jetpack Glance
- Test different screen and window sizes
If you haven’t already, make sure to check out the following talks and workshops at I/O this year related to building across all form factors:
- [Talk] Building adaptive apps on Android
- [Talk] Building UI with the Material 3 adaptive library
- [Talk] What’s new on Google TV and the Android TV OS
- [Talk] Building the future of Wear OS
- [Talk] Building beautiful Android widgets with Jetpack Glance
- [Codelab] Create a widget with Glance
- [Workshop] 3 things to improve your Android App experience: Edge to Edge, Predictive Back, and Glance
Got any questions or feedback? Leave a comment below!