Other considerations

While migrating from Views to Compose is purely UI-related, there are a lot of things to take into account to perform a safe and incremental migration. This page contains some considerations while migrating your View-based app to Compose.

Migrating your app's theme

Material Design is the recommended design system for theming Android apps.

For View-based apps, there are three versions of Material available:

  • Material Design 1 using the AppCompat library (i.e. Theme.AppCompat.*)
  • Material Design 2 using the MDC-Android library (i.e. Theme.MaterialComponents.*)
  • Material Design 3 using the MDC-Android library (i.e. Theme.Material3.*)

For Compose apps, there are two versions of Material available:

  • Material Design 2 using the Compose Material library (i.e. androidx.compose.material.MaterialTheme)
  • Material Design 3 using the Compose Material 3 library (i.e. androidx.compose.material3.MaterialTheme)

We recommend using the latest version (Material 3) if your app's design system is in a position to do so. There are migration guides available for both Views and Compose:

When creating new screens in Compose, regardless of which version of Material Design you're using, ensure that you apply a MaterialTheme before any composables that emit UI from the Compose Material libraries. The Material components (Button, Text, etc.) depend on a MaterialTheme being in place and their behaviour is undefined without it.

All Jetpack Compose samples use a custom Compose theme built on top of MaterialTheme.

See Design systems in Compose and Migrating XML themes to Compose to learn more.

If you use the Navigation component in your app, see the Navigating with Compose - Interoperability and Migrate Jetpack Navigation to Navigation Compose for more information.

Test your mixed Compose/Views UI

After migrating parts of your app to Compose, testing is critical to make sure you haven't broken anything.

When an activity or fragment uses Compose, you need to use createAndroidComposeRule instead of using ActivityScenarioRule. createAndroidComposeRule integrates ActivityScenarioRule with a ComposeTestRule that lets you test Compose and View code at the same time.

class MyActivityTest {
    @Rule
    @JvmField
    val composeTestRule = createAndroidComposeRule<MyActivity>()

    @Test
    fun testGreeting() {
        val greeting = InstrumentationRegistry.getInstrumentation()
            .targetContext.resources.getString(R.string.greeting)

        composeTestRule.onNodeWithText(greeting).assertIsDisplayed()
    }
}

See Testing your Compose layout to learn more about testing. For interoperability with UI testing frameworks, see interoperability with Espresso and interoperability with UiAutomator.

Integrating Compose with your existing app architecture

Unidirectional Data Flow (UDF) architecture patterns work seamlessly with Compose. If the app uses other types of architecture patterns instead, like Model View Presenter (MVP), we recommend you migrate that part of the UI to UDF before or whilst adopting Compose.

Using a ViewModel in Compose

If you use the Architecture Components ViewModel library, you can access a ViewModel from any composable by calling the viewModel() function, as explained in Compose and other libraries.

When adopting Compose, be careful about using the same ViewModel type in different composables as ViewModel elements follow View-lifecycle scopes. The scope will be either the host activity, fragment, or the navigation graph if the Navigation library is used.

For example, if the composables are hosted in an activity, viewModel() always returns the same instance that is only cleared when the activity finishes. In the following example, the same user ("user1") is greeted twice because the same GreetingViewModel instance is reused in all composables under the host activity. The first ViewModel instance created is reused in other composables.

class GreetingActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MaterialTheme {
                Column {
                    GreetingScreen("user1")
                    GreetingScreen("user2")
                }
            }
        }
    }
}

@Composable
fun GreetingScreen(
    userId: String,
    viewModel: GreetingViewModel = viewModel(  
        factory = GreetingViewModelFactory(userId)  
    )
) {
    val messageUser by viewModel.message.observeAsState("")
    Text(messageUser)
}

class GreetingViewModel(private val userId: String) : ViewModel() {
    private val _message = MutableLiveData("Hi $userId")
    val message: LiveData<String> = _message
}

class GreetingViewModelFactory(private val userId: String) : ViewModelProvider.Factory {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return GreetingViewModel(userId) as T
    }
}

