refactor: use item keys instead of indexes

This commit is contained in:
andrekir 2024-06-30 07:51:13 -03:00 committed by Andre K
parent 218100e9d5
commit c95cba097c

View file

@ -20,8 +20,10 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.stopScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
@ -48,7 +50,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
@ -56,19 +61,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
// Derived in part from: https://github.com/androidx/androidx/blob/c92ad2941368202b2d78b8d14c71bf81e9525944/compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
@Preview @Preview
@Composable @Composable
fun LazyColumnDragAndDropDemo() { fun LazyColumnDragAndDropDemo() {
var list by remember { mutableStateOf(List(50) { it }) } var list by remember { mutableStateOf(List(50) { it }) }
val listState = rememberLazyListState() val listState = rememberLazyListState()
val dragDropState = val dragDropState = rememberDragDropState(listState) { from, to ->
rememberDragDropState(listState) { fromIndex, toIndex -> list = list.toMutableList().apply { add(to.index, removeAt(from.index)) }
list = list.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } }
}
LazyColumn( LazyColumn(
modifier = Modifier.dragContainer(dragDropState), modifier = Modifier.dragContainer(
dragDropState = dragDropState,
haptics = LocalHapticFeedback.current,
),
state = listState, state = listState,
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
@ -77,7 +85,10 @@ fun LazyColumnDragAndDropDemo() {
DraggableItem(dragDropState, index) { isDragging -> DraggableItem(dragDropState, index) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 1.dp) val elevation by animateDpAsState(if (isDragging) 4.dp else 1.dp)
Card(elevation = elevation) { Card(elevation = elevation) {
Text("Item $item", Modifier.fillMaxWidth().padding(20.dp)) Text("Item $item",
Modifier
.fillMaxWidth()
.padding(20.dp))
} }
} }
} }
@ -85,12 +96,14 @@ fun LazyColumnDragAndDropDemo() {
} }
@Composable @Composable
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState { fun rememberDragDropState(
lazyListState: LazyListState,
onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit,
): DragDropState {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state = val state = remember(lazyListState) {
remember(lazyListState) { DragDropState(state = lazyListState, onMove = onMove, scope = scope)
DragDropState(state = lazyListState, onMove = onMove, scope = scope) }
}
LaunchedEffect(state) { LaunchedEffect(state) {
while (true) { while (true) {
val diff = state.scrollChannel.receive() val diff = state.scrollChannel.receive()
@ -104,9 +117,9 @@ class DragDropState
internal constructor( internal constructor(
private val state: LazyListState, private val state: LazyListState,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit private val onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit
) { ) {
var draggingItemIndex by mutableStateOf<Int?>(null) var draggingItemKey by mutableStateOf<Any?>(null)
private set private set
internal val scrollChannel = Channel<Float>() internal val scrollChannel = Channel<Float>()
@ -114,32 +127,34 @@ internal constructor(
private var draggingItemDraggedDelta by mutableFloatStateOf(0f) private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0) private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float internal val draggingItemOffset: Float
get() = get() = draggingItemLayoutInfo?.let { item ->
draggingItemLayoutInfo?.let { item -> draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset } ?: 0f
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo? private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex } get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null) internal var previousKeyOfDraggedItem by mutableStateOf<Any?>(null)
private set private set
internal var previousItemOffset = Animatable(0f) internal var previousItemOffset = Animatable(0f)
private set private set
internal fun onDragStart(offset: Offset) { internal fun gridItemKeyAtPosition(offset: Offset): Int? = state.layoutInfo.visibleItemsInfo
.find { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }?.key as? Int
internal fun onDragStart(key: Int) {
state.layoutInfo.visibleItemsInfo state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) } .firstOrNull { item -> item.key == key }
?.also { ?.also {
draggingItemIndex = it.index draggingItemKey = it.key
draggingItemInitialOffset = it.offset draggingItemInitialOffset = it.offset
} }
} }
internal fun onDragInterrupted() { internal fun onDragInterrupted() {
if (draggingItemIndex != null) { if (draggingItemKey != null) {
previousIndexOfDraggedItem = draggingItemIndex previousKeyOfDraggedItem = draggingItemKey
val startOffset = draggingItemOffset val startOffset = draggingItemOffset
scope.launch { scope.launch {
previousItemOffset.snapTo(startOffset) previousItemOffset.snapTo(startOffset)
@ -147,11 +162,11 @@ internal constructor(
0f, 0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f) spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
) )
previousIndexOfDraggedItem = null previousKeyOfDraggedItem = null
} }
} }
draggingItemDraggedDelta = 0f draggingItemDraggedDelta = 0f
draggingItemIndex = null draggingItemKey = null
draggingItemInitialOffset = 0 draggingItemInitialOffset = 0
} }
@ -163,8 +178,9 @@ internal constructor(
val endOffset = startOffset + draggingItem.size val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = val targetItem = state.layoutInfo.visibleItemsInfo
state.layoutInfo.visibleItemsInfo.find { item -> .filter { it.key is Int }
.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd && middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index draggingItem.index != item.index
} }
@ -173,22 +189,31 @@ internal constructor(
draggingItem.index == state.firstVisibleItemIndex || draggingItem.index == state.firstVisibleItemIndex ||
targetItem.index == state.firstVisibleItemIndex targetItem.index == state.firstVisibleItemIndex
) { ) {
state.requestScrollToItem( // state.requestScrollToItem( FIXME 1.7.0 method
state.firstVisibleItemIndex, // state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset // state.firstVisibleItemScrollOffset
) // )
} scope.launch {
onMove.invoke(draggingItem.index, targetItem.index) if (state.isScrollInProgress) {
draggingItemIndex = targetItem.index state.stopScroll()
} else { }
val overscroll = state.scrollToItem(
when { state.firstVisibleItemIndex,
draggingItemDraggedDelta > 0 -> state.firstVisibleItemScrollOffset
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f) )
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
} }
}
onMove.invoke(draggingItem, targetItem)
} else {
val overscroll = when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) { if (overscroll != 0f) {
scrollChannel.trySend(overscroll) scrollChannel.trySend(overscroll)
} }
@ -199,37 +224,47 @@ internal constructor(
get() = this.offset + this.size get() = this.offset + this.size
} }
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier { fun Modifier.dragContainer(
return pointerInput(dragDropState) { dragDropState: DragDropState,
haptics: HapticFeedback,
): Modifier {
return this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDrag = { change, offset -> onDrag = { change, offset ->
change.consume() change.consume()
dragDropState.onDrag(offset = offset) dragDropState.onDrag(offset = offset)
}, },
onDragStart = { offset -> dragDropState.onDragStart(offset) }, onDragStart = { offset ->
dragDropState.gridItemKeyAtPosition(offset)?.let { key ->
dragDropState.onDragStart(key)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
},
onDragEnd = { dragDropState.onDragInterrupted() }, onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() } onDragCancel = { dragDropState.onDragInterrupted() }
) )
} }
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LazyItemScope.DraggableItem( fun LazyItemScope.DraggableItem(
dragDropState: DragDropState, dragDropState: DragDropState,
index: Int, key: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
) { ) {
val dragging = index == dragDropState.draggingItemIndex val dragging = key == dragDropState.draggingItemKey
val draggingModifier = val draggingModifier = if (dragging) {
if (dragging) { Modifier
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset } .zIndex(1f)
} else if (index == dragDropState.previousIndexOfDraggedItem) { .graphicsLayer { translationY = dragDropState.draggingItemOffset }
Modifier.zIndex(1f).graphicsLayer { } else if (key == dragDropState.previousKeyOfDraggedItem) {
translationY = dragDropState.previousItemOffset.value Modifier
} .zIndex(1f)
} else { .graphicsLayer { translationY = dragDropState.previousItemOffset.value }
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null) } else {
} Modifier.animateItemPlacement()
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) } Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
} }