Jetpack Compose Interview Questions

Jetpack Compose questions from basics to production: recomposition, state and state hoisting, side effects, collecting StateFlow, navigation, animations, View interop, stability and lazy lists, plus real UI scenarios you may be asked to build.

By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked

50 questions with answers20 medium · 30 hard24 marked most asked

All 50 questions

  1. What is Jetpack Compose? How is it different from the View system? Most asked
  2. What is recomposition? How does Compose decide what to recompose and how do you minimise it? Most asked
  3. What is the difference between remember and rememberSaveable? Most asked
  4. Explain side effects in Compose — LaunchedEffect, SideEffect, DisposableEffect, produceState. Most asked
  5. What is State Hoisting in Compose? Why is it important? Most asked
  6. What are the phases of Compose? Explain Composition, Layout, and Drawing. Most asked
  7. What is the Modifier system in Compose? What is the correct order of modifiers? Most asked
  8. What are the main layout composables in Compose? Column, Row, Box, LazyColumn. Most asked
  9. How do you collect StateFlow and handle UI state in Compose?
  10. How do you implement navigation in Compose? Explain NavHost and type-safe navigation.
  11. What is the difference between stateful and stateless composables? Most asked
  12. How do you build custom layouts in Compose using Layout and SubcomposeLayout?
  13. How do you implement animations in Jetpack Compose? Most asked
  14. How do you test Compose UI? Explain composeTestRule and semantics. Most asked
  15. What is CompositionLocal in Compose? When and how do you use it?
  16. What is derivedStateOf and when should you use it? Most asked
  17. How does Compose interop with the View system? When would you use it?
  18. What is Material3 in Compose? How do you implement theming? Most asked
  19. How do you handle one-time UI events (navigation, snackbar, toast) in Compose?
  20. What are Compose stability and the Compose compiler metrics? How do you diagnose recomposition issues?
  21. What is Scaffold in Compose? How do you use it with TopAppBar, FAB, and SnackbarHost? Most asked
  22. What is the Compose ViewModel integration? How do you use hiltViewModel and viewModel?
  23. How do you handle lists efficiently in Compose — LazyColumn keys, item types, and sticky headers? Most asked
  24. What is the Compose runtime and how does it differ from the Compose UI toolkit?
  25. What are Compose previews? How do you use @Preview effectively?
  26. Scenario: Your LazyColumn is janky with 10,000 items. How do you diagnose and fix it?
  27. Scenario: Build a search screen with debounce, loading state, and error handling in Compose.
  28. Scenario: Bottom sheet with "Add to Cart" button that shows a snackbar confirmation. Implement correctly.
  29. Scenario: Your team is migrating from XML to Compose. How do you plan the migration?
  30. Scenario: App crashes on screen rotation in Compose. How do you debug and fix it?
  31. What is rememberCoroutineScope and when do you use it vs LaunchedEffect? Most asked
  32. What is the Slot API in Compose? How does it enable flexible composable design? Most asked
  33. How do you implement a tab layout with bottom navigation in Compose? Most asked
  34. What makes a @Composable function different from a regular Kotlin function?
  35. What are Compose gestures? Explain clickable, pointerInput, and draggable. Most asked
  36. Scenario: Implement a collapsing toolbar that shrinks as user scrolls down.
  37. What is SharedTransitionLayout? How do you implement shared element transitions?
  38. What is snapshotFlow and when do you use it? Most asked
  39. Scenario: Compose app startup is slow. How do you optimise Time-To-First-Frame?
  40. How do you implement Paging 3 in Compose? Most asked
  41. Scenario: Build a PIN entry screen with 6 boxes, auto-advance focus, and backspace support.
  42. What is the Compose lifecycle? How does it differ from Android Activity lifecycle?
  43. Scenario: A composable is recomposing thousands of times per minute. Walk through debugging this.
  44. Scenario: Build a multi-step onboarding flow with shared state and progress indicator.
  45. What are Compose text components? Explain Text, TextField, BasicTextField, and OutlinedTextField. Most asked
  46. What is Compose Multiplatform? How does it differ from Jetpack Compose?
  47. How do you handle accessibility in Jetpack Compose? Most asked
  48. Scenario: Build a chat screen — messages from bottom, real-time new messages, auto-scroll to latest.
  49. What is Scaffold and how do you use it correctly with innerPadding? Most asked
  50. Scenario: PM asks you to compare Compose vs XML for a new project starting today. What do you recommend?
MediumMost asked

1. What is Jetpack Compose? How is it different from the View system?

Jetpack Compose is Android's modern declarative UI toolkit. Instead of mutating views, you describe what the UI should look like for a given state — Compose figures out the updates.

// View system — imperative, mutate existing views
val textView = findViewById<TextView>(R.id.name)
textView.text = "Rahul"
textView.visibility = View.VISIBLE
// Developer manages WHEN and HOW to update UI

// Compose — declarative, describe the UI for current state
@Composable
fun UserCard(name: String, visible: Boolean) {
    if (visible) {
        Text(text = name)  // Compose handles updates automatically
    }
}
// When name or visible changes, Compose re-runs this function

// Key differences:
// View system: XML + Kotlin/Java, View tree, manual updates
// Compose:     100% Kotlin, no XML, reactive, automatic updates

// Compose advantages:
// Less code — no adapters, ViewHolders, xml layouts
// Preview in Android Studio
// Built-in animations, Material3
// Interops with View system — can use both together

Compose is declarative — you describe the UI for a given state and Compose updates the screen automatically, compared to the View system where you manually mutate views. There is no XML; all UI is written in Kotlin, making it type-safe and refactorable. When state changes, Compose re-executes only the affected composables through recomposition. It interops with the View system in both directions — embed Compose in a Fragment via ComposeView, or embed legacy Views in Compose via AndroidView. Compose has been Google's official recommendation for new Android UI development since 2021.

💡 Interview Tip

The key mental shift: in View system you say "update this text view to X." In Compose you say "show a text view with X" and Compose decides what to update. This declarative model is what makes Compose powerful.

Mark it done or save it in the question bank · Back to top

HardMost asked

2. What is recomposition? How does Compose decide what to recompose and how do you minimise it?

Recomposition is Compose re-executing a composable function when its inputs change. Understanding and minimising unnecessary recompositions is critical for performance.

// Recomposition triggered when state/param changes
@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) { Text("Count: $count") }
    // count changes → Button AND Text recompose
}

// ❌ Bad — whole parent recomposes when count changes
@Composable
fun ParentScreen() {
    var count by remember { mutableStateOf(0) }
    ExpensiveHeader()          // recomposes unnecessarily!
    Text("Count: $count")
    ExpensiveFooter()          // recomposes unnecessarily!
}

// ✅ Good — move state down, scope recomposition
@Composable
fun ParentScreen() {
    ExpensiveHeader()          // never recomposes
    CounterSection()           // only this recomposes
    ExpensiveFooter()          // never recomposes
}

// Stable types — Compose skips recomposition if inputs unchanged
// Primitives, String, data classes with val properties = stable
@Stable
data class UserUiState(val name: String, val age: Int)

// @Immutable — tells Compose type never changes, safe to skip
@Immutable
data class Config(val baseUrl: String)

// key() — stable identity for items in lists
LazyColumn {
    items(users, key = { it.id }) { UserRow(it) }
}

Recomposition triggers when observed state changes — Compose re-runs the composable function to produce an updated UI tree. To minimise it, move state as low in the tree as possible so only the composables that actually need the value recompose. Compose can skip a composable entirely when all its inputs are equal and stable — primitives, Strings, and data classes with val properties qualify. Annotating a class with @Stable or @Immutable hints to the compiler that it is safe to skip. In lists, providing a key() gives each item a stable identity, preventing unnecessary recomposition when the list is reordered or partially updated.

💡 Interview Tip

The most common recomposition mistake: reading state high in the tree. Rule: "move state down to the composable that needs it." If only the counter needs count, don't put count in the screen-level composable.

Mark it done or save it in the question bank · Back to top

MediumMost asked

3. What is the difference between remember and rememberSaveable?

Both persist state across recompositions, but rememberSaveable also survives configuration changes (rotation) and process death by saving to a Bundle.

// remember — survives recomposition only
var count by remember { mutableStateOf(0) }
// count = 0 after screen rotation ❌

// rememberSaveable — survives recomposition + rotation + process death
var count by rememberSaveable { mutableStateOf(0) }
// count preserved after rotation ✅

// rememberSaveable with custom Saver (for non-primitive types)
@Parcelize
data class SearchState(val query: String, val filters: List<String>) : Parcelable

var state by rememberSaveable { mutableStateOf(SearchState("", emptyList())) }

// Custom Saver for complex types
val colorSaver = Saver<Color, Long>(
    save = { it.value.toLong() },
    restore = { Color(it.toULong()) }
)
var color by rememberSaveable(stateSaver = colorSaver) { mutableStateOf(Color.Red) }

// When to use which:
// remember:          UI-only state (animation state, dropdown open/closed)
// rememberSaveable:  user input, scroll position, selected tab
// ViewModel:         business data, network results — best for most cases

remember survives recompositions but the value is lost on rotation or process death. rememberSaveable goes further — it persists through recompositions, configuration changes, and process death by saving to a Bundle. It works automatically for primitives, Strings, and @Parcelize types. For non-parcelable complex types, supply a custom Saver with explicit save and restore logic. As a rule, prefer ViewModel for business and network data — use rememberSaveable only for transient UI state like scroll position, typed text, or selected tab.

💡 Interview Tip

Good rule: "If the user would be annoyed to lose it on rotation, use rememberSaveable or ViewModel." Scroll position, typed text, selected tab → rememberSaveable. API results, user data → ViewModel.

Mark it done or save it in the question bank · Back to top

HardMost asked

4. Explain side effects in Compose — LaunchedEffect, SideEffect, DisposableEffect, produceState.

Side effects are operations that escape the scope of a composable — network calls, analytics, subscriptions. Compose provides specific APIs for each use case to keep side effects safe and lifecycle-aware.

// LaunchedEffect — coroutine scoped to composable lifecycle
// Runs when key changes, cancelled when composable leaves
@Composable
fun UserScreen(userId: String) {
    LaunchedEffect(userId) {          // key = userId
        viewModel.loadUser(userId)    // re-runs if userId changes
    }                                  // cancelled if screen leaves
}

// SideEffect — runs after every successful recomposition
// Use for: sync Compose state to non-Compose code
@Composable
fun AnalyticsScreen(name: String) {
    SideEffect {
        analytics.setScreen(name)  // runs after each recomposition
    }
}

// DisposableEffect — setup + teardown (like onStart/onStop)
// Use for: event listeners, sensors, observers
@Composable
fun LifecycleObserver(onStop: () -> Unit) {
    val owner = LocalLifecycleOwner.current
    DisposableEffect(owner) {
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_STOP) onStop()
        }
        owner.lifecycle.addObserver(observer)
        onDispose { owner.lifecycle.removeObserver(observer) } // cleanup!
    }
}

// produceState — convert non-Compose state to Compose State
@Composable
fun NetworkImage(url: String): State<Bitmap?> = produceState<Bitmap?>(null, url) {
    value = withContext(Dispatchers.IO) { loadBitmap(url) }
}

LaunchedEffect launches a coroutine tied to the composable's lifecycle — it relaunches when the key changes and is cancelled when the composable leaves the composition. SideEffect runs after every successful recomposition and is the right place to sync Compose state to non-Compose systems like analytics. DisposableEffect provides a setup/teardown pair, ideal for registering event listeners or observers — always provide onDispose for cleanup to avoid memory leaks. produceState bridges external state sources such as Flows or callbacks into Compose State. For user-triggered coroutines like button clicks, use rememberCoroutineScope to get a scope that is cancelled when the composable leaves.

💡 Interview Tip

Missing onDispose in DisposableEffect is a memory leak — the observer never gets removed. This is the most common Compose bug. Always ask: "What needs to be cleaned up when this composable leaves?"

Mark it done or save it in the question bank · Back to top

HardMost asked

5. What is State Hoisting in Compose? Why is it important?

State hoisting is the pattern of moving state up to the caller, making composables stateless and reusable. It's Compose's answer to the separation of concerns problem.

// ❌ Stateful — state owned inside, not reusable/testable
@Composable
fun BadTextField() {
    var text by remember { mutableStateOf("") }
    TextField(value = text, onValueChange = { text = it })
    // Caller can't read or control the text value!
}

// ✅ Stateless (hoisted) — caller controls state
@Composable
fun GoodTextField(
    value: String,              // state flows DOWN
    onValueChange: (String) -> Unit  // events flow UP
) {
    TextField(value = value, onValueChange = onValueChange)
}

// Caller owns the state
@Composable
fun LoginScreen() {
    var email by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    GoodTextField(value = email, onValueChange = { email = it })
    GoodTextField(value = password, onValueChange = { password = it })
    Button(onClick = { login(email, password) }) { Text("Login") }
}

// Unidirectional Data Flow (UDF):
// State flows DOWN (from parent to child)
// Events flow UP (from child to parent via lambdas)
// This is the foundation of Compose architecture

State hoisting means moving state out of a composable and into its caller, making the composable stateless and reusable. The pattern is always the same: state flows down as parameters, and events flow up via lambda callbacks. Stateless composables are much easier to test, preview in Android Studio, and reuse across different screens. This is the Compose implementation of Unidirectional Data Flow (UDF) — the architectural principle where the source of truth always lives in the parent. State should be hoisted to the lowest common ancestor that needs access to it — no higher.

💡 Interview Tip

State hoisting is the single most important Compose concept. Every composable should ask: "Who needs this state?" Hoist it to that level. The golden rule: state down, events up.

Mark it done or save it in the question bank · Back to top

HardMost asked

6. What are the phases of Compose? Explain Composition, Layout, and Drawing.

Compose renders UI in three phases each frame. Understanding this model helps optimise performance — some operations can skip earlier phases entirely.

// Phase 1: COMPOSITION
// Compose runs @Composable functions
// Builds the UI tree (slot table)
// Detects what changed vs last composition

// Phase 2: LAYOUT
// Measures and places each node
// Single-pass measurement (vs View system's multi-pass)

// Phase 3: DRAWING
// Renders to Canvas

// Skipping phases for performance:
// Modifier.offset with lambda — skips Composition & Layout!
@Composable
fun AnimatedBox(scrollState: ScrollState) {
    // ❌ Reads scroll during Composition — triggers full recompose
    Box(Modifier.offset(y = scrollState.value.dp))

    // ✅ Reads scroll during Layout only — skips Composition
    Box(Modifier.offset { IntOffset(0, scrollState.value) })
}

// graphicsLayer — changes applied at Drawing phase only
@Composable
fun FadeBox(alpha: Float) {
    // ❌ alpha param → recomposition on every change
    Box(Modifier.alpha(alpha))

    // ✅ graphicsLayer lambda → Drawing phase only, no recomposition
    Box(Modifier.graphicsLayer { this.alpha = alpha })
}

// derivedStateOf — compute only when inputs change
val showButton by remember {
    derivedStateOf { scrollState.value > 100 }
}
// showButton changes only when threshold crossed, not on every scroll

Compose renders UI in three phases each frame: Composition (running composable functions and building the UI tree), Layout (measuring and placing nodes), and Drawing (rendering to Canvas). Using lambda-based Modifiers defers state reads to a later phase, allowing earlier phases to be skipped entirely — a significant performance win for animations. graphicsLayer applies alpha, scale, and rotation at the Draw phase with no recomposition required. derivedStateOf memoizes derived values so a composable only recomposes when the derived result actually changes, not every time the source state changes. Unlike the View system's multi-pass measurement, Compose measures each node exactly once.

💡 Interview Tip

The lambda Modifier trick is the most impactful Compose optimisation: Modifier.offset { } vs Modifier.offset(). The lambda version defers the read to Layout phase, skipping recomposition entirely on scroll. This alone can eliminate jank in scroll-heavy UIs.

Mark it done or save it in the question bank · Back to top

MediumMost asked

7. What is the Modifier system in Compose? What is the correct order of modifiers?

Modifiers decorate composables with layout behaviour, drawing, and interaction. Order matters — each modifier wraps the next, like decorators applied inside-out.

// Modifier order matters — applied outside-in

// ❌ Wrong order — clickable area doesn't include padding
Box(
    Modifier
        .clickable { }   // small clickable area
        .padding(16.dp)  // padding added OUTSIDE click area
)

// ✅ Correct — clickable includes padding area
Box(
    Modifier
        .padding(16.dp)  // padding first
        .clickable { }   // click area includes the padding
)

// Common modifier operations
Modifier
    .fillMaxWidth()          // fill parent width
    .fillMaxSize()           // fill parent width and height
    .size(48.dp)             // fixed size
    .wrapContentSize()       // wrap content
    .padding(16.dp)          // inner spacing
    .background(Color.Blue)  // background color
    .clip(RoundedCornerShape(8.dp))  // clip shape
    .border(1.dp, Color.Gray)        // border
    .clickable { }           // handle clicks
    .semantics { }           // accessibility
    .testTag("myButton")    // UI testing

// Custom modifier — reusable combination
fun Modifier.cardStyle() = this
    .fillMaxWidth()
    .clip(RoundedCornerShape(12.dp))
    .background(MaterialTheme.colorScheme.surface)
    .padding(16.dp)

Modifier order matters in Compose — each modifier wraps everything that comes after it, applied from outside-in. A practical consequence: placing padding before clickable means the padded area is not clickable, while placing clickable before padding makes the full padded region respond to taps — usually what you want. Multiple Modifiers can be extracted into a reusable extension function on Modifier to avoid repetition across composables. Use Modifier.then() to programmatically chain modifiers based on runtime conditions.

💡 Interview Tip

Modifier order is a classic interview question. The golden rule: think of modifiers as wrapping layers. padding().clickable() means the click zone is INSIDE the padding. clickable().padding() means the click zone INCLUDES the padding. Almost always you want clickable().padding().

Mark it done or save it in the question bank · Back to top

MediumMost asked

8. What are the main layout composables in Compose? Column, Row, Box, LazyColumn.

Compose provides a set of layout primitives that replace LinearLayout, FrameLayout, and RecyclerView. Knowing when to use each is fundamental.

// Column — vertical arrangement (like LinearLayout vertical)
Column(
    modifier = Modifier.fillMaxWidth(),
    verticalArrangement = Arrangement.spacedBy(8.dp),
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Text("Title")
    Text("Subtitle")
    Button(onClick = {}) { Text("OK") }
}

// Row — horizontal arrangement (like LinearLayout horizontal)
Row(
    horizontalArrangement = Arrangement.SpaceBetween,
    verticalAlignment = Alignment.CenterVertically
) {
    Icon(Icons.Default.Home, contentDescription = null)
    Text("Home")
    Spacer(Modifier.weight(1f))  // push items apart
    Badge { Text("3") }
}

// Box — stack overlapping children (like FrameLayout)
Box(Modifier.size(200.dp)) {
    Image(painter, contentDescription = null, modifier = Modifier.fillMaxSize())
    Text("Overlay", modifier = Modifier.align(Alignment.BottomCenter))
}

// LazyColumn — efficient scrollable vertical list (like RecyclerView)
LazyColumn(contentPadding = PaddingValues(16.dp)) {
    item { Header() }                                  // single item
    items(users, key = { it.id }) { UserRow(it) }    // list
    itemsIndexed(items) { index, item -> Row(index, item) }
    item { Footer() }
}

// LazyRow — horizontal scrollable list
// LazyVerticalGrid — grid layout
// LazyVerticalStaggeredGrid — Pinterest-style staggered grid

Column and Row are non-scrollable linear layouts — use them for a known, fixed number of items that fit on screen. Box stacks children on top of each other and is the right choice for overlapping UI like badges, overlays, and FAB positioning. LazyColumn and LazyRow only compose and render visible items, making them the correct choice for lists of any dynamic size. Arrangement controls the spacing between children — options include SpaceBetween, spacedBy(dp), and Center. Modifier.weight() distributes remaining space proportionally between children, equivalent to layout_weight in XML.

💡 Interview Tip

Never use Column with forEach for large lists — it composes ALL items upfront. Always use LazyColumn which only composes visible items. This is the single most common Compose performance mistake in production apps.

Mark it done or save it in the question bank · Back to top

Hard

9. How do you collect StateFlow and handle UI state in Compose?

Collecting ViewModel state safely in Compose requires lifecycle-aware collection to stop updates when the app is backgrounded. The recommended approach has evolved significantly.

// ViewModel with StateFlow
class UserViewModel : ViewModel() {
    private val _state = MutableStateFlow<UiState>(UiState.Loading)
    val state: StateFlow<UiState> = _state.asStateFlow()
}

// ❌ collectAsState() — doesn't stop when app backgrounds
val state by viewModel.state.collectAsState()

// ✅ collectAsStateWithLifecycle() — lifecycle-aware (RECOMMENDED)
// implementation("androidx.lifecycle:lifecycle-runtime-compose")
val state by viewModel.state.collectAsStateWithLifecycle()

// Full example
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    when (val s = state) {
        is UiState.Loading  -> CircularProgressIndicator()
        is UiState.Success -> UserContent(s.user)
        is UiState.Error   -> ErrorMessage(s.message)
    }
}

// Sealed UI state — best practice
sealed class UiState {
    object Loading : UiState()
    data class Success(val user: User) : UiState()
    data class Error(val message: String) : UiState()
}

collectAsState() is simple but continues collecting even when the app is backgrounded, wasting resources. collectAsStateWithLifecycle() is the lifecycle-aware alternative — it pauses collection when the lifecycle drops below STARTED and resumes at START, the recommended approach for production apps. Use hiltViewModel() to inject ViewModels in Compose with correct Hilt scoping. A sealed UiState class with Loading, Success, and Error variants pairs cleanly with Compose's when expression, giving exhaustive UI rendering. Prefer a single StateFlow object for all screen state to avoid multiple simultaneous collections.

💡 Interview Tip

Always use collectAsStateWithLifecycle() in production — collectAsState() wastes resources collecting in background. This was Google's official guidance update in 2022 and is still the right answer in 2025.

Mark it done or save it in the question bank · Back to top

Hard

10. How do you implement navigation in Compose? Explain NavHost and type-safe navigation.

Navigation in Compose uses Navigation Compose — a declarative NavHost with routes. Navigation 2.8+ introduced type-safe routes using @Serializable, eliminating string-based route errors.

// Navigation Compose 2.8+ — type-safe routes
// implementation("androidx.navigation:navigation-compose:2.8+")

// Define routes as @Serializable objects/classes
@Serializable object HomeRoute
@Serializable object ProfileRoute
@Serializable data class DetailRoute(val userId: String)

// NavHost — declares all destinations
@Composable
fun AppNavHost() {
    val navController = rememberNavController()
    NavHost(navController, startDestination = HomeRoute) {
        composable<HomeRoute> {
            HomeScreen(onUserClick = { id ->
                navController.navigate(DetailRoute(id))
            })
        }
        composable<DetailRoute> { backStackEntry ->
            val route: DetailRoute = backStackEntry.toRoute()
            DetailScreen(userId = route.userId)
        }
        composable<ProfileRoute> { ProfileScreen() }
    }
}

// Back stack management
navController.navigate(HomeRoute) {
    popUpTo(HomeRoute) { inclusive = true }  // clear back stack
    launchSingleTop = true                     // avoid duplicate
}
navController.navigateUp()  // go back

Navigation Compose 2.8+ introduced type-safe navigation via @Serializable route objects — no more hand-written string routes or manual argument parsing, giving compile-time safety. toRoute() extracts typed route data from a NavBackStackEntry cleanly. popUpTo controls the back stack on navigation, preventing duplicate screen accumulation. launchSingleTop = true ensures a destination is not pushed again if it is already at the top of the stack — essential for bottom navigation tabs. Nested navigation graphs group related routes by feature, keeping the nav graph organised and scalable.

💡 Interview Tip

Type-safe navigation with @Serializable is the 2025 answer. String-based routes like "detail/{userId}" are error-prone and deprecated in favour of this. Knowing the newer API immediately shows you're current.

Mark it done or save it in the question bank · Back to top

MediumMost asked

11. What is the difference between stateful and stateless composables?

Stateful composables own state internally. Stateless composables receive state as parameters. Good architecture maximises stateless composables — they're easier to test, preview, and reuse.

// Stateful — owns its own state
@Composable
fun StatefulCounter() {
    var count by remember { mutableStateOf(0) }
    StatelessCounter(count = count, onIncrement = { count++ })
}

// Stateless — receives state from caller
@Composable
fun StatelessCounter(
    count: Int,
    onIncrement: () -> Unit
) {
    Column {
        Text("Count: $count")
        Button(onClick = onIncrement) { Text("+") }
    }
}

// Benefits of stateless:
// ✅ Easily previewable with any count value
@Preview
@Composable
fun CounterPreview() {
    StatelessCounter(count = 42, onIncrement = {})  // inject any state
}

// ✅ Easily testable
@Test
fun counterDisplaysCorrectly() {
    composeTestRule.setContent {
        StatelessCounter(count = 5, onIncrement = {})
    }
    composeTestRule.onNodeWithText("Count: 5").assertIsDisplayed()
}

// Real pattern: ViewModel provides state → Screen composable (stateless)
@Composable
fun HomeScreen(viewModel: HomeViewModel = hiltViewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    HomeContent(state = state, onAction = viewModel::handleAction)
}
// HomeContent is stateless — previewed and tested without ViewModel

A stateful composable owns its state internally — necessary at the root of a screen or hierarchy, but harder to test and reuse. A stateless composable receives state as parameters and emits events via lambdas — fully reusable, testable, and previewable. The recommended pattern is: ViewModel exposes a StateFlow, a stateful Screen composable collects it and passes data down, and stateless Content composables render the UI. This keeps business logic in the ViewModel and UI logic in stateless leaf composables. Stateless composables can have multiple @Preview annotations to visualise all states side by side.

💡 Interview Tip

The split pattern — HomeScreen(viewModel) wraps HomeContent(state, onAction) — is the official Architecture pattern for Compose. HomeContent is stateless so it's fully testable without mocking a ViewModel.

Mark it done or save it in the question bank · Back to top

Hard

12. How do you build custom layouts in Compose using Layout and SubcomposeLayout?

Custom layouts let you control exactly how children are measured and placed. SubcomposeLayout allows measuring children based on other children's sizes — needed for dynamic layouts.

// Custom Layout — measure and place children manually
@Composable
fun MyVerticalLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        // Step 1: Measure all children
        val placeables = measurables.map { it.measure(constraints) }

        // Step 2: Calculate layout size
        val totalHeight = placeables.sumOf { it.height }
        val maxWidth = placeables.maxOf { it.width }

        // Step 3: Place children
        layout(maxWidth, totalHeight) {
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = y)
                y += placeable.height
            }
        }
    }
}

// SubcomposeLayout — compose content based on measured sizes
// Example: show content only if it fits, else show placeholder
@Composable
fun AdaptiveContent(content: @Composable () -> Unit, fallback: @Composable () -> Unit) {
    SubcomposeLayout { constraints ->
        val main = subcompose("main", content).first().measure(constraints)
        if (main.height <= constraints.maxHeight) {
            layout(main.width, main.height) { main.placeRelative(0, 0) }
        } else {
            val fb = subcompose("fallback", fallback).first().measure(constraints)
            layout(fb.width, fb.height) { fb.placeRelative(0, 0) }
        }
    }
}

The Layout composable gives full control over measurement and placement: measure all children first via measurables.map { it.measure(constraints) }, then calculate the composite size, then call layout(width, height) and position each child using placeRelative(). You must always measure before placing — placing an unmeasured child crashes at runtime. SubcomposeLayout takes this further by allowing composition to happen during the layout phase, enabling size-dependent composition — for example, composing content only after knowing the available space. It is used internally by LazyColumn, Scaffold, and ConstraintLayout.

💡 Interview Tip

SubcomposeLayout is what makes Scaffold work — it measures the FAB first, then composes the content with appropriate padding. Knowing this internal detail shows deep Compose understanding.

Mark it done or save it in the question bank · Back to top

MediumMost asked

13. How do you implement animations in Jetpack Compose?

Compose has a rich animation API ranging from simple value animations to complex choreographed sequences. Choose the right API for the complexity of the animation.

// animateFloatAsState — simple value animation
val alpha by animateFloatAsState(
    targetValue = if (visible) 1f else 0f,
    animationSpec = tween(durationMillis = 300),
    label = "alpha"
)
Box(Modifier.graphicsLayer { this.alpha = alpha })

// AnimatedVisibility — show/hide with animation
AnimatedVisibility(
    visible = isVisible,
    enter = fadeIn() + slideInVertically(),
    exit  = fadeOut() + slideOutVertically()
) {
    Card { Text("I appear and disappear") }
}

// Crossfade — animate between composables
Crossfade(targetState = currentScreen) { screen ->
    when (screen) {
        Screen.Home    -> HomeContent()
        Screen.Profile -> ProfileContent()
    }
}

// animateContentSize — animate size changes
var expanded by remember { mutableStateOf(false) }
Card(Modifier.animateContentSize()) {
    Text(if (expanded) longText else shortText)
    Button(onClick = { expanded = !expanded }) { Text("Toggle") }
}

// Transition — multiple values animated together
val transition = updateTransition(selected, label = "selected")
val borderColor by transition.animateColor(label = "border") {
    if (it) Color.Green else Color.Gray
}
val elevation by transition.animateDp(label = "elevation") {
    if (it) 8.dp else 2.dp
}

animateXAsState (e.g. animateFloatAsState, animateDpAsState) is the easiest animation API — it animates a single value whenever the target changes, covering the majority of use cases. AnimatedVisibility handles show/hide with built-in enter and exit transitions. animateContentSize automatically animates layout size changes when content grows or shrinks. When multiple values must animate in sync, updateTransition manages them as a single coordinated transition. rememberInfiniteTransition drives looping animations like loading spinners and pulsing effects that run indefinitely until the composable leaves the composition.

💡 Interview Tip

Always add label parameter to animations — it shows up in the Animation Inspector in Android Studio, making debugging much easier. This is a small detail that shows production experience.

Mark it done or save it in the question bank · Back to top

MediumMost asked

14. How do you test Compose UI? Explain composeTestRule and semantics.

Compose has its own UI testing framework that works without a real device or emulator. Tests interact with the UI through semantics — accessibility labels that describe what each element does.

// Setup
// testImplementation("androidx.compose.ui:ui-test-junit4")
// debugImplementation("androidx.compose.ui:ui-test-manifest")

class CounterTest {
    @get:Rule val composeTestRule = createComposeRule()

    @Test fun counterIncrementsOnClick() {
        composeTestRule.setContent {
            CounterScreen()
        }

        // Find by text
        composeTestRule.onNodeWithText("Count: 0").assertIsDisplayed()

        // Perform action
        composeTestRule.onNodeWithText("+").performClick()

        // Assert result
        composeTestRule.onNodeWithText("Count: 1").assertIsDisplayed()
    }
}

// testTag — reliable node identification
Button(modifier = Modifier.testTag("submit_button")) { }
composeTestRule.onNodeWithTag("submit_button").performClick()

// semantics — custom accessibility labels
Icon(Icons.Default.Favorite, modifier = Modifier.semantics {
    contentDescription = "Like button"
})
composeTestRule.onNodeWithContentDescription("Like button").assertIsDisplayed()

// Common assertions
.assertIsDisplayed()
.assertIsEnabled()
.assertIsSelected()
.assertTextEquals("hello")
.assertContentDescriptionContains("...")

Compose UI testing uses createComposeRule() to set up the test environment on the JVM — no emulator needed for most tests. Any composable is rendered via setContent { } directly in the test. Nodes are located with semantic finders: onNodeWithText(), onNodeWithTag(), or onNodeWithContentDescription(). Actions simulate user interactions: performClick(), performTextInput(), performScrollTo(). Assertions verify the outcome: assertIsDisplayed(), assertIsEnabled(), assertTextEquals(). Add testTag Modifiers to composables that lack natural text or content descriptions to make them easily findable in tests.

💡 Interview Tip

Prefer testTag over onNodeWithText for buttons/icons — text might change with localisation, but testTag is stable. Use semantics contentDescription for icon-only elements to make them both accessible and testable.

Mark it done or save it in the question bank · Back to top

Hard

15. What is CompositionLocal in Compose? When and how do you use it?

CompositionLocal provides implicit data passing down the composition tree without explicit parameter passing — like dependency injection within the UI tree.

// Built-in CompositionLocals you use daily
LocalContext.current          // Android Context
LocalLifecycleOwner.current   // LifecycleOwner
LocalDensity.current          // Density for dp↔px conversion
LocalConfiguration.current   // screen size, orientation
MaterialTheme.colorScheme     // Material3 colors
MaterialTheme.typography      // Text styles

// Create custom CompositionLocal
data class AppConfig(val isDarkMode: Boolean, val locale: String)

val LocalAppConfig = compositionLocalOf { AppConfig(false, "en") }

// Provide value — wraps the subtree
@Composable
fun App() {
    CompositionLocalProvider(LocalAppConfig provides AppConfig(true, "hi")) {
        HomeScreen()   // and all children have access to LocalAppConfig
    }
}

// Consume anywhere in subtree — no parameter passing!
@Composable
fun DeepNestedChild() {
    val config = LocalAppConfig.current
    Text(if (config.isDarkMode) "Dark" else "Light")
}

// compositionLocalOf vs staticCompositionLocalOf
// compositionLocalOf:       recomposes ONLY consumers when value changes
// staticCompositionLocalOf: recomposes ENTIRE subtree when value changes
// Use staticCompositionLocalOf for values that rarely change (theme, config)
val LocalNavController = staticCompositionLocalOf<NavController> {
    error("No NavController provided")
}

CompositionLocal provides implicit data down the UI tree without parameter drilling — any composable in the subtree can read it via LocalX.current. Choose compositionLocalOf when the value changes frequently — only direct consumers recompose on change. Use staticCompositionLocalOf for rarely changing values like the app theme; it is more efficient but recomposes the entire subtree when the value changes. Common use cases include the app theme, NavController, analytics trackers, and feature flags. Avoid overuse — explicit parameters are easier to follow and test; reserve CompositionLocal for genuinely cross-cutting concerns.

💡 Interview Tip

CompositionLocal is how MaterialTheme works — colors, typography, shapes are provided at the top and consumed anywhere without passing through every composable. Don't use it for business data — that belongs in ViewModel.

Mark it done or save it in the question bank · Back to top

MediumMost asked

16. What is derivedStateOf and when should you use it?

derivedStateOf creates a State whose value is derived from other state objects. It prevents excessive recomposition when the source state changes often but the derived value changes rarely.

// Problem: showButton recomposes on EVERY scroll position change
@Composable
fun BadScrollScreen() {
    val listState = rememberLazyListState()
    // ❌ reads firstVisibleItemIndex on EVERY frame during scroll
    val showButton = listState.firstVisibleItemIndex > 0
    FloatingActionButton(visible = showButton) { }
}

// Solution: derivedStateOf — only recomposes when DERIVED value changes
@Composable
fun GoodScrollScreen() {
    val listState = rememberLazyListState()
    // ✅ showButton only changes when crossing the 0→1 threshold
    val showButton by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 0 }
    }
    AnimatedVisibility(visible = showButton) {
        FloatingActionButton(onClick = { }) { Icon(Icons.Default.KeyboardArrowUp, null) }
    }
}

// Another example — enable submit only when form is valid
val isFormValid by remember {
    derivedStateOf {
        name.isNotBlank() && email.contains("@") && password.length >= 8
    }
}
Button(enabled = isFormValid, onClick = { submit() }) { Text("Submit") }
// Button only recomposes when isFormValid changes — not on every keystroke

derivedStateOf memoizes a computation — it only recalculates when its observed inputs change, and only triggers recomposition when the derived result actually changes. Without it, a composable recomposes on every source state change even if the derived outcome is identical. For example, a "show scroll-to-top button" boolean derived from scroll offset only needs to recompose when it flips between true and false, not on every scroll pixel. Always wrap it in remember — otherwise derivedStateOf is re-created on every recomposition, defeating its purpose. It is ideal for scroll thresholds, form validation flags, filter counts, and sorted list derivations.

💡 Interview Tip

The scroll FAB example is the textbook derivedStateOf use case. Scroll position changes 60 times/second. Without derivedStateOf the FAB recomposes 60 times/second even when it's not changing. With it — zero unnecessary recompositions.

Mark it done or save it in the question bank · Back to top

Hard

17. How does Compose interop with the View system? When would you use it?

Compose and the View system can coexist — you can embed Compose inside Views and Views inside Compose. This is essential for incremental migration and third-party library integration.

// 1. Compose inside View system (ComposeView)
// In Fragment/Activity:
val composeView = ComposeView(context).apply {
    setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
    setContent {
        MaterialTheme { MyComposableScreen() }
    }
}

// In XML layout:
// <androidx.compose.ui.platform.ComposeView
//     android:id="@+id/compose_view" />
binding.composeView.setContent { MyComposable() }

// 2. View inside Compose (AndroidView)
@Composable
fun LegacyMapView(onMapReady: (GoogleMap) -> Unit) {
    AndroidView(
        factory = { ctx ->
            MapView(ctx).apply { onCreate(null); onResume() }
        },
        update = { mapView ->
            mapView.getMapAsync(onMapReady)
        }
    )
}

// 3. AndroidViewBinding — use ViewBinding in Compose
@Composable
fun LegacyChart() {
    AndroidViewBinding(ChartLayoutBinding::inflate) {
        chart.setData(chartData)
    }
}

// When to use:
// ✅ Incremental migration (View → Compose screen by screen)
// ✅ Third-party libraries without Compose equivalent (Maps, Charts)
// ✅ Complex custom Views hard to rewrite in Compose

ComposeView embeds a Compose UI tree inside an existing Fragment or Activity without restructuring the app — the entry point for screen-by-screen migration. Going the other direction, AndroidView embeds a legacy View inside a Compose layout, which is essential for third-party libraries and complex custom Views that have no Compose equivalent yet. AndroidViewBinding uses ViewBinding inside Compose for safer view access. Always call setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) on a ComposeView inside a Fragment to prevent memory leaks. The recommended migration strategy is incremental — write new screens in Compose and migrate old ones over time rather than a big-bang rewrite.

💡 Interview Tip

setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) is critical for ComposeView in Fragments. Without it, the composition survives the view being destroyed, causing memory leaks. This is the most common ComposeView migration bug.

Mark it done or save it in the question bank · Back to top

MediumMost asked

18. What is Material3 in Compose? How do you implement theming?

Material Design 3 (Material You) is Google's latest design system — it includes dynamic colour, updated components, and improved typography. All new Compose apps should use Material3.

// Material3 theme setup
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,   // Material You (Android 12+)
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= 31 -> {
            if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
            else dynamicLightColorScheme(LocalContext.current)
        }
        darkTheme -> DarkColorScheme
        else      -> LightColorScheme
    }
    MaterialTheme(colorScheme = colorScheme, typography = AppTypography, content = content)
}

// Access theme values anywhere
Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary,
    style = MaterialTheme.typography.headlineMedium
)
Surface(color = MaterialTheme.colorScheme.surface) { }

// Custom color scheme
private val LightColorScheme = lightColorScheme(
    primary = Color(0xFF6650A4),
    secondary = Color(0xFF625B71),
    background = Color(0xFFFFFBFE)
)

// Material3 components
// Button, OutlinedButton, TextButton, FilledTonalButton
// Card, ElevatedCard, OutlinedCard
// TextField, OutlinedTextField
// Scaffold with TopAppBar, BottomAppBar, FAB, Snackbar
// NavigationBar (bottom nav), NavigationRail (tablet side nav)

Material3 (M3) is the replacement for Material2, bringing redesigned components, an updated token system, and dynamic color. Dynamic color (Android 12+) derives the colorScheme from the user's wallpaper — the core of Material You personalisation. The color system uses semantic roles: primary, secondary, tertiary, surface, background, error, and their on-color and container variants. Typography follows a semantic scale from displayLarge down to labelSmall. Always wrap your app in MaterialTheme — it provides color, typography, and shape tokens to every composable in the tree, ensuring consistent, themeable UI.

💡 Interview Tip

Dynamic color is Android 12+ only — always provide a fallback color scheme for older devices. The code pattern with when(dynamicColor && Build.VERSION >= 31) is the standard approach used in every new Android project template.

Mark it done or save it in the question bank · Back to top

Hard

19. How do you handle one-time UI events (navigation, snackbar, toast) in Compose?

One-time events should never be part of regular UiState — they can't be deduplicated like state. Using the wrong approach causes events to fire multiple times or get lost on rotation.

// ❌ Wrong — StateFlow deduplicates same value
// If user clicks "Navigate" twice rapidly, second navigation lost!
private val _navigate = MutableStateFlow<String?>(null)

// ✅ Option 1: Channel — one consumer, guaranteed delivery
class HomeViewModel : ViewModel() {
    private val _events = Channel<UiEvent>(Channel.BUFFERED)
    val events = _events.receiveAsFlow()

    fun onLoginSuccess() {
        viewModelScope.launch { _events.send(UiEvent.NavigateToHome) }
    }
    fun onError(msg: String) {
        viewModelScope.launch { _events.send(UiEvent.ShowSnackbar(msg)) }
    }
}

sealed class UiEvent {
    object NavigateToHome : UiEvent()
    data class ShowSnackbar(val message: String) : UiEvent()
}

// Collect in Compose
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
    viewModel.events.collect { event ->
        when (event) {
            is UiEvent.NavigateToHome    -> navController.navigate(HomeRoute)
            is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
        }
    }
}

// ✅ Option 2: SharedFlow(replay=0) — no replay, multiple collectors
private val _events = MutableSharedFlow<UiEvent>()
viewModelScope.launch { _events.emit(UiEvent.NavigateToHome) }

StateFlow deduplicates identical values — never use it for one-time events like navigation triggers or toasts, because a repeated emission is dropped. Channel.receiveAsFlow() guarantees delivery to exactly one collector and is the best general-purpose solution for ViewModel events. SharedFlow(replay=0) supports multiple collectors with no caching — useful when multiple parts of the UI need to observe the same event stream. Collect events inside LaunchedEffect(Unit) so collection starts once when the composable enters composition. For snackbars, SnackbarHostState.showSnackbar() is a suspending call that automatically queues messages and waits for each to be dismissed.

💡 Interview Tip

This is one of the most frequently asked advanced Compose questions in 2025. The StateFlow deduplication problem is subtle — navigation to the same screen twice only fires once. Channel or SharedFlow(replay=0) solves this correctly.

Mark it done or save it in the question bank · Back to top

Hard

20. What are Compose stability and the Compose compiler metrics? How do you diagnose recomposition issues?

Compose's smart recomposition relies on stability — stable types can be skipped if parameters haven't changed. The Compose compiler reports which composables are skippable and why.

// Enable Compose compiler metrics in build.gradle.kts
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    compilerOptions.freeCompilerArgs.addAll(
        "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir}/compose_metrics",
        "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir}/compose_reports"
    )
}

// Stable types (Compose can skip recomposition):
// ✅ Primitives (Int, String, Boolean, Float)
// ✅ @Immutable or @Stable annotated classes
// ✅ data class with only stable properties

// Unstable types (Compose cannot skip):
// ❌ List, Map, Set (mutable by default in Kotlin)
// ❌ Classes with var properties
// ❌ Classes from external libraries not annotated

// Fix: use immutable collections
@Stable
data class UserListState(
    val users: ImmutableList<User>  // kotlinx.collections.immutable
)

// Or wrap in @Immutable
@Immutable
data class UserListState(val users: List<User>)

// Layout Inspector — visualise recompositions live
// Android Studio → View → Tool Windows → Layout Inspector
// Enable "Show Recomposition Counts" — highlights hot composables

Compose skips recomposing a composable when all its parameters are stable and equal. Standard Kotlin List and Map are considered unstable because their contents can change — replace them with ImmutableList from Kotlinx Immutable Collections, or wrap data in an @Immutable data class. The Compose compiler metrics (enabled via Gradle flags) report exactly which composables are skippable and which are not, helping pinpoint instability. Layout Inspector shows live recomposition counts per composable, making it easy to spot hot spots visually. @Stable is a contract: you promise that two instances that are equals() always produce the same observable value, enabling the compiler to trust skipping.

💡 Interview Tip

The List stability issue catches everyone. UserListScreen(users: List<User>) is never skippable because List is unstable. Fix: use ImmutableList from kotlinx-collections-immutable or wrap in @Immutable data class. This one fix can eliminate most recompositions in list-heavy screens.

Mark it done or save it in the question bank · Back to top

MediumMost asked

21. What is Scaffold in Compose? How do you use it with TopAppBar, FAB, and SnackbarHost?

Scaffold implements the basic Material Design layout structure — it handles the coordination of TopAppBar, BottomAppBar, FAB, SnackbarHost, and content padding automatically.

@Composable
fun HomeScreen(viewModel: HomeViewModel) {
    val snackbarHostState = remember { SnackbarHostState() }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Home") },
                navigationIcon = {
                    IconButton(onClick = { navController.navigateUp() }) {
                        Icon(Icons.Default.ArrowBack, "Back")
                    }
                },
                actions = {
                    IconButton(onClick = { openSettings() }) {
                        Icon(Icons.Default.Settings, "Settings")
                    }
                }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = { onAddClick() }) {
                Icon(Icons.Default.Add, "Add")
            }
        },
        snackbarHost = { SnackbarHost(snackbarHostState) },
        bottomBar = { BottomNavigationBar(navController) }
    ) { innerPadding ->
        // innerPadding avoids content going under AppBar or BottomBar
        LazyColumn(contentPadding = innerPadding) {
            items(items) { ItemRow(it) }
        }
    }
}

