> For the complete documentation index, see [llms.txt](https://notes.tejpratapsingh.com/_/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://notes.tejpratapsingh.com/_/android-tips/interview/compose/compose-part-4.md).

# 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.

```kotlin
@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.

```kotlin
// Embedding a lifecycle-aware MapView inside Compose.
@Composable
fun MapScreen(latLng: LatLng, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val lifecycle = LocalLifecycleOwner.current.lifecycle

    AndroidView(
        factory = {
            MapView(context).apply {
                // Initialize map
                getMapAsync { googleMap ->
                    googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 12f))
                }
            }
        },
        modifier = modifier,
        update = { mapView ->
            // Sync state on recomposition
            mapView.getMapAsync { it.animateCamera(CameraUpdateFactory.newLatLng(latLng)) }
        },
        onRelease = { mapView ->
            // Cleanup if needed
        }
    )

    // Manually forward lifecycle events because MapView expects them.
    DisposableEffect(lifecycle) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> { /* mapView?.onResume() */ }
                Lifecycle.Event.ON_PAUSE -> { /* mapView?.onPause() */ }
                Lifecycle.Event.ON_DESTROY -> { /* mapView?.onDestroy() */ }
                else -> {}
            }
        }
        lifecycle.addObserver(observer)
        onDispose { lifecycle.removeObserver(observer) }
    }
}

// Embedding Compose inside a legacy Fragment/Activity.
class LegacyFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            // CRITICAL: Dispose when the view tree lifecycle is destroyed to prevent leaks.
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                MaterialTheme {
                    Text("Hello from Compose inside a Fragment")
                }
            }
        }
    }
}
```

***

#### **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.

```kotlin
// Prefer AndroidView for a complex GL surface or camera preview that cannot be easily replicated.
@Composable
fun CameraPreview(
    modifier: Modifier = Modifier,
    onPreviewReady: (PreviewView) -> Unit
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    AndroidView(
        factory = { context ->
            PreviewView(context).apply {
                scaleType = PreviewView.ScaleType.FILL_CENTER
                implementationMode = PreviewView.ImplementationMode.COMPATIBLE
                onPreviewReady(this)
            }
        },
        modifier = modifier,
        onReset = { /* Called if the factory needs to be re-invoked; rarely needed. */ }
    )

    // Bind lifecycle-aware camera use cases outside the factory/update.
    DisposableEffect(lifecycleOwner) {
        val cameraController = LifecycleCameraController(context)
        cameraController.bindToLifecycle(lifecycleOwner)
        onDispose { cameraController.unbind() }
    }
}

// Do NOT use AndroidView for simple components. Prefer pure Compose:
@Composable
fun CustomButton(text: String, onClick: () -> Unit) {
    Button(onClick = onClick) { Text(text) } // Native Compose, theme-aware, accessible.
}
```

***

#### **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 `Fragment`s or `Activity`s 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.

```kotlin
// Strategy 1: Compose inside an existing Fragment (new screen in old architecture).
class NewSettingsFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                AppTheme { // Your custom theme bridging XML attributes.
                    SettingsScreen()
                }
            }
        }
    }
}

// Strategy 2: Compose widget inside an existing XML layout.
// In XML: <androidx.compose.ui.platform.ComposeView android:id="@+id/compose_banner" />
class LegacyDashboardActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_dashboard)
        findViewById<<ComposeView>(R.id.compose_banner).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindow)
            setContent {
                AppTheme { PromoBanner() }
            }
        }
    }
}

