Blog Infos
Author
Published
Topics
,
Author
Published

Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).

Jetpack Compose v1.0 has been out for quite some time now 🎉

At the time of writing this article in v1.0, Compose’s RecyclerView counterpart LazyColumn・LazyRow don’t have an implementation for Drag and drop, though Support Drag and Drop is very well a part of the Compose Roadmap.

https://developer.android.com/jetpack/androidx/compose-roadmap

In the meantime, I tried implementing basic Drag-n-Drop with the existing APIs.

                                  Drag-n-Drop in LazyList

Check this gist for the detailed and entire implementation of Drag-n-Drop.

In this article I’ll walk you over the key portions of code to help you understand the implementation.

Drag on Long-press

LazyListacts similar to RecyclerView, where reordering of data doesn’t reorder the View instances, instead recycles to reuse them.

So in this implementation, instead of applying Long-press gesture detector to the Modifier of each individual list element, which may require complex handling of View-Data mapping, we’ll add it to the Modifier of the LazyList itself. We can then translate the captured drag event of the LazyList onto the individual elements.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Determining the element to be dragged

With LazyColumn’s LazyListState keeping track of the visible elements (check LazyListState.visibleItemsInfo), we determine the element to be dragged by checking whether the drag’s start offset (in onDragStartcallback) lies within the bounds of any of the elements.

This will help us ‘remember’ the initial offset from where drag event had begun throughout further recompositions.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}

With the initial offsets and element to be dragged determined in onDragStart handled, let’s move onto onDrag when the actual drag happens.

                  Translate LazyList’s onDrag offset to an element
While Dragging

Once the dragged element has been identified, we now determine the element over which the dragged element is currently hovering on. We then shift it up or down (based on drag direction) and shift the item in the List` or `Array` also to trigger recomposition of the `LazyList`.

Translating the dragged element

With the drag event’s initial offset determined, we can translate the drag event’s offset to the dragged element by the cumulative sum of offset value from the onDrag callback.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Identifying currently hovered items

As an element is dragged, it’ll be crossing over the other elements and based on the direction, once the element crosses a particular “threshold” of the element it is currently crossing (or hovering upon), reordering of the List will be triggered.

The elements which fall beyond the top or bottom of the dragged element are not considered as hovered upon.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Determining the element to be replaced

Once the list of hovered elements are determined we then choose the first element, based on drag direction, whose the top or bottom (the threshold in our case) has been crossed over by the dragged element.
This element will be the one that the dragged element will be replacing.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Trigger reordering of List

With the to-be-replaced element determined, we can trigger a recomposition of the LazyColumn with the List of updated positions.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Handling overscroll

In any typical Drag-n-Drop reordering list, it is expected that the list autoscrolls when an element reaches either visibile thresholds of the screen.

To implement this, we first check if the dragged element has crossed either of the visible thresholds and calculate the overscolled offset. If there’s a non-zero offset, we can then initiate a scroll by calling `LazyListState.scrollBy(offset)`.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Checking for any overscroll

In case of a positive draggedDistance which indicates drag in the downward direction, if the ‘bottom’ of the dragged element crosses the visible bottom threshold (check viewPortEndOffset) of the LazyList we can confirm that the element has over-scrolled beyond the visible bottom.

On the other hand, a negative value we would be looking for if the ‘top’ of the dragged element crosses the visible top threshold (check viewPortStartOffset) of the LazyList.

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Drag Interrupted
Reset variables on interrupting the Drag

On cancelling or ending the drag, all the remembered values are to be reset. so that the dragged element settles at its latest position in the LazyColumn

fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
val viewPortStart = lazyListState.layoutInfo.viewportStartOffset
val viewPortEnd = lazyListState.layoutInfo.viewportEndOffset
when {
draggedDistance > 0 -> (endOffset - viewPortEnd).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - viewPortStart).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
onDrag = { offset ->
..
initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot { item ->
item.offsetEnd < startOffset || item.offset > endOffset
}
}
}
}
onDragEnd = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
},
onDragCancel = {
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
overscrollJob?.cancel()
}
view raw DragCancel.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(..) {
val lazyListState = rememberLazyListState()
// used to obtain initial offsets on drag start
var initiallyDraggedElement by remember {mutableStateOf<LazyListItemInfo?>(null) }
var currentIndexOfDraggedItem by remember { mutableStateOf<Int?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..item.offsetEnd }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
}
},
)
},
state = lazyListState
)
}
var elementDisplacement by remember { mutableStateOf(0f) }
var currentIndexOfDraggedItem by remember { .. }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
draggedDistance += offset.y
}
),
) {
itemsIndexed(items) { index, item ->
Box(modifier = Modifier
.graphicsLayer {
// only move the element if that is where Drag started
translationY = draggedDistance
.takeIf { index == currentIndexOfDraggedItem } ?: 0f
}
)
}
}
onDrag = { offset ->
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull { item ->
val delta = startOffset - hovered.offset
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
}
}
@Composable
fun ReorderableList(models: List<T>) {
val lazyListState = rememberLazyListState()
val calculatedOffset = remember { mutableStateOf<Float>() }
LazyColumn(
modifier = ..
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consumeAllChanges()
// compute calculatedOffset
..
},
onDragStart = { offset -> .. },
onDragEnd = { .. },
onDragCancel = { .. }
)
},
state = lazyListState
) {
items(models) {
Box(modifier = Modifier
.graphicsLayer(translationY = calculatedOffset ?: 0f)
)
}
}
}
view raw Overall.kt hosted with ❤ by GitHub
// since LazyListState.scrollBy() is a suspend function
val scope = rememberCoroutineScope()
var overscrollJob by remember { mutableStateOf<Job?>(null) }
LazyColumn(
modifier = ..
detectDragGesturesAfterLongPress(
onDrag = {
..
// let current LazyListState.scrollBy() not be interrupted
if (overscrollJob?.isActive == true)
return
// launch LazyListState.scrollBy only if overscrolled offset != 0
checkForOverScroll()
.takeIf { offset -> offset != 0f }
?.let { offset -> overscrollJob = scope.launch { lazyListState.scrollBy(offset) } }
?: run { overscrollJob?.cancel() }
}
)
)
view raw Overscroll.kt hosted with ❤ by GitHub
@Composable
fun ReorderList(
items: List<T>,
onMove: (fromIndex: Int, toIndex: Int) -> Unit
) {
..
onDrag = {
..
currentElementItemInfo?.let { hovered ->
lazyListState.layoutInfo.visibleItemsInfo
.filterNot {..}
.firstOrNull {..}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
onMove.invoke(current, item.index)
}
currentIndexOfDraggedItem = item.index
}
}
}
}
@Composable
fun Screen() {
val list = listOf(..).toMutableStateList()
ReorderList(
items = list,
onMove = { fromIndex, toIndex -> list.move(fromIndex, toIndex) }
)
}
Conclusion

While this might not be the most optimal solution in terms of composition optimisation for Drag-n-Drop. Hopefully this gave a little insight into the APIs of LazyList・LazyRow provided in v1.0.

Meanwhile, I wait curiously to see how the Compose team implements the Drag-n-Drop support 😁

Happy coding 💻 and stay safe during these trying times 😷

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu