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:
- Make it Easy: How to implement Drag and Drop List Item in Jetpack Compose
- LazyColumnDragAndDropDemo.kt
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
anddata class UserEntityUi
-
Domain - consists of
data class UserEntity
andinterface 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 = { }
)
}
)
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
)
And of course we need to remember our state.
@Composable
fun rememberDragAndDropListState(
lazyListState: LazyListState,
onMove: (Int, Int) -> Unit
): DragAndDropListState {
return remember { DragAndDropListState(lazyListState, onMove) }
}
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)
}
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. */
}
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)
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
fun onDragStart(offset: Offset) {
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
initialDraggingElement = it
currentIndexOfDraggedItem = it.index
}
}
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*/
}
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
}
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
}
}
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)
}
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let {
lazyListState.getVisibleItemInfo(it)
}
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
}
}
}
onDrag
function is completed. Let's paste it to LazyColumn
.
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragAndDropListState.onDrag(offset)
/*some code below*/
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
}
Add Coroutine Scope and Coroutine Job to the top of list
val coroutineScope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
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() }
}
onDragEnd, onDragCancel
Here we just reset variables in class DragAndDropListState
fun onDragInterrupted() {
initialDraggingElement = null
currentIndexOfDraggedItem = null
draggingDistance = 0f
}
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
}
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
}
}
)
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
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)