What You Will Build

A list of items that can be reordered by long-pressing and dragging. The dragged item elevates and scales up slightly for visual feedback, and items swap positions in real time as you drag over them. This is the pattern used in to-do apps, playlist editors, and settings screens.

Why This Pattern Matters

Drag-to-reorder is a fundamental mobile interaction. Implementing it from scratch teaches you detectDragGesturesAfterLongPress, offset-based position tracking, and animated elevation changes during gesture state transitions.

The Drag and Drop List

@Composable
fun DragDropScreen() {
    val items = remember {
        mutableStateListOf(
            "Apples", "Bananas", "Cherries", "Dates",
            "Elderberries", "Figs", "Grapes", "Honeydew"
        )
    }
    var draggedIndex by remember { mutableIntStateOf(-1) }
    var dragOffsetY by remember { mutableFloatStateOf(0f) }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text("Long press and drag to reorder",
            color = Color.Gray, fontSize = 14.sp)
        Spacer(modifier = Modifier.height(12.dp))

        items.forEachIndexed { index, item ->
            val isDragging = index == draggedIndex
            val elevation by animateDpAsState(
                if (isDragging) 12.dp else 2.dp, label = "elev"
            )
            val scale by animateFloatAsState(
                if (isDragging) 1.05f else 1f, label = "scale"
            )

            Surface(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(vertical = 4.dp)
                    .zIndex(if (isDragging) 1f else 0f)
                    .offset {
                        IntOffset(0,
                            if (isDragging) dragOffsetY.roundToInt() else 0)
                    }
                    .scale(scale)
                    .shadow(elevation, RoundedCornerShape(12.dp))
                    .pointerInput(Unit) {
                        detectDragGesturesAfterLongPress(
                            onDragStart = { draggedIndex = index },
                            onDrag = { change, offset ->
                                change.consume()
                                dragOffsetY += offset.y
                                val targetIndex =
                                    (draggedIndex + (dragOffsetY / 72f)
                                        .roundToInt())
                                        .coerceIn(0, items.size - 1)
                                if (targetIndex != draggedIndex) {
                                    val movedItem =
                                        items.removeAt(draggedIndex)
                                    items.add(targetIndex, movedItem)
                                    draggedIndex = targetIndex
                                    dragOffsetY = 0f
                                }
                            },
                            onDragEnd = {
                                draggedIndex = -1
                                dragOffsetY = 0f
                            },
                            onDragCancel = {
                                draggedIndex = -1
                                dragOffsetY = 0f
                            }
                        )
                    },
                shape = RoundedCornerShape(12.dp),
                tonalElevation = 2.dp
            ) {
                Row(
                    modifier = Modifier.fillMaxWidth().padding(16.dp),
                    verticalAlignment = Alignment.CenterVertically
                ) {
                    Text("\u2630", fontSize = 20.sp, color = Color.Gray)
                    Spacer(modifier = Modifier.width(16.dp))
                    Text(item, fontSize = 16.sp,
                        fontWeight = FontWeight.Medium)
                }
            }
        }
    }
}

Tips and Pitfalls

  • 72f divisor in the swap calculation should approximate item height in pixels. Adjust to match your actual row height for accurate targeting.
  • Reset dragOffsetY to 0f after each swap so the offset recalculates from the new position.
  • Always call change.consume() to prevent the drag from propagating to parent scrollables.
  • zIndex(1f) on the dragged item ensures it renders above sibling items during the drag.

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.