Scaffold coordinates the standard Material layout slots — topBar, bottomBar, floatingActionButton, and snackbarHost — handling insets and positioning automatically. The innerPadding lambda parameter it provides is critical: apply it to your content to prevent it from being obscured by the app bar or system bars. SnackbarHostState.showSnackbar() suspends until the snackbar is dismissed and automatically queues multiple messages. For the top bar, CenterAlignedTopAppBar gives the centred title style common in social apps. NavigationBar with NavigationBarItem is the M3 replacement for the deprecated BottomNavigation.

💡 Interview Tip

Forgetting to apply innerPadding is the most common Scaffold mistake — content goes under the TopAppBar or BottomBar. Always pass innerPadding to the scrollable content either as contentPadding or Modifier.padding(innerPadding).

Mark it done or save it in the question bank · Back to top

Hard

22. What is the Compose ViewModel integration? How do you use hiltViewModel and viewModel?

ViewModels in Compose are scoped to navigation destinations, not Activities. The lifecycle and scoping rules are different from the View system — important to understand to avoid memory leaks.

// viewModel() — from lifecycle-viewmodel-compose
@Composable
fun HomeScreen() {
    val viewModel: HomeViewModel = viewModel()
    // Scoped to the NavBackStackEntry (destination lifecycle)
}

// hiltViewModel() — Hilt-injected ViewModel (recommended)
@Composable
fun HomeScreen() {
    val viewModel: HomeViewModel = hiltViewModel()
    // Hilt provides dependencies, scoped to destination
}

// Shared ViewModel across screens (activity-scoped)
@Composable
fun ProfileScreen() {
    val sharedVm: SharedViewModel = hiltViewModel(LocalActivity.current)
    // Same instance across all screens
}

// ViewModel scoped to NavGraph (parent route)
@Composable
fun CheckoutScreen() {
    val backStack = rememberNavBackStackEntry("checkout_graph")
    val checkoutVm: CheckoutViewModel = hiltViewModel(backStack)
    // Lives as long as user is in checkout flow
}

// ViewModel lifecycle in Compose vs View system:
// View: scoped to Activity or Fragment backstack entry
// Compose: scoped to NavBackStackEntry (destination)
// Destroyed when navigating back past that destination

Always use hiltViewModel() in Compose — it injects a Hilt-managed ViewModel scoped to the NavBackStackEntry, so the ViewModel is cleared automatically when the user navigates back. For a ViewModel shared across all screens, scope it to the Activity by passing LocalActivity.current as the viewModelStoreOwner. For multi-step flows like a checkout or onboarding wizard, scope the ViewModel to the nested nav graph so it is shared within the flow but cleared when the user exits it. Never pass a ViewModel instance as a parameter to child composables — pass the state and lambda callbacks instead to keep composables testable and stateless.

💡 Interview Tip

NavGraph-scoped ViewModel is powerful for flows like checkout or onboarding — the ViewModel lives across multiple steps but is cleared when the user exits the flow. This is much cleaner than passing data through each screen.

Mark it done or save it in the question bank · Back to top

MediumMost asked

23. How do you handle lists efficiently in Compose — LazyColumn keys, item types, and sticky headers?

LazyColumn is Compose's RecyclerView equivalent but requires specific optimisations to avoid common performance pitfalls — especially around keys and item types.

// ✅ Always provide keys — stable identity
LazyColumn {
    items(users, key = { it.id }) { user ->  // key prevents re-creation on reorder
        UserRow(user)
    }
}

// ❌ No key — items recreated on list changes (animations broken)
LazyColumn {
    items(users) { user -> UserRow(user) }
}

// contentType — hint Compose to reuse similar items
LazyColumn {
    items(
        items = feedItems,
        key = { it.id },
        contentType = { it.type }  // VIDEO, IMAGE, TEXT — different composables reused separately
    ) { item ->
        when (item.type) {
            FeedType.VIDEO -> VideoItem(item)
            FeedType.IMAGE -> ImageItem(item)
            FeedType.TEXT  -> TextItem(item)
        }
    }
}

// Sticky headers
val grouped = users.groupBy { it.department }
LazyColumn {
    grouped.forEach { (dept, users) ->
        stickyHeader { DepartmentHeader(dept) }
        items(users, key = { it.id }) { UserRow(it) }
    }
}

// rememberLazyListState — scroll control and observation
val listState = rememberLazyListState()
LazyColumn(state = listState) { }
// Scroll to position
LaunchedEffect(Unit) { listState.scrollToItem(10) }
// Animate scroll
LaunchedEffect(Unit) { listState.animateScrollToItem(0) }

Providing a key for each LazyColumn item gives it a stable identity across data changes — this enables smooth reorder animations and prevents item composables from being destroyed and recreated unnecessarily. contentType hints to the Compose runtime that items of the same type can share composable slots during recycling, improving scroll performance. stickyHeader pins section headers to the top of the list while the user scrolls through their items. rememberLazyListState exposes the scroll position as observable state and provides animateScrollToItem() for programmatic scrolling. Keep item composables lightweight — compute sorting, filtering, and formatting in the ViewModel and pass ready-to-render results down.

💡 Interview Tip

contentType is the most overlooked LazyColumn optimisation. Without it, Compose might try to reuse a VideoItem composable for a TextItem — causing a full recompose. With contentType, only same-type items are reused — like RecyclerView's viewType.

Mark it done or save it in the question bank · Back to top

Hard

24. What is the Compose runtime and how does it differ from the Compose UI toolkit?

Compose is split into layers — the runtime (slot table, recomposition engine) is separate from the UI toolkit. This separation enables Compose to be used outside of Android UI — for example in Compose for Desktop and Compose Multiplatform.

// Compose layers (bottom to top):
// 1. compose-runtime     — core: slot table, snapshot state, recomposition
// 2. compose-ui          — draw, layout, input, accessibility
// 3. compose-foundation  — basic building blocks: Box, Text, Image, LazyColumn
// 4. compose-material3   — Material Design components

// compose-runtime: no Android dependency
// Powers: Compose UI, Compose for Desktop, Compose for iOS (Compose Multiplatform)

// Snapshot state system — how Compose tracks changes
val state = mutableStateOf(0)
// Reading state inside a composable subscribes to it
// Writing state marks the composable as needing recomposition

// Snapshot — consistent view of state at a point in time
// Allows reads from multiple threads safely
// Compose.async is thread-safe because of snapshots

// Slot table — Compose's internal data structure
// Stores composition (composable calls + their positions)
// Enables Compose to diff and update efficiently
// Like a virtual DOM but for Android UI

// Compose Multiplatform — shared UI across platforms
// implementation("org.jetbrains.compose.ui:ui")
// Write Compose UI once → run on Android, iOS, Desktop, Web
@Composable
fun SharedUi() {
    Column {
        Text("Runs on Android AND iOS!")  // Compose Multiplatform
    }
}

The Compose runtime is platform-agnostic — it contains the slot table, snapshot state system, and recomposition engine, with no Android dependency. The snapshot state system makes state reads thread-safe and consistent: writes from any thread are batched and applied atomically, ensuring the UI always sees a coherent snapshot. The slot table is the internal data structure that stores the composition tree; Compose diffs it to determine exactly what changed between recompositions. JetBrains builds on the platform-agnostic runtime for Compose Multiplatform, targeting iOS, Desktop, and Web from a shared Kotlin codebase. The layered architecture also means you can use compose-runtime without compose-ui to build non-UI reactive trees.

💡 Interview Tip

Compose's "virtual DOM" analogy: the slot table tracks what composables called what, in what order. When state changes, Compose replays from the affected point. Knowing this makes recomposition behaviour intuitive rather than magical.

Mark it done or save it in the question bank · Back to top

Hard

25. What are Compose previews? How do you use @Preview effectively?

@Preview renders composables in Android Studio without running the app. Used effectively, previews become a development superpower — enabling rapid UI iteration with instant visual feedback.

// Basic preview
@Preview(showBackground = true)
@Composable
fun UserCardPreview() {
    AppTheme {
        UserCard(user = User("Rahul", "Android Developer"))
    }
}

// Multiple previews — test different states
@Preview(name = "Loading", showBackground = true)
@Composable
fun LoadingPreview() { HomeContent(state = UiState.Loading) }

@Preview(name = "Success", showBackground = true)
@Composable
fun SuccessPreview() { HomeContent(state = UiState.Success(fakeUsers)) }

@Preview(name = "Error", showBackground = true)
@Composable
fun ErrorPreview() { HomeContent(state = UiState.Error("Network error")) }

// Preview with different configurations
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Large text", fontScale = 1.5f)
@Preview(name = "Tablet", device = Devices.TABLET)
@Composable
fun MultiConfigPreview() { AppTheme { HomeScreen() } }

// Custom annotation for reuse
@Preview(name = "Light", showBackground = true)
@Preview(name = "Dark", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
annotation class ThemePreviews

@ThemePreviews
@Composable
fun ButtonPreview() { AppTheme { PrimaryButton("Click me") } }

@Preview works best on stateless composables — another strong reason to practice state hoisting. Multiple @Preview annotations on a single function let you visualise all UI states (loading, error, success, empty) side by side in Android Studio without running the app. Preview parameters like uiMode, fontScale, device, and locale let you catch edge cases — dark mode, large font, small screen — at design time. Combine multiple @Preview annotations into a single custom annotation (e.g. @MultiThemePreview) to apply them consistently across the codebase. PreviewParameterProvider generates a separate preview for each item in a provided dataset, useful for testing with real-looking data variations.

💡 Interview Tip

Effective preview usage is a sign of a mature Compose developer. Say: "I have Loading, Success, Error, and Dark mode previews for every screen. I catch 80% of UI bugs without running the app." This shows you use Compose as intended, not just write it.

Mark it done or save it in the question bank · Back to top

Hard

26. Scenario: Your LazyColumn is janky with 10,000 items. How do you diagnose and fix it?

Profile first, fix second. The answer involves Layout Inspector, stability annotations, missing keys, and heavy work inside composables.

// Step 1: Layout Inspector → Show Recomposition Counts
// Red = excessive recompositions

// FIX 1: Always provide keys
// ❌
items(posts) { PostCard(it) }
// ✅
items(posts, key = { it.id }) { PostCard(it) }

// FIX 2: Unstable types prevent skipping
// ❌ List<Post> is unstable — PostCard never skipped
// ✅ Wrap in @Immutable
@Immutable data class FeedState(val posts: List<Post>)

// FIX 3: Heavy work inside composable
// ❌ Date formatting on every recompose
Text(SimpleDateFormat("dd/MM").format(post.date))
// ✅ Pre-format in remember
val date = remember(post.date) { formatDate(post.date) }

// FIX 4: contentType — reuse composables of same type
items(feed, key = { it.id }, contentType = { it.type }) { FeedItem(it) }

// FIX 5: Image sizing — load at display size only
AsyncImage(model = ImageRequest.Builder(ctx).data(url).size(200).build(),
    modifier = Modifier.size(48.dp), contentDescription = null)

The golden rule for Compose performance: profile before you optimise. Open Layout Inspector, enable "Show Recomposition Counts," and scroll the feed. Any composable highlighted red or showing a high count is your target — guessing without data wastes time and often misses the real bottleneck.

Once you have your hot composable, the most impactful single fix is nearly always adding a key to items(). Without a key, Compose has no way to match items across data changes — it destroys and recreates every visible composable whenever the list updates. With key = { it.id }, unchanged items are simply skipped. On a feed of 200 posts this is the difference between smooth 60fps and noticeable jank.

The next fix is stability. Compose can skip recomposing a composable whose inputs are all equal and stable — but List<Post> is not considered stable because its contents could change at any time. Wrapping your state in an @Immutable data class signals to the compiler that the contents are frozen, making PostCard skippable when the post itself hasn't changed. Similarly, contentType tells the runtime which items share the same composable structure so their slots can be recycled during fast scroll, the same way RecyclerView recycles ViewHolders by view type. Finally, wrap any expensive per-item computation — date formatting, number parsing — in remember(post.date) { ... } so it only runs when the input actually changes, not on every recompose.

💡 Interview Tip

Always start with "profile first with Layout Inspector." Interviewers want to see engineering discipline. Then list fixes in order of impact: keys → stability → heavy work → image sizes.

Mark it done or save it in the question bank · Back to top

Hard

27. Scenario: Build a search screen with debounce, loading state, and error handling in Compose.

This tests the full ViewModel + Flow + Compose UI pipeline — the most common architecture pattern in production Android apps.

@HiltViewModel
class SearchViewModel @Inject constructor(private val repo: SearchRepo) : ViewModel() {
    private val _query = MutableStateFlow("")

    val state = _query
        .debounce(300)
        .distinctUntilChanged()
        .flatMapLatest { q ->
            if (q.isBlank()) flowOf(SearchState.Idle)
            else flow {
                emit(SearchState.Loading)
                runCatching { repo.search(q) }
                    .onSuccess { emit(SearchState.Success(it)) }
                    .onFailure { emit(SearchState.Error(it.message!!)) }
            }
        }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), SearchState.Idle)

    fun onQuery(q: String) { _query.value = q }
}

@Composable
fun SearchScreen(vm: SearchViewModel = hiltViewModel()) {
    var query by rememberSaveable { mutableStateOf("") }
    val state by vm.state.collectAsStateWithLifecycle()
    Column {
        TextField(value = query, onValueChange = { query = it; vm.onQuery(it) })
        when (val s = state) {
            is SearchState.Idle    -> Hint()
            is SearchState.Loading -> CircularProgressIndicator()
            is SearchState.Success -> ResultList(s.results)
            is SearchState.Error   -> ErrorText(s.msg)
        }
    }
}

Every operator in the chain solves a specific real-world problem. debounce(300) waits 300ms after the last keystroke before firing — without it, every character the user types triggers a network request, easily generating dozens of calls per second. distinctUntilChanged() ensures a query that hasn't actually changed (the user deleted and retyped the same letter) doesn't trigger a redundant fetch.

flatMapLatest is the most important operator here: when a new query arrives, it cancels the previous in-flight search coroutine immediately. Without it, an older slow request could complete after a newer fast one, overwriting fresh results with stale data — a classic race condition. Pairing this with a sealed SearchState class gives you an exhaustive when expression in the UI: the compiler forces you to handle Idle, Loading, Success, and Error — no forgotten empty states.

rememberSaveable for the query string means the user's typed text survives screen rotation. stateIn(WhileSubscribed(5000)) converts the cold flow to a hot StateFlow and shuts down the upstream pipeline 5 seconds after the last collector disappears — preventing unnecessary background work when the user navigates away, while still keeping state alive briefly enough to survive a quick config change.

💡 Interview Tip

Walk through the chain: query → debounce → flatMapLatest → sealed state → stateIn → collectAsStateWithLifecycle → when(state). Each step solves a specific problem. Explaining WHY each is there separates senior from junior answers.

Mark it done or save it in the question bank · Back to top

Hard

28. Scenario: Bottom sheet with "Add to Cart" button that shows a snackbar confirmation. Implement correctly.

