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.
The sessions revealed several interconnected themes that define modern Compose development:
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.
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.
Google's WindowSizeClass system simplifies adaptive design by reducing thousands of potential device dimensions into three standardized width categories:
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
compact = { /* List only */ }, medium = { /* List + icon navigation rail */ }, expanded = { /* List + full detail pane */ }) |
Google provides three canonical layout patterns that address common application structures:
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.
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.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 |
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.
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.
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 |
Nested graphs organize feature-specific navigation while keeping modules independent. The navigation function defines graph boundaries with a start destination:
Kotlin |
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.
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 |
When the deep link galaxy://navigation/earth/SevenPeaks arrives, the library automatically parses the path parameter and constructs an EarthScreen(spacePort = "SevenPeaks") instance.
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 |
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.
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 |
Checking the destination hierarchy rather than just the current route determines bottom bar selection, since nested screens should highlight their parent graph's tab.
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.
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
|
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 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 |
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's testing APIs verify component behavior through finders, matchers, actions, and assertions. Component libraries benefit from interaction tests that serve as executable documentation:
Kotlin |
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.
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 |
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.
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.