DEV Community

Cover image for When the Window Lies: Compose Apps That Survive Resizing, Folding, and Docking
Android Expert
Android Expert

Posted on Originally published at Medium

When the Window Lies: Compose Apps That Survive Resizing, Folding, and Docking

Most adaptive-UI bugs are not layout bugs. They are assumptions that were true on the device you developed on: the screen is about 400dp wide, it stays portrait, it never changes while a screen is open, and a back press always means “go up one level”. Resizable windows, foldables, desktop windowing, and connected displays break every one of those assumptions at the same time.

This post walks through the session’s chapters in order and pairs each with the failure mode that tends to show up in production first. Code is idiomatic Kotlin. Where an API is still experimental, I say so, because pinning that down early saves a painful upgrade later. The mediaQuery, Grid, and FlexBox APIs are experimental, so confirm the exact artifact versions and import paths against the release notes for the Compose version you depend on before copying any snippet.

FieldNotes has three screens: a list of survey sites, a detail screen with a photo and notes, and a small dashboard. Nothing exotic, which is exactly why it makes a good test subject.

1. The assumption that breaks: device versus window

The mental shift is small to state and large to apply. The OS reports a window, and the window is what your UI actually lives in. A tablet can host your app in a one-third split-screen. A phone can unfold into something nearly square. A desktop session can drag your window to any size and keep changing it.

window-not-device

Two practical rules follow from this:

  • Derive layout from the window’s size and posture, never from a device category. A boolean like isTablet is wrong the moment someone splits the screen.
  • Treat any window property as something that can change while a screen is visible.

If your codebase has a DeviceType enum, search for every read of it. Each one is a future bug report.

2. Configuration changes and the state that disappears

Resizing a window can recreate the Activity, and it can also restructure your composition without recreating anything. Both look identical to the user: the text they were typing is gone.

state-survival

Failure mode: state declared inside a branch

This is the subtle one. When a window crosses a breakpoint, the composable that was in the “narrow” branch leaves the composition, and its remember state goes with it, even though the Activity never restarted.

// Looks fine, loses scroll position every time the window crosses the breakpoint.
@Composable
fun SitesPane(isWide: Boolean, sites: List<Site>) {
    if (isWide) {
        Row {
            SiteList(sites, rememberLazyListState())
            DetailPlaceholder()
        }
    } else {
        SiteList(sites, rememberLazyListState())
    }
}
Enter fullscreen mode Exit fullscreen mode

The fix is to hoist the state above the branch so both layouts share one instance:

@Composable
fun SitesPane(isWide: Boolean, sites: List<Site>) {
    val listState = rememberLazyListState() // lives above the branch

    if (isWide) {
        Row {
            SiteList(sites, listState, Modifier.weight(1f))
            DetailPlaceholder(Modifier.weight(1f))
        }
    } else {
        SiteList(sites, listState)
    }
}
Enter fullscreen mode Exit fullscreen mode

Failure mode: durable state parked in remember

User input and filters need to survive recreation, and sometimes process death. Two tools cover most cases. For text, the state-based text field holder is saveable out of the box:

@Composable
fun NoteEditor(modifier: Modifier = Modifier) {
    val note = rememberTextFieldState()
    BasicTextField(state = note, modifier = modifier)
}
Enter fullscreen mode Exit fullscreen mode

For screen-level state that outlives a composable, use a ViewModel backed by SavedStateHandle, so it also survives process death:

class SiteFilterViewModel(private val handle: SavedStateHandle) : ViewModel() {

    val onlyUnsynced: StateFlow<Boolean> =
        handle.getStateFlow(KEY_ONLY_UNSYNCED, false)

    fun setOnlyUnsynced(value: Boolean) {
        handle[KEY_ONLY_UNSYNCED] = value
    }

    private companion object {
        const val KEY_ONLY_UNSYNCED = "only_unsynced"
    }
}
Enter fullscreen mode Exit fullscreen mode

Failure mode: the configChanges escape hatch

