Compete in the Jetpack Compose #AndroidDevChallenge for a chance to win one of over 1,000 prizes, including a Google Pixel 5. Learn more

Compose interoperability

Jetpack Compose is designed to work with the established view-based UI approach. If you're building a new app, the best option might be to implement your entire UI with Compose. But if you're modifying an existing app, you might not want to fully migrate your app all at once. Instead, you can combine Compose with your existing UI design implementation.

Adopting Compose in your app

There are two main ways you can integrate Compose with a view-based UI:

  • You can add Compose elements into your existing UI, either by creating an entirely new Compose-based screen, or by adding Compose elements into an existing activity, fragment or view layout.

  • You can add a view-based UI element into your composable functions. Doing so lets you add Android views into a Compose-based design.

Migrating the entire app to Compose is best achieved step by step with the granularity the project needs. You can migrate one screen at a time, or even one fragment or any other reusable UI element at a time. You can use several different approaches:

  • The bottom-up approach starts migrating the smaller UI elements on the screen, like a Button or a TextView, followed by its ViewGroup elements until everything is converted to composable functions.

  • The top-down approach starts migrating the fragments or view containers, like a FrameLayout, ConstraintLayout, or RecyclerView, followed by the smaller UI elements on the screen.

These approaches assume each screen is self-contained, but it's also possible to migrate shared UI, like a design system, to Jetpack Compose. See Migrating shared UI below for more information.

Interoperability APIs

While adopting Compose in your app, Compose and view-based UIs could be combined. Here's a list of APIs, recommendations, and tips to make the transition to Compose easier.

Compose in Android Views

You can add Compose-based UI into an existing app that uses a view-based design.

To create a new, entirely Compose-based screen, have your activity call the setContent() method, and pass whatever composable functions you like.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent { // In here, we can call composables!
            MaterialTheme {
                Greeting(name = "compose")
            }
        }
    }
}

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

This code looks just like what you'd find in a Compose-only app.

If you want to incorporate Compose UI content in a fragment or an existing View layout, use ComposeView and call its setContent() method. ComposeView is an Android View. You must attach the ComposeView to a ViewTreeLifecycleOwner. The ViewTreeLifecycleOwner allows the view to be attached and detached repeatedly while preserving the composition. ComponentActivity, FragmentActivity and AppCompatActivity are all examples of classes that implement ViewTreeLifecycleOwner.

You can put the ComposeView in your XML layout just like any other View:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:id="@+id/hello_world"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello Android!" />

    <androidx.compose.ui.platform.ComposeView
        android:id="@+id/compose_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

In the Kotlin source code, inflate the layout from the layout resource defined in XML. Then get the ComposeView using the XML ID, and call setContent() to use Compose.

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        // Inflate the layout for this fragment
        return inflater.inflate(
            R.layout.fragment_example, container, false
        ).apply {
            findViewById<ComposeView>(R.id.compose_view).setContent {
                // In Compose world
                MaterialTheme {
                    Text("Hello Compose!")
                }
            }
        }
    }
}

Two slightly different text elements, one above the other

Figure 1. This shows the output of the code that adds Compose elements in a View UI hierarchy. The "Hello Android!" text is displayed by a TextView widget. The "Hello Compose!" text is displayed by a Compose text element.

You can also include a ComposeView directly in a fragment if your full screen is built with Compose, which lets you avoid using an XML layout file entirely.

class ExampleFragment : Fragment() {

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        return ComposeView(requireContext()).apply {
            setContent {
                MaterialTheme {
                    // In Compose world
                    Text("Hello Compose!")
                }
            }
        }
    }
}

If there are multiple ComposeView elements in the same layout, each one must have a unique ID for savedInstanceState to work. There's more information about this in the SavedInstanceState section.

class ExampleFragment : Fragment() {

  override fun onCreateView(...): View = LinearLayout(...).apply {
      addView(ComposeView(...).apply {
        id = R.id.compose_view_x
        ...
      })
      addView(TextView(...))
      addView(ComposeView(...).apply {
        id = R.id.compose_view_y
        ...
      })
    }
  }
}

The ComposeView IDs are defined in the res/values/ids.xml file:

<resources>
    <item name="compose_view_x" type="id" />
    <item name="compose_view_y" type="id" />
</resources>

Android Views in Compose

You can include an Android View hierarchy in a Compose UI. This approach is particularly useful if you want to use UI elements that are not yet available in Compose, like AdView or MapView. This approach also lets you reuse custom views you may have designed.

To include a view element or hierarchy, use the AndroidView composable. AndroidView is passed a lambda that returns a View. AndroidView also provides an update callback that is called when the view is inflated. The AndroidView recomposes whenever a State read within the callback changes.

