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.

6 min read
androidcomposekotlingestures

Day 77 — Drag-and-drop crosses app boundaries

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.

Cross-app drag carries ClipData through the platform; list reordering is internal state

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.