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.
In the meantime, I tried implementing basic Drag-n-Drop with the existing APIs.
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
LazyList
acts 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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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
onDragStart
callback) 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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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.
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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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
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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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() | |
} |
@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) | |
) | |
} | |
} | |
} |
// 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() } | |
} | |
) | |
) |
@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 😷