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

Compose Part 5

Q41. How do you perform screenshot or visual regression testing for Compose UIs?

Compose UI is purely declarative and renders to a canvas, making it ideal for screenshot testing because you can render composables without a full Activity or device window in many cases. The primary tool is Paparazzi from Cash App, which renders Compose directly to an off-screen LayoutLib (the same engine Android Studio Layout Preview uses) without an emulator. This makes tests extremely fast and deterministic. You define a @Composable unit inside a Paparazzi test, call paparazzi.snapshot { ... }, and it generates a PNG. You then compare against golden images in CI. For instrumented tests on real devices, Shot (from Karumi) wraps your existing Espresso/Compose tests to capture screenshots and compare them. Another option is the official Compose Preview Screenshot Testing (introduced in Android Studio Hedgehog/Compose UI 1.6+), which generates screenshots from @Preview annotations automatically. The key challenge is handling dynamic data (timestamps, random images) and platform differences (fonts, shadows); you should inject fake, static data and disable animations. For CI, you must run on a consistent OS version and screen density, or use Paparazzi to avoid emulator variance entirely.

// Paparazzi test: Fast, no emulator, deterministic pixel comparison.
class ProfileCardScreenshotTest {
    @get:Rule
    val paparazzi = Paparazzi(
        deviceConfig = DeviceConfig.PIXEL_5,
        theme = "android:Theme.Material3.Light.NoActionBar"
    )

    @Test
    fun `profile card default state`() {
        paparazzi.snapshot {
            AppTheme {
                ProfileCard(
                    user = User(
                        name = "Alex Johnson",
                        role = "Lead Developer",
                        avatarUrl = null // Use placeholder in UI
                    ),
                    onClick = {}
                )
            }
        }
    }

    @Test
    fun `profile card dark theme`() {
        paparazzi.snapshot {
            AppTheme(darkTheme = true) {
                ProfileCard(
                    user = User(name = "Alex Johnson", role = "Lead Developer", avatarUrl = null),
                    onClick = {}
                )
            }
        }
    }
}

// Compose Preview Screenshot Testing (Android Studio / Gradle plugin):
// 1. Apply plugin: id("com.android.compose.screenshot") version "0.0.1-alpha05"
// 2. Annotate previews:
@Preview(device = "id:pixel_5")
@Composable
fun ProfileCardPreview() {
    AppTheme { ProfileCard(User("Alex", "Dev", null), {}) }
}

// Gradle generates reference screenshots and fails on diff:
// ./gradlew updateDebugScreenshotTest
// ./gradlew validateDebugScreenshotTest

Q42. Explain the difference between animate*AsState, updateTransition, and AnimatedContent.

animate*AsState (e.g., animateFloatAsState, animateColorAsState) is the simplest animation API. It animates a single value from its current value to a target value whenever the target changes. It is best for independent properties like alpha, scale, or offset that do not need to be synchronized with other properties. updateTransition is used when you need to coordinate multiple properties simultaneously against a single state change (e.g., fading out and sliding and scaling when a button is pressed). It takes a target state (often an enum or sealed class) and lets you define child animations (animateFloat, animateColor, etc.) that all run in lockstep with the same transition spec and interruption handling. AnimatedContent is a container composable that animates between different content based on a target state key. It is not for animating properties of a single composable, but for cross-fading or sliding entire UI layouts in and out when the screen state changes (e.g., switching between a loading spinner and a success screen). AnimatedContent uses AnimatedContentTransitionScope to define enter and exit transitions for the incoming and outgoing content simultaneously.


Q43. How do you implement a draggable list item with reordering in Compose?

