Exploring Drag-and-Drop Functionality in Compose
This is the ongoing personal project of Pomodoro you can see all source code here Github, In this project, I am using TOML for library dependencies management, Koin.
For this project, I add the helper class to support drag-and-drop functionality in compose. In app design, user interaction plays a pivotal role in engaging the audience. The PomodoroListScreen is a prime example of this, showcasing how Jetpack Compose empowers developers to create a smooth and intuitive user experience. By integrating drag-and-drop capabilities, the code enhances user interaction by allowing effortless reordering of list items.
Structure and Composition:
The PomodoroListScreen function serves as the central point of the component. It takes three parameters: items
, onMove
, and an optional modifier
. The items
parameter holds the list of data to be displayed, while onMove
is a callback triggered during item movement. The modifier
parameter allows customization of the UI's appearance.
Inside the function, several key components are utilized to create the interactive list:
Coroutine Scope and State: coroutineScope
and mutableItems
are declared using the remember
function. These enable efficient coroutine management and state persistence across recompositions.
- Coroutine Scope and State:
coroutineScope
andmutableItems
are declared using theremember
function. These enable efficient coroutine management and state persistence across recompositions. - Drag-and-Drop State:
dragDropListState
is obtained using therememberDragDropListState
function. This encapsulates the logic required for drag-and-drop behavior, improving code modularity. - LazyColumn: This Composable element displays the list of items. It receives the drag gesture handler modifier and the drag-and-drop state as parameters. Additionally, the state of the LazyColumn is set to the drag-and-drop state's LazyListState.
Drag Gesture Handling:
The following code defines an extension function, dragGestureHandler
that creates a Modifier for handling drag gestures. It takes the CoroutineScope ItemListDragAndDropState
and an overscrollJob
as parameters. This function uses Compose's pointer input system to detect drag gestures after a long press.
fun Modifier.dragGestureHandler(
scope: CoroutineScope,
itemListDragAndDropState: ItemListDragAndDropState,
overscrollJob: MutableState<Job?>
): Modifier = this.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
itemListDragAndDropState.onDrag(offset)
handleOverscrollJob(overscrollJob, scope, itemListDragAndDropState)
},
onDragStart = { offset -> itemListDragAndDropState.onDragStart(offset) },
onDragEnd = { itemListDragAndDropState.onDragInterrupted() },
onDragCancel = { itemListDragAndDropState.onDragInterrupted() }
)
}
Inside the PomodoroListScreen
function, the LazyColumn
modifier is set using the dragGestureHandler
extension. This effectively binds the drag gesture handling logic to the LazyColumn, making it responsive to user interactions.
Handling Overscroll:
The handleOverscrollJob
function efficiently manages over-scrolling behavior. It checks if an over-scroll job is active and calculates the over-scroll offset using the checkForOverScroll
function from the ItemListDragAndDropState
. If over-scroll is detected, the job is launched to scroll the list accordingly.
private fun handleOverscrollJob(
overscrollJob: MutableState<Job?>,
scope: CoroutineScope,
itemListDragAndDropState: ItemListDragAndDropState
) {
if (overscrollJob.value?.isActive == true) return
val overscrollOffset = itemListDragAndDropState.checkForOverScroll()
if (overscrollOffset != 0f) {
overscrollJob.value = scope.launch {
itemListDragAndDropState.getLazyListState().scrollBy(overscrollOffset)
}
} else {
overscrollJob.value?.cancel()
}
}
List Item Composition:
The PomodoroListItem
The composable function encapsulates the visual representation of individual list items. It takes item
and displacementOffset
as parameters. The displacementOffset
indicates the vertical displacement of the item due to dragging.
@Composable
private fun PomodoroListItem(
item: String,
displacementOffset: Float?
) {
val isBeingDragged = displacementOffset != null
val backgroundColor = if (isBeingDragged) {
Color.LightGray
} else {
Color.White
}
Column(
modifier = Modifier
.graphicsLayer { translationY = displacementOffset ?: 0f }
.background(Color.White, shape = RoundedCornerShape(4.dp))
.fillMaxWidth()
.fillMaxHeight()
) {
Card(
shape = RoundedCornerShape(8.dp), backgroundColor = backgroundColor,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(text = item, modifier = Modifier.padding(16.dp))
}
}
}
@Composable
fun rememberDragDropListState(
lazyListState: LazyListState = rememberLazyListState(),
onMove: (Int, Int) -> Unit,
): ItemListDragAndDropState {
return remember { ItemListDragAndDropState(lazyListState, onMove) }
}
class ItemListDragAndDropState(
private val lazyListState: LazyListState,
private val onMove: (Int, Int) -> Unit
) {
private var draggedDistance by mutableStateOf(0f)
private var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
private var currentIndexOfDraggedItem by mutableStateOf(-1)
private var overscrollJob by mutableStateOf<Job?>(null)
// Retrieve the currently dragged element's info
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem.let {
lazyListState.getVisibleItemInfoFor(absoluteIndex = it)
}
// Calculate the initial offsets of the dragged element
private val initialOffsets: Pair<Int, Int>?
get() = initiallyDraggedElement?.let { Pair(it.offset, it.offsetEnd) }
// Calculate the displacement of the dragged element
val elementDisplacement: Float?
get() = currentIndexOfDraggedItem
.let { lazyListState.getVisibleItemInfoFor(absoluteIndex = it) }
?.let { item ->
(initiallyDraggedElement?.offset ?: 0f).toFloat() + draggedDistance - item.offset
}
// Functions for handling drag gestures
fun onDragStart(offset: Offset) {
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
}
// Handle interrupted drag gesture
fun onDragInterrupted() {
draggedDistance = 0f
currentIndexOfDraggedItem = -1
initiallyDraggedElement = null
overscrollJob?.cancel()
}
// Helper function to calculate start and end offsets
// Calculate the start and end offsets of the dragged element
private fun calculateOffsets(offset: Float): Pair<Float, Float> {
val startOffset = offset + draggedDistance
val currentElementSize = currentElement?.size ?: 0
val endOffset = offset + draggedDistance + currentElementSize
return startOffset to endOffset
}
// Handle the drag gesture
fun onDrag(offset: Offset) {
draggedDistance += offset.y
val topOffset = initialOffsets?.first ?: return
val (startOffset, endOffset) = calculateOffsets(topOffset.toFloat())
val hoveredElement = currentElement
if (hoveredElement != null) {
val delta = startOffset - hoveredElement.offset
val isDeltaPositive = delta > 0
val isEndOffsetGreater = endOffset > hoveredElement.offsetEnd
val validItems = lazyListState.layoutInfo.visibleItemsInfo.filter { item ->
!(item.offsetEnd < startOffset || item.offset > endOffset || hoveredElement.index == item.index)
}
val targetItem = validItems.firstOrNull {
when {
isDeltaPositive -> isEndOffsetGreater
else -> startOffset < it.offset
}
}
if (targetItem != null) {
currentIndexOfDraggedItem.let { current ->
onMove.invoke(current, targetItem.index)
currentIndexOfDraggedItem = targetItem.index
}
}
}
}
fun checkForOverScroll(): Float {
val draggedElement = initiallyDraggedElement
if (draggedElement != null) {
val (startOffset, endOffset) = calculateOffsets(draggedElement.offset.toFloat())
val diffToEnd = endOffset - lazyListState.layoutInfo.viewportEndOffset
val diffToStart = startOffset - lazyListState.layoutInfo.viewportStartOffset
return when {
draggedDistance > 0 && diffToEnd > 0 -> diffToEnd
draggedDistance < 0 && diffToStart < 0 -> diffToStart
else -> 0f
}
}
return 0f
}
fun getLazyListState(): LazyListState {
return lazyListState
}
fun getCurrentIndexOfDraggedListItem(): Int {
return currentIndexOfDraggedItem
}
}
fun LazyListState.getVisibleItemInfoFor(absoluteIndex: Int): LazyListItemInfo? {
return this.layoutInfo.visibleItemsInfo.getOrNull(
absoluteIndex - this.layoutInfo.visibleItemsInfo.first().index
)
}
/*
Bottom offset of the element in Vertical list
*/
val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
/*
Moving element in the list
*/
fun <T> MutableList<T>.move(
from: Int,
to: Int
) {
if (from == to)
return
val element = this.removeAt(from) ?: return
this.add(to, element)
}