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:
- Material 1 to Material 2 in Views
- Material 2 to Material 3 in Views
- Material 2 to Material 3 in 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.
Navigation
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 currentisEnabled
state is. It doesn’t need to know thatControlPanelWithToggle
exists, or even how it is controllable.ControlPanelWithToggle
doesn’t know thatImageWithEnabledOverlay
exists. There could be zero, one, or more ways thatisEnabled
is displayed, andControlPanelWithToggle
wouldn’t have to change.To the parent, it doesn’t matter how deeply nested
ImageWithEnabledOverlay
orControlPanelWithToggle
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.
Recommended for you
- Note: link text is displayed when JavaScript is off
- Display emoji
- Material Design 2 in Compose
- Window insets in Compose