For the complete documentation index, see llms.txt. This page is also available as Markdown.

Compose Part 4

Q31. How do you implement a bottom navigation bar with multiple back stacks using Compose Navigation?

In Compose Navigation, a single NavHost shares one back stack, so switching tabs would destroy the previous tab's navigation state. To give each tab its own independent back stack, you maintain a separate NavHostController per tab. The selected tab's NavHost is composed while others are conditionally excluded, but you must preserve the NavController instances across tab switches so the back stack is not lost. You achieve this by storing the controllers in a remember block keyed by the tab list, or by using a rememberSaveableStateHolder (via SaveableStateHolder) to save and restore the state of inactive tabs. Alternatively, you can wrap each tab's content in SaveableStateHolder.SaveableStateProvider to keep their LazyList scroll positions and NavController states alive. The system back button must be intercepted with BackHandler to pop the current tab's back stack before exiting the app.

@Composable
fun MultiStackBottomNav() {
    val tabs = listOf("Home", "Search", "Profile")
    var selectedTab by rememberSaveable { mutableIntStateOf(0) }

    // One NavController per tab, remembered across recomposition.
    val navControllers = remember {
        List(tabs.size) { mutableStateOf<<Bundle?>(null) }
    }.mapIndexed { index, savedState ->
        rememberNavController().apply {
            // Restore previous state if available
            savedState.value?.let { restoreState(it) }
        }
    }

    // Save state when switching away from a tab.
    DisposableEffect(selectedTab) {
        onDispose {
            navControllers.forEachIndexed { i, ctrl ->
                if (i != selectedTab) {
                    // In a real implementation, you'd save nav state bundles here.
                }
            }
        }
    }

    Scaffold(
        bottomBar = {
            NavigationBar {
                tabs.forEachIndexed { index, label ->
                    NavigationBarItem(
                        selected = selectedTab == index,
                        onClick = { selectedTab = index },
                        icon = { Icon(Icons.Default.Home, contentDescription = label) },
                        label = { Text(label) }
                    )
                }
            }
        }
    ) { padding ->
        Box(Modifier.padding(padding).fillMaxSize()) {
            tabs.forEachIndexed { index, _ ->
                if (index == selectedTab) {
                    TabNavHost(
                        navController = navControllers[index],
                        startDestination = "${tabs[index].lowercase()}_root"
                    )
                }
            }
        }
        // Intercept system back for the active tab.
        BackHandler {
            if (!navControllers[selectedTab].popBackStack()) {
                // If back stack is empty, maybe exit or switch to first tab.
            }
        }
    }
}

@Composable
fun TabNavHost(navController: NavHostController, startDestination: String) {
    NavHost(navController = navController, startDestination = startDestination) {
        composable("home_root") { HomeTab() }
        composable("home_detail/{id}") { DetailScreen(it.arguments?.getString("id")!!) }
        composable("search_root") { SearchTab() }
        composable("profile_root") { ProfileTab() }
    }
}

Q32. How do you embed a View inside Compose and vice versa? What are the lifecycle implications?

To embed a legacy View inside Compose, you use the AndroidView composable or the AndroidViewBinding helper. AndroidView takes a factory lambda that creates the View instance, and an optional update lambda that runs on every recomposition to sync external state into the view. The lifecycle implication is that the view is attached to the window inside the Compose layout pass, but it does not automatically participate in the Compose lifecycle. If the view is lifecycle-aware (e.g., MapView, CameraX preview), you must manually forward lifecycle events (onResume, onPause, onDestroy) by observing the LocalLifecycleOwner. To embed Compose inside a legacy View system, you use ComposeView. You call setContent { ... } on it, and it creates a Composition tied to the view's window. The critical lifecycle implication is disposal: when the ComposeView is detached or the Fragment/Activity is destroyed, you must set a ViewCompositionStrategy (e.g., DisposeOnViewTreeLifecycleDestroyed) to ensure the composition is disposed and does not leak coroutines or state.


Q33. What is AndroidView and when should you prefer it over rewriting a custom view in Compose?

