Google se compromete a impulsar la igualdad racial para las comunidades afrodescendientes. Obtén información al respecto.

Migrating to Jetpack Compose

Compose and the View system can work together side by side.

In this codelab, you'll be migrating parts of the Sunflower‘s plant details screen to Compose. We created a copy of the project for you to try out migrating a realistic app to Compose.

By the end of the codelab, you'll be able to continue with the migration and convert the rest of Sunflower's screens if you wish.

What you will learn

In this codelab, you will learn:

  • The different migration paths you can follow
  • How to incrementally migrate an app to Compose
  • How to add Compose to an existing screen built using Android views
  • How to use an Android View from inside Compose
  • How you can use your theme from the View system in Compose
  • How to test a screen with View system and Compose code

Prerequisites

What you will need

Migrating to Compose depends on you and your team. There are many different ways to integrate Jetpack Compose into an existing Android app. Two of the more common strategies are to migrate only new screens and to use compose as a replacement for the View system for part of an existing screen.

Compose in new screens

A common approach when refactoring your app to a new technology is adopting it in new features you build for your app. In this case, new screens apply. If you need to build a new UI screen for your app, consider using Compose for it while the rest of the app might remain in the View system.

In this case, you'll be doing Compose interop at the edges of those migrated features.

Compose and Views together

Given a screen, you can have some parts migrated to Compose and others in the View system. For example, you could migrate a RecyclerView while leaving the rest of the screen in the View system.

Or vice-versa, use Compose as the outer layout and use some existing views that might not be available in Compose such as MapView or AdView.

Complete migration

Migrate whole fragments or screens to Compose one at a time. Simplest, but very coarse grained.

And in this codelab?

In this codelab, you'll be doing an incremental migration to Compose of the Sunflower's plant details screen having Compose and Views working together. After that, you'll know enough to continue with the migration if you wish.

Get the code

Get the codelab code from GitHub:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Alternatively you can download the repository as a Zip file:

Download Zip

Open Android Studio

This codelab requires the latest canary of Android Studio 4.2. If you need to download Android Studio, you can do so here.

Running the sample app

The code you just downloaded contains code for all Compose codelabs available. To complete this codelab, open the MigrationCodelab project inside Android Studio.

In this codelab, you're going to migrate Sunflower‘s plant details screen to Compose. You can open the plant details screen by tapping in one of the plants available in the plant list screen.

b1b024893a997c68.png

Project setup

The project is built in multiple GitHub branches:

  • main is the branch you checked out or downloaded. This is the codelab's starting point.
  • end contains the solution to this codelab.

We recommend that you start with the code in the main branch and follow the codelab step-by-step at your own pace.

During the codelab, you'll be presented with snippets of code that you'll need to add to the project. In some places, you'll also need to remove code that is explicitly mentioned in comments on the code snippets.

To get the end branch using git, use this command:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

Or download the solution code from here:

Download the final code

Frequently asked questions

Compose is already added to the code you downloaded from the main branch. However, let's take a look at what's required to have it working.

If you open the app/build.gradle (or build.gradle (Module: compose-migration.app)) file, see how it imports the Compose dependencies and enables Android Studio to work with Compose with using the buildFeatures { compose true } flag.

app/build.gradle

android {
    ...
    kotlinOptions {
        jvmTarget = '1.8'
        useIR = true
    }
    buildFeatures {
        ...
        compose true
    }
    composeOptions {
        kotlinCompilerVersion rootProject.kotlinVersion
        kotlinCompilerExtensionVersion rootProject.composeVersion
    }
}