Tests ModalBottomSheet, SnackbarHost, one-time events via Channel, and correct Scaffold usage.

@Composable
fun ProductScreen(vm: ProductViewModel = hiltViewModel()) {
    val snackbar = remember { SnackbarHostState() }
    var selected by remember { mutableStateOf<Product?>(null) }

    LaunchedEffect(Unit) {
        vm.events.collect { event ->
            when (event) {
                is ProductEvent.AddedToCart ->
                    snackbar.showSnackbar("${event.name} added!")
            }
        }
    }
    Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { p ->
        ProductList(Modifier.padding(p), onTap = { selected = it })
    }
    selected?.let { product ->
        ModalBottomSheet(onDismissRequest = { selected = null }) {
            ProductDetail(product, onAdd = { vm.addToCart(product); selected = null })
        }
    }
}

// ViewModel uses Channel — StateFlow would deduplicate same product
private val _events = Channel<ProductEvent>()
val events = _events.receiveAsFlow()

The correct way to drive a ModalBottomSheet in Compose is with nullable state: the selected product is null when the sheet is hidden and non-null when shown. This is clean because the sheet content knows exactly which product to display, dismissing it is a single selected = null assignment, and there's no separate boolean flag that could get out of sync with the actual product data.

For the snackbar confirmation, a Channel is the correct choice over StateFlow. StateFlow deduplicates identical values — if the user adds the same product to cart twice in quick succession, the second event would be silently dropped because the value hasn't changed. Channel.receiveAsFlow() guarantees every emission is delivered exactly once to exactly one collector, making it the right tool for one-time UI events like toasts and snackbars.

Collecting the event Channel inside LaunchedEffect(Unit) means the collection starts exactly once when the composable enters composition and lives for its entire lifetime. SnackbarHostState.showSnackbar() is a suspending function that automatically queues multiple messages — if three snackbars are triggered in rapid succession, they are shown one after another rather than stacking on top of each other.

💡 Interview Tip

The Channel vs StateFlow distinction for events is the key insight. StateFlow deduplicates same value — if user adds the same product twice rapidly, second snackbar would be lost. Channel guarantees every event is delivered.

Mark it done or save it in the question bank · Back to top

Hard

29. Scenario: Your team is migrating from XML to Compose. How do you plan the migration?

Incremental migration is the only safe approach — never big-bang rewrite. Compose and Views coexist seamlessly through interop APIs.

// Strategy: Incremental, screen by screen

// Phase 1: New screens in Compose
// All new features built in Compose, old screens stay XML

// Phase 2: ComposeView in existing Fragments
class ProfileFragment : Fragment() {
    override fun onCreateView(...) = ComposeView(requireContext()).apply {
        setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
        setContent { AppTheme { ProfileScreen() } }
    }
}

// Phase 3: AndroidView for unmigrated components
@Composable
fun LegacyMap() {
    AndroidView(factory = { ctx -> MapView(ctx) })
}

// Migration order (safest to riskiest):
// 1. Leaf components (buttons, cards, list items)
// 2. Reusable components (headers, footers)
// 3. Full screens
// 4. Navigation (last — most disruptive)

// ViewModel stays unchanged throughout
// Both XML and Compose observe the same StateFlow

A big-bang XML-to-Compose rewrite is a guaranteed disaster for any app of meaningful size — features regress, edge cases are missed, and the team is blocked from shipping new functionality for months. The only safe approach is incremental migration: keep all existing XML screens working, write every new screen in Compose, and migrate old screens one at a time when they need changes anyway.

ComposeView is the entry point for this strategy. Drop it into any existing Fragment's onCreateView alongside setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) and you can write the entire screen body in Compose without touching the Activity, navigation, or any other Fragment. Always set the composition strategy in Fragments — without it, the composition is tied to the Fragment lifecycle rather than the view lifecycle, causing memory leaks when the Fragment is placed on the back stack. Going the other direction, AndroidView lets you embed legacy Views like Google Maps or a third-party chart library inside a fully Compose screen, so you never have to block migration waiting for a Compose equivalent of every component.

The cleanest aspect of this strategy: ViewModels don't change at all. A ViewModel exposing a StateFlow works identically whether the observer is a lifecycleScope.collect in an XML Fragment or a collectAsStateWithLifecycle() in a composable. Migrate navigation last — it involves the most coordinated changes across the entire app and is most disruptive to the team while it is in progress.

💡 Interview Tip

setViewCompositionStrategy(DisposeOnViewTreeLifecycleDestroyed) is critical for Fragments — without it, the composition leaks when the Fragment view is destroyed. This is the most common ComposeView migration bug.

Mark it done or save it in the question bank · Back to top

Hard

30. Scenario: App crashes on screen rotation in Compose. How do you debug and fix it?

Rotation crashes in Compose are almost always state management issues — remember vs rememberSaveable, ViewModel scoping, or unstable LaunchedEffect keys.

// Debug: Enable "Don't keep activities" in Dev Options
// Forces Activity recreation aggressively

// CAUSE 1: remember loses state on rotation
// ❌
var index by remember { mutableStateOf(0) }  // 0 after rotation
// Crash: list[0] accessed when list is empty on restart
// ✅
var index by rememberSaveable { mutableStateOf(0) }

// CAUSE 2: ViewModel created manually
// ❌ New instance every rotation
val vm = MyViewModel()
// ✅ Survives rotation
val vm: MyViewModel = hiltViewModel()

// CAUSE 3: LaunchedEffect with unstable key re-runs on rotation
// ❌
LaunchedEffect(users.size) {
    loadDetailFor(users[0])  // crash if users empty after rotation
}
// ✅
LaunchedEffect(Unit) {
    users.firstOrNull()?.let { loadDetailFor(it) }
}

// CAUSE 4: NavController not remembered
// ❌
val nav = NavHostController(context)  // lost on rotation
// ✅
val nav = rememberNavController()

The first step when debugging a rotation crash is to turn on "Don't keep activities" in Developer Options — it aggressively destroys and recreates Activities even on simple navigation, far more ruthlessly than a real rotation. This surfaces all state management bugs quickly rather than waiting for the specific moment a user happens to rotate.

Rotation crashes almost always fall into four categories. First: remember vs rememberSaveable. When the Activity is recreated, all remember state resets to its initial value. If your code then accesses an index into a list that loads asynchronously, you get an out-of-bounds crash before the list arrives. Use rememberSaveable for any UI state the user would notice losing. Second: manually instantiated ViewModels. val vm = MyViewModel() creates a fresh instance on every rotation, losing all loaded data. Always use hiltViewModel() or viewModel() — they return the same instance across rotations.

Third: unstable LaunchedEffect keys. A key like users.size re-triggers the effect on rotation if the list is empty initially — if your effect accesses users[0] that causes a crash. Use Unit or a stable ID as the key. Fourth: NavController not remembered. Constructing NavHostController(context) directly drops the entire back stack on rotation. Always use rememberNavController() which persists the back stack across configuration changes.

💡 Interview Tip

"Enable Don't keep activities" immediately signals you know how to properly test rotation. It forces Activity recreation far more aggressively than just rotating, exposing all state management bugs quickly.

Mark it done or save it in the question bank · Back to top

MediumMost asked

31. What is rememberCoroutineScope and when do you use it vs LaunchedEffect?

Both provide coroutine scopes in Compose — LaunchedEffect for automatic state-driven effects, rememberCoroutineScope for user-triggered actions like button clicks.

// LaunchedEffect — runs automatically when key changes
@Composable
fun Screen(userId: String) {
    LaunchedEffect(userId) {
        viewModel.loadUser(userId)  // runs on composition + key change
    }
}

// rememberCoroutineScope — user-triggered actions
@Composable
fun ScrollToTopButton(listState: LazyListState) {
    val scope = rememberCoroutineScope()
    FloatingActionButton(onClick = {
        scope.launch { listState.animateScrollToItem(0) }
    }) { Icon(Icons.Default.KeyboardArrowUp, null) }
}

// Decision rule:
// State/lifecycle drives it → LaunchedEffect
// User action drives it   → rememberCoroutineScope

// ❌ Anti-pattern: LaunchedEffect for click
var clicked by remember { mutableStateOf(false) }
LaunchedEffect(clicked) { if (clicked) doWork() }

// ✅ Correct: scope for click
val scope = rememberCoroutineScope()
Button(onClick = { scope.launch { doWork() } }) { Text("Go") }

The distinction is simple once you know the mental model: LaunchedEffect is for things that happen to the composable — it runs automatically when the composable enters the composition or when a key changes. You don't call it; the composition system calls it. It's the right place for loading initial data, subscribing to a stream, or reacting to a parameter change like a new userId arriving via navigation.

rememberCoroutineScope is for things the user makes happen — button clicks, swipe gestures, scroll-to-top. It gives you a CoroutineScope that you call launch on from inside an onClick lambda. Both scopes are tied to the composable's lifetime and are cancelled when it leaves the composition, so there's no leak either way.

A common anti-pattern to avoid: using LaunchedEffect with a boolean flag for click events — setting clicked = true then observing it in LaunchedEffect(clicked). This is awkward, race-prone, and semantically wrong. The correct pattern is always scope.launch { doWork() } directly in the click handler. Also remember: most async work — network calls, database reads — belongs in the ViewModel. rememberCoroutineScope is for UI-layer coroutines only: scroll animations, keyboard dismissal, and similar short-lived UI actions.

💡 Interview Tip

Simple rule: "LaunchedEffect = something that happens TO the UI. rememberCoroutineScope = something the USER makes happen." Scroll-to-top on button click → scope.launch. Load data when screen appears → LaunchedEffect.

Mark it done or save it in the question bank · Back to top

MediumMost asked

32. What is the Slot API in Compose? How does it enable flexible composable design?

The slot API uses @Composable lambdas as parameters — letting callers inject any UI into predefined slots. It's how Material3 achieves maximum flexibility with minimal parameters.

// Slot API — @Composable lambda parameters
@Composable
fun CustomCard(
    header: (@Composable () -> Unit)? = null,
    footer: (@Composable () -> Unit)? = null,
    content: @Composable () -> Unit
) {
    Card {
        Column {
            header?.invoke()
            Box(Modifier.padding(16.dp)) { content() }
            footer?.invoke()
        }
    }
}

// Usage — caller decides each slot's content
CustomCard(
    header = { Image(hero, contentDescription = null) },
    content = { Text("Main content") },
    footer = {
        Row {
            Button(onClick = {}) { Text("OK") }
            TextButton(onClick = {}) { Text("Cancel") }
        }
    }
)

// Material3 uses slots everywhere
TopAppBar(
    title = { Text("Title") },          // title slot
    navigationIcon = { BackButton() },  // icon slot
    actions = { SearchIcon() }          // actions slot
)
Button(onClick = {}) {
    Icon(Icons.Default.Add, null)
    Text("Add")   // content slot — any composable works
}

The slot API is what makes Compose composables genuinely reusable. Instead of adding a parameter for every possible content variation — showLeadingIcon, leadingIconRes, showTrailingText, trailingText — you expose a single content: @Composable () -> Unit lambda and let the caller inject whatever UI they need. The composable defines where content goes; the caller defines what the content is.

Optional slots use nullable lambdas: header: (@Composable () -> Unit)? = null. When the caller passes null, the composable skips rendering that section entirely — no empty space, no visibility toggling. This is far cleaner than a boolean parameter that still requires the caller to think about what to hide. It's why Button's content slot can hold just Text("Save"), or Icon + Text, or an animated spinner — the Button composable doesn't need to know.

All of Material3 is built on this pattern: TopAppBar has title, navigationIcon, and actions slots; Scaffold has topBar, bottomBar, floatingActionButton, and snackbarHost slots. Scope-restricted slots add a further layer — RowScope and ColumnScope receivers expose Modifier.weight() and alignment APIs that only make sense inside those layouts, giving compile-time guarantees that callers can't misuse the API.

💡 Interview Tip

Button's content lambda IS a slot — you can put Icon+Text, just Image, or anything else. This flexibility is impossible with traditional View parameters. Understanding the slot API is what makes you design reusable Compose components rather than one-off widgets.

Mark it done or save it in the question bank · Back to top

MediumMost asked

33. How do you implement a tab layout with bottom navigation in Compose?

Bottom navigation with multiple tabs is a fundamental app pattern. The key is saving and restoring tab state so scroll position is preserved when switching tabs.

@Serializable object HomeTab
@Serializable object SearchTab
@Serializable object ProfileTab

@Composable
fun MainScreen() {
    val nav = rememberNavController()
    val dest by nav.currentBackStackEntryAsState()

    Scaffold(
        bottomBar = {
            NavigationBar {
                NavigationBarItem(
                    selected = dest?.destination?.hasRoute(HomeTab::class) == true,
                    onClick = {
                        nav.navigate(HomeTab) {
                            popUpTo(nav.graph.startDestinationId) { saveState = true }
                            launchSingleTop = true
                            restoreState = true   // restore scroll position!
                        }
                    },
                    icon = { Icon(Icons.Default.Home, null) },
                    label = { Text("Home") }
                )
                // Repeat for Search, Profile...
            }
        }
    ) { padding ->
        NavHost(nav, HomeTab, Modifier.padding(padding)) {
            composable<HomeTab> { HomeScreen() }
            composable<SearchTab> { SearchScreen() }
            composable<ProfileTab> { ProfileScreen() }
        }
    }
}

Bottom navigation in Compose uses NavigationBar with NavigationBarItem — the Material3 replacements for the deprecated BottomNavigation. The component itself is straightforward; the complexity is in the navigation options you pass to nav.navigate(), and getting them wrong produces the two most common bottom-nav bugs.

The first bug: tapping a tab twice pushes a duplicate destination onto the back stack. Fix: launchSingleTop = true. This reuses the existing instance if it's already the top destination rather than stacking a second copy. The second bug: the user scrolls deep into the Home tab, switches to Search, comes back to Home, and the list has jumped back to the top. Fix: saveState = true before leaving (saves the Home back stack and scroll position) and restoreState = true on return (restores it). This is what makes Compose bottom navigation feel native — exactly like Instagram or YouTube where each tab remembers where you were.

popUpTo(startDestination) { saveState = true } ensures that pressing Back from any tab collapses the back stack to the start destination rather than navigating backwards through every tab the user has visited — so Back from Search goes to Home once, then exits the app cleanly. These four options together — launchSingleTop, saveState, restoreState, popUpTo — are the complete correct bottom-nav implementation.

💡 Interview Tip

restoreState = true is what makes tabs feel native — scroll position preserved just like Instagram or YouTube. Without saveState/restoreState, every tab switch resets to the top. This is the most common bottom-nav implementation bug.

Mark it done or save it in the question bank · Back to top

Hard

34. What makes a @Composable function different from a regular Kotlin function?

@Composable is a compiler plugin annotation that fundamentally transforms the function — adding hidden parameters and making it part of the Compose slot table and recomposition system.

// What you write:
@Composable
fun Greeting(name: String) { Text("Hello $name") }

// What compiler generates (conceptually):
fun Greeting(name: String, composer: Composer, changed: Int) {
    composer.startRestartGroup(...)
    if (changed or !composer.skipping) {
        Text(name, composer, ...)
    }
    composer.endRestartGroup()
}

