
Build 2D surfaces by declaring relations between items instead of coordinates; lazily composes nearby items, deterministic constraint solver, self‑healing relations, pinch‑zoom/pan gestures and snap paging.
Build 2D surfaces where items say who they sit next to, not where they are. Lazy, on every Compose platform.
Items live on an infinite plane, declared by key and by spatial relations to each other (below("hero"), endOf("stats")), never by index or coordinates. Composition, measurement and placement happen lazily as the viewport approaches an item.
below("hero", margin = 16.dp) and the layout follows, reflows and heals as content changes.commonMain. One implementation for Android, iOS, desktop JVM and wasm.Every capture below is the demo app on an iPhone (app/shared runs unchanged on all targets).
Run them yourself: ./gradlew :app:androidApp:installDebug, :app:desktopApp:run, :app:webApp:wasmJsBrowserDevelopmentRun, or open app/iosApp/iosApp.xcodeproj.
commonMain.dependencies {
implementation("io.github.07jasjeet:lazysurface:<latest-version>")
}Published for Android, iOS, desktop JVM and wasm. On Android the Compose dependencies resolve to the same androidx.compose.* artifacts a plain Android app already ships.
val state = rememberLazySurfaceState()
LazySurface(
state = state,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 90.dp, bottom = 120.dp), // clear your app bars
) {
item(key = "hero", neighbors = { atPivot() }) {
HeroCard(Modifier.fillParentMaxWidth(0.9f))
}
item(key = "stats", neighbors = {
below("hero", margin = 16.dp) // this item sits below "hero", 16dp away
}) {
StatsCard()
}
item(key = "sleep", neighbors = {
endOf("stats", margin = 16.dp) // this item sits at the end of "stats" (right in LTR)
}) {
SleepCard()
}
}Collections: items(list, key = {...}, neighbors = { item -> ... }).
item(key, contentType, neighbors) { content }. Keys are unique (duplicates throw) and must be Bundle-storable on Android (strings, numbers, enums, Pair, Parcelable; a plain data class crashes at state save). Content is measured under unbounded constraints and must size itself: Modifier.size or fillParentMaxWidth/Height/Size (viewport size at 1x zoom). Plain fillMaxWidth is a no-op.below("a") means "this item sits below a". They are bidirectional (declaring one side is enough), joint (an item with several relations is positioned against all of them), and direction-relative (startOf/endOf mirror under RTL). Cross-axis Alignment: Start, Center (default), End, plus Free. A pair related along both axes (endOf(x); above(x)) is a diagonal declaration and resolves to the corner where the two gaps meet.below("a", margin = 16.dp) (or the infix below("a") margin 16.dp) declares the gap along that relation's axis, and it leaks into no other relation; a third item can hug either endpoint on the same side. Margins are not padding: they stay outside the content box so laziness can defer composition until the content itself would show.Alignment.Free: pure adjacency. Routes navigation, links the bounding shape, and enforces no-overlap; but never positions or aligns, so it can never fight the positioning relations. Every item still needs at least one non-Free relation path (Free-only connectivity throws at registration).LazySurfacePivot is the zero-size origin and graph root. atPivot() centers an item on it; the viewport starts there. Items with no relation chain to the pivot never render.The graph is walked breadth-first from the pivot each pass. Items inside the resolution area (viewport plus one viewport per side) are measured; everything beyond is positioned provisionally (cached size, or zero until first measured). Items whose relation endpoints have not resolved defer within the pass; endpoints that never resolve are dropped for that frame only.
All positions are then refined jointly over every declared relation:
| Constraint | Strength |
|---|---|
| Minimum separation along the relation axis (no overlap) | Hard |
| Exact declared gap | Soft |
| Cross-axis alignment | Soft |
Deterministic; a consistent graph converges in one sweep. Contradictions blend into visible warping and cost roughly 17x per frame (about 1 ms vs 0.05 ms for the 84-item demo). Treat warping as a declaration bug.
contentPadding is screen space (constant across zoom). null allows content to stop half a viewport in.settleIntoBounds = true: a viewport stranded outside the shape by content changes springs back. User input always wins.zoomRange), fling with pointer-sample velocity. interactable = false disables input; programmatic scrolling still works.LocalOverscrollFactory).Relations pointing at a removed item splice to its nearest surviving anchor, preserving side and alignment, transitively across removal chains. Re-declaring a removed key revives it and wins back its original relations. Not configurable.
Modifier.animateItem(placementSpec, fadeInSpec) glides position changes and fades new items in. Purely visual: every position in LazySurfaceState stays logical. Re-entering the viewport snaps rather than gliding across the screen.
Snap paging (the surface as a pager). Every release lands centered on an anchor; the fling's projected landing picks the nearest one, so momentum decides the page and a still release re-centers:
LazySurface(
state = state,
flingBehavior = rememberSnapFlingBehavior(state) { it.key != "background" },
settleIntoBounds = false,
) { ... }Disable settleIntoBounds when snapping: the settle animation re-centers into the bounding shape while the snap wants the viewport on an anchor, and the two policies fight over where an idle viewport belongs. Custom policies implement the standard SnapLayoutInfoProvider via LazySurfaceSnapLayoutInfoProvider(state, anchors).
Fly to an item on tap. animateToItem travels along relation edges; unmeasured targets are aimed at provisionally and corrected en route:
val scope = rememberCoroutineScope()
item(key = "hub", neighbors = { atPivot() }) {
HubCard(onClick = { scope.launch { state.animateToItem("far-away-key") } })
}iOS-style overscroll everywhere. The environment decides the overscroll feel; the library ships a rubber band:
CompositionLocalProvider(LocalOverscrollFactory provides RubberBandOverscrollFactory()) {
LazySurface(...) { ... }
}On iOS specifically, prefer this over the platform default: the platform effect is one-dimensional and fights 2D flings.
Radial layouts. Rings, orbits and flowers come out of one reusable routine built purely on relations; see placeAround in the sample app (app/shared/.../PlaceAround.kt): cardinals ray out on the axes, corners on the diagonals at a quarter margin, everything else links one layer in, and in-ring neighbours are joined by Free no-overlap edges. It rings items around any placed item, so clusters nest (the Flower sample is two levels of it).
Frame budget and analytics. The library exposes two hooks, both null by default with zero overhead, built for wiring into your analytics/telemetry pipeline as much as for local debugging:
// Per-frame phase timings: ship percentiles of Measure / Resolve / Solve to your
// metrics backend, or alert when solve time spikes (a contradiction signature).
LazySurfacePerformance.monitor = { phase, duration -> analytics.track(phase.name, duration) }
// Structured decision log: gesture, fling, clamp, navigation and recovery events,
// useful as breadcrumbs attached to bug reports from the field.
LazySurfaceDebug.logger = { crashReporter.log(it) }Both run on the UI thread; record and hand off, never process inline.
Relation lines overlay. The demo app draws every item's declared relations as lines between cards (dotted = Free) with a drawWithContent modifier over state.resolvedRects; see relationLines in app/shared for the pattern.
@Composable
fun LazySurface(
modifier: Modifier = Modifier,
state: LazySurfaceState,
zoomRange: ClosedFloatingPointRange<Float> = 0.5f..2f,
contentPadding: PaddingValues? = null,
interactable: Boolean = true,
settleIntoBounds: Boolean = true,
flingBehavior: FlingBehavior? = null,
content: LazySurfaceScope.() -> Unit,
)rememberLazySurfaceState(initialOffset, initialZoom) survives configuration changes (offset and zoom persist; sizes re-measure).
LazySurfaceState |
|
|---|---|
offset, zoom, viewportSize
|
Observable viewport position, scale, size |
itemsInfo, visibleItemsInfo
|
Registered / visible items; visible entries carry the surface rect and viewportRect (on-screen footprint in viewport pixels) |
resolvedRects, resolvedBounds
|
Known item rects and their union |
suspend animateToItem(key) |
Flies to an item along relation edges |
Each is a symptom you will actually see, why it happens, and the fix.
fillMaxWidth resolves to zero and scrollable children (e.g. LazyColumn) throw. Size content explicitly: Modifier.size(...) or fillParentMaxWidth(fraction).atPivot() and relate everything else to it, directly or transitively.IllegalArgumentException about keys. Duplicates throw at registration (distinctBy your source data). On Android, keys must also be Bundle-storable; a plain data class Cell(...) key crashes at state save; use Pair, a string, or a Parcelable.Free edges purely as no-overlap guards.Modifier.offset moved the pixels but nothing else followed. Offsets are visual only; the logical rect (clamping, visibility, relations) stays put. Move items with relations, or animate with Modifier.animateItem.contentPadding is screen space and already divided by zoom internally. Do not pre-scale it.100% commonMain; targets: Android (minSdk 24), iOS (iosArm64, iosSimulatorArm64), desktop JVM, wasmJs. JDK 21, Android SDK 36+; versions pinned in gradle/libs.versions.toml; keep kotlin and composeMultiplatform in lockstep with consuming apps.
| Module | |
|---|---|
LazySurface/ |
The library |
app/shared/ |
Demo UI (commonMain) |
app/androidApp/, app/desktopApp/, app/webApp/, app/iosApp/
|
Platform entry points |
| Tests | ./gradlew :LazySurface:desktopTest |
| Android demo | ./gradlew :app:androidApp:installDebug |
| Desktop demo | ./gradlew :app:desktopApp:run |
| Web demo | ./gradlew :app:webApp:wasmJsBrowserDevelopmentRun |
| iOS demo | open app/iosApp/iosApp.xcodeproj
|
| Android AAR | ./gradlew :LazySurface:bundleAndroidMainAar |
TEAM_ID=... in app/iosApp/Configuration/Local.xcconfig (gitignored). Without a full Xcode install the shared module skips its framework link tasks automatically.String.format, System.currentTimeMillis); the iOS compilation catches them, so build all targets before assuming green.org.jetbrains.compose Gradle plugin to modules that also apply AGP's KMP library plugin (incompatible resources wiring). Only app:desktopApp and app:webApp apply it.<profileable> build; on iOS set the Xcode scheme's build configuration to Release (Kotlin/Native debug binaries are unoptimized). The Worst case sample is the perf harness; its HUD reports the library's Measure / Resolve / Solve phases live. ConstraintSolverPerfTest is a JVM micro-benchmark, not device numbers.Build 2D surfaces where items say who they sit next to, not where they are. Lazy, on every Compose platform.
Items live on an infinite plane, declared by key and by spatial relations to each other (below("hero"), endOf("stats")), never by index or coordinates. Composition, measurement and placement happen lazily as the viewport approaches an item.
below("hero", margin = 16.dp) and the layout follows, reflows and heals as content changes.commonMain. One implementation for Android, iOS, desktop JVM and wasm.Every capture below is the demo app on an iPhone (app/shared runs unchanged on all targets).
Run them yourself: ./gradlew :app:androidApp:installDebug, :app:desktopApp:run, :app:webApp:wasmJsBrowserDevelopmentRun, or open app/iosApp/iosApp.xcodeproj.
commonMain.dependencies {
implementation("io.github.07jasjeet:lazysurface:<latest-version>")
}Published for Android, iOS, desktop JVM and wasm. On Android the Compose dependencies resolve to the same androidx.compose.* artifacts a plain Android app already ships.
val state = rememberLazySurfaceState()
LazySurface(
state = state,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 90.dp, bottom = 120.dp), // clear your app bars
) {
item(key = "hero", neighbors = { atPivot() }) {
HeroCard(Modifier.fillParentMaxWidth(0.9f))
}
item(key = "stats", neighbors = {
below("hero", margin = 16.dp) // this item sits below "hero", 16dp away
}) {
StatsCard()
}
item(key = "sleep", neighbors = {
endOf("stats", margin = 16.dp) // this item sits at the end of "stats" (right in LTR)
}) {
SleepCard()
}
}Collections: items(list, key = {...}, neighbors = { item -> ... }).
item(key, contentType, neighbors) { content }. Keys are unique (duplicates throw) and must be Bundle-storable on Android (strings, numbers, enums, Pair, Parcelable; a plain data class crashes at state save). Content is measured under unbounded constraints and must size itself: Modifier.size or fillParentMaxWidth/Height/Size (viewport size at 1x zoom). Plain fillMaxWidth is a no-op.below("a") means "this item sits below a". They are bidirectional (declaring one side is enough), joint (an item with several relations is positioned against all of them), and direction-relative (startOf/endOf mirror under RTL). Cross-axis Alignment: Start, Center (default), End, plus Free. A pair related along both axes (endOf(x); above(x)) is a diagonal declaration and resolves to the corner where the two gaps meet.below("a", margin = 16.dp) (or the infix below("a") margin 16.dp) declares the gap along that relation's axis, and it leaks into no other relation; a third item can hug either endpoint on the same side. Margins are not padding: they stay outside the content box so laziness can defer composition until the content itself would show.Alignment.Free: pure adjacency. Routes navigation, links the bounding shape, and enforces no-overlap; but never positions or aligns, so it can never fight the positioning relations. Every item still needs at least one non-Free relation path (Free-only connectivity throws at registration).LazySurfacePivot is the zero-size origin and graph root. atPivot() centers an item on it; the viewport starts there. Items with no relation chain to the pivot never render.The graph is walked breadth-first from the pivot each pass. Items inside the resolution area (viewport plus one viewport per side) are measured; everything beyond is positioned provisionally (cached size, or zero until first measured). Items whose relation endpoints have not resolved defer within the pass; endpoints that never resolve are dropped for that frame only.
All positions are then refined jointly over every declared relation:
| Constraint | Strength |
|---|---|
| Minimum separation along the relation axis (no overlap) | Hard |
| Exact declared gap | Soft |
| Cross-axis alignment | Soft |
Deterministic; a consistent graph converges in one sweep. Contradictions blend into visible warping and cost roughly 17x per frame (about 1 ms vs 0.05 ms for the 84-item demo). Treat warping as a declaration bug.
contentPadding is screen space (constant across zoom). null allows content to stop half a viewport in.settleIntoBounds = true: a viewport stranded outside the shape by content changes springs back. User input always wins.zoomRange), fling with pointer-sample velocity. interactable = false disables input; programmatic scrolling still works.LocalOverscrollFactory).Relations pointing at a removed item splice to its nearest surviving anchor, preserving side and alignment, transitively across removal chains. Re-declaring a removed key revives it and wins back its original relations. Not configurable.
Modifier.animateItem(placementSpec, fadeInSpec) glides position changes and fades new items in. Purely visual: every position in LazySurfaceState stays logical. Re-entering the viewport snaps rather than gliding across the screen.
Snap paging (the surface as a pager). Every release lands centered on an anchor; the fling's projected landing picks the nearest one, so momentum decides the page and a still release re-centers:
LazySurface(
state = state,
flingBehavior = rememberSnapFlingBehavior(state) { it.key != "background" },
settleIntoBounds = false,
) { ... }Disable settleIntoBounds when snapping: the settle animation re-centers into the bounding shape while the snap wants the viewport on an anchor, and the two policies fight over where an idle viewport belongs. Custom policies implement the standard SnapLayoutInfoProvider via LazySurfaceSnapLayoutInfoProvider(state, anchors).
Fly to an item on tap. animateToItem travels along relation edges; unmeasured targets are aimed at provisionally and corrected en route:
val scope = rememberCoroutineScope()
item(key = "hub", neighbors = { atPivot() }) {
HubCard(onClick = { scope.launch { state.animateToItem("far-away-key") } })
}iOS-style overscroll everywhere. The environment decides the overscroll feel; the library ships a rubber band:
CompositionLocalProvider(LocalOverscrollFactory provides RubberBandOverscrollFactory()) {
LazySurface(...) { ... }
}On iOS specifically, prefer this over the platform default: the platform effect is one-dimensional and fights 2D flings.
Radial layouts. Rings, orbits and flowers come out of one reusable routine built purely on relations; see placeAround in the sample app (app/shared/.../PlaceAround.kt): cardinals ray out on the axes, corners on the diagonals at a quarter margin, everything else links one layer in, and in-ring neighbours are joined by Free no-overlap edges. It rings items around any placed item, so clusters nest (the Flower sample is two levels of it).
Frame budget and analytics. The library exposes two hooks, both null by default with zero overhead, built for wiring into your analytics/telemetry pipeline as much as for local debugging:
// Per-frame phase timings: ship percentiles of Measure / Resolve / Solve to your
// metrics backend, or alert when solve time spikes (a contradiction signature).
LazySurfacePerformance.monitor = { phase, duration -> analytics.track(phase.name, duration) }
// Structured decision log: gesture, fling, clamp, navigation and recovery events,
// useful as breadcrumbs attached to bug reports from the field.
LazySurfaceDebug.logger = { crashReporter.log(it) }Both run on the UI thread; record and hand off, never process inline.
Relation lines overlay. The demo app draws every item's declared relations as lines between cards (dotted = Free) with a drawWithContent modifier over state.resolvedRects; see relationLines in app/shared for the pattern.
@Composable
fun LazySurface(
modifier: Modifier = Modifier,
state: LazySurfaceState,
zoomRange: ClosedFloatingPointRange<Float> = 0.5f..2f,
contentPadding: PaddingValues? = null,
interactable: Boolean = true,
settleIntoBounds: Boolean = true,
flingBehavior: FlingBehavior? = null,
content: LazySurfaceScope.() -> Unit,
)rememberLazySurfaceState(initialOffset, initialZoom) survives configuration changes (offset and zoom persist; sizes re-measure).
LazySurfaceState |
|
|---|---|
offset, zoom, viewportSize
|
Observable viewport position, scale, size |
itemsInfo, visibleItemsInfo
|
Registered / visible items; visible entries carry the surface rect and viewportRect (on-screen footprint in viewport pixels) |
resolvedRects, resolvedBounds
|
Known item rects and their union |
suspend animateToItem(key) |
Flies to an item along relation edges |
Each is a symptom you will actually see, why it happens, and the fix.
fillMaxWidth resolves to zero and scrollable children (e.g. LazyColumn) throw. Size content explicitly: Modifier.size(...) or fillParentMaxWidth(fraction).atPivot() and relate everything else to it, directly or transitively.IllegalArgumentException about keys. Duplicates throw at registration (distinctBy your source data). On Android, keys must also be Bundle-storable; a plain data class Cell(...) key crashes at state save; use Pair, a string, or a Parcelable.Free edges purely as no-overlap guards.Modifier.offset moved the pixels but nothing else followed. Offsets are visual only; the logical rect (clamping, visibility, relations) stays put. Move items with relations, or animate with Modifier.animateItem.contentPadding is screen space and already divided by zoom internally. Do not pre-scale it.100% commonMain; targets: Android (minSdk 24), iOS (iosArm64, iosSimulatorArm64), desktop JVM, wasmJs. JDK 21, Android SDK 36+; versions pinned in gradle/libs.versions.toml; keep kotlin and composeMultiplatform in lockstep with consuming apps.
| Module | |
|---|---|
LazySurface/ |
The library |
app/shared/ |
Demo UI (commonMain) |
app/androidApp/, app/desktopApp/, app/webApp/, app/iosApp/
|
Platform entry points |
| Tests | ./gradlew :LazySurface:desktopTest |
| Android demo | ./gradlew :app:androidApp:installDebug |
| Desktop demo | ./gradlew :app:desktopApp:run |
| Web demo | ./gradlew :app:webApp:wasmJsBrowserDevelopmentRun |
| iOS demo | open app/iosApp/iosApp.xcodeproj
|
| Android AAR | ./gradlew :LazySurface:bundleAndroidMainAar |
TEAM_ID=... in app/iosApp/Configuration/Local.xcconfig (gitignored). Without a full Xcode install the shared module skips its framework link tasks automatically.String.format, System.currentTimeMillis); the iOS compilation catches them, so build all targets before assuming green.org.jetbrains.compose Gradle plugin to modules that also apply AGP's KMP library plugin (incompatible resources wiring). Only app:desktopApp and app:webApp apply it.<profileable> build; on iOS set the Xcode scheme's build configuration to Release (Kotlin/Native debug binaries are unoptimized). The Worst case sample is the perf harness; its HUD reports the library's Measure / Resolve / Solve phases live. ConstraintSolverPerfTest is a JVM micro-benchmark, not device numbers.