AndroidView is a Compose composable that acts as a bridge to the legacy Android View system. It creates and hosts a native View instance within the Compose UI tree. You should prefer AndroidView when the view is complex, heavily optimized, or depends on APIs that are not yet available or performant in Compose. Prime examples include MapView (Google Maps), WebView, SurfaceView/TextureView (camera or video playback), complex charting libraries (MPAndroidChart), or existing custom views with thousands of lines of imperative drawing and touch handling. Rewriting these in Compose is often impractical due to the sheer volume of code, performance-critical rendering loops, or hardware acceleration requirements. However, you should not use AndroidView for simple UI elements like buttons, text fields, or cards—Compose equivalents are more performant, theme-aware, and accessible. Another valid case is during incremental migration: if a team has a highly customized RecyclerView or ViewPager with complex item animations, wrapping it in AndroidView allows the rest of the screen to be Compose while the heavy component is ported later.


Q34. How do you gradually migrate a large XML-based codebase to Compose without a full rewrite?

The safest migration strategy is screen-by-screen or component-by-component, using interoperability APIs to bridge the two worlds. The most common pattern is the bottom-up approach: start by building new screens entirely in Compose within existing Fragments or Activitys by returning a ComposeView from onCreateView. This lets you adopt Compose for new features while leaving legacy code untouched. For shared components (e.g., a custom chart or a complex list item), wrap the legacy custom view in AndroidView so Compose screens can reuse it. Conversely, if a legacy XML screen needs a new Compose widget, embed a ComposeView inside the XML layout. To avoid theming inconsistencies, create a Compose Theme that mirrors your existing XML theme attributes and bridge colors/dimens via LocalContext and TypedArray. You should also establish a ViewCompositionStrategy for every ComposeView to prevent memory leaks. Over time, as you touch legacy screens for feature work, rewrite them in Compose. Avoid rewriting stable, rarely touched legacy screens unless there is a clear business justification.


Q35. What are the challenges of using Compose inside RecyclerView or ViewPager2?

Both RecyclerView and ViewPager2 operate on a View-based recycling model. When you place a ComposeView inside an item layout, the RecyclerView will detach and reattach that view as the user scrolls. The first challenge is composition disposal: if you do not explicitly set a ViewCompositionStrategy, the Compose composition will leak or continue running off-screen, wasting CPU and memory. You must use DisposeOnViewTreeLifecycleDestroyed or DisposeOnDetachedFromWindow. The second challenge is state loss: because RecyclerView recycles the View (not the data identity), any remembered state inside the item's composition is tied to the view instance, not the data item. If the view is rebound to a different data item, the old state persists incorrectly. You must hoist all item state into the adapter/ViewModel and pass it as parameters, or use a unique key in ComposeView content to force recomposition. The third challenge is performance: instantiating a new ComposeView and running composition for every item bind is heavier than rebinding a legacy ViewHolder. For large lists, prefer LazyColumn (native Compose) over RecyclerView with ComposeView items.


Q36. How do you handle WindowInsets when mixing Compose and legacy View-based screens?

WindowInsets represent system bars (status bar, navigation bar, IME). In Compose, you use WindowInsets objects (e.g., WindowInsets.statusBars, WindowInsets.navigationBars, WindowInsets.ime) and convert them to padding via WindowInsets.asPaddingValues() or directly via Modifier.windowInsetsPadding(). In legacy Views, you handle them via WindowInsetsCompat and ViewCompat.setOnApplyWindowInsetsListener. The challenge when mixing the two is ensuring edge-to-edge behavior is applied consistently at the root and that insets are not consumed twice. If the legacy root consumes all insets with consumeSystemWindowInsets(), the Compose layer will receive zero insets. The solution is to apply edge-to-edge at the Activity level using WindowCompat.setDecorFitsSystemWindows(window, false) and then let each layer handle only the insets it needs. For Compose screens inside a legacy container, you can read the insets from the LocalView and forward them, or simply rely on LocalWindowInsets if available. For legacy views inside Compose, wrap them in AndroidView and apply insets inside the update block using ViewCompat.setOnApplyWindowInsetsListener.


Q37. How do you write unit tests for Composables? What is the role of createComposeRule?

