Drag-to-Reorder With Wiggle Animation Building a Customizable Widget Grid in Jetpack Compose, like…
Open the Apple Wallet app. Long-press a card. The whole stack starts wiggling. Drag a card onto another card — they swap places. Tap and…
Drag-to-Reorder With Wiggle Animation Building a Customizable Widget Grid in Jetpack Compose, like IOS Style
Open the Apple Wallet app. Long-press a card. The whole stack starts wiggling. Drag a card onto another card — they swap places. Tap and hold an account tile — a floating menu appears anchored just below the tile, listing every account underneath that one. Tap “Customize”, reorder your widgets, hit Save. Tap Cancel — every tile snaps back to where it was before you started editing.
Building this in Compose looks easy until you actually try it. The reorder libraries on GitHub all use insert-while-dragging semantics — drag tile A toward tile B and every tile between them slides over by one slot. Users hate this for “drag A onto B” interactions because it doesn’t match what they’re trying to do. The Material DropdownMenu doesn't anchor to the exact pixel position of a tile — it positions itself near the parent. The Compose LazyVerticalStaggeredGrid doesn't expose a drag-to-reorder API. And every tutorial I read about wiggle animation triggers 60 recompositions per second across every tile in the grid.
This article builds the entire interaction from scratch, on pure AOSP Compose Foundation. No androidx.compose.material:material extras, no org.burnoutcrew.composereorderable, no external animation libraries. ~170 lines of reorder code split across three files. Swap-on-drop semantics that match user intuition. A peek menu that anchors to the exact boundsInWindow() of a tile and flips above when it would clip the bottom edge. A wiggle animation that uses ONE shared InfiniteTransition for the entire grid and only recomposes the dragged tile per drag delta. Edit mode with O(1) snapshot revert. And a StableList wrapper that gives you the recomposition-skipping benefits of kotlinx.collections.immutable without adding the dependency.
By the end you’ll have the complete architecture for a banking app’s “My Products” sheet — total balance card, expandable section, staggered widget grid, customize-and-save flow, peek menu, and a one-shot vibration when the screen first appears. Every file is shown in full. Every architectural decision is explained.

