/Reorderable

Reorder items in Lists and Grids in Jetpack Compose and Compose Multiplatform with drag and drop.

Primary LanguageKotlinApache License 2.0Apache-2.0

Reorderable

Reorderable is a simple library that allows you to reorder items in LazyColumn and LazyRow as well as Column and Row in Jetpack Compose with drag and drop.

The latest demo app APK can be found in the releases section under the "Assets" section of the latest release.

Features

  • Supports Compose Multiplatform (Android, iOS, Desktop/JVM, Wasm)
  • Supports items of different sizes
  • Some items can be made non-reorderable
  • Supports dragging immediately or long press to start dragging
  • Scrolls when dragging to the edge of the screen (only for LazyColumn and LazyRow) The scroll speed is based on the distance from the edge of the screen
  • Uses the new Modifier.animateItemPlacement API to animate item movement in LazyColumn and LazyRow
  • Supports using a child of an item as the drag handle

Usage

Version Catalog

If you're using Version Catalog, add the following to your libs.versions.toml file:

[versions]
#...
reorderable = "1.5.0"

[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }

or

[libraries]
#...
reorderable = { module = "sh.calvin.reorderable:reorderable", version = "1.5.0" }

Gradle

If you're using Gradle instead, add the following to your build.gradle file:

Kotlin DSL

dependencies {
    implementation("sh.calvin.reorderable:reorderable:1.5.0")
}

Groovy DSL

dependencies {
    implementation 'sh.calvin.reorderable:reorderable:1.5.0'
}

Examples

See demo app code for more examples.

Table of Contents

Find 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 reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to ->
    // Update the list
}

LazyColumn(state = lazyListState) {
    items(list, key = { /* item key */ }) {
        ReorderableItem(reorderableLazyColumnState, key = /* item key */) { isDragging ->
            // Item content

            IconButton(
                modifier = Modifier.draggableHandle(),
                /* ... */
            )
        }
    }
}

Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableItemScope, you may need to pass ReorderableItemScope to a child composable. For example:

@Composable
fun List() {
    // ...

    LazyColumn(state = lazyListState) {
        items(list, key = { /* item key */ }) {
            ReorderableItem(reorderableLazyColumnState, key = /* item key */) { isDragging ->
                // Item content

                DragHandle(this)
            }
        }
    }
}

@Composable
fun DragHandle(scope: ReorderableItemScope) {
    IconButton(
        modifier = with(scope) {
            Modifier.draggableHandle()
        },
        /* ... */
    )
}

Here's a more complete example with (with haptic feedback):

val view = LocalView.current

var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to ->
    list = list.toMutableList().apply {
        add(to.index, removeAt(from.index))
    }

    view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
}

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    state = lazyListState,
    contentPadding = PaddingValues(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(list, key = { it }) {
        ReorderableItem(reorderableLazyColumnState, 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 = {
                                view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
                            },
                            onDragStopped = {
                                view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
                            },
                        ),
                        onClick = {},
                    ) {
                        Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                    }
                }
            }
        }
    }
}

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 view = LocalView.current

var list by remember { mutableStateOf(List(100) { "Item $it" }) }
val lazyListState = rememberLazyListState()
val reorderableLazyColumnState = rememberReorderableLazyColumnState(lazyListState) { from, to ->
    list = list.toMutableList().apply {
        add(to.index, removeAt(from.index))
    }

    view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
}

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    state = lazyListState,
    contentPadding = PaddingValues(8.dp),
    verticalArrangement = Arrangement.spacedBy(8.dp)
) {
    items(list, key = { it }) { item ->
        ReorderableItem(reorderableLazyColumnState, key = item) {
            val interactionSource = remember { MutableInteractionSource() }

            Card(
                onClick = {},
                interactionSource = interactionSource,
            ) {
                Row {
                    Text(item, Modifier.padding(horizontal = 8.dp))
                    IconButton(
                        modifier = Modifier.draggableHandle(
                            onDragStarted = {
                                view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
                            },
                            onDragStopped = {
                                view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
                            },
                            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) {
        // Item content

        IconButton(modifier = Modifier.draggableHandle(), /* ... */)
    }
}

Since Modifier.draggableHandle and Modifier.longPressDraggableHandle can only be used in ReorderableScope, you may need to pass ReorderableScope to a child composable. For example:

@Composable
fun List() {
    // ...

    ReorderableColumn(
        list = list,
        onSettle = { fromIndex, toIndex ->
            // Update the list
        },
    ) { index, item, isDragging ->
        key(item.id) {
            // Item content

            DragHandle(this)
        }
    }
}

@Composable
fun DragHandle(scope: ReorderableScope) {
    IconButton(modifier = with(scope) { Modifier.draggableHandle() }, /* ... */)
}

Here's a more complete example (with haptic feedback):

val view = LocalView.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 = {
        view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
    },
    verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, isDragging ->
    key(item) {
        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 = {
                            view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
                        },
                        onDragStopped = {
                            view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
                        },
                    ),
                    onClick = {},
                ) {
                    Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                }
            }
        }
    }
}

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 view = LocalView.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 = {
        view.performHapticFeedback(HapticFeedbackConstants.SEGMENT_FREQUENT_TICK)
    },
    verticalArrangement = Arrangement.spacedBy(8.dp),
) { _, item, _ ->
    key(item) {
        val interactionSource = remember { MutableInteractionSource() }

        Card(
            onClick = {},
            interactionSource = interactionSource,
        ) {
            Row {
                Text(item, Modifier.padding(horizontal = 8.dp))
                IconButton(
                    modifier = Modifier.draggableHandle(
                        onDragStarted = {
                            view.performHapticFeedback(HapticFeedbackConstants.DRAG_START)
                        },
                        onDragStopped = {
                            view.performHapticFeedback(HapticFeedbackConstants.GESTURE_END)
                        },
                        interactionSource = interactionSource,
                    ),
                    onClick = {},
                ) {
                    Icon(Icons.Rounded.DragHandle, contentDescription = "Reorder")
                }
            }
        }
    }
}

LazyRow

See SimpleReorderableLazyRowScreen.kt and ComplexReorderableLazyRowScreen.kt in the demo app.

You can just replace Column with Row in the LazyColumn examples above.

Row

See ReorderableRowScreen.kt in the demo app.

You can just replace Column with Row in the Column examples above.

API

Running the demo app

To 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.

Screenshot 2024-02-10 at 20 58 54

To run the web demo app, run ./gradlew :composeApp:wasmJsBrowserDevelopmentRun.

To run the desktop demo app, run ./gradlew :demoApp:ComposeApp:run.

Contributing

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.

License

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.