I want to create a LazyColumn
with items that can be reordered by drag & drop. Without compose, my approach would be to use ItemTouchHelper.SimpleCallback
, but I haven't found anything like that for compose.
I've tried using Modifier.longPressDragGestureFilter
and Modifier.draggable
, but that merely allows me to drag the card around using an offset. It doesn't give me a list index (like fromPosition
/toPosition
in ItemTouchHelper.SimpleCallback
), which I need to swap the items in my list.
Is there a compose equivalent to ItemTouchHelper.SimpleCallback
's onMove
function? If not, is it a planned feature?
Is it possible/feasible to try and implement this sort of thing myself?
A simple(not perfect) reorderable list can be build by using detectDragGesturesAfterLongPress
and rememberLazyListState
.
The basic idea is to add a drag gesture modifier to the LazyColumn and detect the dragged item our self instead of adding a modifier per item.
val listState: LazyListState = rememberLazyListState()
...
LazyColumn(
state = listState,
modifier = Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(....)
Find the item using the layoutInfo provided by LazyListState :
var position by remember {
mutableStateOf<Float?>(null)
}
...
onDragStart = { offset ->
listState.layoutInfo.visibleItemsInfo
.firstOrNull { offset.y.toInt() in it.offset..it.offset + it.size }
?.also {
position = it.offset + it.size / 2f
}
}
Update the position on every drag :
onDrag = { change, dragAmount ->
change.consumeAllChanges()
position = position?.plus(dragAmount.y)
// Start autoscrolling if position is out of bounds
}
To support reording while scrolling we cant do the reording in onDrag
.
For this we create a flow to find the nearest item on every position/scroll update :
var draggedItem by remember {
mutableStateOf<Int?>(null)
}
....
snapshotFlow { listState.layoutInfo }
.combine(snapshotFlow { position }.distinctUntilChanged()) { state, pos ->
pos?.let { draggedCenter ->
state.visibleItemsInfo
.minByOrNull { (draggedCenter - (it.offset + it.size / 2f)).absoluteValue }
}?.index
}
.distinctUntilChanged()
.collect { near -> ...}
Update the dragged item index and move the item in your MutableStateList.
draggedItem = when {
near == null -> null
draggedItem == null -> near
else -> near.also { items.move(draggedItem, it) }
}
fun <T> MutableList<T>.move(fromIdx: Int, toIdx: Int) {
if (toIdx > fromIdx) {
for (i in fromIdx until toIdx) {
this[i] = this[i + 1].also { this[i + 1] = this[i] }
}
} else {
for (i in fromIdx downTo toIdx + 1) {
this[i] = this[i - 1].also { this[i - 1] = this[i] }
}
}
}
Calculate the relative item offset :
val indexWithOffset by derivedStateOf {
draggedItem
?.let { listState.layoutInfo.visibleItemsInfo.getOrNull(it - listState.firstVisibleItemIndex) }
?.let { Pair(it.index, (position ?: 0f) - it.offset - it.size / 2f) }
}
Which can then used to apply the offset to the dragged item (Don`t use item keys !) :
itemsIndexed(items) { idx, item ->
val offset by remember {
derivedStateOf { state.indexWithOffset?.takeIf { it.first == idx }?.second }
}
Column(
modifier = Modifier
.zIndex(offset?.let { 1f } ?: 0f)
.graphicsLayer {
translationY = offset ?: 0f
}
)
....
}
A sample implementation can be found here
So far from what I can tell Compose doesn't offer a way of handling this yet, although I'm assuming this will be in the works as they have added the draggable and longPressDragGestureFilter modifiers as you have already mentioned. As they have added these in, maybe this is a precursor to drag and drop inside of lazy columns.
A issue was raised with Google in Feb 2021, their response was there will be no official solution from them for the 1.0 release, although within this they have provided some guidance on how to approach the solution. It looks like the best solution for now is to have a RecyclerView with the ItemTouchHelper.
Here is the issue mentioned: https://issuetracker.google.com/issues/181282427
UPDATE 23/11/21
Although this doesn't handle the clicks of the items themselves, it only handles the animation, this is a step towards a internal drag to reorder. They have added the option to use a new modifier called Modifier.animateItemPlacement()
in combination with providing a key when setting the items up.
https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.1.0-beta03
Example:
var list by remember { mutableStateOf(listOf("A", "B", "C")) }
LazyColumn {
item {
Button(onClick = { list = list.shuffled() }) {
Text("Shuffle")
}
}
items(list, key = { it }) {
Text("Item $it", Modifier.animateItemPlacement())
}
}
Based on google samples and some posts in Medium, i came up with this implementation:
DragDropColumn.kt ->
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DragDropColumn(
items: List<T>,
onSwap: (Int, Int) -> Unit,
itemContent: @Composable LazyItemScope.(item: T) -> Unit
) {
var overscrollJob by remember { mutableStateOf<Job?>(null) }
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
onSwap(fromIndex, toIndex)
}
LazyColumn(
modifier = Modifier
.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
if (overscrollJob?.isActive == true)
return@detectDragGesturesAfterLongPress
dragDropState
.checkForOverScroll()
.takeIf { it != 0f }
?.let {
overscrollJob =
scope.launch {
dragDropState.state.animateScrollBy(
it*1.3f, tween(easing = FastOutLinearInEasing)
)
}
}
?: run { overscrollJob?.cancel() }
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
},
onDragCancel = {
dragDropState.onDragInterrupted()
overscrollJob?.cancel()
}
)
},
state = listState,
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(items = items) { index, item ->
DraggableItem(
dragDropState = dragDropState,
index = index
) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Card(elevation = elevation) {
itemContent(item)
}
}
}
}
}
DragDropState.kt ->
class DragDropState internal constructor(
val state: LazyListState,
private val scope: CoroutineScope,
private val onSwap: (Int, Int) -> Unit
) {
private var draggedDistance by mutableStateOf(0f)
private var draggingItemInitialOffset by mutableStateOf(0)
internal val draggingItemOffset: Float
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggedDistance - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == currentIndexOfDraggedItem }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
// used to obtain initial offsets on drag start
private var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
private val initialOffsets: Pair<Int, Int>?
get() = initiallyDraggedElement?.let { Pair(it.offset, it.offsetEnd) }
private val currentElement: LazyListItemInfo?
get() = currentIndexOfDraggedItem?.let {
state.getVisibleItemInfoFor(absoluteIndex = it)
}
fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
currentIndexOfDraggedItem = it.index
initiallyDraggedElement = it
draggingItemInitialOffset = it.offset
}
}
fun onDragInterrupted() {
if (currentIndexOfDraggedItem != null) {
previousIndexOfDraggedItem = currentIndexOfDraggedItem
// val startOffset = draggingItemOffset
scope.launch {
//previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
tween(easing = FastOutLinearInEasing)
)
previousIndexOfDraggedItem = null
}
}
draggingItemInitialOffset = 0
draggedDistance = 0f
currentIndexOfDraggedItem = null
initiallyDraggedElement = null
}
fun onDrag(offset: Offset) {
draggedDistance += offset.y
initialOffsets?.let { (topOffset, bottomOffset) ->
val startOffset = topOffset + draggedDistance
val endOffset = bottomOffset + draggedDistance
currentElement?.let { hovered ->
state.layoutInfo.visibleItemsInfo
.filterNot { item -> item.offsetEnd < startOffset || item.offset > endOffset || hovered.index == item.index }
.firstOrNull { item ->
val delta = (startOffset - hovered.offset)
when {
delta > 0 -> (endOffset > item.offsetEnd)
else -> (startOffset < item.offset)
}
}
?.also { item ->
currentIndexOfDraggedItem?.let { current ->
scope.launch {
onSwap.invoke(
current,
item.index
)
}
}
currentIndexOfDraggedItem = item.index
}
}
}
}
fun checkForOverScroll(): Float {
return initiallyDraggedElement?.let {
val startOffset = it.offset + draggedDistance
val endOffset = it.offsetEnd + draggedDistance
return@let when {
draggedDistance > 0 -> (endOffset - state.layoutInfo.viewportEndOffset+50f).takeIf { diff -> diff > 0 }
draggedDistance < 0 -> (startOffset - state.layoutInfo.viewportStartOffset-50f).takeIf { diff -> diff < 0 }
else -> null
}
} ?: 0f
}
}
DragDropExt.kt ->
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onSwap: (Int, Int) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(
state = lazyListState,
onMove = onSwap,
scope = scope
)
}
return state
}
fun LazyListState.getVisibleItemInfoFor(absoluteIndex: Int): LazyListItemInfo? {
return this
.layoutInfo
.visibleItemsInfo
.getOrNull(absoluteIndex - this.layoutInfo.visibleItemsInfo.first().index)
}
val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
@ExperimentalFoundationApi
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) {
val current: Float by animateFloatAsState(dragDropState.draggingItemOffset * 0.67f)
val previous: Float by animateFloatAsState(dragDropState.previousItemOffset.value * 0.67f)
val dragging = index == dragDropState.currentIndexOfDraggedItem
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = current
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = previous
}
} else {
Modifier.animateItemPlacement(
tween(easing = FastOutLinearInEasing)
)
}
Column(modifier = modifier.then(draggingModifier)) {
content(dragging)
}
}
And using this will look like:
@Composable
fun SectionListUI() {
val viewModel: SectionListViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsState()
DragDropColumn(items = uiState.sections, onSwap = viewModel::swapSections) { item ->
Card(
modifier = Modifier
.clickable { viewModel.sectionClick(item) },
) {
Text(
text = item.title,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
}
This implementation suits well for my usecase.. But, feel free to comment and make improvements
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With