Declaring configChanges in the manifest stops the Activity from being recreated on resize, and the missing-state bugs go quiet. They have not been fixed. Process death still wipes whatever you only kept in memory, and your layout code still has to react to the new size. Use the flag only when you have deliberately designed for it.

3. Detecting postures with the mediaQuery API

For years, reacting to a half-folded device meant wiring up a window-layout flow, collecting it, mapping it, and threading the result down. The mediaQuery API, introduced as experimental in the Compose April '26 release, collapses that into a condition evaluated against a UiMediaScope. It exposes window size and posture, plus input signals such as pointer precision and keyboard kind, and capability signals such as camera and microphone.

posture-states

The important detail, easy to miss: posture describes the window, not the hardware. A phone that is half-folded but showing your app in single-panel split-screen reports Flat.

Turning it on

The feature sits behind a flag you set once, before the first composition. The documentation sets it in Application:

class FieldNotesApp : Application() {
    override fun onCreate() {
        ComposeUiFlags.isMediaQueryIntegrationEnabled = true
        super.onCreate()
    }
}
Enter fullscreen mode Exit fullscreen mode

Centralize the decision

The temptation is to sprinkle mediaQuery { ... } through leaf composables. Resist it. Turn the signals into one explicit model and let screens consume that:

sealed interface DetailLayout {
    data object Stacked : DetailLayout
    data object SideBySide : DetailLayout
    data object TabletopSplit : DetailLayout
}

@OptIn(ExperimentalMediaQueryApi::class)
@Composable
fun rememberDetailLayout(): DetailLayout {
    // Size changes arrive at high frequency while a window edge is dragged,
    // so read them through the derived variant.
    val narrow by derivedMediaQuery {
        windowWidth < WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND.dp
    }

    return when {
        mediaQuery { windowPosture == UiMediaScope.Posture.Tabletop } -> DetailLayout.TabletopSplit
        mediaQuery { windowPosture == UiMediaScope.Posture.Book } -> DetailLayout.SideBySide
        narrow -> DetailLayout.Stacked
        else -> DetailLayout.SideBySide
    }
}
Enter fullscreen mode Exit fullscreen mode

The same pattern works for input. Touch targets and shortcut hints depend on what the user is actually holding:

@OptIn(ExperimentalMediaQueryApi::class)
@Composable
fun SaveButton(onSave: () -> Unit) {
    val imprecisePointer = mediaQuery {
        pointerPrecision == UiMediaScope.PointerPrecision.Blunt
    }
    val hasPhysicalKeyboard = mediaQuery {
        keyboardKind == UiMediaScope.KeyboardKind.Physical
    }

    Button(
        onClick = onSave,
        modifier = Modifier.heightIn(min = if (imprecisePointer) 56.dp else 40.dp),
    ) {
        Text(if (hasPhysicalKeyboard) "Save  (Ctrl+S)" else "Save")
    }
}
Enter fullscreen mode Exit fullscreen mode

Failure mode: previews that never show the interesting state

You will not have a folded device attached to every code review. The scope is provided through a composition local, so a preview can substitute its own. A small helper keeps this honest:

@OptIn(ExperimentalMediaQueryApi::class)
@Composable
fun PreviewWithPosture(
    posture: UiMediaScope.Posture,
    content: @Composable () -> Unit,
) {
    val base = LocalUiMediaScope.current
    val overridden = remember(base, posture) {
        object : UiMediaScope by base {
            override val windowPosture: UiMediaScope.Posture = posture
        }
    }
    CompositionLocalProvider(LocalUiMediaScope provides overridden, content = content)
}
Enter fullscreen mode Exit fullscreen mode

Class delegation means everything you did not override still comes from the real scope, so the preview stays realistic for width, input, and the rest.

Notice what the helper does not do: it never touches ComposeUiFlags. The flag is global state that has to be set before the first composition, so it belongs at startup (Application.onCreate() in the app, test setup in instrumented or Robolectric tests), not inside a composable where it would run as a side effect on every composition. Studio previews do not run your Application, so check how your tooling version handles the flag for previews rather than assuming it carries over.

Failure mode: building on an experimental surface with no seam

