Seven Peaks Insights

Scaling Compose with Adaptive UIs, Testing, and Type-Safe Navigation

Written by Seven Peaks | Nov 20, 2025 6:58:04 AM
These days, mobile applications are virtually required to work seamlessly across an ever expanding range of devices, from phones to foldables to tablets, all while remaining easy for developers to maintain and extend.

To explore practical solutions to these challenges, Seven Peaks recently hosted a meetup featuring four leading engineers in Thailand: Per-Erik Bergman (Principal Mobile Architect at Seven Peaks), Somkiat Khitwongwattana (Software Engineer at LINE MAN Wongnai), Fedor Erofeev (Senior Android Developer at Seven Peaks), and Tipatai Puthanukunkit (Principal Engineer at MuvMi). 

While centered on mobile development with Jetpack Compose and Compose Multiplatform, the sessions offered valuable architectural insights applicable to any software engineer focused on building maintainable, high-quality applications.

Key Takeaway

The sessions revealed several interconnected themes that define modern Compose development:

  • Plan for adaptability from day one: Rather than retrofitting apps for tablets and foldables, building adaptive layouts from the start creates applications that gracefully handle any screen size. This forward-thinking approach parallels how experienced engineers design systems and anticipates future requirements rather than reacting to them.
  • State management is the foundation of testability: Separating stateful coordination from stateless presentation unlocks both comprehensive testing and visual documentation through screenshot tests. This architectural choice ripples through the entire development workflow.
  • Type safety eliminates entire categories of bugs: Modern navigation APIs replace error-prone string-based routing with compile-time guarantees, making large codebases easier to refactor and maintain.
  • Testing strategies should match component types: Different composables require different testing approaches. Components benefit from screenshot tests, screens from state-based testing, and complete user flows from end-to-end integration tests using the Robot pattern.

Building adaptive UIs that go beyond responsiveness

The difference between responsive and adaptive design becomes clear when you open a responsive app on a foldable device and find overlapping text, misplaced buttons, and an interface clearly designed for a different screen size. To see how this happened, consider an analogy. Imagine two engineers tasked with building a bridge. The first engineer designs a perfect two-lane bridge optimized for cars, the primary users. When delivery trucks, farm equipment, or cyclists need to cross, the bridge becomes inefficient or unusable. The second engineer also prioritizes cars but researches future needs before drawing blueprints. They design a bridge with wide lanes, strong supports, and protected walkways that serves all traffic types from day one.

Responsive vs. adaptive design

These two approaches to bridge-building mirror the difference between responsive and adaptive design.

Responsive UIs react to changes. They reflow content when constraints shift. Text wraps, lists rearrange from vertical to grid layouts as screens widen. Like the first engineer's bridge, a responsive approach optimizes for the primary use case but struggles when requirements change dramatically.

Adaptive UIs are architected for different contexts from the start. They swap entire UI structures rather than merely rearranging content. On small screens, an adaptive design shows a list. On large screens, it displays the list alongside a detail pane. Like the second engineer's bridge, the structure itself is fundamentally different and optimized for each use case.

WindowSizeClass as the foundation

Google's WindowSizeClass system simplifies adaptive design by reducing thousands of potential device dimensions into three standardized width categories:

  • Compact (< 600dp): Most phones in portrait mode
  • Medium (600-839dp): Large phones in landscape, tablets in portrait mode
  • Expanded (≥ 840dp): Tablets in landscape mode, desktop applications

Rather than thinking about specific devices, developers focus on available real estate. A 36-inch tablet falls into the extra-large category and receives the appropriate layout automatically.

Using these categories in code is straightforward. A common pattern creates a reusable AdaptiveLayout composable that accepts different content for each size class. Developers define compact, medium, and expanded layouts, with fallbacks for less common large and extra-large categories:

Kotlin

AdaptiveLayout(

    compact = { /* List only */ },
    medium = { /* List + icon navigation rail */ },
    expanded = { /* List + full detail pane */ }
)
 

