Improve drag-to-reorder auto scroll behavior when dragging an item up the list.

This commit is contained in:
jeffrey-signal
2026-04-09 15:18:42 -04:00
parent ed89f3a78e
commit f0109f3e6b
2 changed files with 117 additions and 51 deletions

View File

@@ -18,17 +18,19 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent.OnItemMove
@@ -50,10 +52,22 @@ fun rememberDragDropState(
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onEvent = onEvent, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope)
}
val maxAutoScrollSpeed = with(LocalDensity.current) { 30.dp.toPx() }
val baseAutoScrollSpeed = with(LocalDensity.current) { 10.dp.toPx() }
val scrollAcceleration = 2f
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
withFrameNanos { }
val overscrollAmount = state.dragOverscrollAmount
if (overscrollAmount != 0f) {
val scrollDirection = if (overscrollAmount < 0f) -1f else 1f
val scrollAmount = (scrollDirection * baseAutoScrollSpeed + overscrollAmount * scrollAcceleration)
.coerceIn(-maxAutoScrollSpeed, maxAutoScrollSpeed)
lazyListState.scrollBy(scrollAmount)
state.swapDraggingItemIfNeeded()
}
}
}
return state
@@ -70,15 +84,16 @@ internal constructor(
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
var dragOverscrollAmount by mutableFloatStateOf(0f)
private set
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
@@ -125,9 +140,11 @@ internal constructor(
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
dragOverscrollAmount = 0f
}
internal fun onDrag(offset: Offset, change: PointerInputChange) {
@@ -139,41 +156,78 @@ internal constructor(
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo
val isDraggingItemOffScreen = draggingItem == null
if (isDraggingItemOffScreen) {
draggingItemIndex?.let { itemIndex ->
val firstVisibleIndex = state.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: Int.MAX_VALUE
val lastVisibleIndex = state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: Int.MIN_VALUE
val scrollingDownPastItem = itemIndex < firstVisibleIndex && dragOverscrollAmount > 0
val scrollingUpPastItem = itemIndex > lastVisibleIndex && dragOverscrollAmount < 0
if (scrollingDownPastItem || scrollingUpPastItem) {
// stop auto-scroll to guard against runaway scrolling
dragOverscrollAmount = 0f
}
}
return
}
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
findSwapTarget(draggingItem, startOffset, endOffset)
?.let { targetItem -> performSwap(draggingItem, targetItem) }
val topOverscrollAmount = (startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
val bottomOverscrollAmount = (endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
dragOverscrollAmount = when {
bottomOverscrollAmount > 0f -> bottomOverscrollAmount
else -> topOverscrollAmount
}
}
fun swapDraggingItemIfNeeded() {
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
findSwapTarget(draggingItem, startOffset, endOffset)
?.let { targetItem -> performSwap(draggingItem, targetItem) }
}
private fun findSwapTarget(draggingItem: LazyListItemInfo, startOffset: Float, endOffset: Float): LazyListItemInfo? {
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
item.index != draggingItem.index &&
(!includeHeader || item.index != 0) &&
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
}
return state.layoutInfo.visibleItemsInfo.find { item ->
when {
item.index == draggingItem.index -> false
includeHeader && item.index == 0 -> false
includeFooter && item.index == (state.layoutInfo.totalItemsCount - 1) -> false
if (targetItem != null &&
(!includeHeader || targetItem.index != 0) &&
(!includeFooter || targetItem.index != (state.layoutInfo.totalItemsCount - 1))
) {
if (includeHeader) {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index - 1, toIndex = targetItem.index - 1))
} else {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index, toIndex = targetItem.index))
}
draggingItemIndex = targetItem.index
} 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) {
scrollChannel.trySend(overscroll)
item.index > draggingItem.index -> {
val centerOfDraggedItem = middleOffset.toInt()
val centerOfItemBelow = item.offset + item.size / 2
val draggedItemOverlapsItemBelow = centerOfDraggedItem in item.offset..item.offsetEnd
draggedItemOverlapsItemBelow && centerOfDraggedItem >= centerOfItemBelow
}
else -> {
val isDirectlyAboveDraggingItem = item.index == draggingItem.index - 1
val topOfItemAbove = item.offset.toFloat()
isDirectlyAboveDraggingItem && endOffset <= topOfItemAbove
}
}
}
}
private fun performSwap(draggingItem: LazyListItemInfo, targetItem: LazyListItemInfo) {
if (includeHeader) {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index - 1, toIndex = targetItem.index - 1))
} else {
onEvent.invoke(OnItemMove(fromIndex = draggingItem.index, toIndex = targetItem.index))
}
draggingItemIndex = targetItem.index
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}

View File

@@ -37,24 +37,9 @@ suspend fun PointerInputScope.detectDragGestures(
awaitEachGesture {
try {
val down = awaitFirstDown(requireUnconsumed = false)
val drag = awaitLongPressOrCancellation(down.id)
if (drag != null && down.position.x in dragHandleXRange) {
onDragStart.invoke(drag.position)
if (
drag(drag.id) {
onDrag(it, it.positionChange())
it.consume()
}
) {
// consume up if we quit drag gracefully with the up
currentEvent.changes.fastForEach {
if (it.changedToUp()) it.consume()
}
onDragEnd()
} else {
onDragCancel()
}
val dragChange = awaitLongPressOrCancellation(down.id)
if (dragChange != null && dragChange.position.x in dragHandleXRange) {
dispatchDragCallbacks(dragChange, onDragStart, onDragEnd, onDragCancel, onDrag)
}
} catch (c: CancellationException) {
onDragCancel()
@@ -63,6 +48,33 @@ suspend fun PointerInputScope.detectDragGestures(
}
}
private suspend fun AwaitPointerEventScope.dispatchDragCallbacks(
dragChange: PointerInputChange,
onDragStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
onDragStart.invoke(dragChange.position)
val dragCompleted = drag(
pointerId = dragChange.id,
onDrag = { change ->
onDrag(change, change.positionChange())
change.consume()
}
)
if (dragCompleted) {
currentEvent.changes.fastForEach {
if (it.changedToUp()) it.consume()
}
onDragEnd()
} else {
onDragCancel()
}
}
/**
* Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a reduced long press timeout
*/