Reordering items in a list requires tracking the dragged item, detecting when it crosses over another item's position, and updating the underlying data list accordingly. The modern approach uses Modifier.pointerInput with detectDragGesturesAfterLongPress (or detectVerticalDragGestures) combined with LazyListState to calculate item offsets and detect drop targets. However, the easiest and most robust production solution is the Reorderable library (or Accompanist's deprecated swipe utilities), or implementing it via Modifier.anchoredDraggable with a custom LazyList item offset manipulation. The core algorithm is: on long-press, record the initial index and offset; as the drag progresses, calculate which visible item's bounds the pointer is currently over; when the pointer is released, move the item in the list and animate the layout change. You must use LazyColumn with items(data, key = { it.id }) so that item state and composition identity are preserved during reordering. The animateItemPlacement modifier (from LazyItemScope) automatically animates items to their new positions after the list data changes.


Q44. What is the difference between pointerInput and Modifier.draggable / Modifier.swipeable / Modifier.anchoredDraggable?

pointerInput is the lowest-level gesture API in Compose. It gives you access to PointerInputScope where you can use awaitPointerEventScope to read raw pointer events (down, move, up), calculate velocities, and implement completely custom gesture logic. It is the most flexible but requires the most boilerplate. Modifier.draggable (now largely superseded) provided a mid-level API for one-dimensional drag gestures (horizontal or vertical), exposing drag distance and state but not settling or anchoring logic. Modifier.swipeable was a Material component-level API that allowed dragging between predefined anchors (like a swipe-to-dismiss or bottom sheet) with physics-based settling. It is now deprecated. Modifier.anchoredDraggable (Compose Foundation 1.6+) is the official replacement for swipeable. It provides a robust system for dragging an element between discrete anchor points with velocity-based settling, thresholds, and snap animations. It is built on top of pointerInput but abstracts away the math for anchors, resistance, and decay animations. The hierarchy is: pointerInput (raw, any gesture) → anchoredDraggable (structured anchor-based drag) → high-level components like BottomSheet or SwipeToDismissBox.


Q45. How do you coordinate animations across multiple Composables using Animatable and coroutines?

Animatable is a low-level animation controller that provides imperative, coroutine-based animation APIs. Unlike animate*AsState, which is declarative and driven by target value changes, Animatable exposes animateTo, snapTo, and animateDecay as suspend functions that you call inside a coroutine scope. This makes it perfect for coordinating multiple animations across different composables because you can launch multiple coroutines from a single event or use coroutineScope { ... } to run them concurrently and await their completion. For example, when a user taps a button, you can simultaneously animate a scale on an icon, a color on a background, and a translation on a card, with specific delays and easing for each, all synchronized by the structured concurrency of Kotlin coroutines. You typically store Animatable instances in remember at a shared ancestor or pass them down. To ensure the animations are cancellable and lifecycle-aware, use LaunchedEffect or rememberCoroutineScope. The key advantage is precise choreography: you can sequence animations with sequential animateTo calls, run them in parallel with async, and apply different AnimationSpecs per property.


Q46. How would you implement a shared element transition between two screens in Compose?

Shared element transitions (now officially supported in Compose Animation 1.8.0+) allow a visual element (like an image or text) to morph and animate from its position/size in one screen to another as navigation occurs. The implementation requires three pieces: SharedTransitionLayout as the root, AnimatedVisibilityScope or AnimatedContentScope to provide the animation context, and Modifier.sharedElement() or Modifier.sharedBounds() on the matching elements in both the origin and destination composables. The sharedElement modifier requires a matching SharedContentState key in both screens. Because Compose Navigation replaces the entire screen content, both the outgoing and incoming composables must be in the composition simultaneously during the transition. In Compose Navigation, the composable builder provides an AnimatedContentScope (via this@composable), which you pass down to the screen composables. The sharedElement modifier uses this scope to interpolate bounds between the two layouts. You can customize the animation with boundsTransform (for the path of motion) and placeHolderSize to handle asynchronous image loading.


Q47. How do you build a multi-theme design system in Compose that supports dynamic color, light/dark mode, and brand themes?