Advanced canonical layouts

Google provides three canonical layout patterns that address common application structures:

  • List-detail suits messaging apps, contact lists, and news readers.
    • In compact mode, the list fills the screen and tapping navigates to a separate detail view.
    • In expanded mode, the list appears in a left pane with the detail view permanently visible on the right (the familiar Gmail browser experience).
  • Feed optimizes content galleries and social media.
    • Compact mode displays a single-column vertical list.
    • Medium and expanded modes create multi-column grids that leverage horizontal space.
  • Supporting pane handles applications with primary content and secondary controls, like document editors with formatting panels.
    • In compact mode, the supporting pane is hidden by default and appears in a bottom sheet or dialog when triggered.
    • In medium mode, the pane appears as a modal drawer that overlays the content.
    • In expanded mode, the pane is permanently visible and docked to the side of the main content.

Snackbar Compose with friendly UI testing

Snackbars create a timing problem in Compose tests. When user actions trigger rapid state changes (like applying a coupon and immediately receiving a server response), multiple snackbar messages can overlap. End-to-end tests become flaky when the first message hasn't been dismissed before the second appears. Assertions fail unpredictably in CI/CD pipelines.

A controller and container pattern solves this by enforcing indefinite snackbar duration during UI testing while maintaining normal behavior in production. The approach requires three components working together.

Three components for testing snack bars

  1. SnackbarUiTestController lives in the androidTest directory and maintains a set of SnackbarHostState instances. It provides methods to register and unregister states, manually dismiss current snackbars, and clear all states to prevent memory leaks. Implemented as an object, the controller uses Java reflection to remain invisible to production code.

  2. SnackbarHostStateProvider wraps application content and uses DisposableEffect to inject SnackbarHostState instances into the controller via reflection. When the composable disposes, it unregisters the state. This approach keeps production code isolated from test infrastructure. Running the app in production simply catches and ignores the reflection exceptions.

  3. SnackbarContainer intercepts snackbar data and modifies the duration to Indefinite when running under test. It detects the test environment through reflection, checking for the controller's class availability. Since SnackbarData is an interface rather than a data class, the container creates a new object implementing the interface with modified duration while preserving all other properties.

Using these components in practice is straightforward. The SnackbarContainer wraps the snackbar in the host:

Kotlin

@Composable

fun HomeRoute(...) {

    val snackbarHostState = LocalSnackbarHostState.current

    Scaffold(

        snackbarHost = {

            SnackbarHost(snackbarHostState) { data ->

                SnackbarContainer(data)

            }

        }

    ) { /* content */ }

}
 

Once configured, tests gain full control over snackbar timing. You can explicitly dismiss snackbars at appropriate points using SnackbarUiTestController.dismissCurrentSnackbar(). This manual control eliminates timing-related flakiness while maintaining test readability.

Implementation considerations

This pattern requires careful handling in multi-module projects. Test dependencies must never leak into production builds, which could force snackbars to display indefinitely for actual users. CI/CD pipelines should include checks to detect test module inclusion in production configurations.

Complex applications with multiple internal snackbar sources can also produce unexpected test failures. When various components independently trigger snackbars, tests may encounter messages from unrelated features. Teams need clear patterns for isolating snackbar behavior during testing.

Type-safe navigation in Compose Multiplatform

Navigation has evolved significantly in Compose, moving from fragile string-based routes toward increasingly safe and flexible APIs.

The string-based past created multiple failure points. A single typo in a route definition caused crashes. Forgetting to pass an argument caused crashes. Forgetting to encode string arguments into UTF-8 caused crashes. Even retrieving arguments required verbose, error-prone code.

The type-safe present (Navigation 2.8+) eliminates these issues through compile-time guarantees. Using Kotlin serialization and data classes to define screens and nested graphs, the compiler catches errors before the app runs. Deep links work automatically, checking current routes becomes straightforward, and the crashes disappear.