mediaQuery is explicitly experimental and the team asks for feedback. That is a reason to adopt it behind your own function like rememberDetailLayout() above, not a reason to avoid it. When names or shapes change, you edit one file.

4. Responsive layouts with Grid and FlexBox

Compose has long had good tools for one dimension (Row, Column) and for large scrolling collections (the lazy grids). What was awkward was the middle: the structural, all-on-screen, two-dimensional layout of a dashboard, which people built from nested rows and columns and then fought every time the screen changed shape.

container-chooser

Grid is the new answer for two-dimensional structure. You describe tracks and named areas in a config lambda and place items into them. The big win for adaptivity is that you change the config, not the tree, so composables keep their identity when the window changes shape:

enum class DashArea { Map, Conditions, Notes }

@Composable
fun SiteDashboard(expanded: Boolean, modifier: Modifier = Modifier) {
    Grid(
        config = {
            if (expanded) {
                column(2.fr)
                column(1.fr)
                row(1.fr)
                row(1.fr)
                area(areaId = DashArea.Map, rows = 1..2, columns = 1..1)
                area(areaId = DashArea.Conditions, row = 1, column = 2)
                area(areaId = DashArea.Notes, row = 2, column = 2)
            } else {
                column(1.fr)
                repeat(3) { row(GridTrackSize.Auto) }
                area(areaId = DashArea.Map, row = 1, column = 1)
                area(areaId = DashArea.Conditions, row = 2, column = 1)
                area(areaId = DashArea.Notes, row = 3, column = 1)
            }
            gap(12.dp)
        },
        modifier = modifier,
    ) {
        MapCard(Modifier.gridItem(areaId = DashArea.Map))
        ConditionsCard(Modifier.gridItem(areaId = DashArea.Conditions))
        NotesCard(Modifier.gridItem(areaId = DashArea.Notes))
    }
}
Enter fullscreen mode Exit fullscreen mode

FlexBox covers the one-dimensional case that Row and Column cannot: items that wrap, grow into spare space, shrink under pressure, start from their own preferred size, change visual order, or align themselves individually. The official container guide lists it as superseding FlowRow and FlowColumn for these jobs. A tag chip strip or a toolbar that collapses gracefully is the textbook fit. I am deliberately not printing a FlexBox sample here: its configuration surface was still moving when I wrote this, so check the reference for the exact artifact version you depend on.

Failure mode: treating Grid like a lazy list

Grid does not lazy-load. Put five hundred items in it and you compose five hundred items. Use it for structure, and keep the lazy containers for data.

Failure mode: a scrolling child in a flexible track

If a LazyColumn sits inside a Grid cell, give its track a minimum of zero and a flexible maximum so the list can shrink and scroll, for example row(GridTrackSize.MinMax(0.dp, 1.fr)). An Auto track sizes to content, which a long list will happily inflate.

Failure mode: fractional tracks with nothing to divide

Flexible (fr) tracks divide available space. If the grid is measured with unbounded height, such as directly inside a vertically scrolling parent, there is nothing to divide. Prefer Auto tracks in that situation, and verify the behavior on your Compose version rather than trusting memory.

5. App-level layout architecture and Navigation 3

Per-screen adaptivity gets you far, but the biggest structural question is navigation. On a compact window, “list then detail” is two destinations on a stack. On an expanded window, it is one screen with two panes. If those are different navigation graphs, you will spend your life keeping them in sync.

Navigation 3 resolves this by keeping the back stack as plain data (your own keys) and delegating presentation to a SceneStrategy. The strategy looks at the entries and decides what to render: one pane, two panes, or something else. Strategies can be chained, and the first one that returns a scene wins.

nav3-scene-pipeline

@Serializable data object ListKey : NavKey
@Serializable data class DetailKey(val siteId: String) : NavKey

