
Enables drag-and-drop reordering of items in various list and grid layouts, supporting different item sizes and non-reorderable items. Features scroll edge activation, animated item movement, and customizable drag handles.
Reorderable is a simple library that allows you to reorder items in LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid, LazyVerticalStaggeredGrid, and LazyHorizontalStaggeredGrid as well as Column and Row in Jetpack Compose and Compose Multiplatform with drag and drop.
The latest demo app APK can be found in the releases section under the "Assets" section of the latest release.
| LazyColumn | LazyGrid |
|---|---|
|
|
Column and Row) The scroll speed is based on the distance from the edge of the screen.Modifier.animateItem API to animate item movement in LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid, LazyVerticalStaggeredGrid, and LazyHorizontalStaggeredGrid
If you're using Version Catalog, add the following to your libs.versions.toml file:
[versions]
#...
reorderable = "3.0.0"
[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }or
[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "3.0.0" }then
dependencies {
// ...
implementation(libs.reorderable)
}If you're using Gradle instead, add the following to your build.gradle file:
dependencies {
// ...
implementation("sh.calvin.reorderable:reorderable:3.0.0")
}dependencies {
// ...
implementation 'sh.calvin.reorderable:reorderable:3.0.0'
}See demo app code for more examples.
LazyColumnLazyRowLazyVerticalGridLazyHorizontalGridLazyVerticalStaggeredGridLazyHorizontalStaggeredGridColumnRowFind more examples in SimpleReorderableLazyColumnScreen.kt, SimpleLongPressHandleReorderableLazyColumnScreen.kt and ComplexReorderableLazyColumnScreen.kt in the demo app.
To use this library with LazyColumn, follow this basic structure:
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Update the list
}
LazyColumn(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyListState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyColumn. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyColumn(
state = lazyListState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyColumnState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun List() {
// ...
LazyColumn(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyColumn displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyListState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState = lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyListState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See SimpleReorderableLazyRowScreen.kt and ComplexReorderableLazyRowScreen.kt in the demo app.
To use this library with LazyRow, follow this basic structure:
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Update the list
}
LazyRow(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyRow(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyListState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Column {
Text(it, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyRow. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyRowState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyRow(
state = lazyListState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyRowState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun List() {
// ...
LazyRow(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyRow displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyListState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState = lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyRow(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyListState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyVerticalGridScreen.kt in the demo app.
To use this library with LazyVerticalGrid, follow this basic structure:
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
// Update the list
}
LazyVerticalGrid(state = lazyGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyVerticalGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyVerticalGrid(
state = lazyGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyVerticalGrid(state = lazyGridState) {
items(Grid, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyVerticalGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyGridState = rememberReorderableLazyGridState(
lazyGridState = lazyGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyHorizontalGridScreen.kt in the demo app.
To use this library with LazyHorizontalGrid, follow this basic structure:
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
// Update the list
}
LazyHorizontalGrid(state = lazyGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalGrid(
rows = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyHorizontalGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyHorizontalGrid(
state = lazyGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyHorizontalGrid(state = lazyGridState) {
items(Grid, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyHorizontalGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyGridState = rememberReorderableLazyGridState(
lazyGridState = lazyGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalGrid(
rows = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyVerticalStaggeredGridScreen.kt in the demo app.
To use this library with LazyVerticalStaggeredGrid, follow this basic structure:
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
// Update the list
}
LazyVerticalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyVerticalStaggeredGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyVerticalStaggeredGrid(
state = lazyStaggeredGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyVerticalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyVerticalStaggeredGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyStaggeredGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(
lazyStaggeredGridState = lazyStaggeredGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyHorizontalStaggeredGridScreen.kt in the demo app.
To use this library with LazyHorizontalStaggeredGrid, follow this basic structure:
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
// Update the list
}
LazyHorizontalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalItemSpacing = 8.dp,
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyHorizontalStaggeredGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyHorizontalStaggeredGrid(
state = lazyStaggeredGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyHorizontalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyHorizontalStaggeredGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyStaggeredGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(
lazyStaggeredGridState = lazyStaggeredGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalItemSpacing = 8.dp,
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in ReorderableColumnScreen.kt and LongPressHandleReorderableColumnScreen.kt in the demo app.
To use this library with Column, follow this basic structure:
ReorderableColumn(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
IconButton(modifier = Modifier.draggableHandle(), /* ... */)
}
}
}val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, isDragging ->
key(item) {
ReorderableItem {
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableListItemScope, you may need to pass ReorderableListItemScope to a child composable. For example:
@Composable
fun List() {
// ...
ReorderableColumn(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableListItemScope) {
IconButton(modifier = with(scope) { Modifier.draggableHandle() }, /* ... */)
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, _ ->
key(item) {
ReorderableItem {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See ReorderableRowScreen.kt in the demo app.
To use this library with Row, follow this basic structure:
ReorderableRow(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
IconButton(modifier = Modifier.draggableHandle(), /* ... */)
}
}
}val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableRow(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, isDragging ->
key(item) {
ReorderableItem {
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableListItemScope, you may need to pass ReorderableListItemScope to a child composable. For example:
@Composable
fun List() {
// ...
ReorderableRow(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableListItemScope) {
IconButton(modifier = with(scope) { Modifier.draggableHandle() }, /* ... */)
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableRow(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, _ ->
key(item) {
ReorderableItem {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See the demo app for examples of how to make the reorderable list accessible.
If the items in the list do not contain any button besides the drag handle, I recommend adding "Move Up"/"Move Down"/"Move Left"/"Move Right" actions to the TalkBack menu in each item via SemanticsPropertyReceiver.customActions and applying Modifier.clearAndSetSemantics to the drag handle button to make the drag handle button not focusable for TalkBack. For more information, see Key steps to improve Compose accessibility.
[!NOTE]
This assumes you're using version 2.0.3 or later of this library.
The onMove function expects the list to be updated before it returns. If the list is updated after onMove returns, the dragging item will flicker. To fix this, update the list before returning from onMove.
val reorderableLazyXXXXState = rememberReorderableLazyXXXXState(listState) { from, to ->
// do NOT wrap the updateList call in `launch`
updateList(from, to)
}
suspend fun updateList(from: Int, to: Int) {
// long update operation
}If you can't keep the list update inside onMove, you can use a channel to communicate between onMove and the list update composition. Here's an example:
val listUpdatedChannel = remember { Channel<Unit>() }
val reorderableLazyXXXXState = rememberReorderableLazyXXXXState(listState) { from, to ->
// clear the channel
listUpdatedChannel.tryReceive()
// update the list
// wait for the list to be updated
listUpdatedChannel.receive()
}
LaunchedEffect(list) {
// notify the list is updated
listUpdatedChannel.trySend(Unit)
}rememberReorderableLazyListStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandlerememberReorderableLazyGridStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandlerememberReorderableLazyStaggeredGridStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandleTo run the Android demo app, open the project in Android Studio and run the app.
To run the iOS demo app, open the iosApp project in Xcode and run the app or add the following Configuration to the Android Studio project, you may need to install the Kotlin Multiplatform Mobile plugin first.
To run the web demo app, run ./gradlew :composeApp:wasmJsBrowserDevelopmentRun.
To run the desktop demo app, run ./gradlew :demoApp:ComposeApp:run.
Open this project with Android Studio Preview.
You'll want to install the Kotlin Multiplatform Mobile plugin in Android Studio before you open this project.
Copyright 2023 Calvin Liang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Reorderable is a simple library that allows you to reorder items in LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid, LazyVerticalStaggeredGrid, and LazyHorizontalStaggeredGrid as well as Column and Row in Jetpack Compose and Compose Multiplatform with drag and drop.
The latest demo app APK can be found in the releases section under the "Assets" section of the latest release.
| LazyColumn | LazyGrid |
|---|---|
|
|
Column and Row) The scroll speed is based on the distance from the edge of the screen.Modifier.animateItem API to animate item movement in LazyColumn, LazyRow, LazyVerticalGrid, LazyHorizontalGrid, LazyVerticalStaggeredGrid, and LazyHorizontalStaggeredGrid
If you're using Version Catalog, add the following to your libs.versions.toml file:
[versions]
#...
reorderable = "3.0.0"
[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }or
[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "3.0.0" }then
dependencies {
// ...
implementation(libs.reorderable)
}If you're using Gradle instead, add the following to your build.gradle file:
dependencies {
// ...
implementation("sh.calvin.reorderable:reorderable:3.0.0")
}dependencies {
// ...
implementation 'sh.calvin.reorderable:reorderable:3.0.0'
}See demo app code for more examples.
LazyColumnLazyRowLazyVerticalGridLazyHorizontalGridLazyVerticalStaggeredGridLazyHorizontalStaggeredGridColumnRowFind more examples in SimpleReorderableLazyColumnScreen.kt, SimpleLongPressHandleReorderableLazyColumnScreen.kt and ComplexReorderableLazyColumnScreen.kt in the demo app.
To use this library with LazyColumn, follow this basic structure:
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Update the list
}
LazyColumn(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyListState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyColumn. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyColumn(
state = lazyListState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyColumnState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun List() {
// ...
LazyColumn(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyColumn displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyListState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState = lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyListState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See SimpleReorderableLazyRowScreen.kt and ComplexReorderableLazyRowScreen.kt in the demo app.
To use this library with LazyRow, follow this basic structure:
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
// Update the list
}
LazyRow(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyRow(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyListState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Column {
Text(it, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyRow. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyRowState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyRow(
state = lazyListState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyRowState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun List() {
// ...
LazyRow(state = lazyListState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyListState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyRow displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyListState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyListState = rememberReorderableLazyListState(
lazyListState = lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyListState = rememberReorderableLazyListState(lazyListState) { from, to ->
list = list.toMutableList().apply {
add(to.index, removeAt(from.index))
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyRow(
modifier = Modifier.fillMaxSize(),
state = lazyListState,
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyListState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyVerticalGridScreen.kt in the demo app.
To use this library with LazyVerticalGrid, follow this basic structure:
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
// Update the list
}
LazyVerticalGrid(state = lazyGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyVerticalGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyVerticalGrid(
state = lazyGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyVerticalGrid(state = lazyGridState) {
items(Grid, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyVerticalGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyGridState = rememberReorderableLazyGridState(
lazyGridState = lazyGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyHorizontalGridScreen.kt in the demo app.
To use this library with LazyHorizontalGrid, follow this basic structure:
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
// Update the list
}
LazyHorizontalGrid(state = lazyGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalGrid(
rows = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyHorizontalGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyHorizontalGrid(
state = lazyGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyHorizontalGrid(state = lazyGridState) {
items(Grid, key = { /* item key */ }) {
ReorderableItem(reorderableLazyGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyHorizontalGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyGridState = rememberReorderableLazyGridState(
lazyGridState = lazyGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyGridState = rememberLazyGridState()
val reorderableLazyGridState = rememberReorderableLazyGridState(lazyGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalGrid(
rows = GridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyVerticalStaggeredGridScreen.kt in the demo app.
To use this library with LazyVerticalStaggeredGrid, follow this basic structure:
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
// Update the list
}
LazyVerticalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyVerticalStaggeredGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyVerticalStaggeredGrid(
state = lazyStaggeredGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyVerticalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyVerticalStaggeredGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyStaggeredGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(
lazyStaggeredGridState = lazyStaggeredGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in SimpleReorderableLazyHorizontalStaggeredGridScreen.kt in the demo app.
To use this library with LazyHorizontalStaggeredGrid, follow this basic structure:
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
// Update the list
}
LazyHorizontalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
IconButton(
modifier = Modifier.draggableHandle(),
/* ... */
)
}
}
}
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalItemSpacing = 8.dp,
) {
items(list, key = { it }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = it) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(it, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}The from.index and to.index in onMove are the indices of the items in the LazyHorizontalStaggeredGrid. If you have section headers or footers, you may need to adjust the indices accordingly. For example:
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
add(to.index - 1, removeAt(from.index - 1))
}
}
LazyHorizontalStaggeredGrid(
state = lazyStaggeredGridState,
// ...
) {
item {
Text("Header")
}
items(list, key = { item -> item.id }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, item.id) {
// ...
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableCollectionItemScope, you may need to pass ReorderableCollectionItemScope to a child composable. For example:
@Composable
fun Grid() {
// ...
LazyHorizontalStaggeredGrid(state = lazyStaggeredGridState) {
items(list, key = { /* item key */ }) {
ReorderableItem(reorderableLazyStaggeredGridState, key = /* item key */) { isDragging ->
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableCollectionItemScope) {
IconButton(
modifier = with(scope) {
Modifier.draggableHandle()
},
/* ... */
)
}If your LazyHorizontalStaggeredGrid displays under navigation bar or notification bar, you may want to add scrollThresholdPadding to rememberReorderableLazyStaggeredGridState to move the scroll trigger area out from under the navigation bar or notification bar.
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(
lazyStaggeredGridState = lazyStaggeredGridState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
...
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyStaggeredGridState = rememberLazyStaggeredGridState()
val reorderableLazyStaggeredGridState = rememberReorderableLazyStaggeredGridState(lazyStaggeredGridState) { from, to ->
list = list.toMutableList().apply {
this[to.index] = this[from.index].also {
this[from.index] = this[to.index]
}
}
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyHorizontalStaggeredGrid(
rows = StaggeredGridCells.Adaptive(minSize = 96.dp),
modifier = Modifier.fillMaxSize(),
state = lazyStaggeredGridState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalItemSpacing = 8.dp,
) {
items(list, key = { it }) { item ->
ReorderableItem(reorderableLazyStaggeredGridState, key = item) {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Find more examples in ReorderableColumnScreen.kt and LongPressHandleReorderableColumnScreen.kt in the demo app.
To use this library with Column, follow this basic structure:
ReorderableColumn(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
IconButton(modifier = Modifier.draggableHandle(), /* ... */)
}
}
}val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, isDragging ->
key(item) {
ReorderableItem {
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableListItemScope, you may need to pass ReorderableListItemScope to a child composable. For example:
@Composable
fun List() {
// ...
ReorderableColumn(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableListItemScope) {
IconButton(modifier = with(scope) { Modifier.draggableHandle() }, /* ... */)
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, _ ->
key(item) {
ReorderableItem {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Row {
Text(item, Modifier.padding(horizontal = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See ReorderableRowScreen.kt in the demo app.
To use this library with Row, follow this basic structure:
ReorderableRow(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
IconButton(modifier = Modifier.draggableHandle(), /* ... */)
}
}
}val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableRow(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, isDragging ->
key(item) {
ReorderableItem {
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableListItemScope, you may need to pass ReorderableListItemScope to a child composable. For example:
@Composable
fun List() {
// ...
ReorderableRow(
list = list,
onSettle = { fromIndex, toIndex ->
// Update the list
},
) { index, item, isDragging ->
key(item.id) {
ReorderableItem {
// Item content
DragHandle(this)
}
}
}
}
@Composable
fun DragHandle(scope: ReorderableListItemScope) {
IconButton(modifier = with(scope) { Modifier.draggableHandle() }, /* ... */)
}Card
If you want to use the material3's Clickable Card, you can create a MutableInteractionSource and pass it to both the Card and the Modifier.draggableHandle (or Modifier.longPressDraggableHandle), Modifier.draggableHandle will emit drag events to the MutableInteractionSource so that the Card can respond to the drag events:
val hapticFeedback = LocalHapticFeedback.current
var list by remember { mutableStateOf(List(4) { "Item $it" }) }
ReorderableRow(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
list = list,
onSettle = { fromIndex, toIndex ->
list = list.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
},
onMove = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK
)
},
verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, _ ->
key(item) {
ReorderableItem {
val interactionSource = remember { MutableInteractionSource() }
Card(
onClick = {},
interactionSource = interactionSource,
) {
Column {
Text(item, Modifier.padding(vertical = 8.dp))
IconButton(
modifier = Modifier.draggableHandle(
onDragStarted = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_START
)
},
onDragStopped = {
ViewCompat.performHapticFeedback(
view,
HapticFeedbackConstantsCompat.GESTURE_END
)
},
interactionSource = interactionSource,
),
onClick = {},
) {
Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
}
}
}
}
}
}See the demo app for examples of how to make the reorderable list accessible.
If the items in the list do not contain any button besides the drag handle, I recommend adding "Move Up"/"Move Down"/"Move Left"/"Move Right" actions to the TalkBack menu in each item via SemanticsPropertyReceiver.customActions and applying Modifier.clearAndSetSemantics to the drag handle button to make the drag handle button not focusable for TalkBack. For more information, see Key steps to improve Compose accessibility.
[!NOTE]
This assumes you're using version 2.0.3 or later of this library.
The onMove function expects the list to be updated before it returns. If the list is updated after onMove returns, the dragging item will flicker. To fix this, update the list before returning from onMove.
val reorderableLazyXXXXState = rememberReorderableLazyXXXXState(listState) { from, to ->
// do NOT wrap the updateList call in `launch`
updateList(from, to)
}
suspend fun updateList(from: Int, to: Int) {
// long update operation
}If you can't keep the list update inside onMove, you can use a channel to communicate between onMove and the list update composition. Here's an example:
val listUpdatedChannel = remember { Channel<Unit>() }
val reorderableLazyXXXXState = rememberReorderableLazyXXXXState(listState) { from, to ->
// clear the channel
listUpdatedChannel.tryReceive()
// update the list
// wait for the list to be updated
listUpdatedChannel.receive()
}
LaunchedEffect(list) {
// notify the list is updated
listUpdatedChannel.trySend(Unit)
}rememberReorderableLazyListStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandlerememberReorderableLazyGridStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandlerememberReorderableLazyStaggeredGridStateReorderableItemModifier.draggableHandleModifier.longPressDraggableHandleTo run the Android demo app, open the project in Android Studio and run the app.
To run the iOS demo app, open the iosApp project in Xcode and run the app or add the following Configuration to the Android Studio project, you may need to install the Kotlin Multiplatform Mobile plugin first.
To run the web demo app, run ./gradlew :composeApp:wasmJsBrowserDevelopmentRun.
To run the desktop demo app, run ./gradlew :demoApp:ComposeApp:run.
Open this project with Android Studio Preview.
You'll want to install the Kotlin Multiplatform Mobile plugin in Android Studio before you open this project.
Copyright 2023 Calvin Liang
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.