Drag-and-drop crosses app boundaries, which is why it isn't just a drag gesture
Compose's drag-and-drop moves ClipData between sources and targets, including across apps. Reordering a list is a different problem with a different solution, and conflating them is why reorder implementations get complicated.

Day 77 of 100, closing the gestures pillar. Two problems share a name, and separating them is most of the work.
The symptom
A reorderable list built as a drag gesture:
var draggedIndex by remember { mutableStateOf<Int?>(null) }
var dragOffset by remember { mutableFloatStateOf(0f) }
LazyColumn {
itemsIndexed(items) { index, item ->
Row(
Modifier
.offset { IntOffset(0, if (index == draggedIndex) dragOffset.roundToInt() else 0) }
.pointerInput(item.id) {
detectDragGesturesAfterLongPress(
onDragStart = { draggedIndex = index },
onDrag = { _, delta -> dragOffset += delta.y },
onDragEnd = { /* work out the target index… */ },
)
}
) { ItemContent(item) }
}
}
Then the list needs to scroll while dragging near an edge, items need to shift out of the
way, the target index has to be computed from offsets and item heights, and the whole
thing has to survive LazyColumn disposing the dragged item when it scrolls off screen.
Reaching for dragAndDrop at this point doesn't help — and understanding why is the
useful part.
Why the obvious approach fails
The obvious approach is to assume Compose's drag-and-drop API is for this. It isn't.
dragAndDropSource / dragAndDropTarget implement the platform drag-and-drop
protocol: a drag that carries ClipData, renders a system-drawn shadow, and can be
dropped into a different application. It's built for dragging a photo from your gallery
into a chat app in split screen.
Reordering a list is an entirely internal problem — no data leaves the process, no other
app is involved, and the interesting behaviour is the animation of items moving aside.
Using the cross-process API for it means serialising data to ClipData for a drop target
in the same composable.
Two problems, two solutions.

The actual mechanism: cross-app drag
The source declares what it carries:
Modifier.dragAndDropSource {
detectTapGestures(
onLongPress = {
startTransfer(
DragAndDropTransferData(
ClipData.newPlainText("label", item.text),
flags = View.DRAG_FLAG_GLOBAL, // allow other apps
)
)
}
)
}
The target declares what it accepts:
val callback = remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
val text = event.toAndroidDragEvent().clipData?.getItemAt(0)?.text
return text?.let { onTextDropped(it.toString()); true } ?: false
}
override fun onEntered(event: DragAndDropEvent) { highlighted = true }
override fun onExited(event: DragAndDropEvent) { highlighted = false }
}
}
Modifier.dragAndDropTarget(
shouldStartDragAndDrop = { event ->
event.mimeTypes().contains(ClipDescription.MIMETYPE_TEXT_PLAIN)
},
target = callback,
)
Three details that matter:
DRAG_FLAG_GLOBAL is what makes it cross-app. Without it the drag stays inside your
process, which is correct for an internal drag that still wants the platform's shadow
rendering.
shouldStartDragAndDrop filters by MIME type, so a target that accepts images doesn't
light up for a text drag. Answering true for everything and rejecting in onDrop gives
the user misleading feedback during the drag.
onEntered/onExited are your only chance to show a drop affordance. A target that
doesn't visibly react is one users won't believe accepts the drop.
Receiving drops you didn't ask for
The most valuable use of this API is often the target side alone: accepting an image dragged from another app into your composer, or text dropped onto a search field.
TextField already accepts text drops. For anything else, adding a target is a few lines
and makes your app a good citizen in split-screen and desktop windowing — Day 34's
context, where drag-and-drop stops being exotic and becomes an expected interaction.
The permission detail worth knowing for images: a Uri arriving from another app is not
readable by default. DRAG_FLAG_GLOBAL_URI_READ on the source side grants it, and on the
target side you request it from the event:
override fun onDrop(event: DragAndDropEvent): Boolean {
val permission = activity.requestDragAndDropPermissions(event.toAndroidDragEvent())
val uri = event.toAndroidDragEvent().clipData?.getItemAt(0)?.uri ?: return false
return runCatching { importImage(uri) }.isSuccess.also { permission?.release() }
}
A drop that works for text and silently fails for images is almost always this — the
Uri arrives fine and reading it throws a SecurityException that gets swallowed.
Reordering, done properly
The list case has its own answer, and the load-bearing piece is Day 64's
Modifier.animateItem():
LazyColumn {
items(items, key = { it.id }) { item ->
ReorderableRow(
item = item,
modifier = Modifier.animateItem(), // items move aside, animated
onMove = { from, to -> viewModel.move(from, to) },
)
}
}
Because the list is keyed, moving an item in the underlying list is enough — Compose
animates every other item to its new position automatically. The drag handler's job
shrinks to: track which item is held, work out which index the pointer is over, and call
move.
That's still real work, and it's the reason the community libraries for this exist. The point is that the hard part is index calculation plus auto-scroll, not the drag — and the platform drag-and-drop API helps with neither.
Three things to get right if you write it yourself: the dragged item needs zIndex so it
draws above its neighbours, the list needs to auto-scroll when the pointer nears an edge,
and the reorder must be committed to your state rather than to a local offset — otherwise
LazyColumn disposing the row mid-drag loses it.
The accessibility obligation
Day 67's rule applies with force here: a reorder that only works by dragging is unreachable for anyone who can't drag.
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Move up") { viewModel.move(index, index - 1); true },
CustomAccessibilityAction("Move down") { viewModel.move(index, index + 1); true },
)
}
Two custom actions, and the feature becomes usable by everyone. This is the single most skipped accessibility addition in list UIs, and it's four lines.
How to prove it
For cross-app drag, split screen is the test: put your app beside a notes app and drag
text both ways. A target that never highlights has a shouldStartDragAndDrop rejecting
the MIME type; a drop that silently fails is usually onDrop returning false.
For reordering, the checks are: does the list auto-scroll near the edges, do the other items animate aside, does the order survive a rotation, and can the reorder be performed with TalkBack? The last one fails in most implementations. The rotation one is worth stressing: a reorder held in a local offset rather than committed to the ViewModel looks correct until the first configuration change loses it.
What this generalizes to
The closing point of the gestures pillar: the same physical gesture can mean different things, and the right API depends on the meaning rather than the motion. A finger moving across the screen might be a scroll, a swipe-to-dismiss, a pan, a reorder, or a transfer of data to another process — identical input, five different mechanisms.
That's been the shape of all seven days. clickable over detectTapGestures because a
button is more than a tap; anchors over offsets because a swipe is a choice; nested scroll
over consumption because a drag can be shared; and today, platform drag-and-drop over a
drag gesture because a transfer crosses a boundary. In each case the question that picks
the API is what the interaction means, and the gesture itself is the least informative
part of it.
Tomorrow, Day 78 opens the performance pillar.
Day 77 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Drag and drop.