@Composable
fun FieldNotesNav(modifier: Modifier = Modifier) {
    val backStack = rememberNavBackStack(ListKey)
    val sceneStrategy = rememberListDetailSceneStrategy<NavKey>()

    NavDisplay(
        backStack = backStack,
        modifier = modifier,
        sceneStrategy = sceneStrategy,
        entryProvider = entryProvider {
            entry<ListKey>(
                metadata = ListDetailSceneStrategy.listPane(
                    detailPlaceholder = { Text("Pick a site to see its notes") },
                ),
            ) {
                SiteListScreen(onOpen = { id -> backStack.showDetail(id) })
            }
            entry<DetailKey>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
                SiteDetailScreen(siteId = key.siteId)
            }
        },
    )
}
Enter fullscreen mode Exit fullscreen mode

Failure mode: stacking detail on detail

In a two-pane layout, tapping a different list row should swap the detail pane, not push another entry. Otherwise back has to unwind every row the user touched. One small extension keeps that rule in a single place:

fun MutableList<NavKey>.showDetail(siteId: String) {
    val target = DetailKey(siteId)
    if (lastOrNull() is DetailKey) removeLastOrNull() // replace, do not stack
    if (lastOrNull() != target) add(target)
}
Enter fullscreen mode Exit fullscreen mode

Failure mode: deep links that skip the list

A notification that opens a site directly can leave a back stack of just DetailKey. On a phone, back then exits the app instead of showing the list. When handling an external entry point, seed the stack with ListKey underneath.

Failure mode: keys that carry payloads

Back-stack entries get saved and restored, so keep keys small and serializable: ids, not objects. Load the data in the destination.

6. Agentic development and automation

The session closes its technical section with a demonstration of using Gemini and Android’s skills, including an adaptive skill, to refactor legacy code for larger screens. I am working from the session description rather than reproducing the demo, so treat the following as my own workflow rather than a summary of theirs.

An agent is good at the mechanical part: replacing device checks, hoisting state, converting nested rows into a Grid. It is poor at knowing whether the result still behaves. So the safety net has to exist before the refactor, and the cheapest version is a resize matrix test that renders your screen at several fixed sizes and asserts the structure you expect:

@RunWith(Parameterized::class)
class SitesPaneResizeTest(private val width: Dp, private val expectDetailPane: Boolean) {

    @get:Rule val rule = createComposeRule()

    @Test
    fun showsDetailPaneOnlyWhenWide() {
        rule.setContent {
            Box(Modifier.size(width, 800.dp)) {
                SitesPane(isWide = width >= 600.dp, sites = sampleSites)
            }
        }
        val detail = rule.onAllNodesWithTag("detail_pane")
        if (expectDetailPane) detail.onFirst().assertExists() else detail.assertCountEquals(0)
    }

    companion object {
        @JvmStatic @Parameterized.Parameters
        fun sizes() = listOf(
            arrayOf(360.dp, false),
            arrayOf(600.dp, true),
            arrayOf(900.dp, true),
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Review agent diffs against the failure list in this post: any new remember inside a conditional, any fresh device-type check, any configChanges addition. Those are the three regressions I would watch for first.

7. The return on investing in adaptive

I will not quote numbers I cannot source. The qualitative argument is strong enough on its own.

  • One layout model serves phones, foldables, tablets, and desktop windows, so a new form factor is a configuration change rather than a project.
  • Whole bug classes disappear: lost drafts, stretched content, stuck back stacks.
  • The test surface becomes explicit. Sizes and postures are inputs you can enumerate.

failure-modes

Takeaways

  1. Start from the window. Remove device-type checks and let size and posture drive layout.
  2. Hoist state above any branch that changes with window size, and put durable state in saveable holders.
  3. Route mediaQuery through one function that returns your own layout model, and use the derived variant for size.
  4. Use Grid for structure and change its config per window; use lazy containers for data.
  5. Keep navigation as plain keys and let a scene strategy decide pane count.
  6. Wrap every experimental API behind a seam you own.
  7. Build the resize test matrix before letting any tool refactor your screens.

Sources


📘 Master Your Next Technical Interview

Since Java is the foundation of Android development, mastering DSA is essential. I highly recommend “Mastering Data Structures & Algorithms in Java”. It’s a focused roadmap covering 100+ coding challenges to help you ace your technical rounds.

Top comments (0)