简体   繁体   中英

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?

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

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

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM