Migrating to Jetpack Compose — an interop love story [part 2] | by Simona Milanović | Android Developers | Sep, 2023
Simona Milanović
Android Developers

This blog series covers a “common UI first” migration strategy of a View sample to Jetpack Compose, guiding you through the steps, with interop and form factor support, and why you might consider a similar approach for your Compose migration.

Welcome back to our Compose migration journey 🚢 ! In part 1️⃣, we covered:

  • The intro and goals of this migration
  • Brief overview of the migration sample
  • First three steps of the captain’s log: 1. migration prep, 2. dependencies & theming, 3. migration of smallest common UI

Let’s continue onto new adventures!

Step 0: Migration prep [in part 1]
Step 1: Dependencies and theming [in part 1]
Step 2: Smallest common UI components [in part 1]
Step 3: Migration of more complex components [in this post]
Step 4: Migration of low risk screens [in this post]
Step 5: Migration of more complex screens [in this post]

Step 3: Migration of more complex components

🗒️ Tasks:

  • Find complex elements
  • Migrate elements

Find complex elements

One of the biggest advantages of Compose, in my totally unbiased opinion 🙄, are Lazy layouts and their simplicity compared to RecyclerView. Therefore, this step rightly aims to remove as much RecyclerView-related code as possible and replace it with Compose. For a detailed step-by-step doc, refer to Migrate RecyclerView to Lazy list migration guide.

Our first target is the email attachment grid.

Migrate elements

We start by replacing EmailFragment’sRecyclerView XML with a ComposeView, keeping its positioning constraints as is:

All other attributes, like paddings, etc., will be defined in its composable, as we want to make it customizable. We then find where the previous RecyclerView binding was used, in the EmailFragment, and add:

That’s all it takes. No ViewHolders, no Adapters — a vast amount of code removed by migrating just one component.

The previous EmailAttachmentGridAdapter had complex, custom logic to simulate a staggered layout, which we’ve simply replaced with a LazyVerticalStaggeredGrid. This API has a very cool way of defining a dynamic and adaptive size of your grid, by setting a minimum size for an item and letting the grid fit as many columns or rows as it can in the given space. This is perfect for supporting different form factors and a great adaptive user experience:

Previews of adaptive grid sizing on different devices

In the PR, we also replace another RecyclerView with EmailAttachmentRow, but we will ⏩ that.

Now enjoy removing entire classes such as EmailAttachmentAdapter and EmailAttachmentViewHolder and other layouts 😈.

💻 PR: Step 3

🏅 MVP player: Arrangement API in Lazy layouts. Its capabilities make the customization of lists and grids in Compose even easier. For more visuals, check out the MAD Skills episode — Fundamentals of Compose Layouts and Modifiers.

💎 Pro tip: Instead of a grid, you could also use a Flow layout, depending on your designs. Or write your own custom layout. Up to you. The beauty of Compose is that you can easily achieve one design in multiple different ways.

Step 4: Migration of low risk screens

🗒️ Tasks:

  • Find the best screen candidates
  • Migrate screens

Find the best screen candidates

Now, almost all fragments have a bit of Compose code, and we’re able to move onto screen-level migration.

Remember that you don’t need to migrate old code to Compose — you can just use it for all new features and screens onward. However, if you are able to fully migrate, you should know it brings additional benefits:

  • Less UI code and files to dig through
  • Maintenance of a singular, declarative UI framework
  • Avoiding context switching between Views and Compose
  • Simpler and easier APIs to write and use, like animations and Lazy layouts
  • Simplifying testing and navigation by using Compose-only, avoiding interop

Since Reply isn’t getting any new features anytime soon, we choose to refactor existing screens.

On screen-level, there are different tiers of form factor support. Some require major design changes, like implementing list-detail or other canonical layouts, while some require a basic support, like not being letterboxed.

Target screens that don’t require major layout changes and are low-risk to migrate first to Compose.

Migrate screens

Search screen was the perfect candidate, as it is a low-engagement, low-complexity feature.

Search screen (cropped)