// Rules for composables:
// ✅ Can only be called from @Composable context
// ✅ Can use remember, LaunchedEffect, state
// ✅ Can be skipped if inputs unchanged (smart recomposition)
// ✅ Can return values (not just Unit)
// ❌ Must be idempotent — same input → same output
// ❌ Must not have side effects outside Compose APIs

// ❌ Not idempotent — different output every recompose
@Composable
fun Bad() { Text(Random.nextInt().toString()) }

// ✅ Stabilised with remember
@Composable
fun Good() {
    val value = remember { Random.nextInt() }
    Text(value.toString())
}

@Composable is not a simple annotation — it triggers a compiler plugin transformation that fundamentally changes the function's signature and behaviour. The compiler adds a hidden Composer parameter to every @Composable function and uses the call-site position in the source code to assign each composable a unique slot in the slot table. This positional identity is how Compose knows that the Text on line 42 is the same one as last composition, even without you giving it a name.

This explains why @Composable is "infectious": the Composer must be threaded through every call in the chain. You cannot call a @Composable function from a regular function because there is no Composer to pass. This constraint is enforced at compile time — the IDE and compiler both flag the violation immediately. It is also why @Composable functions can return values — remember { } is a @Composable function that returns a value, and helper functions like rememberLazyListState() follow the same pattern.

The most important behavioural contract: composable functions must be idempotent. Given the same inputs they must always produce the same UI output, because Compose may re-run them at any time for recomposition. Generating a random number, accessing a mutable global, or starting a network call directly inside a composable body violates this contract and produces unpredictable bugs — side effects must always go through the dedicated side-effect APIs like LaunchedEffect or SideEffect.

💡 Interview Tip

The hidden Composer parameter explains why @Composable is "infectious." The Composer must be threaded through every call — that's why you can't call LaunchedEffect from a regular function. Knowing this shows deep Compose understanding.

Mark it done or save it in the question bank · Back to top

MediumMost asked

35. What are Compose gestures? Explain clickable, pointerInput, and draggable.

Compose provides a gesture API ranging from simple clicks to complex multi-touch. Each level trades simplicity for control.

// clickable — simplest, handles ripple + accessibility
Box(Modifier.clickable(
    onClick = { handleClick() },
    onLongClick = { showMenu() }
))

// combinedClickable — adds double-click
Box(Modifier.combinedClickable(
    onClick = { select() },
    onDoubleClick = { zoomIn() },
    onLongClick = { showContextMenu() }
))

// draggable — single-axis drag
var offsetX by remember { mutableFloatStateOf(0f) }
Box(Modifier
    .offset { IntOffset(offsetX.roundToInt(), 0) }
    .draggable(rememberDraggableState { offsetX += it }, Orientation.Horizontal)
)

// pointerInput — full raw gesture control
Box(Modifier.pointerInput(Unit) {
    detectTapGestures(
        onTap = { println("Tap at $it") },
        onDoubleTap = { zoomIn() },
        onLongPress = { showMenu() }
    )
})

// detectTransformGestures — pinch-zoom + pan + rotate
Box(Modifier.pointerInput(Unit) {
    detectTransformGestures { _, pan, zoom, rotation ->
        scale *= zoom; offset += pan; angle += rotation
    }
})

Compose's gesture system is layered — use the highest-level API that fits your use case, and only go lower when you need behaviour the higher level doesn't provide. Modifier.clickable is the right choice for anything that just needs to respond to a tap or long press: it adds a ripple effect, announces the interaction to accessibility services, and handles all the touch state for you. There is almost no reason to reach below clickable for a standard button or list item.

combinedClickable adds double-tap detection on top of the standard click and long press — useful for Instagram-style "double tap to like." Modifier.draggable handles single-axis drag and reports delta updates, making it the right choice for a horizontal swipe-to-dismiss or a vertical volume slider. It doesn't care about the current position, just how much the finger moved since the last event.

When neither of those fits, drop down to Modifier.pointerInput with detectTapGestures for precise control over tap, double-tap, and long press with the exact pointer position. For the most complex use case — simultaneous pinch-zoom, pan, and rotation like a photo viewer — detectTransformGestures reports all three transforms in a single callback. The key principle: clickable handles everything by default, and you trade simplicity for control as you go lower in the stack.

💡 Interview Tip

Use the highest-level API that fits. clickable for buttons (automatic ripple and accessibility), draggable for sliders/drawers, pointerInput for custom gestures like pinch-zoom. Going lower than needed adds complexity with no benefit.

Mark it done or save it in the question bank · Back to top

Hard

36. Scenario: Implement a collapsing toolbar that shrinks as user scrolls down.

Collapsing toolbars connect scroll state to layout transformations. Material3 provides a built-in solution via LargeTopAppBar — always prefer this over custom implementations.

// Material3 built-in — easiest approach (RECOMMENDED)
@Composable
fun CollapsingScreen() {
    val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
    Scaffold(
        topBar = {
            LargeTopAppBar(
                title = { Text("Profile") },
                scrollBehavior = scrollBehavior
            )
        },
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
    ) { padding ->
        LazyColumn(Modifier.padding(padding)) {
            items(100) { ListItem({ Text("Item $it") }) }
        }
    }
}

// Custom approach — when Material3 style doesn't fit
val scrollBehavior2 = TopAppBarDefaults.pinnedScrollBehavior()      // stays visible
val scrollBehavior3 = TopAppBarDefaults.enterAlwaysScrollBehavior() // re-appears on up

// NestedScrollConnection for custom behavior
val connection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            // intercept scroll delta, update toolbar height
            return Offset.Zero
        }
    }
}

Material3 provides a fully built-in collapsing toolbar through LargeTopAppBar — reach for it first before writing any custom scroll-connection code. The key wiring is Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) on the Scaffold: this connects the LazyColumn's scroll events to the app bar's collapse animation through the nested scroll system. Without it, the bar has no idea the list is being scrolled.

Choose your scroll behaviour based on the UX you want. exitUntilCollapsedScrollBehavior collapses the bar as the user scrolls down and keeps it collapsed — good for reading-heavy content like an article or profile page where screen real estate matters. enterAlwaysScrollBehavior collapses on scroll-down and re-appears as soon as the user scrolls back up — the YouTube-style pattern, good for browsable feeds where the user frequently reverses direction. pinnedScrollBehavior keeps the bar visible the whole time — for screens where navigation is always needed.

Only reach for NestedScrollConnection when you need behaviour that none of the built-in scroll behaviors provide — a parallax hero image that scrolls at half speed, a toolbar that changes colour mid-collapse, or a custom gesture that consumes scroll delta before the list sees it. It intercepts scroll events at the pre-scroll and post-scroll phase, giving you full control. For interviews: lead with LargeTopAppBar, then explain NestedScrollConnection for custom cases.

💡 Interview Tip

For interviews: "I'd use LargeTopAppBar with exitUntilCollapsedScrollBehavior — it's Material3's built-in solution." Only reach for NestedScrollConnection for non-standard behaviour like a parallax hero image or custom shrink animations.

Mark it done or save it in the question bank · Back to top

Hard

37. What is SharedTransitionLayout? How do you implement shared element transitions?

Shared element transitions animate UI elements smoothly between screens. Native Compose support arrived in Compose 1.7 (2024) via SharedTransitionLayout.

// Stable since Compose 1.7 / BOM 2024.09.00
@Composable
fun App() {
    val nav = rememberNavController()

    SharedTransitionLayout {  // wraps NavHost
        NavHost(nav, startDestination = "list") {
            composable("list") {
                AnimatedVisibilityScope {
                    LazyColumn {
                        items(products, key = { it.id }) { p ->
                            Image(
                                painter = painterResource(p.image),
                                contentDescription = null,
                                modifier = Modifier
                                    .sharedElement(
                                        rememberSharedContentState("img-${p.id}"),
                                        animatedVisibilityScope = this@AnimatedVisibilityScope
                                    )
                                    .clickable { nav.navigate("detail/${p.id}") }
                            )
                        }
                    }
                }
            }
            composable("detail/{id}") { back ->
                AnimatedVisibilityScope {
                    val id = back.arguments?.getString("id")
                    Image(
                        painter = painterResource(products.first { it.id == id }.image),
                        contentDescription = null,
                        modifier = Modifier
                            .sharedElement(
                                rememberSharedContentState("img-$id"),  // same key!
                                animatedVisibilityScope = this@AnimatedVisibilityScope
                            )
                    )
                }
            }
        }
    }
}

Shared element transitions animate a UI element — a thumbnail, a card, an avatar — smoothly from its position on one screen to its position on another, rather than the two screens simply crossfading. They're one of the most powerful UX details in modern apps and were one of the most-requested Compose features for years. Native Compose support arrived with Compose 1.7 (September 2024) via SharedTransitionLayout.

The setup requires three things working together. First, SharedTransitionLayout wraps the entire NavHost — it acts as the coordinator that knows about both the source and destination composables at the same time, which is what makes the morphing animation possible. Second, you apply Modifier.sharedElement() to the element in both screens — the list thumbnail and the detail image — using the exact same key string in rememberSharedContentState("img-${product.id}"). The matching key is how Compose knows these two elements are the same thing and should animate between each other. Third, both usages need access to AnimatedVisibilityScope, which is available inside the AnimatedContent or NavHost context and provides the enter/exit transition timing.

The API is production-ready as of Compose BOM 2024.09.00 and is already in use in JetBrains and Google's own apps. Mentioning the specific release in an interview signals you follow Compose releases closely — a strong signal at senior level.

💡 Interview Tip

Shared element transitions were one of the most requested Compose features for years. Knowing they're stable in Compose 1.7 (2024) and the SharedTransitionLayout API shows you follow Compose releases — a strong signal at senior level.

Mark it done or save it in the question bank · Back to top

MediumMost asked

38. What is snapshotFlow and when do you use it?

snapshotFlow converts Compose State into a Flow — enabling Flow operators like debounce, filter, distinctUntilChanged on state changes.

// snapshotFlow — Compose State → Flow
@Composable
fun ScrollAnalytics() {
    val listState = rememberLazyListState()

    LaunchedEffect(listState) {
        // Track scroll position changes
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .filter { it > 0 }
            .collect { index -> analytics.track(index) }
    }

    // Debounce scroll for persistence
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .distinctUntilChanged()
            .debounce(500)  // wait until scrolling stops
            .collect { index -> viewModel.saveScroll(index) }
    }

    LazyColumn(state = listState) { /* ... */ }
}

// Read multiple state values
snapshotFlow {
    listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset
}.collect { (index, offset) -> save(index, offset) }

// Must be inside a coroutine — use in LaunchedEffect
// Re-emits whenever any State read inside lambda changes

snapshotFlow is the bridge between Compose's snapshot state system and Kotlin Flow. You pass it a lambda that reads Compose State values — like listState.firstVisibleItemIndex — and it produces a Flow that emits a new value every time any State read inside that lambda changes. This gives you access to the entire Flow operator library — debounce, filter, distinctUntilChanged, flatMapLatest — on top of Compose state, which you cannot do with a plain LaunchedEffect alone.

A concrete example: you want to save the user's scroll position to disk, but only after they've stopped scrolling for 500ms (not on every pixel). With snapshotFlow { listState.firstVisibleItemIndex } inside a LaunchedEffect, you chain .distinctUntilChanged().debounce(500).collect { viewModel.saveScrollPosition(it) }. Without snapshotFlow, you'd need a timer, a coroutine, and manual state comparison — much more code and much more error-prone.

Two important details. First, snapshotFlow returns a Flow, not a suspend function — it must be collected inside a coroutine, which is why it always lives inside a LaunchedEffect. Second, if your lambda reads multiple State values, any one of them changing triggers a new emission — so be deliberate about what you read inside the lambda to avoid collecting more often than needed.

💡 Interview Tip

snapshotFlow is the bridge from Compose's snapshot system to Kotlin Flow. Use it when you need Flow operators on Compose State — debouncing scroll saves, throttling analytics, or filtering state changes before reacting to them.

Mark it done or save it in the question bank · Back to top

Hard

39. Scenario: Compose app startup is slow. How do you optimise Time-To-First-Frame?

Compose startup involves both app-level init and first composition. Both need optimisation. Baseline Profiles give the biggest single impact.

// 1. Baseline Profiles — biggest single impact
// ./gradlew :app:generateBaselineProfile
// AOT compiles hot Compose code → 40% faster cold start

// 2. Defer heavy ViewModel work
// ❌
class HomeViewModel : ViewModel() {
    private val data = loadAllData()  // blocks constructor!
}
// ✅
class HomeViewModel : ViewModel() {
    init { viewModelScope.launch(Dispatchers.IO) { loadData() } }
}

// 3. Skeleton screens — show immediately
when (state) {
    is UiState.Loading -> SkeletonScreen()  // instant visual response
    is UiState.Success -> ContentScreen(state.data)
}

// 4. Defer non-critical composables
var showHeavy by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { delay(100); showHeavy = true }
if (showHeavy) HeavyAnalyticsDashboard()

// 5. R8 full mode — shrinks Compose runtime
// buildTypes { release { isMinifyEnabled = true } }

// 6. App Startup library — parallelise init
// Move heavy init from Application.onCreate to lazy Initializers

Compose startup optimisation has two distinct problems: actual startup time and perceived startup time. Both matter — users form their impression of app speed within the first 300ms. Address them separately.

