
Drag-to-reorder grid supporting multi-cell spanning tiles, continuous onMove plus single onSettle callbacks, derived packing preserving reading order, auto-scroll, customizable spacing and lift visuals.
A drag-to-reorder grid for Compose Multiplatform whose items span multiple cells.
Compose has LazyVerticalGrid for spans and a handful of libraries for
drag-to-reorder lists. Nothing does both: a grid where a tile is two cells wide,
another is two-by-two, and the user rearranges them by dragging.
▶ Try it in your browser — the sample compiled to WebAssembly.
implementation("io.github.nghicv:slotgrid-compose:0.1.1")var items by remember { mutableStateOf(tiles) }
SlotGrid(
items = items.map { SlotItem(it.id, it.span) { TileContent(it) } },
onMove = { from, to -> items = items.toMutableList().apply { add(to, removeAt(from)) } },
onSettle = { key, index -> viewModel.persistOrder(key, index) },
columns = 4,
scrollState = scrollState,
)That is the whole API. The grid never inspects your content — it positions a box and calls you.
onMove fires continuously during a drag; apply it to your in-memory list so the
preview reflows under the finger. onSettle fires once, on release, and only
if the order actually changed; that is where you write to a database.
Collapsing them into a single callback forces a choice between a preview that lags and a database write on every frame.
SlotGrid places items from order plus span. It does not store (x, y).
That is the decision everything else rests on. Absolute coordinates need their own conflict rule when two devices edit the same list, can leave holes nobody repairs, and turn resizing one item into a correction pass over every other one. Derived layout has none of those failure modes: every reachable state is a legal layout, and reordering is a list operation rather than a geometry problem.
The packing preserves monotonic reading order — item n never lands earlier
than item n - 1. This costs density: a later small item is not pulled forward
into an earlier gap, so grids are sometimes less tightly packed than they could
be. That is deliberate. Without it, a user drags an item to second place and
watches it appear somewhere in the middle.
The packer is public and has no UI dependency, if you want the layout without the grid:
val placements: List<SlotPlacement> = packSlots(spans, columns = 4)
val rows: Int = slotRowCount(placements)One continuous gesture per item, three phases, no modes:
| The user | Result |
|---|---|
| Taps |
onClick, with the item's window bounds so a transition can start from it |
| Moves before the hold completes | Nothing is consumed — the scroll container underneath takes over |
| Holds | The item lifts, with haptic feedback and onLongPress
|
| Holds, then moves | A drag begins; onDragStart fires so you can dismiss any menu |
Pass a ScrollState and the grid auto-scrolls when a dragged item reaches the
viewport edge.
Every visual constant is a parameter. SlotGridDefaults holds the defaults, and
SlotLiftStyle controls how an item looks while it is held.
SlotGrid(
// ...
columns = 3,
spacing = 8.dp,
cellAspectRatio = 1f,
liftStyle = SlotGridDefaults.liftStyle(scale = 1.06f, tiltDegrees = 0f),
)Android · iOS · JVM desktop · Wasm browser. Pure commonMain — the library
contains no expect/actual at all.
Boxlet — a visual journaling app on the App Store and Google Play — is where this component came from and where it runs today, on both platforms, from one Compose codebase.
./gradlew :sample:run # desktop
./gradlew :sample:wasmJsBrowserDevelopmentRun # browser
./gradlew :sample:androidApp:installDebug # Android deviceApache 2.0. See LICENSE.
A drag-to-reorder grid for Compose Multiplatform whose items span multiple cells.
Compose has LazyVerticalGrid for spans and a handful of libraries for
drag-to-reorder lists. Nothing does both: a grid where a tile is two cells wide,
another is two-by-two, and the user rearranges them by dragging.
▶ Try it in your browser — the sample compiled to WebAssembly.
implementation("io.github.nghicv:slotgrid-compose:0.1.1")var items by remember { mutableStateOf(tiles) }
SlotGrid(
items = items.map { SlotItem(it.id, it.span) { TileContent(it) } },
onMove = { from, to -> items = items.toMutableList().apply { add(to, removeAt(from)) } },
onSettle = { key, index -> viewModel.persistOrder(key, index) },
columns = 4,
scrollState = scrollState,
)That is the whole API. The grid never inspects your content — it positions a box and calls you.
onMove fires continuously during a drag; apply it to your in-memory list so the
preview reflows under the finger. onSettle fires once, on release, and only
if the order actually changed; that is where you write to a database.
Collapsing them into a single callback forces a choice between a preview that lags and a database write on every frame.
SlotGrid places items from order plus span. It does not store (x, y).
That is the decision everything else rests on. Absolute coordinates need their own conflict rule when two devices edit the same list, can leave holes nobody repairs, and turn resizing one item into a correction pass over every other one. Derived layout has none of those failure modes: every reachable state is a legal layout, and reordering is a list operation rather than a geometry problem.
The packing preserves monotonic reading order — item n never lands earlier
than item n - 1. This costs density: a later small item is not pulled forward
into an earlier gap, so grids are sometimes less tightly packed than they could
be. That is deliberate. Without it, a user drags an item to second place and
watches it appear somewhere in the middle.
The packer is public and has no UI dependency, if you want the layout without the grid:
val placements: List<SlotPlacement> = packSlots(spans, columns = 4)
val rows: Int = slotRowCount(placements)One continuous gesture per item, three phases, no modes:
| The user | Result |
|---|---|
| Taps |
onClick, with the item's window bounds so a transition can start from it |
| Moves before the hold completes | Nothing is consumed — the scroll container underneath takes over |
| Holds | The item lifts, with haptic feedback and onLongPress
|
| Holds, then moves | A drag begins; onDragStart fires so you can dismiss any menu |
Pass a ScrollState and the grid auto-scrolls when a dragged item reaches the
viewport edge.
Every visual constant is a parameter. SlotGridDefaults holds the defaults, and
SlotLiftStyle controls how an item looks while it is held.
SlotGrid(
// ...
columns = 3,
spacing = 8.dp,
cellAspectRatio = 1f,
liftStyle = SlotGridDefaults.liftStyle(scale = 1.06f, tiltDegrees = 0f),
)Android · iOS · JVM desktop · Wasm browser. Pure commonMain — the library
contains no expect/actual at all.
Boxlet — a visual journaling app on the App Store and Google Play — is where this component came from and where it runs today, on both platforms, from one Compose codebase.
./gradlew :sample:run # desktop
./gradlew :sample:wasmJsBrowserDevelopmentRun # browser
./gradlew :sample:androidApp:installDebug # Android deviceApache 2.0. See LICENSE.