Multiple Lists (Kanban)¶
A Kanban board combines several features into a single screen: multiple columns, cross-column card transfer, positional insertion, auto-scroll on both axes, and reordering the columns themselves. This guide walks through the pattern used by the Kanban Board sample.
The building blocks:
- One shared
DragAndDropStatefor cards, so they can move between any columns. - A second
DragAndDropStatefor the columns, dragged by their header. Two states let card drags and column drags coexist on one screen. - An outer
LazyRowof columns and an innerLazyColumnper column, each with its owndragAutoScroll. Modifier.reorderableItemon each card, handling both same-column reorder and cross-column insertion in a single callback.- A
dropTargetunder the cards of each column, so a card can be dropped below the last card or into an empty column.
On a narrow screen the board shows one column at a time. Holding a card or a column at the edge scrolls the board to the next ones:
Data Model and State¶
Keep columns and cards in immutable state. A single state instance is shared across the whole board:
data class Card(val id: String, val title: String)
data class Column(val id: String, val title: String, val cards: List<Card>)
val dndState = rememberDragAndDropState<Card>()
var columns by remember { mutableStateOf(initialColumns()) }
Board Layout¶
Wrap the board in one DragAndDropContainer. The outer LazyRow scrolls horizontally between columns while a card is dragged toward the screen edge; each column's LazyColumn scrolls vertically:
@Composable
fun KanbanBoard() {
val dndState = rememberDragAndDropState<Card>()
var columns by remember { mutableStateOf(initialColumns()) }
val rowState = rememberLazyListState()
DragAndDropContainer(state = dndState) {
LazyRow(
state = rowState,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.fillMaxSize()
.dragAutoScroll(
state = dndState,
lazyListState = rowState,
),
) {
items(columns, key = { it.id }) { column ->
ColumnUi(
column = column,
dndState = dndState,
onReorder = { dragged, target ->
columns = columns.moveCard(dragged, target)
},
onDropInEmpty = { dragged ->
columns = columns.moveCardToColumn(dragged, column.id)
},
)
}
}
}
}
Cards with reorderableItem¶
Each card uses the reorderableItem modifier. Because every card in every column shares the same state, dragging a card over a card in another column fires onDragEnter there — cross-column transfer works with the same callback as same-column reorder:
@Composable
fun ColumnUi(
column: Column,
dndState: DragAndDropState<Card>,
onReorder: (dragged: Card, target: Card) -> Unit,
onDropInEmpty: (dragged: Card) -> Unit,
) {
val colState = rememberLazyListState()
LazyColumn(
state = colState,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.width(280.dp)
.dragAutoScroll(
state = dndState,
lazyListState = colState,
),
) {
items(column.cards, key = { it.id }) { card ->
val isDragging = dndState.isDragging(card.id)
CardUi(
card = card,
modifier = Modifier
.animateItem()
.graphicsLayer { alpha = if (isDragging) 0f else 1f }
.reorderableItem(
key = card.id,
data = card,
state = dndState,
dropStrategy = DropStrategy.CenterDistance,
dragAfterLongPress = true,
onDragEnter = { state -> onReorder(state.data, card) },
draggableContent = {
CardUi(card = card, isDragShadow = true)
},
)
.fillMaxWidth(),
)
}
}
}
Two choices matter here:
dropStrategy = DropStrategy.CenterDistancepicks the closest card when the dragged card reaches several at once, e.g. on its way into another column. See Drop Strategies.dragAfterLongPress = truekeeps normal touch scrolling working — a plain swipe scrolls the column, a long press picks up the card.
Cross-Column Move Logic¶
The reorder callback removes the dragged card from whichever column holds it and inserts it before the target card. One immutable transformation handles both same-column and cross-column moves:
fun List<Column>.moveCard(dragged: Card, target: Card): List<Column> {
if (dragged.id == target.id) return this
return map { col ->
val targetIndex = col.cards.indexOfFirst { it.id == target.id }
if (targetIndex == -1) {
// Not the target column: just remove the dragged card if present
col.copy(cards = col.cards.filter { it.id != dragged.id })
} else {
// Target column: remove then insert at the target position
col.copy(
cards = col.cards
.filter { it.id != dragged.id }
.toMutableList()
.apply { add(targetIndex.coerceAtMost(size), dragged) },
)
}
}
}
Two lists are the smallest version of the same idea. Here a person either joins the end of the other team, or lands on the row they are dropped on:
Dropping at the End of a Column¶
Cards only react when another card is under the dragged one. The empty area below the last card, and a column with no cards at all, need a target of their own. Make the whole list a dropTarget that sits under its cards:
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.dropTarget(
key = "end-${column.id}",
state = dndState,
zIndex = -1f,
dropAlignment = Alignment.TopStart,
dropOffset = Offset(0f, lastSlotTop.toFloat()),
onDragEnter = { state -> onCardEnterEnd(state.data) },
)
.dragAutoScroll(state = dndState, lazyListState = listState),
) {
// cards
}
fun moveCardToEnd(card: Card, columnId: String) {
columns = columns.map { column ->
val rest = column.cards.filter { it.id != card.id }
column.copy(cards = if (column.id == columnId) rest + card else rest)
}
}
zIndex = -1fputs the list below its cards: when both are under the dragged card, only the cards are considered. The list gets the card when no card does.onDragEntermoves the card to the end right away, like the cards do, so its slot opens while the card is still held.dropAlignmentanddropOffsetaim the drop animation at that slot instead of the middle of the list.lastSlotTopis the offset of the last visible item:
val lastSlotTop by remember {
derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.offset ?: 0 }
}
Reordering the Columns Themselves¶
Columns are draggable too, by their header. The trick is a second DragAndDropState typed to columns, with its own container wrapped around the card container — each drag layer tracks its own items and renders its own shadow:
val cardDndState = rememberDragAndDropState<Card>()
val columnDndState = rememberDragAndDropState<Column>()
DragAndDropContainer(state = columnDndState) {
DragAndDropContainer(state = cardDndState) {
LazyRow(
modifier = Modifier
.dragAutoScroll(state = cardDndState, lazyListState = rowState)
.dragAutoScroll(state = columnDndState, lazyListState = rowState),
) {
// columns...
}
}
}
Each column becomes a reorderableItem in the column state, with hasDragHandle = true so that only the header initiates the drag — otherwise every card gesture would also grab the column:
Column(
modifier = Modifier
.graphicsLayer { alpha = if (isColumnDragging) 0f else 1f }
.reorderableItem(
key = "col-${column.id}",
data = column,
state = columnDndState,
hasDragHandle = true,
dragAxis = DragAxis.Horizontal,
dropStrategy = DropStrategy.CenterDistance,
onDragEnter = { state -> onColumnEnter(state.data, column) },
draggableContent = { ColumnDragPreview(column) },
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.dragHandle(
key = "col-${column.id}",
state = columnDndState,
),
) {
// header content: colored dot, title, count, grip dots
}
// cards LazyColumn...
}
Useful details:
dragAxis = DragAxis.Horizontalkeeps the lifted column aligned with the row.- The column reorder callback moves the column by id, mirroring the card logic.
draggableContentrenders a simplified preview (header plus the first few cards) instead of the full live column, keeping the drag shadow cheap.
Tip
Cards of different heights swap without bouncing: a card takes the slot of another one when its leading edge passes the middle of that card, whatever their sizes — see Reorder List.
The complete implementation, including priorities, tags, and assignees on the cards, is in the sample app: KanbanBoardScreen.kt.