Building Jetcaster on all Form Factors

Chris Arriola
Android Developers
Published in
12 min readJun 13, 2024

--

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 components
  • androidx.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 dependency
  • androidx.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 dependency
  • androidx.wear.compose:compose-navigation — Wear OS dependency for navigation. This should be used instead of the mobile Navigation Compose library
  • androidx.wear.compose:compose-foundation — Wear OS dependency providing extensions to the Foundation library. This should be used in addition to the Compose Foundation library
  • androidx.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.

Compose on all Form Factors libraries

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:

Jetcaster dependency graph

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:

  1. Creating a new module,
  2. Adding the form factor-specific dependencies as mentioned above, and
  3. 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:

  1. By using a lazy grid instead of a lazy column when displaying a list of items, like in the home and podcast details screen,
  2. 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.

Jetcaster using a column vs a grid on a large screen

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.

Multiple panes displayed on a tablet in landscape more

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.

Single pane displayed on a phone in portrait mode

Note, however, that using SupportingPaneScaffoldrequired 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:

Jetcaster on Wear: Player and Library screens

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.

Jetcaster on Wear: Suggesting Now in Android

From the podcast, users can access episode details and add episodes to the queue.

Jetcaster on Wear: Episode details

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.

Jetcaster on TV: D-pad navigation

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.

Jetcaster on TV: 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:

Jetcaster on TV: The pause button is focused when the player screen is displayed

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.

Jetcaster widget resizing

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:

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:

Got any questions or feedback? Leave a comment below!

--

--