Drag & Drop Kanban Board in Compose Desktop | Kotlin Desktop #10
Video: Drag & Drop Kanban Board in Compose Desktop | Kotlin Desktop #10 by Taught by Celeste AI - AI Coding Coach
Watch full page →Drag & Drop Kanban Board in Compose Desktop with Kotlin
Learn how to build an interactive Kanban board using Kotlin Compose Desktop that supports drag-and-drop functionality. This example demonstrates how to create draggable task cards and drop targets for three columns—To Do, In Progress, and Done—using Compose's dragAndDropSource and dragAndDropTarget modifiers along with reactive state management.
Code
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.ComposeWindow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberDialogState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.input.draganddrop.*
import androidx.compose.ui.text.input.TextFieldValue
enum class Column { TODO, IN_PROGRESS, DONE }
data class Task(val id: Int, val title: String)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TaskCard(
task: Task,
modifier: Modifier = Modifier,
onDragStart: () -> Unit = {}
) {
// Make the card draggable with long press gesture
Box(
modifier = modifier
.padding(8.dp)
.background(MaterialTheme.colorScheme.primaryContainer)
.dragAndDropSource(
data = DragAndDropTransferable(StringSelection(task.id.toString())),
onDragStarted = { onDragStart() }
)
.pointerInput(Unit) {
detectTapGestures(onLongPress = { /* Required to trigger drag */ })
}
.padding(16.dp)
) {
Text(task.title, style = MaterialTheme.typography.bodyLarge)
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun KanbanBoard() {
// State: map columns to list of tasks
val tasksByColumn = remember {
mutableStateMapOf(
Column.TODO to mutableStateListOf(
Task(1, "Write specs"),
Task(2, "Design UI")
),
Column.IN_PROGRESS to mutableStateListOf(
Task(3, "Implement drag & drop")
),
Column.DONE to mutableStateListOf(
Task(4, "Setup project")
)
)
}
var hoveredColumn by remember { mutableStateOf(null) }
var draggedTaskId by remember { mutableStateOf(null) }
var showDialog by remember { mutableStateOf(false) }
var newTaskTitle by remember { mutableStateOf(TextFieldValue("")) }
var newTaskColumn by remember { mutableStateOf(Column.TODO) }
fun moveTask(taskId: Int, toColumn: Column) {
tasksByColumn.forEach { (col, list) ->
val index = list.indexOfFirst { it.id == taskId }
if (index >= 0) {
val task = list.removeAt(index)
tasksByColumn[toColumn]?.add(task)
return
}
}
}
Row(Modifier.fillMaxSize().padding(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column.values().forEach { column ->
Column(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(
if (hoveredColumn == column) MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)
else MaterialTheme.colorScheme.surfaceVariant
)
.dragAndDropTarget(
onDrop = { transferable ->
val data = transferable.getData(StringSelection::class)
val id = data?.data?.toIntOrNull()
if (id != null) {
moveTask(id, column)
draggedTaskId = null
hoveredColumn = null
true
} else false
},
onEntered = {
hoveredColumn = column
true
},
onExited = {
if (hoveredColumn == column) hoveredColumn = null
true
}
)
.padding(8.dp)
) {
Text(column.name.replace("_", " "), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
LazyColumn {
items(tasksByColumn[column] ?: emptyList(), key = { it.id }) { task ->
TaskCard(
task = task,
modifier = Modifier.fillMaxWidth(),
onDragStart = { draggedTaskId = task.id }
)
}
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
newTaskTitle = TextFieldValue("")
newTaskColumn = column
showDialog = true
}) {
Text("Add Task")
}
}
}
}
if (showDialog) {
Dialog(
onCloseRequest = { showDialog = false },
title = "Add Task",
state = rememberDialogState(width = 300.dp, height = 200.dp)
) {
Surface(
modifier = Modifier.fillMaxSize().padding(16.dp),
color = MaterialTheme.colorScheme.background
) {
Column {
OutlinedTextField(
value = newTaskTitle,
onValueChange = { newTaskTitle = it },
label = { Text("Task Title") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
TextButton(onClick = { showDialog = false }) {
Text("Cancel")
}
Spacer(Modifier.width(8.dp))
Button(onClick = {
val title = newTaskTitle.text.trim()
if (title.isNotEmpty()) {
val newId = (tasksByColumn.values.flatten().maxOfOrNull { it.id } ?: 0) + 1
tasksByColumn[newTaskColumn]?.add(Task(newId, title))
showDialog = false
}
}) {
Text("Add")
}
}
}
}
}
}
}
@Preview
@Composable
fun App() {
MaterialTheme {
KanbanBoard()
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Drag & Drop Kanban Board") {
App()
}
}
Key Points
- Use
dragAndDropSourcewith a long-press gesture to initiate dragging of task cards. DragAndDropTransferablecarries the dragged task's ID as transferable data between components.dragAndDropTargetmodifiers handle drop events and provide visual feedback withonDrop,onEntered, andonExitedcallbacks.- Manage task lists reactively using
mutableStateMapOfandmutableStateListOffor smooth UI updates when tasks move between columns. - Use an
AlertDialogto add new tasks dynamically to any column with a simple input form.