As navigation graphs also scope ViewModel elements, composables that are a destination in a navigation graph have a different instance of the ViewModel. In this case, the ViewModel is scoped to the lifecycle of the destination, and it is cleared when the destination is removed from the backstack. In the following example, when the user navigates to the Profile screen, a new instance of GreetingViewModel is created.

@Composable
fun MyApp() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            GreetingScreen(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

State source of truth

When you adopt Compose in one part of the UI, it's possible that Compose and the View system code need to share data. When possible, we recommend you encapsulate that shared state in another class that follows UDF best practices used by both platforms; for example, in a ViewModel that exposes a stream of the shared data to emit data updates.

However, that's not always possible if the data to be shared is mutable or is tightly bound to a UI element. In that case, one system must be the source of truth, and that system needs to share any data updates to the other system. As a general rule of thumb, the source of truth should be owned by whichever element is closer to the root of the UI hierarchy.

Compose as the source of truth

Use the SideEffect composable to publish Compose state to non-Compose code. In this case, the source of truth is kept in a composable, which sends state updates.

As an example, your analytics library might allow you to segment your user population by attaching custom metadata (user properties in this example) to all subsequent analytics events. To communicate the user type of the current user to your analytics library, use SideEffect to update its value.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

For more information, see Side-effects in Compose.

View system as the source of truth

If the View system owns the state and shares it with Compose, we recommend that you wrap the state in mutableStateOf objects to make it thread-safe for Compose. If you use this approach, composable functions are simplified because they no longer have the source of truth, but the View system needs to update the mutable state and the Views that use that state.

In the following example, a CustomViewGroup contains a TextView and a ComposeView with a TextField composable inside. The TextView needs to show the content of what the user types in the TextField.

class CustomViewGroup @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : LinearLayout(context, attrs, defStyle) {

    // Source of truth in the View system as mutableStateOf
    // to make it thread-safe for Compose
    private var text by mutableStateOf("")

    private val textView: TextView

    init {
        orientation = VERTICAL

        textView = TextView(context)
        val composeView = ComposeView(context).apply {
            setContent {
                MaterialTheme {
                    TextField(value = text, onValueChange = { updateState(it) })
                }
            }
        }

        addView(textView)
        addView(composeView)
    }

    // Update both the source of truth and the TextView
    private fun updateState(newValue: String) {
        text = newValue
        textView.text = newValue
    }
}

Migrating shared UI

If you are migrating gradually to Compose, you might need to use shared UI elements in both Compose and the View system. For example, if your app has a custom CallToActionButton component, you might need to use it in both Compose and View-based screens.

In Compose, shared UI elements become composables that can be reused across the app, regardless of the element being styled using XML or being a custom view. For example, you'd create a CallToActionButton composable for your custom call to action Button component.

To use the composable in View-based screens, create a custom view wrapper that extends from AbstractComposeView. In its overridden Content composable, place the composable you created wrapped in your Compose theme, as shown in the example below:

@Composable
fun CallToActionButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Button(
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.secondary
        ),
        onClick = onClick,
        modifier = modifier,
    ) {
        Text(text)
    }
}

class CallToActionViewButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {

    var text by mutableStateOf("")
    var onClick by mutableStateOf({})

    @Composable
    override fun Content() {
        YourAppTheme {
            CallToActionButton(text, onClick)
        }
    }
}

Notice that the composable parameters become mutable variables inside the custom view. This makes the custom CallToActionViewButton view inflatable and usable, like a traditional view. See an example of this with View Binding below:

class ViewBindingActivity : ComponentActivity() {

    private lateinit var binding: ActivityExampleBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityExampleBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.callToAction.apply {
            text = getString(R.string.greeting)
            onClick = { /* Do something */ }
        }
    }
}

If the custom component contains mutable state, see State source of truth.

Prioritize splitting state from presentation