dependencies {
    ...
    // Compose
    implementation "androidx.compose.runtime:runtime:$rootProject.composeVersion"
    implementation "androidx.compose.ui:ui:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation:$rootProject.composeVersion"
    implementation "androidx.compose.foundation:foundation-layout:$rootProject.composeVersion"
    implementation "androidx.compose.material:material:$rootProject.composeVersion"
    implementation "androidx.compose.runtime:runtime-livedata:$rootProject.composeVersion"
    implementation "androidx.ui:ui-tooling:$rootProject.composeVersion"
    implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

The version of those dependencies are defined in the root build.gradle file.

In the plant details screen, we'll migrate the description of the plant to Compose while leaving the overall structure of the screen intact. Here, you'll be following the Compose and Views together migration strategy mentioned in the Plan your migration section.

Compose needs a host Activity or Fragment in order to render UI. In Sunflower, as all screens use fragments, you'll be using ComposeView: an Android View that can host Compose UI content using its setContent method.

Removing XML code

Let's start with the migration! Open fragment_plant_detail.xml and do the following:

  1. Switch to the Code view
  2. Remove the ConstraintLayout code and nested TextViews inside the NestedScrollView (the codelab will compare and reference the XML code when migrating individual items, having the code commented out will be useful)
  3. Add a ComposeView that will host Compose code instead with compose_view as view id

fragment_plant_detail.xml

<androidx.core.widget.NestedScrollView
    android:id="@+id/plant_detail_scrollview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="@dimen/fab_bottom_padding"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    // Step 2) Remove the ConstraintLayout and its children
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_margin="@dimen/margin_normal">

        <TextView
            android:id="@+id/plant_detail_name"
        ...
    
    </androidx.constraintlayout.widget.ConstraintLayout>
    // End Step 2) Comment out until here

    // Step 3) Add a ComposeView to host Compose code
    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</androidx.core.widget.NestedScrollView>

Adding Compose code

At this point, you are ready to start migrating the plant details screen to Compose!

Throughout the codelab, you'll be adding Compose code to the PlantDetailDescription.kt file under the plantdetail folder. Open it and see how we have a placeholder "Hello Compose!" text already available in the project.

plantdetail/PlantDetailDescription.kt

@Composable
fun PlantDetailDescription() {
    Text("Hello Compose")
}

Let's display this on the screen by calling this composable from the ComposeView we added in the previous step. Open plantdetail/PlantDetailFragment.kt.

As the screen is using data binding, you can directly access the composeView and call setContent to display Compose code on the screen. Call the PlantDetailDescription composable inside MaterialTheme as Sunflower uses material design.

plantdetail/PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        val binding = DataBindingUtil.inflate<FragmentPlantDetailBinding>(
            inflater, R.layout.fragment_plant_detail, container, false
        ).apply {
            ...
            composeView.setContent {
                // You're in Compose world!
                MaterialTheme {
                    PlantDetailDescription()
                }
            }
        }
        ...
    }
}

If you run the app, you can see "Hello Compose!" is displayed on the screen.

abb6b7763cc36838.png

Let's start by migrating the name of the plant. More exactly, the TextView with id @+id/plant_detail_name you removed in fragment_plant_detail.xml. Here's the XML code:

<TextView
    android:id="@+id/plant_detail_name"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@{viewModel.plant.name}"
    android:textAppearance="?attr/textAppearanceHeadline5"
    ... />

See how it has a textAppearanceHeadline5 style, has a horizontal margin of 8.dp and it's centered horizontally on the screen. However, the title to be displayed is observed from a LiveData exposed by PlantDetailViewModel that comes from the repository layer.

As observing a LiveData is covered later, let's assume we have the name available and is passed as a parameter to a new PlantName composable that we create in the PlantDetailDescription.kt file. This composable will be called from the PlantDetailDescription composable later.

PlantDetailDescription.kt

@Composable
private fun PlantName(name: String) {
    Text(
        text = name,
        style = MaterialTheme.typography.h5,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .wrapContentWidth(Alignment.CenterHorizontally)
    )
}

@Preview
@Composable
private fun PlantNamePreview() {
    MaterialTheme {
        PlantName("Apple")
    }
}

With preview:

96f0ac15d8cd0745.png

Where:

  • Text's style is MaterialTheme.typography.h5 that maps to the textAppearanceHeadline5 from the XML code.
  • The modifiers decorate the Text to adjust it to look like the XML version:
  • fillMaxWidth modifier corresponds to the android:layout_width="match_parent" in XML code.
  • horizontal padding of margin_small that is a value from the View system using the dimensionResource helper function.
  • wrapContentWidth to align the Text horizontally.

