A hinge is a physical object, and your layout should know where it is

Foldables give two things a resize doesn't: a posture and a hinge with real coordinates. Handling them well means avoiding the seam and using tabletop mode, not just supporting a new width.

6 min read
androidcomposekotlinadaptive

Day 33 — A hinge is a physical object, and your layout should know wh

Day 33 of 100. Size classes from Day 29 handle most of what a foldable needs. The remainder is the part that makes an app feel built for the hardware rather than merely compatible with it.

The symptom

An app that handles unfolding correctly by every measure you can test in an emulator resize, and still looks wrong on the device:

Row {
    ItemList(Modifier.weight(1f))
    ItemDetail(Modifier.weight(1f))
}

Unfolded, the two panes meet exactly at the hinge — so the divider between them sits a few dp off the physical seam, or worse, a button lands on it. On a book-style foldable the seam is a visible, tactile crease, and a control placed there is genuinely harder to press.

Then someone stands the device half-open on a desk and your video plays across the fold instead of sitting above it.

Why the obvious fix fails

The obvious fix is to special-case the device:

val isFoldable = Build.MODEL in knownFoldables      // no

A model list is out of date the week it ships, and it tells you nothing about the current posture — the same device is a phone, a tablet and a tabletop depending on how it's being held right now.

The second obvious fix is a hardcoded gap:

Row {
    ItemList(Modifier.weight(1f))
    Spacer(Modifier.width(30.dp))       // "about hinge-sized"
    ItemDetail(Modifier.weight(1f))
}

Hinge width varies by device, the hinge isn't always centred, and on a non-folding tablet you've added a 30dp gap for nothing.

WindowPosture carries the hinge bounds and whether it occludes — a resize carries neither

The actual mechanism

currentWindowAdaptiveInfo() from Day 29 already carries the posture. You don't need a second API:

val info = currentWindowAdaptiveInfo()
val posture = info.windowPosture

posture.hingeList.forEach { hinge ->
    // hinge.bounds      — the rectangle the hinge occupies, in window coordinates
    // hinge.isVertical  — orientation
    // hinge.isSeparating — does it split the window into two logical areas?
    // hinge.isOccluding  — does it physically hide pixels?
}

Two flags carry the meaning, and they're different:

isSeparating — the hinge divides the display into two areas the user perceives as separate. True when a book-style device is flat and open, and true in tabletop mode.

isOccluding — content behind the hinge is physically hidden. On a device with a gap between two panels this is true; on a continuous folding screen it's false even though the crease is visible.

You avoid placing content across an occluding hinge because it would be invisible. You avoid placing controls across a separating one because it's awkward to touch. Different reasons, different responses.

The two postures worth handling explicitly:

if (posture.isTabletop) {
    // Half-open, hinge horizontal, device standing on a desk
    Column {
        VideoPlayer(Modifier.height(hingeTopDp))     // above the fold
        Controls(Modifier.weight(1f))                // below it
    }
}

Tabletop is the one that's genuinely a new interaction rather than a new size: the top half is a screen you look at and the bottom half is a surface you touch. Video calls, media players, cameras and games all have a natural mapping onto it, and nothing else in Android gives you that.

isBookPosture is the vertical equivalent — half-open with a vertical hinge, held like a book. Reading apps and dual-page layouts use it.

Let the scaffold do it

The good news, and the reason this post is shorter than it could be: if you used the scaffolds from Days 31 and 32, hinge handling is already done.

calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) reads the posture and places the pane split at the hinge, sizing the gutter to the real hinge bounds. That's the horizontalPartitionSpacerSize you saw yesterday, derived rather than guessed.

So the practical advice is mostly negative: don't hand-roll a two-pane Row on a device class where the seam matters. Use the scaffold and you get hinge-awareness you didn't write.

The cases that still need direct posture handling are the ones the scaffolds don't model — tabletop media layouts, camera viewfinders, games.

The lifecycle detail that bites

Folding and unfolding is a configuration change. Everything from Day 8 applies: the composition is torn down and rebuilt, remember is lost, rememberSaveable and ViewModels survive.

The specific trap is a long-running operation started in a LaunchedEffect(Unit) inside the composition. Fold the device and it restarts. On a device that folds and unfolds casually, that's a request fired several times a minute.

The fix is Day 12's: work that should survive belongs in the ViewModel with viewModelScope, not in an effect keyed to the composition. Foldables just make the existing bug easy to reproduce.

How to prove it

The Android Studio emulator has foldable profiles with a working fold control, and the posture APIs report correctly there — this is one case where the emulator is genuinely sufficient for development:

LaunchedEffect(Unit) {
    snapshotFlow { currentWindowAdaptiveInfo().windowPosture }
        .collect { Log.d("Posture", "tabletop=${it.isTabletop} hinges=${it.hingeList}") }
}

Fold the emulator and watch the values change. If hingeList is empty on a folded profile, you're on an older adaptive library version.

The seam check needs the physical device, or at least the emulator's fold overlay: run the layout and look at what lands on the hinge line. A divider is fine. A button, a text field or the middle of a photo is not.

Continuity is the requirement users notice

Everything above is about layout. The thing users actually judge is whether their place is kept across a fold.

Unfold while reading article 12 of a list and you should still be on article 12, at the same scroll offset, with the same text in the search box. Because folding is a configuration change, none of that survives on its own — it survives because you put it somewhere that outlives the composition:

// Survives the fold
val listState = rememberLazyListState()          // saveable by default
var query by rememberSaveable { mutableStateOf("") }
val items by vm.items.collectAsStateWithLifecycle()   // ViewModel

// Does not
var query by remember { mutableStateOf("") }

That's Days 12 and 14 with nothing added — which is the point. Foldable support is mostly the state discipline you should already have, tested by a device that changes configuration far more often than a phone that only rotates.

Declaring android:configChanges to dodge the recreation is the wrong fix, incidentally: it suppresses the symptom, and the app still loses state on process death where the same bug is waiting.

What this generalizes to

The idea is the display is not a rectangle any more. For a decade, layout could assume a flat continuous surface, and that assumption is now wrong in several ways — hinges, camera cutouts, rounded corners, curved edges.

The response is the same in every case: ask the system where the awkward regions are rather than assuming there are none. WindowInsets answers it for cutouts and system bars, WindowPosture for hinges. Both are the same move — replace an assumption about the hardware with a query.

Tomorrow, Day 34: connected displays — what happens when your app is on an external monitor, and why it's a different problem from a bigger window.


Day 33 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Support foldables.