// Strategy 3: Legacy custom view reused inside a Compose screen.
@Composable
fun AnalyticsScreen() {
    Column {
        Text("Revenue Chart")
        AndroidView(
            factory = { MPAndroidChart.PieChart(it) },
            modifier = Modifier.fillMaxWidth().height(300.dp),
            update = { chart -> chart.data = revenueData }
        )
    }
}
```

***

#### **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 `remember`ed 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.

```kotlin
class ComposeRecyclerAdapter(
    private val items: List<ItemData>
) : RecyclerView.Adapter<<ComposeViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ComposeViewHolder {
        val composeView = ComposeView(parent.context).apply {
            // CRITICAL: Dispose composition when the view is detached or recycled.
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        }
        return ComposeViewHolder(composeView)
    }

    override fun onBindViewHolder(holder: ComposeViewHolder, position: Int) {
        val item = items[position]
        holder.composeView.setContent {
            // Use key to ensure state is tied to data identity, not view instance.
            key(item.id) {
                ItemCard(
                    data = item,
                    // All state must be hoisted; never use 'remember' for item-specific state here.
                    onClick = { /* handle click via adapter callback or ViewModel */ }
                )
            }
        }
    }

    override fun getItemCount() = items.size
}

class ComposeViewHolder(val composeView: ComposeView) : RecyclerView.ViewHolder(composeView)

// ViewPager2 uses RecyclerView internally, so the same disposal strategy applies.
class ComposePagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
    override fun getItemCount() = 3
    override fun createFragment(position: Int): Fragment = ComposeTabFragment()
}

class ComposeTabFragment : Fragment() {
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent { TabContent() }
        }
    }
}
```

***

#### **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`.

```kotlin
// Activity level: Enable edge-to-edge for both legacy and Compose screens.
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContentView(R.layout.activity_main) // or Compose content
    }
}

// Compose screen handling insets natively.
@Composable
fun ComposeScreen() {
    val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
    val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(top = topPadding, bottom = bottomPadding)
            // Or use the dedicated modifier:
            // .windowInsetsPadding(WindowInsets.systemBars)
    ) {
        Text("Content respects system bars")
    }
}

// Legacy view inside Compose that needs inset-aware padding.
@Composable
fun LegacyInsetView(modifier: Modifier = Modifier) {
    AndroidView(
        factory = { context -> TextView(context).apply { text = "Legacy View" } },
        modifier = modifier,
        update = { view ->
            ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
                val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
                v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
                insets // Return insets if you want to propagate; consume if not.
            }
        }
    )
}
```

***

#### **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.

```kotlin
// JVM Unit Test (fast, no device): Test the state holder, not the UI.
class CounterViewModelTest {
    @Test
    fun `increment updates state`() = runTest {
        val vm = CounterViewModel()
        vm.increment()
        assertEquals(1, vm.count.value)
    }
}

// Instrumented UI Test (androidTest): Uses createComposeRule to test the Composable.
class CounterComposableTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `clicking increment button updates count`() {
        // Set the composable under test.
        composeTestRule.setContent {
            MaterialTheme {
                var count by remember { mutableIntStateOf(0) }
                Column {
                    Text("Count: $count", testTag = "countText")
                    Button(
                        onClick = { count++ },
                        modifier = Modifier.testTag("incrementBtn")
                    ) {
                        Text("Increment")
                    }
                }
            }
        }

        // Assert initial state via semantics.
        composeTestRule.onNodeWithTag("countText").assertTextEquals("Count: 0")

        // Perform user action.
        composeTestRule.onNodeWithTag("incrementBtn").performClick()

        // Advance frame if needed (though performClick handles this automatically).
        composeTestRule.waitForIdle()

        // Assert updated state.
        composeTestRule.onNodeWithTag("countText").assertTextEquals("Count: 1")
    }

    @Test
    fun `button is disabled when count reaches max`() {
        composeTestRule.setContent {
            var count by remember { mutableIntStateOf(10) }
            Button(onClick = { }, enabled = count < 10) {
                Text("Increment")
            }
        }
        composeTestRule.onNodeWithText("Increment").assertIsNotEnabled()
    }
}
```

***

#### **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.

