DEV Community

Cover image for How to create LazyColumn with drag and drop elements in Jetpack Compose. Part 1.
Aleksei Laptev
Aleksei Laptev

Posted on

How to create LazyColumn with drag and drop elements in Jetpack Compose. Part 1.

Hello there! The subject of drag and drop elements isn't new and you can find many decisions of this problem in the Internet. But the decision isn't so obviously like a creating drag and drop lists by RecyclerView. And I suppose my step-by-step guide will be useful.

Sources:

If you just want to solve your problem you can get code from LazyColumnDragAndDropDemo.kt and implement it to your project.

So, I'm getting a solution from Make it Easy: How to implement Drag and Drop List Item in Jetpack Compose. I'll try to explain step-by-step what happens. Next, I'll solve issues and do refactoring. At the end of refactoring I wanna get something like LazyColumnDragAndDropDemo.kt.

At the beginning I wanna say that the most interesting will be in next parts. You can think of this part as a preview.

OK, let's get started.

About start commit of app

Starter code
So, we have three layers in our app:

  • UI - consists of one screen with two buttons (Add user and Clear list) and class AppViewModel and data class UserEntityUi
  • Domain - consists of data class UserEntity and interface UserRepository
  • Data - consists of database (created by Room) and class UserRepositoryImpl

Also we have Hilt for DI.

ViewModel is subscribed on changes of Flow<List<UserEntity>> and the screen collectAsState() this StateFlow. nothing much...

Step 1

First of all we need to add a reaction on gestures

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .weight(1f)
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDrag = { },
                onDragStart = { },
                onDragEnd = { },
                onDragCancel = { }
            )
        }
)
Enter fullscreen mode Exit fullscreen mode

And reaction we add by Modifier.pointerInput and detectDragGesturesAfterLongPress. You can read about an understanding gestures in Understand gestures | Google Developers by yourself. I just don't want to copy text of official guides and documentation it's not correctly.

Step 2

As usual in Compose we need some state for drag and drop item. Definitely inside this state we need to have LazyListState and some action for moving element onMove: (Int, Int) -> Unit.
OK, let's create it.

class DragAndDropListState(
    val lazyListState: LazyListState,
    private val onMove: (Int, Int) -> Unit
)
Enter fullscreen mode Exit fullscreen mode

And of course we need to remember our state.

@Composable
fun rememberDragAndDropListState(
    lazyListState: LazyListState,
    onMove: (Int, Int) -> Unit
): DragAndDropListState {
    return remember { DragAndDropListState(lazyListState, onMove) }
}
Enter fullscreen mode Exit fullscreen mode

And let's create some extension for moving in a mutableList

fun <T> MutableList<T>.move(from: Int, to: Int) {
    if (from == to) return
    val element = this.removeAt(from)
    this.add(to, element)
}
Enter fullscreen mode Exit fullscreen mode

Next let's inject them into our screen

is UiState.Success -> {
    val users = state.data.toMutableStateList()
    val dragAndDropListState =
        rememberDragAndDropListState(lazyListState) { from, to ->
            users.move(from, to)
        }
/* LazyColumn etc. */
}
Enter fullscreen mode Exit fullscreen mode

toMutableStateList() function create SnapshotStateList<T> that can be observed and snapshot.

Step 3

So, we see 4 params in detectDragGesturesAfterLongPress which we should implement: onDrag, onDragStart, pnDragEnd, onDragCancel. Go back to our class DragAndDropListState and start...

onStart

The first of all we need to know which element we dragged. It needs some variables:

private var initialDraggingElement by mutableStateOf<LazyListItemInfo?>(null)
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
Enter fullscreen mode Exit fullscreen mode

And now we can implement onDragStart function for setting initial values of current element.
But first let's create an extension to getting offset of end of element:

private val LazyListItemInfo.offsetEnd: Int
    get() = this.offset + this.size
Enter fullscreen mode Exit fullscreen mode
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

So, let's paste onStart() into LazyColumn

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .weight(1f)
        .pointerInput(Unit) {
            detectDragGesturesAfterLongPress(
                onDrag = { },
                onDragStart = { offset ->
                    dragAndDropListState.onDragStart(offset)
                },
                onDragEnd = { },
                onDragCancel = { }
            )
        }
){
/*Composable content*/      
}
Enter fullscreen mode Exit fullscreen mode

onDrag