The future with Navigation 3 promises even more control. The upcoming library treats navigation like list manipulation—adding and removing destinations from the back stack becomes as simple as working with a collection. Deep link support is still in development, but the architecture offers developers full control over navigation state.

For now, Navigation 2.8+ provides robust type safety for production applications. One current limitation in Compose Multiplatform: bottom sheets cannot serve as navigation destinations, though this works in Android-only Compose. A multi-module application can organize navigation into separate graphs. For example, a Solar System graph might contain Earth and Mars screens, while a Kepler-22 System graph handles the Kepler-22b exoplanet. Each graph defines routes as serializable objects rather than strings:

Kotlin

object SolarGraph {

    @Serializable

    data object ROUTE

    

    @Serializable

    data class EarthScreen(val spacePort: String)

    

    @Serializable

    data object MarsScreen

}
 

Nested navigation and arguments

Nested graphs organize feature-specific navigation while keeping modules independent. The navigation function defines graph boundaries with a start destination:

Kotlin

navigation<SolarGraph.ROUTE>(

    startDestination = SolarGraph.SolarSystemScreen

) {

    composable<SolarGraph.EarthScreen> {

        val spacePort = it.toRoute<SolarGraph.EarthScreen>().spacePort

        // Earth screen with spacePort argument

    }

}
 

Arguments pass naturally through data class parameters. The toRoute() extension function extracts typed data from the back stack entry, eliminating manual string parsing and encoding concerns.

Automatic deep link handling

Type-safe navigation handles deeplink complexity automatically. After configuring Android manifests and iOS Info.plist files with URI schemes, the navigation library maps deep links to routes without additional code:

Kotlin

composable<SolarGraph.EarthScreen>(

    deepLinks = listOf(

        navDeepLink<SolarGraph.EarthScreen>("galaxy://navigation/earth")

    )

) { /* screen */ }
 

When the deep link galaxy://navigation/earth/SevenPeaks arrives, the library automatically parses the path parameter and constructs an EarthScreen(spacePort = "SevenPeaks") instance.

Cross-platform deep link handling

iOS requires additional infrastructure to bridge deep links into the navigation system. An ExternalUriHandler object with a Channel or Flow collects incoming URIs, which the app consumes when resuming:

Kotlin

LaunchedEffect(currentState) {

    if (currentState == Lifecycle.State.RESUMED) {

        ExternalUriHandler.uriFlow.collect { uri ->

            navController.navigate(NavUri(uri))

        }

    }

}
 

This pattern benefits Android applications, too. Production apps often need to validate user authentication before navigating to deeplinked content, queue deeplinks during API calls, or redirect unauthenticated users to login screens. The handler provides a central point for this logic.

Checking current routes

The hasRoute() extension simplifies conditional UI based on navigation state. Top bars can show dynamic titles, back buttons appear only when not at root screens, and bottom navigation highlights the active section:

Kotlin

val backStackEntry by navController.currentBackStackEntryAsState()

val showBackButton = remember(backStackEntry) {

    backStackEntry?.run {

        when {

            destination.hasRoute<SolarGraph.SolarSystemScreen>() -> false

            destination.hasRoute<Kepler22Graph.Kepler22SystemScreen>() -> false

            else -> true

        }

    } ?: false

}
 

Checking the destination hierarchy rather than just the current route determines bottom bar selection, since nested screens should highlight their parent graph's tab.

A comprehensive composable testing strategy

Different composable types require different testing approaches, and architectural decisions made early in development either enable or constrain testing effectiveness. A well-structured testing strategy matches the testing method to the component's role in the application.

State hoisting enables testing

The MVI (Model-View-Intent) pattern with strict state hoisting proves ideal for Compose testing. Each screen splits into two composables: a stateful screen that observes the ViewModel and handles effects, and a stateless content composable that receives state and dispatches actions:

Kotlin

 

@Composable