A robust multi-theme system in Compose is built around MaterialTheme (or a fully custom theme) and the ability to swap ColorScheme, Typography, and Shapes at runtime. For light/dark mode, you define two ColorScheme instances and select between them using isSystemInDarkTheme(). For dynamic color (Material You), use dynamicLightColorScheme(context) and dynamicDarkColorScheme(context) from androidx.compose.material3 on Android 12+ (API 31+), with a fallback to static brand palettes on older versions. For brand themes (e.g., switching between "Professional Blue" and "Playful Orange" within the same app), you create multiple pairs of color schemes and store the user's selection in a StateFlow or DataStore. The root composable reads this selection and wraps the entire app in the corresponding MaterialTheme. To ensure consistency, centralize all theme tokens in a single design system module. Avoid hardcoding colors in individual screens; always reference MaterialTheme.colorScheme. For edge cases where Material3 tokens are insufficient, create a custom CompositionLocal for extended tokens (e.g., LocalExtendedColors) but keep it scoped to your design system.


Q48. What is the role of CompositionLocalProvider in theming, and how do you avoid overusing it?

CompositionLocalProvider injects values into the Composition tree that descendant composables can read implicitly via LocalX.current. In theming, it is the mechanism behind MaterialTheme, LocalContentColor, LocalTextStyle, and LocalDensity. When you call MaterialTheme(...), it internally wraps your content in CompositionLocalProvider calls for LocalColorScheme, LocalTypography, and LocalShapes. This is appropriate because theme tokens are true cross-cutting concerns: nearly every composable in the subtree needs access to colors and fonts, and threading them through every function parameter would be absurdly verbose. However, overusing CompositionLocalProvider for business logic, navigation controllers, or feature-specific state creates "spaghetti" dependencies. Composables that read a CompositionLocal are context-sensitive—they behave differently depending on where they are placed, making them harder to preview, test, and reason about. The rule is: use CompositionLocal for infrastructure and ambient context (theme, locale, window insets, scroll state) but never for screen-specific data, ViewModels, or event handlers. If a composable needs a callback or a piece of data to render, pass it as a parameter.


Q49. How do you handle typography scaling and accessibility font sizes gracefully in Compose layouts?

Compose uses sp (scalable pixels) for text, which automatically respects the user's system font scale setting. However, extreme font scales (up to 200% in accessibility settings) can break layouts that are designed with fixed heights or tight padding. The first rule is to never use fixed heights for text containers unless absolutely necessary. Use Modifier.wrapContentHeight() and allow text to occupy the space it needs. For lists, ensure rows use IntrinsicSize.Min or simply let the content determine the height. If a design requires a maximum of two lines, use maxLines = 2 and overflow = TextOverflow.Ellipsis rather than clipping. For critical layouts that must not break (e.g., onboarding screens with illustrations), you can read the current font scale via LocalDensity.current.fontScale and apply adaptive layouts: switch from a horizontal to a vertical arrangement, or reduce non-essential elements if the scale exceeds a threshold. Always test with the largest font size enabled in system settings. Material3 components like ListItem, Card, and Button handle scaling reasonably well by default if you avoid overriding their intrinsic heights.


Q50. How do you implement a custom ripple effect or remove the default ripple in Material3 Compose?

In Material3, the default ripple (now called "ripple" rather than "indication" in older versions) is provided by LocalIndication. To change the ripple color, boundedness, or radius globally, you create a custom IndicationNodeFactory or use rememberRipple (legacy API) / IndicationNodeFactory (modern API) and provide it via CompositionLocalProvider(LocalIndication provides ...). To remove the ripple entirely (common for custom buttons or game UIs), provide a no-op Indication instance. In Material3, LocalRippleTheme from Material2 is gone; the modern approach uses Indication directly. For a custom ripple (e.g., a square ripple, a color-matched ripple, or a delayed ripple), you implement IndicationNodeFactory to create a custom Modifier.Node that draws the ripple effect in the DrawModifierNode phase, tracking press state via pointerInput. Alternatively, for simple color changes, wrap the component in a Surface with a specific contentColor and rely on LocalContentColor to propagate to the default ripple. For per-component control, apply Modifier.indication(interactionSource, indication) directly.

Last updated