@Composable
fun CustomView() {
    val selectedItem = remember { mutableStateOf(0) }

    // Adds view to Compose
    AndroidView(
        modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
        factory = { context ->
            // Creates custom view
            CustomView(context).apply {
                // Sets up listeners for View -> Compose communication
                myView.setOnClickListener {
                    selectedItem.value = 1
                }
            }
        },
        update = { view ->
            // View's been inflated or state read in this block has been updated
            // Add logic here if necessary

            // As selectedItem is read here, AndroidView will recompose
            // whenever the state changes
            // Example of Compose -> View communication
            view.coordinator.selectedItem = selectedItem.value
        }
    )
}

@Composable
fun ContentExample() {
    Column(Modifier.fillMaxSize()) {
        Text("Look at this CustomView!")
        CustomView()
    }
}

To embed an XML layout, use the AndroidViewBinding API, which is provided by the androidx.compose.ui:ui-viewbinding library. To do this, your project must enable view binding.

AndroidView, like many other built-in composables, takes a Modifier parameter that can be used, for example, to set its position in the parent composable.

@Composable
fun AndroidViewBindingExample() {
    AndroidViewBinding(ExampleLayoutBinding::inflate) {
        exampleView.setBackgroundColor(Color.GRAY)
    }
}

Calling the Android framework from Compose

Compose is tightly bound to the Android framework classes. For example, it's hosted on Android View classes, like Activity or Fragment, and might need to make use of Android framework classes like the Context, system resources, Service, or BroadcastReceiver.

To learn more about system resources, check out the Resources in Compose documentation.

Composition Locals

CompositionLocal classes allow passing data implicitly through composable functions. They're usually provided with a value in a certain node of the UI tree. That value can be used by its composable descendants without declaring the CompositionLocal as a parameter in the composable function.

CompositionLocal is used to propagate values for Android framework types in Compose such as Context, Configuration or the View in which the Compose code is hosted with the corresponding LocalContext, LocalConfiguration, or LocalView. Note that CompositionLocal classes are prefixed with Local for better discoverability with auto-complete in the IDE.

Access the current value of a CompositionLocal by using its current property. For example, the code below creates a custom view using the Context available in that part of the Compose UI tree by calling LocalContext.current.

@Composable
fun rememberCustomView(): CustomView {
    val context = LocalContext.current
    return remember { CustomView(context).apply { /*...*/ } }
}

For a more complete example, take a look at the Case Study: BroadcastReceivers section at the end of this document.

Other interactions

If there isn't a utility defined for the interaction you need, the best practice is to follow the general Compose guideline, data flows down, events flow up (discussed at more length in Thinking in Compose). For example, this composable launches a different activity:

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // get data from savedInstanceState
        setContent {
            MaterialTheme {
                ExampleComposable(data, onButtonClick = {
                    startActivity(/*...*/)
                })
            }
        }
    }
}

@Composable
fun ExampleComposable(data: DataExample, onButtonClick: () -> Unit) {
    Button(onClick = onButtonClick) {
        Text(data.title)
    }
}

Integration with common libraries

To see how Compose is integrated with common libraries like ViewModel, Flow, Paging, or Hilt, check out the Compose integration with common libraries guide.

Theming

Following Material Design, using the Material Design Components for Android (MDC) library, is the recommended way to theme Android apps. As covered in the Compose Theming documentation, Compose implements these concepts with the MaterialTheme composable.

When creating new screens in Compose, you should ensure that you apply a MaterialTheme before any composables that emit UI from the material components library. 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.

Multiple sources of truth

An existing app is likely to have a large amount of theming and styling for views. When you introduce Compose in an existing app, you'll need to migrate the theme to use MaterialTheme for any Compose screens. This means your app's theming will have 2 sources of truth: the View-based theme and the Compose theme. Any changes to your styling would need to be made in multiple places.

If your plan is to fully migrate the app to Compose, you'll eventually need to create a Compose version of the existing theme. The issue is, the earlier in your development process you create your Compose theme, the more maintenance you'll have to do during development.

MDC Compose Theme adapter

If you're using the MDC library in your Android app, the MDC Compose Theme Adapter library allows you to easily re-use the color, typography and shape theming from your existing View-based themes, in your composables:

import com.google.android.material.composethemeadapter.MdcTheme

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MdcTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

See the MDC library documentation for more information.

AppCompat Compose Theme adapter

AppCompat Compose Theme Adapter library allows you to easily re-use AppCompat XML themes for theming in Jetpack Compose. It creates a MaterialTheme with the color and typography values from the context's theme.

import com.google.accompanist.appcompattheme.AppCompatTheme

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            AppCompatTheme {
                // Colors, typography, and shape have been read from the
                // View-based theme used in this Activity
                ExampleComposable(/*...*/)
            }
        }
    }
}

Default component styles

Both the MDC and AppCompat Compose Theme Adapter libraries don't read any theme-defined default widget styles. This is because Compose doesn't have the concept of default composables.

Read more about component styles and custom design systems in the Theming documentation.

