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

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.

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.



Source link