Now, let's wire up the title to the screen. To do that, you'll need to load the data using the PlantDetailViewModel. For that, Compose comes with integrations for ViewModel and LiveData.

ViewModels

As an instance of the PlantDetailViewModel is used in the Fragment, we could pass it as a parameter to PlantDetailDescription and that'd be it.

Open the PlantDetailDescription.kt file and add the PlantDetailViewModel parameter to PlantDetailDescription:

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    ...
}

Now, pass the instance of the ViewModel when calling this composable from the fragment:

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    override fun onCreateView(...): View? {
        ...
        composeView.setContent {
            MaterialTheme {
                PlantDetailDescription(plantDetailViewModel)
            }
        }
    }
}

LiveData

With this, you already have access to the PlantDetailViewModel's LiveData<Plant> field to get the plant's name.

To observe LiveData from a composable, use the LiveData.observeAsState() function.

As values emitted by the LiveData can be null, you'd need to wrap it's usage in a null check. Because of that and for reusability purposes, it's a good pattern to split the LiveData consumption and listening in different composables. Thus, create a new composable called PlantDetailContent that will display Plant information.

Given the above, this is how the PlantDetailDescription.kt file looks like after adding the LiveData observation.

PlantDetailDescription.kt

@Composable
fun PlantDetailDescription(plantDetailViewModel: PlantDetailViewModel) {
    // Observes values coming from the VM's LiveData<Plant> field
    val plant by plantDetailViewModel.plant.observeAsState()

    // If plant is not null, display the content
    plant?.let {
        PlantDetailContent(it)
    }
}

@Composable
fun PlantDetailContent(plant: Plant) {
    PlantName(plant.name)
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

With same preview as PlantNamePreview since PlantDetailContent just calls PlantName at the moment:

3581b7b21b044e8d.png

Now, you've wired up the ViewModel all the way to display a plant name in Compose. In the next few sections, you'll build the rest of the composables and wire them up to the ViewModel in a similar way.

Now, it's easier to complete what's missing in our UI: the watering info and plant description. Following the same looking at the XML code approach you did before, you can already migrate the rest of the screen.

The watering info XML code you removed before from fragment_plant_detail.xml consists of two TextViews with ids plant_watering_header and plant_watering.

<TextView
    android:id="@+id/plant_watering_header"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginTop="@dimen/margin_normal"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    android:text="@string/watering_needs_prefix"
    android:textColor="?attr/colorAccent"
    android:textStyle="bold"
    ... />

<TextView
    android:id="@+id/plant_watering"
    ...
    android:layout_marginStart="@dimen/margin_small"
    android:layout_marginEnd="@dimen/margin_small"
    android:gravity="center_horizontal"
    app:wateringText="@{viewModel.plant.wateringInterval}"
    .../>

Similar to what you did before, create a new composable called PlantWatering and add Texts to display the watering information on the screen:

PlantDetailDescription.kt

@Composable
private fun PlantWatering(wateringInterval: Int) {
    Column(Modifier.fillMaxWidth()) {
        // Same modifier used by both Texts
        val centerWithPaddingModifier = Modifier
            .padding(horizontal = dimensionResource(R.dimen.margin_small))
            .align(Alignment.CenterHorizontally)

        val normalPadding = dimensionResource(R.dimen.margin_normal)

        Text(
            text = stringResource(R.string.watering_needs_prefix),
            color = MaterialTheme.colors.primaryVariant,
            fontWeight = FontWeight.Bold,
            modifier = centerWithPaddingModifier.padding(top = normalPadding)
        )

        val wateringIntervalText = ContextAmbient.current.resources.getQuantityString(
            R.plurals.watering_needs_suffix, wateringInterval, wateringInterval
        )
        Text(
            text = wateringIntervalText,
            modifier = centerWithPaddingModifier.padding(bottom = normalPadding)
        )
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MaterialTheme {
        PlantWatering(7)
    }
}

With preview:

741b92db42c262df.png

Some things to notice:

  • As the horizontal padding and align decoration is shared by the Text composables, you can reuse the Modifier by assigning it to a local variable (i.e. centerWithPaddingModifier). Since modifiers are regular Kotlin objects, you can do that.
  • Compose's MaterialTheme doesn't have an exact match to the colorAccent used in plant_watering_header. For now, let's use MaterialTheme.colors.primaryVariant that you'll improve in the theming section.

Let's connect all the pieces together and call PlantWatering from the PlantDetailContent as well. The ConstraintLayout XML code we removed at the beginning had a margin of 16.dp that we need to include in our Compose code.

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/margin_normal">

In PlantDetailContent, create a Column to display the name and watering info together and have that as padding. Also, so that the background color and the text colors used are appropriate, add a Surface that will handle that.

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
        }
    }
}