Traditionally, a View is stateful. A View manages fields that describe what to display, in addition to how to display it. When you convert a View to Compose, look to separate the data being rendered to achieve a unidirectional data flow, as explained further in state hoisting.

For example, a View has a visibility property that describes if it is visible, invisible, or gone. This is an inherent property of the View. While other pieces of code may change the visibility of a View, only the View itself really knows what its current visibility is. The logic for ensuring that a View is visible can be error prone, and is often tied to the View itself.

By contrast, Compose makes it easy to display entirely different composables using conditional logic in Kotlin:

@Composable
fun MyComposable(showCautionIcon: Boolean) {
    if (showCautionIcon) {
        CautionIcon(/* ... */)
    }
}

By design, CautionIcon doesn’t need to know or care why it is being displayed, and there is no concept of visibility: it either is in the Composition, or it isn’t.

By cleanly separating state management and presentation logic, you can more freely change how you display content as a conversion of state to UI. Being able to hoist state when needed also makes composables more reusable, since state ownership is more flexible.

Promote encapsulated and reusable components

View elements often have some idea of where they live: inside an Activity, a Dialog, a Fragment or somewhere inside another View hierarchy. Because they are often inflated from static layout files, the overall structure of a View tends to be very rigid. This results in tighter coupling, and makes it harder for a View to be changed or reused.

For example, a custom View might assume that it has a child view of a certain type with a certain id, and change its properties directly in response to some action. This tightly couples those View elements together: the custom View may crash or be broken if it can’t find the child, and the child likely can’t be reused without the custom View parent.

This is less of a problem in Compose with reusable composables. Parents can easily specify state and callbacks, so you can write reusable composables without having to know the exact place where they'll be used.

@Composable
fun AScreen() {
    var isEnabled by rememberSaveable { mutableStateOf(false) }

    Column {
        ImageWithEnabledOverlay(isEnabled)
        ControlPanelWithToggle(
            isEnabled = isEnabled,
            onEnabledChanged = { isEnabled = it }
        )
    }
}

In the example above, all three parts are more encapsulated and less coupled:

  • ImageWithEnabledOverlay only needs to know what the current isEnabled state is. It doesn’t need to know that ControlPanelWithToggle exists, or even how it is controllable.

  • ControlPanelWithToggle doesn’t know that ImageWithEnabledOverlay exists. There could be zero, one, or more ways that isEnabled is displayed, and ControlPanelWithToggle wouldn’t have to change.

  • To the parent, it doesn’t matter how deeply nested ImageWithEnabledOverlay or ControlPanelWithToggle are. Those children could be animating changes, swapping out content, or passing content on to other children.

This pattern is known as the inversion of control, which you can read more about in the CompositionLocal documentation.

Handling screen size changes

Having different resources for different window sizes is one of the main ways to create responsive View layouts. While qualified resources are still an option for screen-level layout decisions, Compose makes it much easier to change layouts entirely in code with normal conditional logic. See Use window size classes to learn more.

Additionally, refer to Support different screen sizes to learn about the techniques Compose offers to build adaptive UIs.

Nested scrolling with Views

For more information on how to enable nested scrolling interop between scrollable View elements and scrollable composables, nested in both directions, read through Nested scrolling interop.

Compose in RecyclerView

Composables in RecyclerView are performant since RecyclerView version 1.3.0-alpha02. Make sure you are on at least version 1.3.0-alpha02 of RecyclerView to see those benefits.

WindowInsets interop with Views

You may need to override default insets when your screen has both Views and Compose code in the same hierarchy. In this case, you need to be explicit in which one should consume the insets, and which one should ignore them.

For example, if your outermost layout is an Android View layout, you should consume the insets in the View system and ignore them for Compose. Alternatively, if your outermost layout is a composable, you should consume the insets in Compose, and pad the AndroidView composables accordingly.

By default, each ComposeView consumes all insets at the WindowInsetsCompat level of consumption. To change this default behavior, set ComposeView.consumeWindowInsets to false.

For more information, read the WindowInsets in Compose documentation.