Back to Blog

Drag & Drop Kanban Board in Compose Desktop | Kotlin Desktop #10

Sandy LaneSandy Lane

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 dragAndDropSource with a long-press gesture to initiate dragging of task cards.
  • DragAndDropTransferable carries the dragged task's ID as transferable data between components.
  • dragAndDropTarget modifiers handle drop events and provide visual feedback with onDrop, onEntered, and onExited callbacks.
  • Manage task lists reactively using mutableStateMapOf and mutableStateListOf for smooth UI updates when tasks move between columns.
  • Use an AlertDialog to add new tasks dynamically to any column with a simple input form.