mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 02:39:55 +01:00
Improve drag-to-reorder auto scroll behavior when dragging an item up the list.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user