If you refresh the preview, you'll see this:

97f35931b72c29b.png

Now, let's migrate the plant description. The code in fragment_plant_detail.xml had a TextView with app:renderHtml="@{viewModel.plant.description}" to tell the XML what text to display on the screen. renderHtml is a binding adapter that you can find in the PlantDetailBindingAdapters.kt file. The implementation uses HtmlCompat.fromHtml to set the text on the TextView!

However, the alpha version of Compose doesn't have support for Spanned classes nor displaying HTML formatted text. Thus, we need to use a TextView from the View system in the Compose code to bypass this limitation.

As Compose is not able to render HTML code yet, you'll create a TextView programmatically to do exactly that using the AndroidView API.

AndroidView takes a View as a parameter and gives you a callback for when the View has been inflated.

A good practice in Compose when having variables in composables is remembering them if they are expensive to create. That's because composables can recompose at any time given a system signal. Thus, you can initialize a TextView that reacts to HTML interactions like this:

PlantDetailDescription.kt

@Composable
private fun rememberTextView(): TextView {
    val context = ContextAmbient.current
    return remember {
        TextView(context).apply {
            movementMethod = LinkMovementMethod.getInstance()
        }
    }
}

Then, you'd call from a new PlantDescription composable. This composable calls AndroidView with the TextView we just remembered in a lambda. In the update callback, set the text with a remembered HTML formatted description.

PlantDetailDescription.kt

@Composable
private fun PlantDescription(description: String) {
    val textView = rememberTextView()

    val htmlDescription = remember(description) {
        HtmlCompat.fromHtml(description, HtmlCompat.FROM_HTML_MODE_COMPACT)
    }
    AndroidView({ textView }) {
        it.text = htmlDescription
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MaterialTheme {
        PlantDescription("HTML<br><br>description")
    }
}

Preview:

95d8ca1832c1ef26.png

Notice that htmlDescription remembers the HTML description for a given description passed as a parameter. If the description parameter changes, the htmlDescription code inside remember will execute again.

Similarly, the AndroidView update callback will recompose if htmlDescription changes. Any state read inside the callback causes a recomposition.

Let's add PlantDescription to the PlantDetailContent composable and change preview code to display a HTML description too:

PlantDetailDescription.kt

@Composable
fun PlantDetailContent(plant: Plant) {
    Surface {
        Column(Modifier.padding(dimensionResource(R.dimen.margin_normal))) {
            PlantName(plant.name)
            PlantWatering(plant.wateringInterval)
            PlantDescription(plant.description)
        }
    }
}

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MaterialTheme {
        PlantDetailContent(plant)
    }
}

With preview:

aa43efe444af4f75.png

At this point, you've migrated all the content inside the original ConstraintLayout to Compose. You can run the app to check that it's working as expected.

e2f5c3ec20d4966f.gif

We have the text content of plant details migrated to Compose. However, you might have noticed that Compose is not using the right theme colors. It's using purple in the plant name when it should be using green.

At this early migration stage, you might want Compose to inherit the themes available in the View system instead of rewriting your own Material theme in Compose from scratch. The Material themes work perfectly with all the Material design components that comes with Compose.