Since all individual items were already converted, migrating this screen was a piece of cake 🍰:

At screen-level, we add device multipreviews to ensure our composables look great on all screen sizes:

SearchFragment UI now consists of a SearchScreen composable. We keep the fragment only as an outer wrapper to continue using the existing navigation framework and transitions, following the principles of the Navigation migration guide, but we can remove any old XML files and resources.

💻 PR: Step 4

🏅 MVP player: Window insets and corresponding modifiers like systemBarsPadding. These are important at screen-level, to ensure your app and the system UI collaborate well and don’t get in each other’s way:

💎 Pro tip: If there’s a View component too complicated to migrate yet, and is blocking you, wrap it in an AndroidView and keep using it in Compose. Make it a problem for your future self 😁.

Step 5: Migration of more complex screens

🗒️ Tasks:

  • Find the best screen candidates
  • Design for form factors at screen-level
  • Migrate screens individually
  • Pair into list-detail canonical layout

Find the best screen candidates

Our previous step was looking at low-risk/low-engagement screens, with minimal to no layout changes required for supporting form factors.

But in every migration journey, there will come a time for those riskier and more complex screens. Fear not, there’s a safe way of doing this.

This is a great time to involve your designer and rethink some screens with a form factor mindset. The functionality and UI of your classical phone display doesn’t need to change, and you’ll be able to leverage the power of Compose to build reusable, adjustable composables and combine them differently based on the screen size.

Canonical layouts play a major role in providing the best user experience, as they consider common use cases for how apps adapt visually to more or less space. Analyze your app and flag screens which could be a good fit for Feed, List-detail, or Supporting pane.

In Reply, the canonical use cases are Home and Email fragments, as they fit the list-detail layout perfectly:

Single Home (Email list), single Email and list-detail with both

Design for form factors at screen-level

To set the right mindset, use this form factor cheatsheet for screen-level composables:

  • Start with low-risk screens that require least design changes, and scale to high-risk, more complex screens
  • Make decisions based on the space that is allocated to your screens, and not fixed, hardware values
  • When given an amount of screen space, think about how you can best use it to display content to the user. Could you use canonical layouts or realign some elements differently, based on the screen size?
  • Ensure the app and screens are consistent during configuration changes like device rotation, by maintaining the scroll position, a logical navigation destination, and relevant visible information
  • Use WindowSize classes to define different content types and turn them into observable state
  • Pass this state only to screen-level composables. For lower-level, you shouldn’t use the current window metrics directly
  • Rely on adaptive tools such as: Grid’s adaptive and span APIs, Window insets, canonical layouts, Flow layouts, previews and multipreviews for all screen sizes, resizable emulator, scrollable modifiers, fillMax… modifiers, weights, adaptive Arrangements for lists and grids, and Accompanist adaptive utils

Migrate screens individually

We start by migrating Home and Email fragment’s UI to Compose HomeScreen and EmailScreen, and then pair them up in a Home and Email list-detail. Writing composable replicas of EmailFragment and HomeFragment is pretty straightforward, so we will ⏩ that.

We use Window Size classes to help convert the raw screen sizes into meaningful size classes and group them into buckets:

This information should only be passed down to screen-level composables. For lower levels, we shouldn’t use the current window metrics directly or through CompositionLocal, but rather use the space that the composable is given to render itself.

We pass the size class down as observable state to the “host” of the list-detail layout, which is the HomeFragment and its corresponding HomeScreen composable UI:

Pair into list-detail canonical layout

Whenever the window size class is Expanded and the content type is TWO_PANE, we want the Home screen host to show the Email list and Email detail in list-detail canonical layout:

Home screen hosting the list-detail layout

Or else, we want the Email list and Email screens separate in their own fragments, as it was originally:

Home (Email list) screen and Email screen

Here, we use a handy shortcut to quickly set up a list-detail layout and provide the strategy on how the two panes should be laid out: the TwoPane layout from Accompanist. However, you could also write your own list-detail, following the JetNews sample or the canonical layouts repository.

To ensure the list-detail looks good, we again use the multipreview for a sweet combo of theme and device previews:

To provide the best user experience, we want to keep the same scroll position of our Email list when switching between single and two pane (for example, when rotating a tablet device):

Keep the same scroll state on rotation

Since HomeScreen decides which mode to display, we hoist a singular scrollState and share it between the Email list alone and Email list in TwoPane:

So, this was all HomeScreen and its fragment so far — what about the EmailScreen and its fragment?

At the start of this step, we migrated both HomeFragment and EmailFragment UI into Compose HomeScreen and EmailScreen, and kept the fragments as outer wrappers for navigation. HomeScreen now hosts two use cases — Home with email list and Home with email list + email detail.

If the Home email list is in single pane and we tap on an email, we still need to handle navigating to EmailFragment and showing EmailScreen standalone, as before the migration:

Clicking in single pane: navigation from Home to Email fragment
Clicking in two pane: opening a new email without navigating

In a Compose-only app, this would be handled by Navigation Compose as the best way to navigate between exclusively composable destinations. But in our interop sample, we’re keeping Jetpack Navigation and, therefore, require fragments as destinations.

The EmailScreen detail needs to remain a separate EmailFragment destination, so that it can be navigated to from either HomeFragment or from any other point in the app.

Thankfully, we extracted the EmailScreen composable, so we just share the UI between these two scenarios:

onEmailClick in HomeScreen is different depending on whether we want to perform an actual navigation action from Home to Email in single pane, or just update the already visible Email composable with new email data in TwoPane (refer to previous images).

Note: At the time of writing this blog, Shared element transitions are still not possible, so we weren’t able to migrate element transitions.

💻 PR: Step 5

🏅 MVP player: NestedScrollConnection. Reply has a View BottomAppBar set in the activity_main.xml which “surrounds” the entire app and all of our nested composable screens, and collapses on scroll:

Collapsing bottom bar on scroll

Now that Home UI is in Compose, how do we maintain the collapsible interop between the View BottomAppBar and the HomeScreen composable? It’s actually so simple it looks like magic.

Just add rememberNestedScrollInteropConnection to the scrollable composable and it connects everything behind the scenes:

💎 Pro tip: DisplayFeature provides a description of a physical feature on the display. For example, the FoldingFeature describes a fold in the flexible display or a hinge between two physical display panels. It works similarly to WindowSize classes and is provided to TwoPane:

And with this, we conclude our migration process!

  • Migrate safely and incrementally — the code you feel comfortable (or just slightly uncomfortable!) migrating. Remember, your app doesn’t have to be fully 100% Compose to reap its benefits
  • Not everyone needs to be a Compose expert to start writing Compose code. You can learn it on the go, while writing and reviewing. The beauty of Compose is that you can achieve one design in multiple ways, all potentially equally “correct”. There’s an extensive set of materials to start your learning process, but in the meantime the best way of learning is by doing!
  • Have fun! I often forget this myself. You get caught up in deadlines, design requirements and a growing pile of Jira tickets, and you forget that we’re in Android development because (presumably) we love making apps. Having fun while doing so, even if it’s part of our work, should be mandatory! Compose contributes a lot to this fun experience.
  • Last but not least, enjoy removing numerous XMLs, resources and massive lines of View code 😈

Once you get more comfortable with Compose, migrating screens and components becomes faster, fun and rewarding, reducing the amount of code and improving readability.

But it can be a daunting task for larger codebases. That’s why refactoring old code is not a necessity and you can use Compose only for building new features and screens.

To enable this, consider migrating common UI and design systems first, but make an informed decision on whether to keep the View components in parallel or not.

Additionally, to ease the migration workload, you can keep using your original navigation framework and only think about migrating to Navigation Compose once all your screens are in Compose.

If you’re redesigning screens or features, consider taking this opportunity to adopt Compose, as you will have to do some rewriting anyways. And this opens up more possibilities of introducing form factor support along the way.

Stay tuned for the sequel!

Migrating to Jetpack Compose — an interop love story [part 1]
Migrating to Jetpack Compose — an interop love story [part 2]