DEV Community

Cover image for LazyColumn with drag and drop elements. Part 2 Refactoring.
Aleksei Laptev
Aleksei Laptev

Posted on

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
)
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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>()
Enter fullscreen mode Exit fullscreen mode
  • 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)
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Inside of onDrag() I add checkOverscroll():
currentElement?.let { current ->
    val targetElement = lazyListState.layoutInfo.visibleItemsInfo
        .filterNot { item ->
            item.offsetEnd < startOffset || item.offset > endOffset || current.index == item.index
        }
        .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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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() }
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

  • comments to variables
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
            }
Enter fullscreen mode Exit fullscreen mode
  • 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
            }
    }
Enter fullscreen mode Exit fullscreen mode
    /**
     * 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
    }
Enter fullscreen mode Exit fullscreen mode
  • 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()
                }
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)