What do we wanna do?

  • we want to move an element along the Y coordinate
  • when we moved an element to the place of another element it needs to change indexes of both elements

OK, let's create a variable for distance of offset

private var draggingDistance by mutableFloatStateOf(0f)
fun onDrag(offset: Offset) {
    draggingDistance += offset.y
}
Enter fullscreen mode Exit fullscreen mode

Next we need to get offsets of our initial element

private var draggingDistance by mutableFloatStateOf(0f)
private val initialOffsets: Pair<Int, Int>?
    get() = initialDraggingElement?.let { Pair(it.offset, it.offsetEnd) }

fun onDrag(offset: Offset) {
    draggingDistance += offset.y

    initialOffsets?.let { (top, bottom) ->
        val startOffset = top.toFloat() + draggingDistance
        val endOffset = bottom.toFloat() + draggingDistance
    }
}
Enter fullscreen mode Exit fullscreen mode

Next I gonna directly get the element. In LazyList (LazyColumn or LazyRow) index of element isn't equal a position on a screen. So, we need some extension for getting the element by index.

private fun LazyListState.getVisibleItemInfo(itemPosition: Int): LazyListItemInfo? {
    return this.layoutInfo.visibleItemsInfo.getOrNull(itemPosition - this.firstVisibleItemIndex)
    }
Enter fullscreen mode Exit fullscreen mode
private val currentElement: LazyListItemInfo?
    get() = currentIndexOfDraggedItem?.let {
        lazyListState.getVisibleItemInfo(it)
    }
Enter fullscreen mode Exit fullscreen mode

Next our task is changing indexes - we have to change the index of element to which we moved the dragged element, and we have to change index of dragged element.

fun onDrag(offset: Offset) {
    draggingDistance += offset.y

    initialOffsets?.let { (top, bottom) ->
        val startOffset = top.toFloat() + draggingDistance
        val endOffset = bottom.toFloat() + draggingDistance

        currentElement?.let { current ->
            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
                    }
                }
        }?.also { item ->
            currentIndexOfDraggedItem?.let { current ->
                onMove.invoke(current, item.index)
            }
            currentIndexOfDraggedItem = item.index
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

onDrag function is completed. Let's paste it to LazyColumn.

detectDragGesturesAfterLongPress(
    onDrag = { change, offset ->
        change.consume()
        dragAndDropListState.onDrag(offset)

/*some code below*/
Enter fullscreen mode Exit fullscreen mode

But it's not all because if we try to use it we can see that list doesn't scroll behind dragged item. For scrolling list we have to add over scroll checker and some CoroutineJob for the .scrollBy() function.

Add over scroll checker function to class DragAndDropListState

fun checkOverscroll(): Float {
    return 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
}
Enter fullscreen mode Exit fullscreen mode

Add Coroutine Scope and Coroutine Job to the top of list

val coroutineScope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
Enter fullscreen mode Exit fullscreen mode

And add scrollBy() to onDrag

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() }

}
Enter fullscreen mode Exit fullscreen mode

onDragEnd, onDragCancel

Here we just reset variables in class DragAndDropListState

fun onDragInterrupted() {
    initialDraggingElement = null
    currentIndexOfDraggedItem = null
    draggingDistance = 0f
}
Enter fullscreen mode Exit fullscreen mode

Step 4

There is one thing what we have to do. We should define the modifier of element.

Let's create a variable inside class DragAndDropListState

val elementDisplacement: Float?
    get() = currentIndexOfDraggedItem?.let {
        lazyListState.getVisibleItemInfo(it)
    }?.let { itemInfo ->
        (initialDraggingElement?.offset ?: 0f).toFloat() + draggingDistance - itemInfo.offset
    }
Enter fullscreen mode Exit fullscreen mode

And our element's modifier looks like this:

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

Preliminary results

Now we have the same code like in Make it Easy: How to implement Drag and Drop List Item in Jetpack Compose video.

You can get the code by this link: Part 1 code

And this even works :)
Working example

Issues

  • If we add a new user our list back to the starter order of elements. It happens we add user to a database, from the database we receive a new users list, obviously our uiState also have a new value, next recomposition.
  • If we add a new user and try to interact with last elements we can catch Index Of Bounds Exception. It happens because we have no any keys for update DragAndDropListState.

Refactoring and resolving issues will be in next Part 2 (place for link).

Top comments (0)