diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt b/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt index a8a0d846ca..4c87feb7c0 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragAndDrop.kt @@ -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(null) private set - internal val scrollChannel = Channel() + 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 } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragGestureDetector.kt b/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragGestureDetector.kt index 062fc1d834..d887e90606 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragGestureDetector.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/copied/androidx/compose/DragGestureDetector.kt @@ -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 */