LazyColumn with drag and drop elements. Part 2 Refactoring.
Previous Part 1
Hello again. Let’s continue working with LazyColumn. Starter code (from the end of the previous part): Part 1 code
Now we have to solve issues and make a code more readable.
Step 1
link to commit
I should to do some refactoring because it’s too uncomfortable to work with so wide code lines.
Now we have LazyColumn with this modifier:
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f)
.pointerInput(Unit)
detectDragGesturesAfterLongPress(
onDrag = change, offset ->
change.consume()
dragAndDropListState.onDrag(offset)
if (overscrollJob?.isActive == true) return@detectDragGesturesAfterLongPress
dragAndDropListState
.checkOverscroll()
.takeIf it != 0f
?.let
overscrollJob = coroutineScope.launch
dragAndDropListState.lazyListState.scrollBy(it)
?: kotlin.run overscrollJob?.cancel()
,
onDragStart = offset ->
dragAndDropListState.onDragStart(offset)
,
onDragEnd = dragAndDropListState.onDragInterrupted() ,
onDragCancel = dragAndDropListState.onDragInterrupted()
)
,
state = dragAndDropListState.lazyListState
)
And we have ItemCard with this modifier:
ItemCard(
userEntityUi = user,
modifier = Modifier
.composed
val offsetOrNull =
dragAndDropListState.elementDisplacement.takeIf
index == dragAndDropListState.currentIndexOfDraggedItem
Modifier.graphicsLayer
translationY = offsetOrNull ?: 0f
)
So, let’s make two extensions for our both composable functions
fun Modifier.dragContainer(
dragAndDropListState: DragAndDropListState,
overscrollJob: Job?,
coroutineScope: CoroutineScope
): Modifier {
var coroutineJob = overscrollJob
return this.pointerInput(Unit)
detectDragGesturesAfterLongPress(
onDrag = change, offset ->
change.consume()
dragAndDropListState.onDrag(offset)
if (overscrollJob?.isActive == true) return@detectDragGesturesAfterLongPress
dragAndDropListState
.checkOverscroll()
.takeIf it != 0f
?.let
coroutineJob = coroutineScope.launch
dragAndDropListState.lazyListState.scrollBy(it)
?: kotlin.run coroutineJob?.cancel()
,
onDragStart = offset ->
dragAndDropListState.onDragStart(offset)
,
onDragEnd = dragAndDropListState.onDragInterrupted() ,
onDragCancel = dragAndDropListState.onDragInterrupted()
)
}
@Composable
fun LazyItemScope.DraggableItem(
dragAndDropListState: DragAndDropListState,
index: Int,
content: @Composable LazyItemScope.(Modifier) -> Unit
)
val draggingModifier = Modifier
.composed
val offsetOrNull =
dragAndDropListState.elementDisplacement.takeIf
index == dragAndDropListState.currentIndexOfDraggedItem
Modifier.graphicsLayer
translationY = offsetOrNull ?: 0f
content(draggingModifier)
And now our LazyColumn looks much more better:
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f)
.dragContainer(
dragAndDropListState = dragAndDropListState,
overscrollJob = overscrollJob,
coroutineScope = coroutineScope
),
state = dragAndDropListState.lazyListState
)
itemsIndexed(users) index, user ->
DraggableItem(
dragAndDropListState = dragAndDropListState,
index = index
) modifier ->
ItemCard(
userEntityUi = user,
modifier = modifier
)
Step 2
link to commit
Next we gonna remove the logic of scrolling list from Modifier.dragContainer to rememberDragAndDropListState. And for this task we gonna use Channels. The Channel is an awesome interface what can help us pass data between coroutines.
- Add a variable to the DragAndDropListState class
val scrollChannel = Channel<Float>()
- Change the checkOverscroll() function. We don’t need to get Float as a result of function, just send it to the channel.
private fun checkOverscroll() {
val overscroll = initialDraggingElement?.let
val startOffset = it.offset + draggingDistance
val endOffset = it.offsetEnd + draggingDistance
return@let when
draggingDistance > 0 ->
(endOffset - lazyListState.layoutInfo.viewportEndOffset).takeIf diff -> diff > 0
draggingDistance < 0 ->
(startOffset - lazyListState.layoutInfo.viewportStartOffset).takeIf diff -> diff < 0
else -> null
?: 0f
if (overscroll != 0f)
scrollChannel.trySend(overscroll)
}
- Inside of onDrag() I add checkOverscroll():
currentElement?.let current ->
val targetElement = lazyListState.layoutInfo.visibleItemsInfo
.filterNot item.offset > endOffset
.firstOrNull item ->
val delta = startOffset - current.offset
when
delta < 0 -> item.offset > startOffset
else -> item.offsetEnd < endOffset
if (targetElement == null)
checkOverscroll()
targetElement
?.also item ->
currentIndexOfDraggedItem?.let current ->
onMove.invoke(current, item.index)
currentIndexOfDraggedItem = item.index
It looks not so pretty good but we’ll refactor that later.
- We created the channel and sent a value there, now we need to receive that value and scroll list to this difference.
@Composable
fun rememberDragAndDropListState(
lazyListState: LazyListState,
onMove: (Int, Int) -> Unit
): DragAndDropListState
val state = remember DragAndDropListState(lazyListState, onMove)
LaunchedEffect(state)
while (true)
val diff = state.scrollChannel.receive()
state.lazyListState.scrollBy(diff)
return state
So, now Modifier.dragContainer looks this:
fun Modifier.dragContainer(
dragAndDropListState: DragAndDropListState,
): Modifier
return this.pointerInput(Unit)
detectDragGesturesAfterLongPress(
onDrag = change, offset ->
change.consume()
dragAndDropListState.onDrag(offset)
,
onDragStart = offset ->
dragAndDropListState.onDragStart(offset)
,
onDragEnd = dragAndDropListState.onDragInterrupted() ,
onDragCancel = dragAndDropListState.onDragInterrupted()
)
Step 3
link to commit
Let’s continue to do refactoring and now we are working with the DragAndDropListState class overall. And here I think I should add comments and javadoc to code.
class DragAndDropListState(
val lazyListState: LazyListState,
private val onMove: (Int, Int) -> Unit
) {
// channel for emitting scroll events
val scrollChannel = Channel<Float>()
// the index of item that is being dragged
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
// the item that is being dragged
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let lazyListState.getVisibleItemInfo(it)
// the initial index of item that is being dragged
private var initialDraggingElement by mutableStateOf<LazyListItemInfo?>(null)
// the initial offset of the item when it is dragged
private val initialOffsets: Pair<Int, Int>?
get() = initialDraggingElement?.let Pair(it.offset, it.offsetEnd)
// distance that has been dragged
private var draggingDistance by mutableFloatStateOf(0f)
// calculates the vertical displacement of the dragged element
// relative to its original position in the LazyList.
val elementDisplacement: Float?
get() = currentIndexOfDraggedItem
?.let lazyListState.getVisibleItemInfo(it)
?.let itemInfo ->
(initialDraggingElement?.offset
?: 0f).toFloat() + draggingDistance - itemInfo.offset
- also add javadoc to functions
/**
* Starts the dragging operation when the user initiates a drag gesture.
*
* This function determines which item in the LazyList is being dragged based on the
* starting offset of the drag gesture. It then stores the information about the
* dragged item and its index for use during the drag operation.
* */
fun onDragStart(offset: Offset)
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull item -> offset.y.toInt() in item.offset..item.offsetEnd
?.also
initialDraggingElement = it
currentIndexOfDraggedItem = it.index
/**
* Called when a drag operation is interrupted or canceled.
*
* Resets the state of the ongoing drag by clearing the dragged element reference,
* the dragged item index, and the dragging distance. This is invoked when a drag
* is canceled, fails, or completes.
*/
fun onDragInterrupted()
initialDraggingElement = null
currentIndexOfDraggedItem = null
draggingDistance = 0f
- and refactoring onDrag() function for more readability
fun onDrag(offset: Offset) {
draggingDistance += offset.y
initialOffsets?.let (top, bottom) ->
val startOffset = top.toFloat() + draggingDistance
val endOffset = bottom.toFloat() + draggingDistance
val middleOffset = (startOffset + endOffset) / 2f
currentElement?.let current ->
val targetElement = lazyListState.layoutInfo.visibleItemsInfo.find
middleOffset.toInt() in it.offset..it.offsetEnd && current.index != it.index
if (targetElement != null)
currentIndexOfDraggedItem?.let
onMove.invoke(it, targetElement.index)
currentIndexOfDraggedItem = targetElement.index
else
checkOverscroll()
}
Looks nice! Simple and understandable 🙂
I realized that the article has been happened too large. So, next Part 3 (place for link) will be about fixing issues.