Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Reorder LazyColumn items with drag & drop

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?

like image 834
Noah Avatar asked Nov 19 '20 13:11

Noah


Video Answer


3 Answers

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

like image 125
Andre Classen Avatar answered Oct 20 '22 21:10

Andre Classen


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())
    }
}
like image 30
James Avatar answered Oct 20 '22 21:10

James


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

like image 1
Jurij Pitulja Avatar answered Oct 20 '22 20:10

Jurij Pitulja