A Kotlin Multiplatform SDK for adaptive layout around a physical fold, on Android foldables and the iPhone Duo.
One shared model and one layout engine, with a Compose Multiplatform adapter on top. Both platforms answer "should this be two panes, and where exactly" with the same code, so the two apps cannot drift.
hinge-core pure Kotlin model + pane layout engine, androidx.window and Duo bridges
hinge-compose Compose Multiplatform adapter (Android + iOS)
sample/ Compose Multiplatform demo app
Start with sample/README.md. The demo ships a posture simulator that forces Book, Tabletop,
seamless, cover-display and occlusion-only states on any device, which is the only practical
way to exercise this code without a drawer full of foldables.
Both platforms independently converged on the same two ideas, and this library takes them at
their word. Reserved regions — FoldingFeature on Android, reservedRegions(kind:) on
iOS — describe where the display is physically interrupted, and they are what layout reads.
Hinge angle is a continuous interaction signal and is explicitly not for layout; Apple
says so outright for the Duo, and the same is true of Android's TYPE_HINGE_ANGLE. So the
core exposes regions as the layout input, keeps the angle nullable and clearly labelled, and
resolves everything down to one function:
fun FoldingState.paneLayout(spec: SplitSpec = SplitSpec()): PaneLayoutPaneLayout is either Single or Split. There is no "is it folded" boolean to get wrong
and no posture enum with a branch you forgot.
setContent {
ProvideFoldingState {
FoldAwarePanes(
primary = { ConversationList(onSelect = { selected = it }) },
secondary = { ConversationDetail(selected) },
modifier = Modifier.fillMaxSize(),
)
}
}For list/detail, ListDetailPanes shows both panes when the layout splits and one at a time
when it does not. The detail keeps its state when the device folds or unfolds:
ListDetailPanes(
showDetail = selected != null,
list = { ConversationList(onSelect = { selected = it }) },
detail = {
val twoPane = LocalPaneLayout.current is PaneLayout.Split
BackHandler(enabled = !twoPane) { selected = null }
ConversationDetail(selected, showBack = !twoPane)
},
)Inside pane content, read LocalPaneLayout rather than rememberPaneLayout(): it is the layout
the panes were measured with, so it cannot disagree with what is on screen. In a right-to-left
layout the primary pane goes on the right; pass mirrorInRtl = false to keep it on the left.
SplitSpec(
strategy = SplitStrategy.FoldAware, // FoldAware | AlwaysSplit | NeverSplit
ratio = 0.4f, // primary's share when there is no fold to follow
minPaneSize = 320f, // below this, collapse to one pane
gutter = 24f, // gap when there is no fold to use as one
preferredAxis = null, // null means side-by-side
)./gradlew :hinge-core:allTests # the layout engine's contract tests
./gradlew :sample:android-app:installDebugThe build needs Gradle 9.7 (the wrapper), JDK 17+, and Android SDK platform 37. AGP 9 does
not allow com.android.application alongside the multiplatform plugin, so the sample's
Android entry point is its own module, sample/android-app, and the shared UI in
sample/compose-app is a library.
Compose Multiplatform's iOS integration produces an Objective-C framework, and that is the
only export path this library uses. The app module's framework must explicitly export
hinge-core, or none of the bridge types reach Swift:
target.binaries.framework {
baseName = "ComposeApp"
isStatic = true
export(project(":hinge-core"))
}Posture itself cannot come from Kotlin: the Duo APIs are Swift-only and version-gated. The app
implements HingeBridge in Swift and registers it at launch:
HingeBridgeRegistry.shared.install(bridge: DuoHingeSource())sample/compose-app/iosApp/DuoHingeSource.swift is a complete implementation, and is the only
file in the repository that names an iPhone Duo API. Building it needs Xcode 27.1+; the
resulting binary runs on iOS 18+, because every Duo reference sits behind
if #available(iOS 27.1, *) and older systems get an unknown posture, which renders as a
single pane.
Geometry is in logical points. dp on Android, points on iOS, interchangeable for layout.
Platform sources divide out density before constructing state. Never put physical pixels in.
Window coordinates are the trap. Platform fold geometry is window-relative. A component
that is not full-window will happily split at a fold line that is nowhere near it.
FoldingState.inLocalSpace() is the fix, and FoldAwarePanes calls it for you. If you consume
FoldingState in your own layout code, call it yourself. Note also that rememberPaneLayout()
resolves window geometry while FoldAwarePanes resolves its own local geometry: they agree
only when the panes fill the window. LocalPaneLayout avoids the question.
Hinge angle is off by default. rememberFoldingState(includeHingeAngle = true) fills
FoldingState.hingeAngle, at the cost of a new state for every sensor sample. Layout never
reads it.
FoldPosture.Unknown is a normal state, not a loading state. Every non-folding device
reports it forever. Never gate rendering on it.
Android never reports Closed. A closed device is either an ordinary small window on the
cover display (reported as Unknown, since it has no fold) or a stopped activity;
androidx.window has no signal to map. minPaneSize
is what makes cover displays behave, which is why it exists.
The hinge angle is off by default on Android. foldingStateFlow(activity) does not
register the sensor unless you pass includeHingeAngle = true. It reports continuously while
the device moves, and every sample would produce a new FoldingState — for a value this
library tells you not to lay out with. It also needs API 30 and an OEM that publishes
TYPE_HINGE_ANGLE, which most are not.
DuoHingeSource.swift is the only file that touches the Duo APIs. Everything downstream of
it speaks in Doubles and Bools. If Apple renames a case between betas, the blast radius is
that one file. Its header lists exactly which signatures to re-verify against your SDK.
Compose: FoldAwarePanes needs bounded constraints. Inside a scrolling parent it has no
extent to divide and will measure the primary pane alone.
What has actually run. hinge-core's tests pass on the Android unit-test and
iosSimulatorArm64 targets. The sample builds with Xcode 27.1 and runs on the iPhone Duo
simulator, where DuoHingeSource reports Flat, Book (a 40pt separating region in the centre,
with list/detail split around it) and Closed (a 466x678 cover display). The Android sample
builds, but has not yet been run on a foldable device or emulator.