For actual startup time, Baseline Profiles give the biggest single impact — typically 30–40% faster cold starts. Compose is a large library; on first launch, the JVM interprets it before JIT compilation kicks in. A Baseline Profile pre-compiles the hot code paths (Compose's core composables, your critical screens) into native code at install time via ART's AOT compiler. Generate them with ./gradlew :app:generateBaselineProfile and commit the output — Play Store delivers the compiled profile to users' devices. Alongside this, R8 full mode aggressively shrinks the Compose runtime itself, reducing both APK size and the amount of code the runtime needs to load.

For perceived startup time, skeleton screens are the most powerful tool. Show a shimmer placeholder layout immediately on first frame — the user sees instant visual response even while data loads. Never block the Application.onCreate() or ViewModel constructor with synchronous work; move heavy init to viewModelScope.launch(Dispatchers.IO) { } in the ViewModel's init block. For non-critical composables like analytics dashboards, a LaunchedEffect(Unit) { delay(100); showHeavy = true } defers their first composition by one frame, freeing the UI thread for the content the user actually sees first.

💡 Interview Tip

Layer your answer: "Baseline Profiles for cold start, skeleton screens for perceived performance, App Startup for init order, R8 for binary size." Multiple techniques with clear reasoning shows senior engineering thinking.

Mark it done or save it in the question bank · Back to top

MediumMost asked

40. How do you implement Paging 3 in Compose?

Paging 3 integrates with Compose through LazyPagingItems — it handles page loading, error states, and retry automatically when combined with LazyColumn.

// ViewModel
@HiltViewModel
class FeedViewModel @Inject constructor(repo: FeedRepo) : ViewModel() {
    val posts = Pager(PagingConfig(pageSize = 20)) {
        repo.getPostsPagingSource()
    }.flow.cachedIn(viewModelScope)  // CRITICAL — preserve pages on recompose
}

// Compose UI
@Composable
fun FeedScreen(vm: FeedViewModel = hiltViewModel()) {
    val posts = vm.posts.collectAsLazyPagingItems()

    LazyColumn {
        items(posts, key = { it.id }) { post ->
            if (post != null) PostCard(post)
            else PostPlaceholder()
        }
        when (posts.loadState.append) {
            is LoadState.Loading -> item { CircularProgressIndicator() }
            is LoadState.Error   -> item { Button(onClick = { posts.retry() }) { Text("Retry") } }
            else -> {}
        }
    }

    when (posts.loadState.refresh) {
        is LoadState.Loading -> FullScreenLoader()
        is LoadState.Error   -> FullScreenError { posts.retry() }
        else -> {}
    }
}

Paging 3 in Compose is powered by collectAsLazyPagingItems(), which converts a Flow<PagingData> into a LazyPagingItems object that LazyColumn can render. As the user scrolls toward the end, Paging automatically fetches the next page — you don't manage page numbers or track when to trigger the next load at all.

The single most important line in any Paging 3 setup is .cachedIn(viewModelScope) in the ViewModel. Without it, every time the Compose screen leaves and re-enters the composition — which happens on every navigation back — the PagingData flow resets and the entire feed reloads from page 1. With cachedIn, the loaded pages are stored in the ViewModel's scope: navigate back to the feed and all 200 loaded items are instantly there, exactly where the user left off. This is what separates a professional Paging implementation from a beginner one.

Paging exposes two loadState channels that need different UI treatment. loadState.refresh represents the initial load — show a full-screen skeleton or spinner while it is Loading, and a full-screen error with retry if it fails. loadState.append represents pages being fetched as the user scrolls — show a small CircularProgressIndicator at the bottom of the list for Loading, and an inline retry Button for errors. posts.retry() re-triggers whichever load state last failed.

💡 Interview Tip

cachedIn(viewModelScope) is the most important line in Paging 3. Without it, every navigation back to the screen reloads from page 1, losing scroll position and all loaded pages. With it, pages are cached in the ViewModel and restored instantly.

Mark it done or save it in the question bank · Back to top

Hard

41. Scenario: Build a PIN entry screen with 6 boxes, auto-advance focus, and backspace support.

Custom OTP/PIN inputs use a hidden BasicTextField for keyboard handling and custom visual boxes for display — the standard pattern for all PIN/OTP screens.

@Composable
fun PinField(onComplete: (String) -> Unit) {
    var pin by remember { mutableStateOf("") }
    val focus = remember { FocusRequester() }
    LaunchedEffect(Unit) { focus.requestFocus() }

    Box(Modifier.fillMaxWidth()) {
        // Hidden input — captures keyboard
        BasicTextField(
            value = pin,
            onValueChange = { new ->
                if (new.length <= 6 && new.all { it.isDigit() }) {
                    pin = new
                    if (new.length == 6) onComplete(new)
                }
            },
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
            modifier = Modifier.focusRequester(focus).size(1.dp)  // hidden
        )

        // Visual boxes
        Row(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            modifier = Modifier.clickable { focus.requestFocus() }
        ) {
            (0..5).forEach { i ->
                val isFocused = pin.length == i
                Box(
                    Modifier.size(48.dp)
                        .border(2.dp, if(isFocused) Color.Blue else Color.Gray, RoundedCornerShape(8.dp)),
                    contentAlignment = Alignment.Center
                ) {
                    if (pin.length > i) Text("●")
                }
            }
        }
    }
}

The core insight for all OTP and PIN screens in Compose is the hidden BasicTextField pattern. You render one BasicTextField sized at 1.dp — effectively invisible — and position your visual digit boxes as a separate overlay. The hidden field captures all keyboard input, handles backspace, manages cursor state, and deals with IME complexity. The visual boxes simply read the string value and render each character. This separation gives you complete control over the visual design while delegating all the hard keyboard work to a proven API.

The FocusRequester is critical for UX: request focus in a LaunchedEffect(Unit) so the keyboard appears automatically when the screen opens, and attach a Modifier.clickable { focus.requestFocus() } to the row of boxes so tapping anywhere on them brings the keyboard back if the user dismissed it. The onValueChange filter enforces the rules: only accept digit characters and reject any input beyond 6 characters, preventing the hidden field from ever getting into an invalid state.

The focused box highlight — where the currently active box gets a blue border — is derived from the pin string length: if pin.length == i, that box is the next to receive input. This means the highlight follows the cursor automatically with no extra state. When the pin reaches 6 digits, call onComplete(pin) immediately to trigger verification — don't wait for a submit button, which is the pattern users expect from every bank and payment app.

💡 Interview Tip

The hidden BasicTextField is the standard Compose OTP pattern. The hidden field handles all keyboard complexity while you control the visual representation completely. This is cleaner than managing 6 separate TextFields with manual focus passing.

Mark it done or save it in the question bank · Back to top

Hard

42. What is the Compose lifecycle? How does it differ from Android Activity lifecycle?

Composables have their own lifecycle independent of Activity. Understanding both and how they interact prevents resource leaks and incorrect behavior.

// Composable lifecycle:
// 1. Enter Composition → composable first appears
// 2. Recompose       → inputs change, re-runs
// 3. Leave Composition → removed from tree

// NOT tied to Activity lifecycle!
// Navigate away → composable leaves composition (Activity still STARTED)
// Navigate back  → composable enters composition again

// LaunchedEffect tracks composable lifecycle
LaunchedEffect(Unit) {
    startWork()  // on Enter Composition
    // cancelled on Leave Composition
}

// DisposableEffect: Enter + Leave cleanup
DisposableEffect(Unit) {
    register()
    onDispose { unregister() }  // always called on Leave
}

// Observe Activity lifecycle FROM Compose
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
    val observer = LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_RESUME -> refresh()
            Lifecycle.Event.ON_PAUSE  -> save()
            else -> {}
        }
    }
    owner.lifecycle.addObserver(observer)
    onDispose { owner.lifecycle.removeObserver(observer) }
}

Composables have their own lifecycle that is completely independent of the Android Activity lifecycle, and confusing the two is one of the most common sources of bugs in Compose apps. A composable's lifecycle has three events: Enter Composition (when it first appears in the UI tree), Recomposition (when inputs change and it re-runs), and Leave Composition (when it is removed from the UI tree). That is all.

The key thing to understand: navigating away from a screen makes the composable leave composition even though the Activity is still in the STARTED state. When you navigate to a detail screen, the list screen's composable leaves composition — its LaunchedEffect coroutines are cancelled. When you navigate back, the list screen re-enters composition — LaunchedEffect(Unit) runs from scratch again. This is why LaunchedEffect is not an equivalent of onResume: it tracks composable lifecycle, not Activity lifecycle.

When you do need to observe the Activity lifecycle from inside a composable — to pause a media player on ON_PAUSE, for example — use LocalLifecycleOwner.current to get the host lifecycle and observe it with a DisposableEffect. For collecting Flows with Activity-lifecycle awareness, use repeatOnLifecycle(Lifecycle.State.STARTED) inside a LaunchedEffect — it suspends the collection when the Activity goes below STARTED and resumes it when it comes back, preventing background work while the app is not visible.

💡 Interview Tip

Key insight: composable lifecycle ≠ Activity lifecycle. When you navigate away, the composable leaves composition (LaunchedEffect cancelled) but Activity is still STARTED. When you navigate back, composable re-enters and LaunchedEffect runs again from scratch.

Mark it done or save it in the question bank · Back to top

Hard

43. Scenario: A composable is recomposing thousands of times per minute. Walk through debugging this.

Excessive recomposition needs a systematic debugging approach. Profile first, then apply targeted fixes.

// Step 1: Layout Inspector → Show Recomposition Counts
// Red = hot composable

// Step 2: Add SideEffect counter for exact count
@Composable
fun SuspiciousComp(data: MyData) {
    val count = remember { Ref(0) }
    SideEffect { println("Recompose #${++count.value}") }
}

// CAUSE A: New lambda per recompose
// ❌ New lambda every time
items(posts) { post ->
    PostCard(post, onClick = { vm.like(post.id) })
}
// ✅ Stable lambda
val onLike = remember(vm) { { id: String -> vm.like(id) } }

// CAUSE B: Unstable parameter
// ❌ List<T> unstable — composable never skipped
// ✅ @Immutable data class wrapper
@Immutable data class FeedState(val posts: List<Post>)

// CAUSE C: High state read
// ❌ Reads scroll every frame
val showFab = listState.firstVisibleItemIndex > 0
// ✅ derivedStateOf
val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }

// CAUSE D: Check compiler metrics
// compilerOptions.freeCompilerArgs +=
// "-P", "plugin:...:reportsDestination=build/compose_reports"

The first response to any recomposition problem is always Layout Inspector with recomposition counts enabled — never guess. Open it, scroll the screen, and look for composables highlighted in red or showing counts in the hundreds per second. This immediately pinpoints the exact composable that is hot, which is often not the one you expect. Once you know which composable is recomposing excessively, add a SideEffect { count++ } to confirm the rate with a precise number in Logcat.

The three most common root causes each have a clear fix. First: inline lambdas inside items(). Writing onClick = { vm.like(post.id) } directly in the items lambda creates a new lambda object on every recomposition, making Compose think the parameter changed and preventing the child composable from ever being skipped. Fix: hoist the lambda out with remember(vm) { { id: String -> vm.like(id) } }. Second: unstable parameter types. A composable receiving a plain List<Post> is never skippable because List is not considered stable by the Compose compiler. Wrap it in an @Immutable data class or switch to ImmutableList from Kotlinx Immutable Collections.

Third: reading high-frequency state too early in the tree. Reading listState.firstVisibleItemIndex directly in a parent composable causes the entire parent — and all its children — to recompose on every scroll pixel. Wrapping it in remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } means the parent only recomposes when the boolean actually flips, not on every scroll event. For the hardest cases, run the Compose compiler metrics by adding the report flags to your Gradle build — the output lists every composable classified as restartable, skippable, or neither, giving you a complete stability audit.

💡 Interview Tip

Walk through your debugging process: "Layout Inspector → find red composable → check stability with compiler report → fix lambda/type/state read issues." A systematic process impresses more than just listing fixes.

Mark it done or save it in the question bank · Back to top

Hard

44. Scenario: Build a multi-step onboarding flow with shared state and progress indicator.

Multi-step flows need shared state across screens, back navigation, and validation per step. A NavGraph-scoped ViewModel is the cleanest solution.

// NavGraph-scoped ViewModel — lives across all steps
@HiltViewModel
class OnboardingViewModel @Inject constructor() : ViewModel() {
    var name by mutableStateOf("")
    var email by mutableStateOf("")
    val step1Valid get() = name.isNotBlank()
    val step2Valid get() = email.contains("@")
    val progress get() = when { name.isNotBlank() && email.isNotBlank() -> 1f; name.isNotBlank() -> 0.5f; else -> 0f }
}

// NavGraph with shared ViewModel scoped to "onboarding" graph
@Composable
fun OnboardingFlow(nav: NavController) {
    NavHost(nav, startDestination = "onboarding/step1") {
        navigation(startDestination = "onboarding/step1", route = "onboarding") {
            composable("onboarding/step1") { entry ->
                val vm: OnboardingViewModel = hiltViewModel(
                    remember(entry) { nav.getBackStackEntry("onboarding") }
                )
                StepContent(
                    progress = vm.progress,
                    content = { TextField(vm.name, { vm.name = it }, label = { Text("Name") }) },
                    onNext = { if (vm.step1Valid) nav.navigate("onboarding/step2") }
                )
            }
            // step2, step3 same pattern
        }
    }
}

@Composable
fun StepContent(progress: Float, content: @Composable () -> Unit, onNext: () -> Unit) {
    Column(Modifier.padding(16.dp)) {
        LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
        content()
        Button(onClick = onNext, Modifier.fillMaxWidth()) { Text("Next") }
    }
}

Multi-step flows have a fundamental architectural problem: how do you share data between steps without passing it through every composable as a parameter? The Compose answer is a NavGraph-scoped ViewModel. You define a nested navigation graph for the onboarding flow and scope the ViewModel to that graph's back stack entry rather than to any individual screen. The ViewModel is then shared across all steps, survives navigating forward and backward within the flow, and is cleared automatically when the user exits the flow — no manual cleanup needed.

The scoping is done by passing the graph's back stack entry as the viewModelStoreOwner to hiltViewModel(): hiltViewModel(remember(entry) { nav.getBackStackEntry("onboarding") }). Every step that calls this gets the exact same ViewModel instance. State like name and email is stored directly as mutableStateOf properties in the ViewModel — Compose observes them automatically, so when Step 1 sets vm.name = "Rahul", Step 2 immediately sees the updated value without any event passing or callback wiring.

Per-step validation becomes simple computed properties on the ViewModel: val step1Valid get() = name.isNotBlank(). The Next button passes this as its enabled parameter — when the field is empty the button is disabled, when filled it enables automatically. Progress across the whole flow is derived the same way: a val progress property inspects all fields and returns a Float from 0 to 1. LinearProgressIndicator renders it directly — no separate progress tracking state, no sync issues.

💡 Interview Tip

The NavGraph-scoped ViewModel solves "how do I share data between steps without prop drilling?" It lives across all steps and is cleared when user exits the flow. This is the correct architectural answer — not passing data through each composable.

Mark it done or save it in the question bank · Back to top

MediumMost asked

45. What are Compose text components? Explain Text, TextField, BasicTextField, and OutlinedTextField.

Compose provides text components at different abstraction levels — from styled Material components to raw BasicTextField for fully custom inputs.

// Text — display only
Text(text = "Hello", style = MaterialTheme.typography.bodyLarge,
     maxLines = 2, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.Bold)

// AnnotatedString — mixed styles
val annotated = buildAnnotatedString {
    append("Click ")
    withStyle(SpanStyle(color = Color.Blue, fontWeight = FontWeight.Bold)) { append("here") }
}
Text(annotated)

// TextField — Material filled input
var text by remember { mutableStateOf("") }
TextField(
    value = text, onValueChange = { text = it },
    label = { Text("Email") },
    isError = !text.contains("@") && text.isNotEmpty(),
    singleLine = true,
    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
)

// OutlinedTextField — outlined variant, same API
OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Name") })

// BasicTextField — no Material styling, full control
BasicTextField(
    value = text, onValueChange = { text = it },
    decorationBox = { innerTextField ->
        Box(Modifier.border(1.dp, Color.Gray).padding(8.dp)) {
            if (text.isEmpty()) Text("Placeholder", color = Color.Gray)
            innerTextField()
        }
    }
)

Text is for display only — it never accepts input. For mixed styling within a single text run, use buildAnnotatedString { } with withStyle(SpanStyle(...)) to apply different colours, weights, or sizes to individual spans. This covers every rich-text scenario: inline links, highlighted keywords, mixed bold and regular text — without needing a separate library.

TextField is the Material3 filled input and OutlinedTextField is its outlined sibling — they share an identical API. Both come with label, placeholder, leadingIcon, trailingIcon, isError, supportingText, and singleLine built in. Use whichever visual style matches your design system. KeyboardOptions wires up the keyboard to the field: keyboardType = KeyboardType.Email shows an email keyboard, imeAction = ImeAction.Next shows a forward arrow that moves focus to the next field, imeAction = ImeAction.Done shows a checkmark that submits the form. Always set these — a number field showing a QWERTY keyboard is a polished-app red flag.