```kotlin
@Composable
fun RatingBar(rating: Int, onRatingChange: (Int) -> Unit) {
    Row(modifier = Modifier.semantics { stateDescription = "Rating $rating of 5" }) {
        repeat(5) { index ->
            val starFilled = index < rating
            Icon(
                imageVector = if (starFilled) Icons.Filled.Star else Icons.Outlined.Star,
                contentDescription = "Star ${index + 1}",
                modifier = Modifier
                    .semantics { testTag = "star_${index + 1}" }
                    .clickable { onRatingChange(index + 1) }
            )
        }
    }
}

// Test using semantic matchers.
class RatingBarTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun `clicking third star sets rating to three`() {
        var currentRating = 0
        rule.setContent {
            RatingBar(rating = currentRating, onRatingChange = { currentRating = it })
        }

        // Use SemanticsMatcher to find the node by testTag and click action.
        rule.onNode(
            SemanticsMatcher.expectValue(SemanticsProperties.TestTag, "star_3")
                .and(hasClickAction())
        ).performClick()

        rule.waitForIdle()
        assertEquals(3, currentRating)

        // Assert the overall state description updated.
        rule.onNode(hasStateDescription("Rating 3 of 5")).assertExists()
    }

    @Test
    fun `all five stars exist`() {
        rule.setContent { RatingBar(rating = 0, onRatingChange = {}) }
        rule.onAllNodesWithContentDescription("Star", substring = true).assertCountEquals(5)
    }
}
```

***

#### **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.

```kotlin
// Stateless composable: easy to test in isolation.
@Composable
fun SearchField(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) {
    OutlinedTextField(
        value = query,
        onValueChange = onQueryChange,
        label = { Text("Search") },
        modifier = modifier.testTag("searchField")
    )
}

class SearchFieldTest {
    @get:Rule val rule = createComposeRule()

    @Test
    fun `typing invokes onQueryChange with new text`() {
        var capturedQuery: String? = null
        rule.setContent {
            SearchField(
                query = "",
                onQueryChange = { capturedQuery = it }
            )
        }

        // Simulate typing.
        rule.onNodeWithTag("searchField").performTextInput("Kotlin")

        // Assert the hoisted event was emitted upward.
        assertEquals("Kotlin", capturedQuery)
    }

    @Test
    fun `state change triggers recomposition and updates display`() {
        var query by mutableStateOf("Initial")

        rule.setContent {
            SearchField(query = query, onQueryChange = { query = it })
        }

        rule.onNodeWithTag("searchField").assertTextContains("Initial")

        // Mutate the hoisted state directly (simulating ViewModel update).
        rule.runOnIdle { query = "Updated" }

        // Compose should have recomposed; assert the new text is displayed.
        rule.onNodeWithTag("searchField").assertTextContains("Updated")
    }
}
```

***

#### **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.

```kotlin
// Limitation 1: WebView inside Compose cannot be tested via Compose semantics.
@Composable
fun HelpScreen() {
    Column {
        Text("Help")
        AndroidView(
            factory = { WebView(it).apply { loadUrl("https://example.com/help") } },
            modifier = Modifier.fillMaxSize().testTag("helpWebView")
        )
    }
}

// Workaround: Use Espresso to interact with the WebView inside the Compose hierarchy.
class HybridTest {
    @get:Rule val composeRule = createComposeRule()

    @Test
    fun `webView content is loaded`() {
        composeRule.setContent { HelpScreen() }
        // Compose test can assert the WebView exists.
        composeRule.onNodeWithTag("helpWebView").assertExists()

        // But to interact with WebView internals, drop to Espresso.
        onView(isAssignableFrom(WebView::class.java))
            .check(matches(isDisplayed()))

        // Or use UIAutomator for system-level interactions.
        val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
        device.findObject(UiSelector().text("Accept Cookies")).click()
    }
}

// Limitation 2: Legacy View inside AndroidView with internal clickable children.
// Compose test can only see the AndroidView node as a whole unless children have semantics.
@Composable
fun LegacyChartWrapper() {
    AndroidView(
        factory = { CustomChartView(it) },
        modifier = Modifier.testTag("chart")
    )
}

// Workaround: Instead of trying to click internal chart points via semantics,
// expose a semantic action or use Espresso on the underlying view.
// Or add a semantic action in the update block:
update = { view ->
    view.setOnPointClickListener { index ->
        // Forward click as a semantic action for testability.
    }
}
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://notes.tejpratapsingh.com/_/android-tips/interview/compose/compose-part-4.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