Strictly speaking, createComposeRule is used for instrumented UI tests (located in src/androidTest), not JVM unit tests, because Compose requires an Android runtime, a window, and a rendering surface to create a Composition. createComposeRule returns a ComposeContentTestRule that sets up a blank ComponentActivity, hosts a ComposeView, and provides APIs to set content, interact with the semantics tree (onNodeWithText, performClick), and control the composition clock (mainClock). For pure JVM unit tests (fast, no emulator), you should test your state holders (ViewModels or plain classes) and business logic in isolation, since the UI layer in Compose is ideally a pure function of state. However, if the question refers to "unit tests" in the broader sense of testing small UI units, createComposeRule is the entry point. It lets you render a single composable in isolation, assert its visual/semantic state, and simulate user interactions. To test recomposition behavior or state changes, you use the rule to set content, mutate state via semantics actions, and assert that the node tree updates accordingly.


Q38. What is semantic testing in Compose, and how do you use SemanticsMatcher and SemanticsNodeInteraction?

Semantic testing in Compose means interacting with the UI through the semantics tree rather than the view hierarchy. The semantics tree is a parallel tree generated by Compose that describes the UI in terms of accessibility properties: text labels, content descriptions, roles (button, checkbox), states (checked, selected), and actions (click, scroll, setText). This tree is what screen readers and automation frameworks see. SemanticsMatcher is a predicate used to find nodes in this tree (e.g., hasText(), hasContentDescription(), hasClickAction(), isToggleable()). You can combine matchers with and/or. SemanticsNodeInteraction represents a single node found by a matcher and provides methods to perform actions (performClick, performTextInput, performScrollTo) and assertions (assertExists, assertIsDisplayed, assertTextEquals). Because Compose UI tests do not use view IDs (mostly), you rely on semantic properties. You can add custom semantics to any composable using Modifier.semantics { ... }, which is critical for testing custom layouts or canvas-based elements that do not expose text by default.


Q39. How do you test state hoisting and verify that state changes trigger recompositions correctly?

To test state hoisting, you separate the stateless composable from the stateful wrapper. For the stateless composable, you write a test that passes a controlled state and a mocked callback (e.g., a lambda that captures the invoked value). You then simulate the user action (e.g., type text, click a button) and assert that the callback was called with the expected argument. This proves the event flows upward correctly. To verify that state changes trigger recompositions, you use createComposeRule and observe the UI before and after mutating the state. Because Compose recomposes automatically, you do not manually "trigger" recomposition; instead, you mutate the state variable that the composable reads and assert that the semantics tree reflects the new value. You can also use Snapshot.sendApplyNotifications() or composeTestRule.waitForIdle() to ensure all pending recompositions have settled before asserting. For complex derived state, you can read the State object directly in the test or use snapshotFlow to observe changes.


Q40. What are the limitations of Compose UI testing compared to Espresso, and how do you work around them?

Compose UI tests run in the same process as the app and interact exclusively through the semantics tree. This design has several limitations compared to Espresso, which operates on the full Android view hierarchy and can interact with any View. First, WebViews: Compose semantics cannot see inside a WebView because it does not expose a Compose semantics tree. You must drop down to Espresso (Espresso.onView()) or UIAutomator for web content. Second, legacy Views inside AndroidView: while Compose tests can find an AndroidView node, they cannot deeply interact with the legacy view's internal children unless those children expose semantics. Use Espresso for precise interactions inside AndroidView. Third, cross-process UI: Compose tests cannot interact with system dialogs, notifications, or other apps; Espresso with UIAutomator can. Fourth, timing and idling: Espresso has sophisticated idling resources; Compose tests rely on waitForIdle() and mainClock, which can be less flexible for async legacy code. Fifth, view matchers: Espresso's Hamcrest matchers and ViewMatchers are more mature for complex view hierarchy queries. The workaround is a hybrid testing strategy: use Compose tests for pure Compose screens, and use Espresso or UIAutomator for boundaries involving WebViews, legacy views, or system UI. You can also add testTag or custom semantics to bridge the gap.

Last updated