Part 1: The Architecture at a Glance
Module Layout
:app
├── MainActivity.kt → enableEdgeToEdge + theme + welcome haptic
│
├── data/
│ ├── WidgetRepository.kt → loadWidgets, loadAccounts, saveOrder
│ └── FakeWidgetRepository.kt → in-memory implementation for previews/testing
│
├── domain/
│ ├── Widget.kt → Widget, WidgetType, WidgetOrderUpdate
│ ├── AccountPage.kt → id, name, currency, balance
│ └── StableList.kt → @Immutable wrapper around List<T>
│
├── ui/
│ ├── theme/
│ │ └── AnalysisTheme.kt → Material3 theme wrapper
│ │
│ ├── screen/
│ │ ├── AnalysisScreen.kt → top-level: total balance + open sheet button
│ │ ├── AnalysisSheetContent.kt → header + grid + bottom CTA + peek menu
│ │ └── AnalysisViewModel.kt → MutableStateFlow<AnalysisUiState>
│ │
│ ├── components/
│ │ ├── SectionHeader.kt → "Section header" + Expand/Tune/Close
│ │ ├── WidgetGrid.kt → 2-column staggered grid + reorder + wiggle
│ │ ├── WidgetCard.kt → routes by WidgetType to body composables
│ │ ├── AccountsTile.kt → HorizontalPager card stack with dot indicator
│ │ ├── AccountsPeekMenu.kt → Popup with custom PositionProvider
│ │ ├── PlaceholderTile.kt → default tile body
│ │ └── BottomActionBar.kt → Customize ↔ Save Changes (with progress)
│ │
│ ├── reorder/ → ~170 lines, three files
│ │ ├── ReorderState.kt → SWAP-ON-DROP state holder
│ │ ├── ReorderableItem.kt → wrapper providing isDragging + translation
│ │ └── DragHandle.kt → longPressDragHandle Modifier extension
│ │
│ └── util/
│ └── Haptics.kt → SDK-version-aware one-shot vibrator helper
The Core Idea — Three Independent Subsystems
The whole feature decomposes into three subsystems that don’t know about each other:
1. STATE MANAGEMENT (AnalysisViewModel)
Owns: widgets list, accounts list, isExpanded, isEditing, isSaving
Knows nothing about: drag gestures, peek menus, animations
API surface: enterEditMode(), cancelEdit(), swapWidgets(from, to), saveChanges()
2. REORDER ENGINE (ui/reorder/)
Owns: which tile is being dragged, drag offset, which tile is hovered
Knows nothing about: ViewModel, Widget type, what tiles look like
API surface: rememberReorderState, ReorderableItem wrapper, longPressDragHandle modifier
Communication: calls onSwap(from, to) when the user drops the tile
3. UI COMPOSITION (screen/, components/)
Owns: layout, animations, visual state of edit mode
Pulls state from: ViewModel
Pulls drag info from: ReorderState
Knows: how to map (1) and (2) onto pixels
The reorder engine is COMPLETELY decoupled from the ViewModel. You could drop it into any other Compose project that uses LazyVerticalStaggeredGrid and it would work — just wire up the onSwap callback to whatever moves items in your data source.
Part 2: The Domain — StableList Without External Libraries
The Problem We’re Solving
Compose’s recomposition skipping relies on detecting whether parameters changed. For a List<T> parameter, Compose can't prove the list won't be mutated — it has to assume it might. So every time the parent recomposes, every child that takes a List recomposes too, even if the items are identical. This is why kotlinx.collections.immutable exists — its ImmutableList is annotated @Stable so Compose KNOWS it can't change.
But pulling in kotlinx-collections-immutable adds a dependency for a 5-line wrapper. Here's that wrapper:
// domain/StableList.kt
package com.example.analysis.domain
import androidx.compose.runtime.Immutable
/**
* A read-only wrapper around `List<T>` annotated `@Immutable` so Compose treats
* it as stable. Equivalent to `ImmutableList<T>` from kotlinx-collections-immutable
* for our recomposition-skipping needs, with zero external dependencies.
*/
@Immutable
data class StableList<T>(val items: List<T>) {
val size: Int get() = items.size
fun isEmpty(): Boolean = items.isEmpty()
operator fun get(index: Int): T = items[index]
inline fun forEach(action: (T) -> Unit) = items.forEach(action)
}
fun <T> List<T>.toStableList(): StableList<T> = StableList(this)
fun <T> emptyStableList(): StableList<T> = StableList(emptyList())
Why This Works
@Immutable is a contract you make with the Compose compiler: "I promise this object's contents will never change." Compose then enables aggressive skipping — if StableList<Widget> is the same instance as last frame, Compose skips recomposition of children that take it. To "modify" the list, you create a NEW StableList instance — the reference changes, Compose detects the change, recomposition happens.
The state list is passed throughout the app via StableList<Widget> and StableList<AccountPage>. Every grid render that doesn't actually change the data skips entirely.
Domain Models
// domain/Widget.kt
package com.example.analysis.domain
import androidx.compose.runtime.Immutable
@Immutable
data class Widget(
val id: WidgetType,
val title: String,
val order: Int
)
enum class WidgetType(val defaultSpan: Int) {
ACCOUNTS(2), // Tall - 2 row units
SPEND_INSIGHTS(2),
NET_WORTH(1), // Short - 1 row unit
GOALS(1),
TRANSACTIONS(2),
CARDS(1),
}
@Immutable
data class WidgetOrderUpdate(val widgetId: String, val order: Int)
// domain/AccountPage.kt
@Immutable
data class AccountPage(
val id: String,
val name: String,
val currency: String, // "KD", "USD"
val balance: String // "9,412"
)
defaultSpan controls how tall a tile is in row units. The grid multiplies it by a GridRowUnit of 80.dp to compute pixel height. A defaultSpan = 2 tile is 160dp tall, a defaultSpan = 1 tile is 80dp tall. This is what gives the staggered grid its mosaic appearance — different tiles have different heights, but they're all multiples of the same atomic unit.
Part 3: The Reorder Engine — ReorderState
This is the heart of the feature. Let’s look at it in detail.
// ui/reorder/ReorderState.kt
package com.example.analysis.ui.reorder
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemInfo
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
class ReorderState internal constructor(
val gridState: LazyStaggeredGridState,
private val onSwap: (from: Int, to: Int) -> Unit
) {
var draggingItemKey by mutableStateOf<Any?>(null)
private set
/** Key of the tile currently under the finger, if any (for visual hint). */
var hoveredKey by mutableStateOf<Any?>(null)
private set
private var draggingItemIndex: Int = -1
private var dragOffset by mutableStateOf(Offset.Zero)
/** Visual translation to apply to the tile with [key]. Zero for non-dragged tiles. */
fun translationFor(key: Any): Offset =
if (key == draggingItemKey) dragOffset else Offset.Zero
internal fun onDragStart(key: Any) {
val info = gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == key }
draggingItemKey = key
draggingItemIndex = info?.index ?: -1
hoveredKey = null
dragOffset = Offset.Zero
}
internal fun onDrag(delta: Offset) {
dragOffset += delta
updateHovered()
}
internal fun onDragEnd(performSwap: Boolean) {
val from = draggingItemIndex
val targetKey = hoveredKey
val toIndex = if (targetKey != null) {
gridState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == targetKey }?.index ?: -1
} else -1
if (performSwap && from >= 0 && toIndex >= 0 && from != toIndex) {
onSwap(from, toIndex)
}
draggingItemKey = null
draggingItemIndex = -1
hoveredKey = null
dragOffset = Offset.Zero
}
private fun updateHovered() {
val key = draggingItemKey ?: return
val visible: List<LazyStaggeredGridItemInfo> = gridState.layoutInfo.visibleItemsInfo
val dragged = visible.firstOrNull { it.key == key } ?: return
// Bounds of the dragged tile in grid coordinates, after the drag offset.
val draggedLeft = dragged.offset.x + dragOffset.x
val draggedTop = dragged.offset.y + dragOffset.y
val draggedRight = draggedLeft + dragged.size.width
val draggedBottom = draggedTop + dragged.size.height
// Pick the tile with the LARGEST OVERLAP with the dragged tile's rect.
var bestKey: Any? = null
var bestOverlap = 0
for (other in visible) {
if (other.key == key) continue
val ix1 = maxOf(draggedLeft.toInt(), other.offset.x)
val iy1 = maxOf(draggedTop.toInt(), other.offset.y)
val ix2 = minOf(draggedRight.toInt(), other.offset.x + other.size.width)
val iy2 = minOf(draggedBottom.toInt(),other.offset.y + other.size.height)
val w = ix2 - ix1
val h = iy2 - iy1
if (w <= 0 || h <= 0) continue
val overlap = w * h
if (overlap > bestOverlap) {
bestOverlap = overlap
bestKey = other.key
}
}
// Only register a hover if the overlap is meaningful (>25% of dragged tile area).
val draggedArea = dragged.size.width * dragged.size.height
hoveredKey = if (bestOverlap * 4 >= draggedArea) bestKey else null
}
}
@Composable
fun rememberReorderState(
gridState: LazyStaggeredGridState,
onSwap: (from: Int, to: Int) -> Unit
): ReorderState {
val swapCallback = rememberUpdatedState(onSwap)
return remember(gridState) {
ReorderState(gridState) { from, to -> swapCallback.value(from, to) }
}
}
The Critical Architectural Decision: Swap-on-Drop, Not Insert-While-Dragging
Most reorder libraries use insert-while-dragging semantics:
- User drags tile A toward tile B
- Every time A crosses B’s centre, B slides over by one position
- When the user releases, A is INSERTED at B’s old position
- Every tile between A and B has shuffled by one slot
This is what androidx.compose.foundation.lazy.LazyListState.animateItem() is built for, and it works well for sorting one-dimensional lists (todo lists, music playlists). But for a 2D grid where the user wants to drag tile A onto tile B to make them swap places, it produces a chaotic visual — every tile between A and B shuffles around while you're still dragging.
This implementation does the opposite:
- During drag, we track
dragOffsetand visually translate ONLY the dragged tile - Other tiles stay exactly where they were
- We track which tile is
hoveredKey(currently under the finger) for a subtle visual hint - On drop, the ViewModel does a single swap between the dragged index and the hovered index
- Two tiles change places. Everything else is untouched.
Why Largest-Overlap Hit Testing?
A naive hit test uses the centre point of the dragged tile. That’s fine for tiles of similar size. But this is a staggered grid where tiles can be 80dp or 160dp tall. A small tile dragged over a tall tile — its centre point crosses tile boundaries every few pixels because the small tile is shorter than the gap between adjacent tile centres in the tall column. The hover target flickers between two tiles as the user drags.
The fix is largest-overlap hit testing: for every visible tile, compute the rectangular intersection between the dragged tile’s current bounds and the other tile’s bounds. The tile with the biggest overlap area wins. This perfectly matches user intuition — “what am I dropping onto” is always the tile with the most visual overlap.
The >25% of dragged tile area threshold prevents flickering when the dragged tile is barely grazing a neighbour during fast drags. Below that threshold, hoveredKey is null and no swap target is registered.
Why the Hit Test Is O(visible)
The loop iterates over gridState.layoutInfo.visibleItemsInfo — typically fewer than 20 items, even on a tablet. For each, we do 4 maxOf/minOf operations, 1 multiplication, 1 comparison. Zero allocations. It runs every frame during a drag (60+ times per second) and contributes nothing measurable to frame time.
The rememberUpdatedState Trick
Look at rememberReorderState carefully:
val swapCallback = rememberUpdatedState(onSwap)
return remember(gridState) {
ReorderState(gridState) { from, to -> swapCallback.value(from, to) }
}
remember(gridState) creates the ReorderState once and reuses it across recompositions. But the onSwap lambda passed in might be a NEW lambda on every recomposition (if the caller writes onMove = viewModel::swapWidgets, that's stable; if they write onMove = { from, to -> ... } inline, it's not).
Without rememberUpdatedState, the ReorderState would capture the FIRST onSwap lambda forever — even if the caller updates it later, the captured one would still be called. With rememberUpdatedState, we wrap it in a state-backed reference: swapCallback.value always returns the LATEST lambda the caller passed, while the ReorderState instance itself stays stable.
This is the canonical Compose idiom for “stable identity that captures the latest callback”.
Part 4: The Drag Modifier and Reorderable Wrapper
The Long-Press Drag Modifier
// ui/reorder/DragHandle.kt
package com.example.analysis.ui.reorder
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
fun Modifier.longPressDragHandle(
state: ReorderState,
key: Any,
onDragStarted: () -> Unit = {},
onDragStopped: () -> Unit = {}
): Modifier = pointerInput(state, key) {
detectDragGesturesAfterLongPress(
onDragStart = {
state.onDragStart(key)
onDragStarted()
},
onDragEnd = {
state.onDragEnd(performSwap = true) // Normal release → commit swap
onDragStopped()
},
onDragCancel = {
state.onDragEnd(performSwap = false) // Cancelled → no swap
},
onDrag = { change, delta ->
change.consume()
state.onDrag(delta)
}
)
}
Three things to notice:
1. pointerInput(state, key) — The keys mean "if state or key change, re-attach the gesture detector." state rarely changes (it's remembered against the grid state). key is the tile's identity (e.g., "ACCOUNTS"). This means each tile gets its own gesture detector instance bound to its own key.
2. detectDragGesturesAfterLongPress — This AOSP primitive waits for a long-press, THEN starts tracking drag. If the user just taps, it never fires. If they tap and immediately drag without holding, it never fires. Only after the long-press timeout (~500ms by default) does it transition into drag mode.
3. onDragEnd vs onDragCancel — Normal finger lift goes through onDragEnd and we commit the swap. Gesture cancellation (another finger touched the screen, the parent stole the gesture, etc.) goes through onDragCancel and we DON'T commit. This is critical — without it, edge cases produce phantom swaps.
The Reorderable Wrapper
// ui/reorder/ReorderableItem.kt
package com.example.analysis.ui.reorder
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
@Composable
fun LazyStaggeredGridItemScope.ReorderableItem(
state: ReorderState,
key: Any,
modifier: Modifier = Modifier,
content: @Composable (isDragging: Boolean) -> Unit
) {
val isDragging = state.draggingItemKey == key
val translation = state.translationFor(key)
val itemModifier = if (isDragging) {
// Apply drag translation. Skip animateItem so the dragged tile follows
// the finger frame-by-frame instead of animating to natural slots.
modifier.graphicsLayer {
translationX = translation.x
translationY = translation.y
}
} else {
// Smooth reflow when the underlying list reorders.
modifier.animateItem()
}
Box(modifier = itemModifier) {
content(isDragging)
}
}
This is a tiny but critical piece. It’s an extension function on LazyStaggeredGridItemScope — meaning it can ONLY be called from inside an items {} block of a staggered grid, which is where animateItem() is available.
The branching logic:
- If the tile is being dragged, apply a
graphicsLayertranslation matchingdragOffset. The tile follows the finger. We do NOT useanimateItem()here — we want frame-perfect tracking, not animation. - If the tile is NOT being dragged, apply
animateItem(). This is the AOSP helper that animates a tile into its new layout position when the underlying list reorders. With swap-on-drop, this only runs ONCE per drag — when the swap is committed and the two affected tiles need to glide into their new slots.
graphicsLayer { translationX = ... } is the right tool here because:
- It happens during the DRAW phase (no recomposition)
- It doesn’t trigger layout
- Reading the values inside the lambda creates a snapshot dependency on
translation, so whentranslationupdates, the draw layer re-renders WITHOUT recomposing the composable
This is why only the dragged tile recomposes per drag delta. Every other tile is reading state.translationFor(theirKey) which returns Offset.Zero (a stable reference for the equality check), so they don't recompose at all.
Part 5: The Widget Grid — Wiggle, Lift, Hover, Dim
// ui/components/WidgetGrid.kt
@Composable
fun WidgetGrid(
widgets: StableList<Widget>,
accounts: StableList<AccountPage>,
isEditing: Boolean,
peekedWidget: WidgetType?,
onMove: (from: Int, to: Int) -> Unit,
onLongPressAccounts: (anchor: Rect) -> Unit,
modifier: Modifier = Modifier
) {
val lazyGridState = rememberLazyStaggeredGridState()
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val reorderState = rememberReorderState(lazyGridState, onMove)
// ONE shared infinite transition drives the wiggle for EVERY tile in edit mode.
val wiggle = rememberInfiniteTransition(label = "wiggle")
val wiggleAngle by wiggle.animateFloat(
initialValue = -0.6f,
targetValue = 0.6f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 180),
repeatMode = RepeatMode.Reverse
),
label = "wiggle-angle"
)
LazyVerticalStaggeredGrid(
state = lazyGridState,
columns = StaggeredGridCells.Fixed(2),
verticalItemSpacing = 12.dp,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp)
) {
itemsIndexed(
items = widgets.items,
key = { _, w -> w.id.name }, // STABLE identity across moves
contentType = { _, _ -> "widget-tile" } // Enables Lazy* recycling
) { _, widget ->
ReorderableItem(reorderState, key = widget.id.name) { isDragging ->
val isPeeked = peekedWidget == widget.id
val isHovered = !isDragging && reorderState.hoveredKey == widget.id.name
val phase = remember(widget.id) { widget.id.ordinal % 2 }
val rotation = if (isEditing && !isDragging) {
if (phase == 0) wiggleAngle else -wiggleAngle // Alternate phase
} else 0f
val dimAlpha by animateFloatAsState(
targetValue = when {
peekedWidget != null && !isPeeked -> 0.35f
isDragging -> 0.75f
else -> 1f
},
label = "dim"
)
val liftScale by animateFloatAsState(
targetValue = when {
isDragging -> 1.05f
isPeeked -> 1.02f
isHovered -> 1.03f // Subtle bump on the swap target
else -> 1f
},
label = "lift-scale"
)
val tileHeight = GridRowUnit * widget.id.defaultSpan
var lastBounds by remember { mutableStateOf(Rect.Zero) }
val tileModifier = Modifier
.fillMaxWidth()
.height(tileHeight)
.onGloballyPositioned { coords ->
lastBounds = coords.boundsInWindow() // For peek menu anchor
}
.graphicsLayer {
rotationZ = rotation
scaleX = liftScale
scaleY = liftScale
if (isDragging) {
shadowElevation = 16.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = false
}
}
.alpha(dimAlpha)
.then(
when {
isEditing -> Modifier.longPressDragHandle(
state = reorderState,
key = widget.id.name,
onDragStopped = {
Haptics.oneShot(context, Haptics.TICK_MS)
}
)
widget.id == WidgetType.ACCOUNTS -> Modifier.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPressAccounts(lastBounds)
}
)
}
else -> Modifier
}
)
WidgetCard(
widget = widget,
accounts = accounts,
isEditing = isEditing,
modifier = tileModifier
)
}
}
}
}
There’s a lot going on. Let me break down each piece.
One Shared InfiniteTransition for the Wiggle
The wiggle animation in iOS rotates each app icon back and forth by ~0.6 degrees on a 180ms cycle. The naive Compose implementation creates an InfiniteTransition per tile — for a 6-tile grid that's 6 separate animation timelines, 6 separate state subscriptions, and 6×60fps = 360 recompositions per second.
The right way: ONE rememberInfiniteTransition at the grid level. Every tile reads the same wiggleAngle value. Total: 1 animation timeline, 60fps recomposition of the items that read it.
But every tile rotating the same direction at the same time looks robotic. The fix is the phase variable:
val phase = remember(widget.id) { widget.id.ordinal % 2 }
val rotation = if (isEditing && !isDragging) {
if (phase == 0) wiggleAngle else -wiggleAngle
} else 0f
Even-indexed widgets rotate one direction; odd-indexed widgets rotate the other. The result looks organic — adjacent tiles wiggle in opposite directions, breaking the visual sync.
!isDragging in the conditional ensures the dragged tile doesn't wiggle while you're moving it. Wiggle is for the surrounding tiles; the dragged tile gets the lift/scale/shadow treatment.
The Visual State Animations
Three independent animations drive the visual feedback:
**dimAlpha** — When a peek menu is open, the non-peeked tiles dim to 35% opacity. When a tile is being dragged, it dims to 75%. Otherwise full opacity. animateFloatAsState smoothly interpolates between values when the state changes.
**liftScale** — Subtle scale effects: 1.05× when dragging, 1.03× when hovered (you're about to drop on this one), 1.02× when peeked, 1.0× normally. This is the "this tile is the focus" feedback that helps the user understand what's happening.
**rotationZ (the wiggle)** — Already covered above.
All three are applied via graphicsLayer, which means they update during the draw phase without triggering recomposition. The single graphicsLayer block on tileModifier reads rotation, liftScale, and isDragging — when any of them changes, the draw layer updates without re-running the composable.
onGloballyPositioned for the Peek Menu Anchor
var lastBounds by remember { mutableStateOf(Rect.Zero) }
val tileModifier = Modifier
.fillMaxWidth()
.height(tileHeight)
.onGloballyPositioned { coords ->
lastBounds = coords.boundsInWindow()
}
Every time the tile is laid out (first composition, scroll, parent reflow), we capture its boundsInWindow() — its rectangular position in the window's coordinate space. We store it in a regular mutableStateOf because we'll need it later when the user long-presses.
boundsInWindow() is the right coordinate system for Popup because Popup's PopupPositionProvider operates in window coordinates. If we used boundsInRoot() instead, the menu would be misplaced by the status bar height.
Conditional Gesture Modifiers
The most interesting Modifier chain in the codebase:
.then(
when {
isEditing -> Modifier.longPressDragHandle(
state = reorderState,
key = widget.id.name,
onDragStopped = { Haptics.oneShot(context, Haptics.TICK_MS) }
)
widget.id == WidgetType.ACCOUNTS -> Modifier.pointerInput(Unit) {
detectTapGestures(onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPressAccounts(lastBounds)
})
}
else -> Modifier
}
)
Three states for a tile’s gesture handling:
- In edit mode, attach
longPressDragHandle→ tile is draggable - Not in edit mode AND it’s the Accounts tile, attach a long-press detector for the peek menu
- Otherwise, no gestures attached
The Modifier.then(...) is the standard way to conditionally apply modifiers. Each branch returns either a real modifier or Modifier (which is effectively a no-op).
Two Different Haptic Sources
Notice that the drag-stop haptic uses Haptics.oneShot(context, Haptics.TICK_MS) (raw vibrator) while the peek-menu trigger uses haptic.performHapticFeedback(HapticFeedbackType.LongPress) (Compose's LocalHapticFeedback).
Why two paths?
LocalHapticFeedbackis a high-level Compose abstraction. It picks the right system effect for the semantic meaning.LongPressis louder and more emphatic — perfect for "I just opened a menu".Haptics.oneShotis a 30ms platform-vibrator pulse — a precise short tick that confirms a drop without competing with system sounds. The kind of "click" feedback you want for confirming an action.
Part 6: The Peek Menu — Anchored Popup with Custom PositionProvider
// ui/components/AccountsPeekMenu.kt
@Composable
fun AccountsPeekMenu(
visible: Boolean,
anchorBounds: Rect,
accounts: StableList<AccountPage>,
onDismiss: () -> Unit,
onSelect: (AccountPage) -> Unit
) {
if (!visible) return
val provider = remember(anchorBounds) { TileAnchorPositionProvider(anchorBounds) }
Popup(
popupPositionProvider = provider,
onDismissRequest = onDismiss,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
AnimatedVisibility(
visible = true,
enter = scaleIn(initialScale = 0.92f) + fadeIn(),
exit = scaleOut(targetScale = 0.92f) + fadeOut()
) {
Surface(
shape = RoundedCornerShape(16.dp),
tonalElevation = 8.dp,
shadowElevation = 12.dp,
color = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier.padding(vertical = 8.dp).width(220.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
accounts.forEach { account ->
AccountRow(account = account, onClick = { onSelect(account) })
}
}
}
}
}
}
private class TileAnchorPositionProvider(
private val anchorWindow: Rect,
private val gapPx: Int = 8
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val rawX = anchorWindow.left.toInt()
val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0)
val x = rawX.coerceIn(0, maxX)
val below = anchorWindow.bottom.toInt() + gapPx
val fitsBelow = below + popupContentSize.height <= windowSize.height
val y = if (fitsBelow) {
below
} else {
(anchorWindow.top.toInt() - popupContentSize.height - gapPx).coerceAtLeast(0)
}
return IntOffset(x, y)
}
}
Why a Custom PopupPositionProvider?
Compose’s Popup is unanchored by default — it positions itself in the centre of the screen. DropdownMenu (the Material version) anchors to its parent composable, but only crudely (offset from parent's top-left). For a precise "appear directly below this specific tile" behaviour, you need a custom PopupPositionProvider.
The provider gets four parameters:
anchorBounds: IntRect— the parent's bounds (we ignore this and use our capturedanchorWindowinstead)windowSize: IntSize— the full window sizelayoutDirection: LayoutDirection— for RTL handlingpopupContentSize: IntSize— the actual measured size of the popup AFTER its content has been laid out
The logic:
- Horizontal: align with the left edge of the tile (
anchorWindow.left), but clamp so the popup doesn't extend past the right edge of the window. - Vertical: try to position 8px below the bottom of the tile. If the popup would extend past the bottom of the window, flip to ABOVE the tile (8px above the top of the tile, clamped at 0 so it doesn’t go off-screen).
This gives you the iOS-style “menu appears below the element, but flips above if there’s no room” behavior with about 12 lines of code.
Why remember(anchorBounds)?
val provider = remember(anchorBounds) { TileAnchorPositionProvider(anchorBounds) }
We could create a new TileAnchorPositionProvider on every recomposition, but that would cause the popup to re-position on every recomposition (since Popup uses provider identity to decide whether to recalculate). Keying it on anchorBounds means we only create a new provider when the anchor actually moves — which is exactly when we want the popup to recalculate its position.
PopupProperties — Accessibility and Dismissal
properties = PopupProperties(
focusable = true, // Capture keyboard focus → ESC works on tablets
dismissOnBackPress = true, // System back button dismisses
dismissOnClickOutside = true // Tapping outside dismisses
)
These three flags give you the standard “modal-like” popup behavior. The user can dismiss with back press, by tapping outside, or by selecting an item. All paths converge on onDismissRequest = onDismiss, which sets peekedWidget = null in the parent.
The Animation
AnimatedVisibility(
visible = true,
enter = scaleIn(initialScale = 0.92f) + fadeIn(),
exit = scaleOut(targetScale = 0.92f) + fadeOut()
) { ... }
Note that visible = true (always true) inside AnimatedVisibility looks weird, but it's correct here: the Popup itself controls VISIBILITY (we early-return if (!visible) return), and AnimatedVisibility only handles the enter/exit transition. When the popup first composes, AnimatedVisibility runs the enter transition (scale up from 0.92× and fade in). When the popup is removed, the exit transition runs.
The 0.92× starting scale is subtle — barely visible but enough to feel like the menu is “popping out” of the tile rather than appearing instantly.
Part 7: The ViewModel — Snapshot-Based Edit Mode
// ui/screen/AnalysisViewModel.kt
@Immutable
data class AnalysisUiState(
val widgets: StableList<Widget> = emptyStableList(),
val accounts: StableList<AccountPage> = emptyStableList(),
val isExpanded: Boolean = false,
val isEditing: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null
)
class AnalysisViewModel(
private val repo: WidgetRepository
) : ViewModel() {
private val _state = MutableStateFlow(AnalysisUiState())
val state: StateFlow<AnalysisUiState> = _state.asStateFlow()
/** Snapshot taken when entering edit mode so Cancel can revert in O(1). */
private var snapshotBeforeEdit: List<Widget>? = null
init {
viewModelScope.launch {
val widgets = repo.loadWidgets().toStableList()
val accounts = repo.loadAccounts().toStableList()
_state.update { it.copy(widgets = widgets, accounts = accounts) }
}
}
fun toggleExpanded() = _state.update { it.copy(isExpanded = !it.isExpanded) }
fun enterEditMode() {
snapshotBeforeEdit = _state.value.widgets.items
_state.update { it.copy(isEditing = true, isExpanded = true) }
}
fun cancelEdit() {
snapshotBeforeEdit?.let { snap ->
_state.update { it.copy(widgets = snap.toStableList(), isEditing = false) }
}
snapshotBeforeEdit = null
}
fun swapWidgets(fromIndex: Int, toIndex: Int) {
if (fromIndex == toIndex) return
val list = _state.value.widgets.items.toMutableList()
if (fromIndex !in list.indices || toIndex !in list.indices) return
val a = list[fromIndex]
val b = list[toIndex]
list[fromIndex] = b
list[toIndex] = a
// Re-stamp `order` so the data matches the new visual position.
val reindexed = list.mapIndexed { i, w -> w.copy(order = i) }
_state.update { it.copy(widgets = reindexed.toStableList()) }
}
fun saveChanges() {
val current = _state.value.widgets.items
_state.update { it.copy(isSaving = true) }
viewModelScope.launch {
val payload = current.map { WidgetOrderUpdate(it.id.name, it.order) }
val result = repo.saveOrder(payload)
_state.update {
if (result.isSuccess) {
it.copy(isSaving = false, isEditing = false, error = null)
} else {
it.copy(isSaving = false, error = result.exceptionOrNull()?.message)
}
}
if (result.isSuccess) snapshotBeforeEdit = null
}
}
class Factory(private val repo: WidgetRepository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
AnalysisViewModel(repo) as T
}
}
The Snapshot Pattern for O(1) Revert
When the user taps “Customize” and enters edit mode, we save a reference to the CURRENT widget list:
fun enterEditMode() {
snapshotBeforeEdit = _state.value.widgets.items
_state.update { it.copy(isEditing = true, isExpanded = true) }
}
Because Kotlin’s List<T> returned from a @Immutable data class is effectively immutable (we never mutate it in place — we always copy() to produce a new instance), this is a constant-time operation. We're just saving a reference. No deep copy needed.
When the user taps “Cancel” or back-presses, we restore the reference:
fun cancelEdit() {
snapshotBeforeEdit?.let { snap ->
_state.update { it.copy(widgets = snap.toStableList(), isEditing = false) }
}
snapshotBeforeEdit = null
}
Wrap the snapshot in a fresh StableList, set isEditing = false, clear the snapshot field. Done. O(1) regardless of how many tiles the user moved during edit mode.
This works because we treat the in-memory list as immutable — swapWidgets never mutates the underlying list, it always creates a new one:
val list = _state.value.widgets.items.toMutableList() // Defensive copy
// ... modify the copy ...
val reindexed = list.mapIndexed { i, w -> w.copy(order = i) } // New list, new Widget instances
_state.update { it.copy(widgets = reindexed.toStableList()) } // New StableList
The original _state.value.widgets.items is never touched. Our snapshot reference still points to it. Pure functional pattern in OOP clothing.
The Re-Indexing Step
After every swap, we re-stamp the order field on every widget:
val reindexed = list.mapIndexed { i, w -> w.copy(order = i) }
This is critical for the save step. The user can swap tiles around any number of times. Each swap reorders the in-memory list. We don’t care what the original order values were — we just want them to be 0, 1, 2, 3, ... in current visual order so the server can persist the new arrangement.
This means saveChanges() is dead simple:
val payload = current.map { WidgetOrderUpdate(it.id.name, it.order) }
val result = repo.saveOrder(payload)
We send the ID and the current order to the server. No diff calculation, no “what changed” tracking. Just the current state.
Part 8: The Sheet Content — Composing It All Together
// ui/screen/AnalysisSheetContent.kt
@Composable
fun AnalysisSheetContent(
widgets: StableList<Widget>,
accounts: StableList<AccountPage>,
isExpanded: Boolean,
isEditing: Boolean,
isSaving: Boolean,
onToggleExpand: () -> Unit,
onClose: () -> Unit,
onMove: (Int, Int) -> Unit,
onCustomize: () -> Unit,
onSaveChanges: () -> Unit,
onCancelEdit: () -> Unit,
modifier: Modifier = Modifier
) {
var peekedWidget by remember { mutableStateOf<WidgetType?>(null) }
var peekAnchor by remember { mutableStateOf(Rect.Zero) }
// Back-press priorities (top-most enabled handler wins):
BackHandler(enabled = peekedWidget != null) { peekedWidget = null }
BackHandler(enabled = isEditing && peekedWidget == null) { onCancelEdit() }
Column(
modifier = modifier.fillMaxWidth().heightIn(min = 320.dp),
verticalArrangement = Arrangement.Top
) {
SectionHeader(
title = "Section header",
isExpanded = isExpanded || isEditing,
onToggleExpand = onToggleExpand,
onTune = { /* TODO */ },
onClose = onClose
)
// Show only first 4 tiles when collapsed; all when expanded or editing.
val visibleWidgets: StableList<Widget> =
remember(widgets, isExpanded, isEditing) {
if (isExpanded || isEditing) widgets
else widgets.items.take(4).toStableList()
}
Box(Modifier.weight(1f)) {
WidgetGrid(
widgets = visibleWidgets,
accounts = accounts,
isEditing = isEditing,
peekedWidget = peekedWidget,
onMove = onMove,
onLongPressAccounts = { bounds ->
peekAnchor = bounds
peekedWidget = WidgetType.ACCOUNTS
}
)
}
BottomActionBar(
isEditing = isEditing,
isSaving = isSaving,
onCustomize = onCustomize,
onSaveChanges = onSaveChanges,
modifier = Modifier.padding(top = 8.dp)
)
}
AccountsPeekMenu(
visible = peekedWidget == WidgetType.ACCOUNTS,
anchorBounds = peekAnchor,
accounts = accounts,
onDismiss = { peekedWidget = null },
onSelect = { peekedWidget = null /* TODO: navigate */ }
)
}
Two BackHandlers with Priorities
BackHandler(enabled = peekedWidget != null) { peekedWidget = null }
BackHandler(enabled = isEditing && peekedWidget == null) { onCancelEdit() }
Compose’s BackHandler is a stack — the most recently composed enabled handler wins. Both are composed at the same level. The enabled parameter is what makes this work:
- If
peekedWidget != null(peek menu is open) → first handler is enabled → back press dismisses the peek - Otherwise, if
isEditing→ second handler is enabled → back press cancels edit mode - Otherwise, neither is enabled → back press falls through to the bottom sheet’s default handler → sheet closes
This gives you the iOS-like “back button does the right thing depending on state” behavior. No giant if-else ladder, no manual state machine — just compose them in priority order with mutually-exclusive enabled conditions.
The remember(widgets, isExpanded, isEditing) Optimization
val visibleWidgets: StableList<Widget> =
remember(widgets, isExpanded, isEditing) {
if (isExpanded || isEditing) widgets
else widgets.items.take(4).toStableList()
}
The take(4).toStableList() allocates a new list on every recomposition without remember. With remember(widgets, isExpanded, isEditing), we only create a new list when one of those three keys changes. This matters because the grid uses StableList identity to skip recomposition — if we kept allocating new StableList instances of the same content, we'd defeat the stability guarantee.
Peek State Lives in the View Layer
peekedWidget and peekAnchor are not in the ViewModel. They're pure UI concerns — they describe "what's on screen right now", not "what's the data". If the user rotates the device while the peek is open, we lose the peek (which is correct — the anchor coordinates are no longer valid). The ViewModel state survives rotation; the peek state doesn't.
This is the right boundary. Don’t pollute your ViewModel with transient UI state.
Part 9: The MainActivity and Welcome Haptic
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AnalysisTheme {
Surface(modifier = Modifier.fillMaxSize()) {
HapticOnEnter()
AnalysisScreen()
}
}
}
}
}
@Composable
private fun HapticOnEnter() {
val context = LocalContext.current
LaunchedEffect(Unit) {
Haptics.oneShot(context, Haptics.WELCOME_MS)
}
}
LaunchedEffect(Unit) runs the coroutine ONCE, when the composable first enters composition. The Unit key never changes, so the effect never restarts. This is the canonical Compose pattern for "fire once on first appearance" — like viewDidLoad in iOS or the init {} block of a class.
A 40ms vibration is barely perceptible — a soft thump that confirms the screen has loaded. It’s the kind of polish that distinguishes an app from a website.
The Centralised Haptics Object
object Haptics {
const val TICK_MS: Long = 30L
const val WELCOME_MS: Long = 40L
fun oneShot(context: Context, millis: Long) {
val v = vibrator(context) ?: return
if (!v.hasVibrator()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(VibrationEffect.createOneShot(millis, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
v.vibrate(millis)
}
}
private fun vibrator(context: Context): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE)
as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
}
Three SDK paths consolidated into one helper:
- API 31+ uses
VibratorManager.defaultVibrator - API 26–30 uses
VibratorwithVibrationEffect - API 21–25 uses the old deprecated
vibrate(millis)
By centralising this, every caller (the welcome haptic, the drop tick) goes through the same code path. If we later need to add support for VibrationEffect.Composition (richer haptics on Pixel devices), there's one place to change.
Part 10: Why This Architecture Wins
✅ ZERO EXTERNAL LIBRARIES
Pure AOSP Compose Foundation. ~170 lines of reorder code.
No org.burnoutcrew, no kotlinx-collections-immutable, no haptic library.
✅ SWAP-ON-DROP MATCHES USER INTUITION
"Drag A onto B" produces a single swap, not a cascade of shifted tiles.
✅ O(1) CANCEL EDIT
Snapshot reference is constant-time. Works even if user moved 100 tiles.
✅ LARGEST-OVERLAP HIT TEST
No flicker on small-tile-over-tall-tile drags. Matches "what am I dropping onto?"
✅ ONE SHARED INFINITETRANSITION FOR WIGGLE
Per-tile InfiniteTransition would create N timelines for N tiles.
This creates ONE timeline. Adjacent tiles get opposite phase for organic look.
✅ ANCHORED PEEK MENU WITH FLIP
Custom PopupPositionProvider - anchors to exact tile bounds, flips above
if it would clip the bottom edge.
✅ ONLY DRAGGED TILE RECOMPOSES PER FRAME
graphicsLayer reads dragOffset → draw layer updates without recomposition.
60fps stays 60fps regardless of grid size.
✅ STABLELIST = IMMUTABLELIST WITHOUT THE DEPENDENCY
@Immutable wrapper around List<T>. Same recomposition-skipping benefit.
✅ TWO BACKHANDLERS WITH MUTUALLY-EXCLUSIVE enabled
No state machine. Compose stack handles priority for free.
✅ HAPTIC FEEDBACK LAYERS
Soft welcome thump → strong long-press → precise drop tick.
Each communicates a different action class.
✅ DECOUPLED REORDER ENGINE
Drop ui/reorder/ into any project. Wire onSwap. Done.
Connect with Me on LinkedIn
Follow me on LinkedIn
Tags: #JetpackCompose #Android #Kotlin #DragAndDrop #StaggeredGrid #CustomGestures #Animation #UI #Architecture #NoLibraries
메타데이터
- post_id
- 6817bb8f511e
- slug
- drag-to-reorder-with-wiggle-animation-building-a-customizable-widget-grid-in-jetpack-compose-like-6817bb8f511e
- url
- https://medium.com/@ramadan123sayed/drag-to-reorder-with-wiggle-animation-building-a-customizable-widget-grid-in-jetpack-compose-like-6817bb8f511e
- canonical_url
- https://medium.com/@ramadan123sayed/drag-to-reorder-with-wiggle-animation-building-a-customizable-widget-grid-in-jetpack-compose-like-6817bb8f511e
- author_url
- https://medium.com/@ramadan123sayed
- status
- ok
- fetched_at
- 2026-08-10 06:16:44