fun LoginScreen(

    onLoginSuccess: (String) -> Unit,

    viewModel: LoginViewModel = koinViewModel()

) {

    val state by viewModel.viewState.collectAsStateWithLifecycle()

    

    LaunchedEffect(viewModel) {

        viewModel.viewEffect.collectLatest {

            when (it) {

                LoginViewEffect.LoginSuccess -> onLoginSuccess(state.email)

            }

        }

    }

    

    LoginContent(state = state, dispatch = viewModel::dispatch)

}
 

The stateless LoginContent receives a LoginViewState data class and a dispatch function. This separation unlocks powerful testing capabilities, as content can be tested with any state without ViewModel complexity, mocking, or dependency injection.

Screenshot testing for visual verification

Screenshot testing with Paparazzi (or Google's official Compose preview screenshot test) captures visual documentation and detects unintended UI changes. Components and stateless content compose ideal targets:

Kotlin

@Preview

@Composable

fun Preview_LoginContent_WithEmail_ErrorMessage() = AppTheme {

    LoginContent(

        state = LoginViewState(

            email = "test@example.com",

            errorMessage = "Please enter a valid email address"

        ),

        dispatch = {}

    )

}
 

Multiple preview functions cover states like default, loading, error conditions, and populated fields. Paparazzi runs on the JVM without emulators, making it fast enough for hundreds of component tests. The screenshots serve as visual regression tests and documentation for other developers.

LocalInspectionMode should be forced to true in screenshot tests to ensure consistent results by disabling animations, network calls, and other runtime behaviors.

Compose UI testing for interaction

Compose's testing APIs verify component behavior through finders, matchers, actions, and assertions. Component libraries benefit from interaction tests that serve as executable documentation:

Kotlin

@Test

fun customButton_onClickCallbackIsInvokedWhenClicked() {

    val onClick = mockk<() -> Unit>(relaxed = true)

    

    composeTestRule.setContent {

        CustomButton(text = "Submit", onClick = onClick)

    }

    

    composeTestRule.onNodeWithText("Submit")

        .assertIsDisplayed()

        .performClick()

    

    verify(exactly = 1) { onClick() }

}
 

These tests can run as instrumented tests on devices or with Robolectric on the JVM. Individual screen testing provides less value than comprehensive flow testing, which validates navigation and interaction between screens.

Flow testing with the robot pattern

End-to-end tests use real activities and dependencies but mock external data sources for stability. The Robot pattern (also called Page Object pattern) wraps screen interactions in readable methods:

Kotlin

@Test

fun loginFlow_robot() {

    val loginRobot = LoginScreenRobot(composeTestRule)

    val otpRobot = OtpScreenRobot(composeTestRule)

    val homeRobot = HomeScreenRobot(composeTestRule)

    

    loginRobot.expectDisplayed()

    loginRobot.typeEmail("test@gmail.com")

    loginRobot.clickLoginButton()

    

    otpRobot.expectDisplayed()

    otpRobot.typeOtp("123456")

    otpRobot.clickVerifyCodeButton()

    

    homeRobot.expectDisplayed()

    homeRobot.expectEmail("test@gmail.com")

}
 

Each robot encapsulates the Compose testing APIs for its screen, creating tests that read like user stories. QA engineers and product managers can understand the test flow without parsing technical implementation details.

Building for the future

Modern Compose development demands thinking beyond immediate requirements. Adaptive layouts anticipate foldables and tablets before they become problems. Type-safe navigation prevents entire categories of bugs through compiler guarantees. Comprehensive testing strategies match testing approaches to component types, ensuring quality without excessive effort.

These patterns share a common thread: they front-load architectural decisions that make applications easier to extend, maintain, and refactor as requirements evolve. The investment in proper abstractions, clear separation of concerns, and thoughtful API design pays dividends throughout an application's lifecycle.

Seven Peaks regularly hosts technical discussions bringing together developers to share practical insights from real-world projects. Join us at upcoming events to continue the conversation about building better software.