BasicTextField has no Material styling whatsoever — no label, no underline, no padding. You control the entire visual through decorationBox, which receives the inner text field as a composable lambda and lets you wrap it in anything. This is the correct API for custom-designed inputs: OTP boxes, chat message input with a send button on the right, an inline search bar inside a list header. Any time the Material TextField style doesn't fit your design, reach for BasicTextField rather than trying to override Material defaults.

💡 Interview Tip

BasicTextField with decorationBox is the secret weapon for custom inputs. OTP boxes, chat bubbles, inline search — any time Material style doesn't fit, BasicTextField gives you full visual control while keeping all keyboard handling. This shows you know the full Compose text API.

Mark it done or save it in the question bank · Back to top

Hard

46. What is Compose Multiplatform? How does it differ from Jetpack Compose?

Compose Multiplatform (by JetBrains) extends Jetpack Compose to iOS, Desktop, and Web. iOS support became stable in 2024 — enabling shared UI across platforms.

// Jetpack Compose: Google's Android-only UI toolkit
// Compose Multiplatform: JetBrains extension → iOS, Desktop, Web, Android

// Shared UI in commonMain — runs on all platforms
@Composable
fun UserCard(user: User) {
    Card {
        Text(user.name, style = MaterialTheme.typography.headlineSmall)
        Text(user.email)
    }
}
// Runs on Android, iOS, Desktop, Web!

// Platform-specific with expect/actual
expect @Composable fun PlatformMap(lat: Double, lng: Double)

// androidMain
actual @Composable fun PlatformMap(lat: Double, lng: Double) {
    AndroidView({ MapView(it) })
}
// iosMain
actual @Composable fun PlatformMap(lat: Double, lng: Double) {
    UIKitView({ MKMapView() })
}

// 2025 status:
// ✅ iOS stable — production apps shipping
// ✅ Desktop stable (Windows/macOS/Linux)
// ✅ Web (Wasm) — stable preview
// Used by JetBrains IDEs, Touchlab, many OSS projects

Jetpack Compose is Google's Android-only UI toolkit. Compose Multiplatform (CMP) is JetBrains' extension of the same API to iOS, Desktop (Windows, macOS, Linux), and Web. The programming model is identical — the same composable functions, state management, and Material3 components work across all platforms. JetBrains maintains a fork of the Compose compiler and runtime that targets Kotlin/Native for iOS and Kotlin/JS for Web, while sharing the platform-agnostic compose-runtime that Google wrote.

iOS support reached stable status in 2024 and production apps are already shipping — JetBrains' own tooling, Touchlab's apps, and a growing number of enterprise products. Platform-specific behaviour uses Kotlin's expect/actual mechanism: you declare expect @Composable fun PlatformMap() in commonMain and provide actual implementations in androidMain (using AndroidView { MapView() }) and iosMain (using UIKitView { MKMapView() }). This lets you share 80–90% of UI code while dropping to native components exactly where platform fidelity matters.

The important distinction between KMP and CMP: Kotlin Multiplatform (KMP) shares business logic — repositories, use cases, ViewModels — but keeps the UI native on each platform. Compose Multiplatform shares both business logic and UI. KMP is generally the safer choice for consumer-facing apps where platform-native UX patterns matter deeply (iOS users expect iOS-specific navigation and interaction). CMP is excellent for internal tools, productivity apps, and cross-team design systems where consistency across platforms is more valuable than platform conformity.

💡 Interview Tip

Key differentiator: KMP shares business logic but keeps native UI. Compose Multiplatform shares BOTH logic AND UI. Choose KMP for consumer apps needing native UX, CMP for internal tools and productivity apps where consistency matters more.

Mark it done or save it in the question bank · Back to top

MediumMost asked

47. How do you handle accessibility in Jetpack Compose?

Compose has built-in accessibility through the semantics API. Good accessibility makes apps usable by all users and is increasingly required by enterprise clients.

// contentDescription — required for icons and images
Icon(Icons.Default.Favorite, contentDescription = "Like")
Image(painter, contentDescription = "Profile photo of Rahul")
Icon(Icons.Default.Star, contentDescription = null)  // decorative

// semantics — rich accessibility info
Box(Modifier.semantics {
    contentDescription = "Like button, currently liked"
    role = Role.Button
    stateDescription = "Liked"
    onClick(label = "Unlike") { onUnlike(); true }
})

// mergeDescendants — treat group as one accessible element
Row(Modifier.semantics(mergeDescendants = true) {}) {
    Image(avatar, contentDescription = null)
    Column {
        Text("Rahul Kumar")
        Text("Android Developer")
    }
}
// TalkBack reads: "Rahul Kumar, Android Developer"

// Minimum touch target
IconButton(onClick = {}, modifier = Modifier.minimumInteractiveComponentSize()) {
    Icon(Icons.Default.Delete, "Delete")
}

// clearAndSetSemantics — custom complex component description
Box(Modifier.clearAndSetSemantics {
    contentDescription = "Rating: 4.5 out of 5 stars"
}) { StarRatingBar(rating = 4.5f) }

// Test accessibility
composeTestRule.onNodeWithContentDescription("Like").performClick()

Compose builds accessibility on top of the semantics tree — a parallel representation of the UI that TalkBack reads aloud and other accessibility services consume. Most Material3 components contribute correct semantics automatically: a Button with a Text child announces its label, a Checkbox announces its checked state. Where you must add semantics manually: icon-only buttons and images. An Icon or Image with no surrounding text is invisible to TalkBack unless you provide contentDescription. Purely decorative images should pass contentDescription = null explicitly so TalkBack skips them entirely rather than announcing a file path.

Modifier.semantics { } lets you add rich information that Compose cannot infer automatically: role = Role.Button tells accessibility services how to interact with a custom clickable element, stateDescription = "Liked" describes the current state of a toggle, and onClick(label = "Unlike") { ... } gives TalkBack a verb to announce ("double-tap to Unlike"). semantics(mergeDescendants = true) combines an entire Row or Column — an avatar, a name, and a subtitle — into a single accessible element that TalkBack announces as one unit, exactly like a native list row. Without it, TalkBack stops separately on the image, the name, and the subtitle, which is disjointed and slow.

minimumInteractiveComponentSize() ensures interactive elements have at least a 48×48dp touch target — the Material accessibility guideline — even if the visual is smaller, like a 24dp icon. For complex custom components like a star rating bar, clearAndSetSemantics { contentDescription = "Rating: 4.5 out of 5 stars" } replaces the auto-generated semantics (which would announce each star individually) with a single meaningful description. Test all of this by enabling TalkBack and navigating your entire app before release.

💡 Interview Tip

Many Material3 components provide good accessibility defaults. Icon with contentDescription, Button with text — mostly handled. Where you need to add work: icon-only buttons, custom components, grouped information. Enable TalkBack and navigate your whole app before release.

Mark it done or save it in the question bank · Back to top

Hard

48. Scenario: Build a chat screen — messages from bottom, real-time new messages, auto-scroll to latest.

Chat UIs need reversed layout, auto-scroll to new messages, and a scroll-to-bottom FAB when reading history. reverseLayout = true is the key API.

@Composable
fun ChatScreen(vm: ChatViewModel = hiltViewModel()) {
    val messages by vm.messages.collectAsStateWithLifecycle()
    val listState = rememberLazyListState()
    val scope = rememberCoroutineScope()

    // Auto-scroll on new message
    val msgCount by remember { derivedStateOf { messages.size } }
    LaunchedEffect(msgCount) {
        if (msgCount > 0) listState.animateScrollToItem(0)  // 0 = bottom in reversed
    }

    Column(Modifier.fillMaxSize()) {
        LazyColumn(
            state = listState,
            reverseLayout = true,   // newest at BOTTOM, index 0 = bottom
            modifier = Modifier.weight(1f),
            contentPadding = PaddingValues(16.dp)
        ) {
            items(messages, key = { it.id }) { msg ->
                MessageBubble(msg, isOwn = msg.senderId == vm.myId)
            }
        }

        // Scroll-to-bottom FAB
        val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
        AnimatedVisibility(showFab) {
            FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
                Icon(Icons.Default.KeyboardArrowDown, null)
            }
        }
        MessageInput(onSend = { vm.send(it) })
    }
}

The fundamental insight for chat UIs is reverseLayout = true on LazyColumn. In reversed layout, item 0 renders at the bottom of the visible area and larger indices are further up. This perfectly matches the chat metaphor — newest messages at the bottom — without requiring you to reverse your list or recalculate positions. When a new message arrives, you scroll to index 0 and you're always at the newest message. animateScrollToItem(0) gives the smooth animated scroll rather than a jarring jump.

Auto-scrolling on new messages uses derivedStateOf { messages.size } as the LaunchedEffect key. This is important: if you use messages itself as the key, the LaunchedEffect would re-run on any list update — including the ViewModel reloading the same data. derivedStateOf { messages.size } only triggers when the count genuinely increases, meaning a new message was added. The auto-scroll should ideally also check whether the user is already near the bottom — if they've scrolled up to read history, jumping them to the bottom on every incoming message is disruptive. Check listState.firstVisibleItemIndex before scrolling and skip if it's beyond a threshold.

The scroll-to-bottom FAB is the solution for history reading: show it with AnimatedVisibility when listState.firstVisibleItemIndex > 2 (derived via derivedStateOf to avoid per-pixel recomposition), and launch a coroutine via rememberCoroutineScope to animate back to index 0 on tap. Always provide a key on message items — without it, adding a new message causes every visible item to be destroyed and recreated, losing any in-progress animations and causing visible flicker.

💡 Interview Tip

reverseLayout = true is the insight for chat. Without it you'd reverse the list and calculate scroll positions manually — much more error-prone. In reversed layout, index 0 IS the bottom — animateScrollToItem(0) always goes to the newest message.

Mark it done or save it in the question bank · Back to top

MediumMost asked

49. What is Scaffold and how do you use it correctly with innerPadding?

Scaffold coordinates Material layout slots and provides innerPadding so content doesn't render under AppBars or system bars. Forgetting innerPadding is the most common Scaffold mistake.

@Composable
fun HomeScreen(vm: HomeViewModel = hiltViewModel()) {
    val snackbar = remember { SnackbarHostState() }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Home") },
                actions = { IconButton(onClick = {}) { Icon(Icons.Default.Settings, "Settings") } }
            )
        },
        floatingActionButton = {
            FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, "Add") }
        },
        snackbarHost = { SnackbarHost(snackbar) },
        bottomBar = { BottomNav() }
    ) { innerPadding ->  // ALWAYS use innerPadding!

        // Option 1: padding modifier
        Column(Modifier.padding(innerPadding)) { Content() }

        // Option 2: contentPadding for LazyColumn
        LazyColumn(contentPadding = innerPadding) {
            items(items) { ItemRow(it) }
        }

        // ❌ Wrong — content goes under TopAppBar and BottomBar!
        LazyColumn {
            items(items) { ItemRow(it) }
        }
    }
}

Scaffold is the standard Material3 screen container. It coordinates the position of the top bar, bottom bar, FAB, snackbar host, and side drawer so they don't overlap each other or the system bars. The key output it produces is innerPadding — a PaddingValues object calculated from the heights of all active bars plus system insets. This padding tells your content exactly how much space to leave so nothing is hidden under a bar. Forgetting to apply innerPadding is the most common Scaffold bug in code reviews, and it manifests as content rendering under the top app bar or bottom navigation.

How you apply innerPadding depends on your content type. For Column or Box, use Modifier.padding(innerPadding) — this creates hard stops at the bar boundaries, so content never scrolls under them. For LazyColumn, use contentPadding = innerPadding instead of a padding modifier. This subtle distinction matters: with contentPadding, the list content can scroll under the bars (giving a beautiful parallax effect where items fade behind the top app bar) but there is always enough padding at the top and bottom so the first and last items are fully visible when scrolled to. Using Modifier.padding(innerPadding) on a LazyColumn creates a hard boundary that prevents this effect.

SnackbarHostState.showSnackbar() is a suspending function — it suspends the calling coroutine until the snackbar is dismissed (either by timeout, swipe, or the user tapping an action). This suspension is what enables automatic queuing: if three snackbars are triggered while one is showing, they wait in line and are displayed sequentially. Always pass SnackbarHostState through to the SnackbarHost composable and collect events from the ViewModel using Channel rather than StateFlow to guarantee every snackbar is shown.

💡 Interview Tip

Forgetting innerPadding is the single most common Scaffold mistake in code reviews. Content renders under the TopAppBar and BottomBar. Always apply innerPadding either as contentPadding (LazyColumn) or Modifier.padding (Column/Box).

Mark it done or save it in the question bank · Back to top

Hard

50. Scenario: PM asks you to compare Compose vs XML for a new project starting today. What do you recommend?

Compose and XML Views are complementary in 2024-25 -- ComposeView and AndroidView interop means you can mix them. For a new project, Compose is the clear recommendation: less code, better state management, easier testing, and where Google is investing all future UI work. For an existing XML codebase, migrate screen-by-screen.

// New screen in Compose -- embed in existing Activity/Fragment via ComposeView
class HomeFragment : Fragment() {
    override fun onCreateView(...) = ComposeView(requireContext()).apply {
        setContent { MaterialTheme { HomeScreen() } }
    }
}

// Existing View inside Compose -- embed via AndroidView
AndroidView(
    factory = { ctx -> MapView(ctx).apply { onCreate(null) } },
    update = { view -> view.getMapAsync { map -> map.moveCamera(...) } }
)

// Performance parity: Compose lazy lists match RecyclerView for most use cases
// Compose UI tests: faster to write than Espresso, same coverage

Be decisive: Compose for all new Android projects, full stop. Google has made this the official recommendation since 2021 and has backed it with investment — every new Jetpack API ships with a Compose-first or Compose-only interface. Material3, Navigation, Paging 3, Wear OS, TV, and Auto all have Compose APIs. XML Views are maintained but receive no new feature development. Starting a new project in XML today means inheriting a technology on maintenance mode from day one.

For an existing XML codebase, a big-bang rewrite is never the answer — it blocks feature delivery and invariably introduces regressions. The correct strategy is incremental migration using ComposeView to embed new Compose screens inside existing Fragments and AndroidView to retain complex legacy Views (Google Maps, third-party charts, custom camera surfaces) inside Compose screens that are otherwise fully migrated. The fact that ViewModels don't change is the key enabler — a ViewModel exposing a StateFlow is observed identically from XML's lifecycleScope.collect and Compose's collectAsStateWithLifecycle().

On performance: Compose lazy lists reach parity with RecyclerView when implemented correctly — with keys, correct stability annotations, and no heavy work inside item composables. graphicsLayer animations run on the RenderThread, equivalent to hardware layers in the View system. On team productivity: budget 2–4 weeks for a team new to Compose to reach full productivity. The main investment is the mental model shift — state drives UI, not the other way around. Once that clicks, teams consistently report faster feature development, less boilerplate, and significantly less time spent debugging UI inconsistencies.

💡 Interview Tip

Be decisive — don't hedge. "Compose for new projects, period. The 3-week learning curve pays back in 3 months of faster feature development." Interviewers at Flipkart, Swiggy, and Google want technical conviction, not wishy-washy "it depends." Know when it doesn't apply and state it clearly.

Mark it done or save it in the question bank · Back to top

More Android interview topics

See all 600+ questions in the question bank