To reuse your View system MDC theme in Compose, you can use the compose-theme-adapter. The MdcTheme function will automatically read the host context's MDC theme and pass them to MaterialTheme on your behalf for both light and dark themes. Even though you just need the theme colors for this codelab, the library also reads the View system's shapes and typography.

The library is already included in the app/build.gradle file as follows:

...
dependencies {
    ...
    implementation "com.google.android.material:compose-theme-adapter:$rootProject.composeVersion"
    ...
}

To use this, replace MaterialTheme usages for MdcTheme. For example, in PlantDetailFragment:

PlantDetailFragment.kt

class PlantDetailFragment : Fragment() {
    ...
    composeView.setContent {
        MdcTheme {
            PlantDetailDescription(plantDetailViewModel)
        }
    }
}

And all the preview composables in the PlantDetailDescription.kt file:

PlantDetailDescription.kt

@Preview
@Composable
private fun PlantDetailContentPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MdcTheme {
        PlantDetailContent(plant)
    }
}

@Preview
@Composable
private fun PlantNamePreview() {
    MdcTheme {
        PlantName("Apple")
    }
}

@Preview
@Composable
private fun PlantWateringPreview() {
    MdcTheme {
        PlantWatering(7)
    }
}

@Preview
@Composable
private fun PlantDescriptionPreview() {
    MdcTheme {
        PlantDescription("HTML<br><br>description")
    }
}

As you can see in the preview, MdcTheme is picking up the colors from the theme in styles.xml file.

44dc929c9b63137d.png

You can also preview the UI in dark theme by creating a new function and passing Configuration.UI_MODE_NIGHT_YES to the uiMode of the preview:

@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlantDetailContentDarkPreview() {
    val plant = Plant("id", "Apple", "HTML<br><br>description", 3, 30, "")
    MdcTheme {
        PlantDetailContent(plant)
    }
}

With preview:

8b676db6b0793855.png

If you run the app, it behaves exactly the same as before the migration in both light and dark theme:

2b95ea2dee5ed3ae.gif

After migrating parts of the plant detail screen to Compose, testing is critical to make sure you haven't broken anything.

In Sunflower, PlantDetailFragmentTest located in the androidTest folder tests some functionality of the app. Open the file and take a look at the current code:

  • testPlantName checks for the name of the plant on the screen
  • testShareTextIntent checks that the right intent is triggered after tapping on the share button

When an activity or fragment uses compose, instead of using ActivityScenarioRule, you need to use createAndroidComposeRule that integrates ActivityScenarioRule with a ComposeTestRule that lets you test Compose code.

In PlantDetailFragmentTest, replace the usage ActivityScenarioRule with createAndroidComposeRule. When the activity rule is needed to configure the test, use the activityRule attribute from AndroidComposeTestRule as follows:

@RunWith(AndroidJUnit4::class)
class PlantDetailFragmentTest {

    @Rule
    @JvmField
    val composeTestRule = AndroidComposeTestRule<GardenActivity>()
   
    ...

    @Before
    fun jumpToPlantDetailFragment() {
        composeTestRule.activityRule.scenario.onActivity { gardenActivity ->
            activity = gardenActivity

            val bundle = Bundle().apply { putString("plantId", "malus-pumila") }
            findNavController(it, R.id.nav_host).navigate(R.id.plant_detail_fragment, bundle)
        }
    }

    ...
}

If you run the tests, testPlantName will fail! testPlantName checks for a TextView to be on the screen. However, you migrated that part of the UI to Compose. Thus, you need to use Compose assertions instead:

@Test
fun testPlantName() {
    composeTestRule.onNodeWithText("Apple").assertIsDisplayed()
}

If you run the tests, you'll see all of them pass.

284a1c1cffbe911b.png

Congratulations, you've successfully completed this codelab!

The compose branch of the original Sunflower github project completely migrates the plant details screen to Compose. Apart from what you've done in this codelab, it also simulates the behavior of the CollapsingToolbarLayout. This involves:

  • Loading images with Compose
  • Animations
  • Better dimensions handling
  • And more!

What's next?

Check out the other codelabs on the Compose pathway:

Further reading