Theme Overlays in Compose

When migrating View-based screens to Compose, watch out for usages of the android:theme attribute. It's likely you need a new MaterialTheme in that part of the Compose UI tree.

Read more about this in the Theming guide.

WindowInsets and IME Animations

You can handle WindowInsets by using the accompanist-insets library, which provides composables and modifiers to handle them within your layouts, as well as support for IME animations.

class ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
                ProvideWindowInsets {
                    MyScreen()
                }
            }
        }
    }
}

@Composable
fun MyScreen() {
    Box {
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding(), // Move it out from under the nav bar
            onClick = { }
        ) {
            Icon( /* ... */)
        }
    }
}

Animation showing a UI element scrolling up and down to make way for a keyboard

Figure 2. IME animations using the accompanist-insets library.

See the accompanists-insets library documentation for more information.

Handling screen size changes

When migrating an app that uses different XML layouts depending on the screen size, use the BoxWithConstraints composable to know the minimum and maximum size a composable can occupy.

@Composable
fun MyComposable() {
    BoxWithConstraints {
        if (minWidth < 480.dp) {
            /* Show grid with 4 columns */
        } else if (minWidth < 720.dp) {
            /* Show grid with 8 columns */
        } else {
            /* Show grid with 12 columns */
        }
    }
}

Architecture and state source of truth

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.

ViewModels 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 the Compose integration with common libraries documentation.

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 will only be cleared when the activity finishes. In the following example, the same user will be 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 ExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

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

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

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

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 will be 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 MyScreen() {
    NavHost(rememberNavController(), startDestination = "profile/{userId}") {
        /* ... */
        composable("profile/{userId}") { backStackEntry ->
            Greeting(backStackEntry.arguments?.getString("userId") ?: "")
        }
    }
}

@Composable
fun Greeting(userId: String) {
    val greetingViewModel: GreetingViewModel = viewModel(
        factory = GreetingViewModelFactory(userId)
    )
    val messageUser by greetingViewModel.message.observeAsState("")

    Text(messageUser)
}

State source of truth

When you adopt Compose in one part of the UI, it's possible that Compose and the View system code will 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 example, an OnBackPressedCallback needs to be registered to listen for the back button being pressed on a OnBackPressedDispatcher. To communicate whether the callback should be enabled or not, use SideEffect to update its value.

@Composable
fun BackHandler(
    enabled: Boolean,
    backDispatcher: OnBackPressedDispatcher,
    onBack: () -> Unit
) {

    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)

    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        // Always intercept back events. See the SideEffect for a more complete version
        object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }

    // On every successful composition, update the callback with the `enabled` value
    // to tell `backCallback` whether back events should be intercepted or not
    SideEffect {
        backCallback.isEnabled = enabled
    }

    // If `backDispatcher` changes, dispose and reset the effect
    DisposableEffect(backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(backCallback)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}

For more information about side effects, check out the Lifecycle and Side effects documentation.

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.

In order to use the composable in View-based screens, you need to 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(
            backgroundColor = MaterialTheme.colors.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<String>("")
    var onClick by mutableStateOf<() -> Unit>({})

    @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, with for example View Binding, like a traditional view. See the example below:

class ExampleActivity : Activity() {

    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.something)
            onClick = { /* Do something */ }
        }
    }
}

If the custom component contains mutable state, check out the state source of truth section above.

Testing

You can test your combined View and Compose code together by using the createAndroidComposeRule() API. For more information, see Testing your Compose layout.

Case Study: BroadcastReceivers

For a more realistic example of features you might want to migrate or implement in Compose and to showcase CompositionLocal and side effects, let's say a BroadcastReceiver needs to be registered from a composable function.

The solution makes use of LocalContext to use the current context, and rememberUpdatedState and DisposableEffect side effects.

@Composable
fun SystemBroadcastReceiver(
    systemAction: String,
    onSystemEvent: (intent: Intent?) -> Unit
) {
    // Grab the current context in this part of the UI tree
    val context = LocalContext.current

    // Safely use the latest onSystemEvent lambda passed to the function
    val currentOnSystemEvent by rememberUpdatedState(onSystemEvent)

    // If either context or systemAction changes, unregister and register again
    DisposableEffect(context, systemAction) {
        val intentFilter = IntentFilter(systemAction)
        val broadcast = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                onSystemEvent(intent)
            }
        }

        context.registerReceiver(broadcast, intentFilter)

        // When the effect leaves the Composition, remove the callback
        onDispose {
            context.unregisterReceiver(broadcast)
        }
    }
}

@Composable
fun HomeScreen() {

    SystemBroadcastReceiver(Intent.ACTION_BATTERY_CHANGED) { batteryStatus ->
        val isCharging = /* Get from batteryStatus ... */ true
        /* Do something if the device is charging */
    }

    /* Rest of the HomeScreen */
}