Exploring Drag-and-Drop Functionality in Compose

YE MON KYAW
Arpalar Tech
Published in
4 min readAug 13, 2023

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.

  1. Coroutine Scope and State: coroutineScope and mutableItems are declared using the remember function. These enable efficient coroutine management and state persistence across recompositions.
  2. Drag-and-Drop State: dragDropListState is obtained using the rememberDragDropListState function. This encapsulates the logic required for drag-and-drop behavior, improving code modularity.
  3. 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, dragGestureHandlerthat creates a Modifier for handling drag gestures. It takes the CoroutineScope ItemListDragAndDropStateand 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)
}

--

--

YE MON KYAW
Arpalar Tech

Software Engineer who write code in Kotlin / Android