mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 21:07:49 +01:00
Allow media select reorder.
This commit is contained in:
@@ -25,10 +25,12 @@ import kotlinx.coroutines.CancellationException
|
||||
* Modified version of detectDragGesturesAfterLongPress from [androidx.compose.foundation.gestures.DragGestureDetector]
|
||||
* that initiates drags when the touch starts within a specified x coordinate range.
|
||||
*
|
||||
* @param dragHandleXRange The x coordinate range (in pixels) where drags can be initiated.
|
||||
* @param dragHandleXRange The x coordinate range (in pixels) where drags can be initiated, or null to allow drags to start
|
||||
* anywhere in the container. A range implies a dedicated drag handle, so drags begin almost immediately; without one, a
|
||||
* full long press is required so that taps and scrolls within the container still work.
|
||||
*/
|
||||
suspend fun PointerInputScope.detectDragGestures(
|
||||
dragHandleXRange: ClosedFloatingPointRange<Float>,
|
||||
dragHandleXRange: ClosedFloatingPointRange<Float>?,
|
||||
onDragStart: (Offset) -> Unit = { },
|
||||
onDragEnd: () -> Unit = { },
|
||||
onDragCancel: () -> Unit = { },
|
||||
@@ -37,8 +39,9 @@ suspend fun PointerInputScope.detectDragGestures(
|
||||
awaitEachGesture {
|
||||
try {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val dragChange = awaitLongPressOrCancellation(down.id)
|
||||
if (dragChange != null && dragChange.position.x in dragHandleXRange) {
|
||||
val timeoutMillis = if (dragHandleXRange != null) viewConfiguration.longPressTimeoutMillis / 100 else viewConfiguration.longPressTimeoutMillis
|
||||
val dragChange = awaitLongPressOrCancellation(down.id, timeoutMillis)
|
||||
if (dragChange != null && (dragHandleXRange == null || dragChange.position.x in dragHandleXRange)) {
|
||||
dispatchDragCallbacks(dragChange, onDragStart, onDragEnd, onDragCancel, onDrag)
|
||||
}
|
||||
} catch (c: CancellationException) {
|
||||
@@ -76,10 +79,11 @@ private suspend fun AwaitPointerEventScope.dispatchDragCallbacks(
|
||||
}
|
||||
|
||||
/**
|
||||
* Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a reduced long press timeout
|
||||
* Modified version of awaitLongPressOrCancellation from [androidx.compose.foundation.gestures.DragGestureDetector] with a configurable long press timeout
|
||||
*/
|
||||
suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
|
||||
pointerId: PointerId
|
||||
pointerId: PointerId,
|
||||
timeoutMillis: Long
|
||||
): PointerInputChange? {
|
||||
if (currentEvent.isPointerUp(pointerId)) {
|
||||
return null // The pointer has already been lifted, so the long press is cancelled.
|
||||
@@ -90,10 +94,9 @@ suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
|
||||
|
||||
var longPress: PointerInputChange? = null
|
||||
var currentDown = initialDown
|
||||
val longPressTimeout = (viewConfiguration.longPressTimeoutMillis / 100)
|
||||
return try {
|
||||
// wait for first tap up or long press
|
||||
withTimeout(longPressTimeout) {
|
||||
withTimeout(timeoutMillis) {
|
||||
var finished = false
|
||||
while (!finished) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Main)
|
||||
|
||||
@@ -8,9 +8,10 @@ package org.signal.core.ui.compose.list
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -26,10 +27,14 @@ 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.GraphicsLayerScope
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
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.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
@@ -43,25 +48,45 @@ import org.signal.core.ui.compose.list.ReorderListEvent.ItemMoved
|
||||
* Adapted from the AndroidX Compose demo
|
||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
|
||||
*
|
||||
* - Allows for dragging and dropping to reorder within lazy columns.
|
||||
* - Allows for dragging and dropping to reorder within lazy columns and lazy rows.
|
||||
* - Supports adding non-draggable headers and footers.
|
||||
*
|
||||
* @param autoScroll Whether dragging past an edge scrolls the list. Pass false when the list's scroll position is owned
|
||||
* by something other than the user, such as a rail driven by a pager, since scrolling it here only fights that owner.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReorderableListState(
|
||||
lazyListState: LazyListState,
|
||||
includeHeader: Boolean,
|
||||
includeFooter: Boolean,
|
||||
orientation: Orientation = Orientation.Vertical,
|
||||
autoScroll: Boolean = true,
|
||||
onEvent: (ReorderListEvent) -> Unit = {}
|
||||
): ReorderableListState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(lazyListState) {
|
||||
ReorderableListState(state = lazyListState, onEvent = onEvent, includeHeader = includeHeader, includeFooter = includeFooter, scope = scope)
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val state = remember(lazyListState, orientation, isRtl, hapticFeedback) {
|
||||
ReorderableListState(
|
||||
state = lazyListState,
|
||||
onEvent = onEvent,
|
||||
includeHeader = includeHeader,
|
||||
includeFooter = includeFooter,
|
||||
scope = scope,
|
||||
orientation = orientation,
|
||||
mainAxisSign = if (orientation == Orientation.Horizontal && isRtl) -1f else 1f,
|
||||
hapticFeedback = hapticFeedback
|
||||
)
|
||||
}
|
||||
val maxAutoScrollSpeed = with(LocalDensity.current) { 30.dp.toPx() }
|
||||
val baseAutoScrollSpeed = with(LocalDensity.current) { 10.dp.toPx() }
|
||||
val scrollAcceleration = 2f
|
||||
|
||||
LaunchedEffect(state) {
|
||||
LaunchedEffect(state, autoScroll) {
|
||||
if (!autoScroll) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
while (true) {
|
||||
withFrameNanos { }
|
||||
|
||||
@@ -84,6 +109,9 @@ class ReorderableListState internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val includeHeader: Boolean,
|
||||
private val includeFooter: Boolean,
|
||||
internal val orientation: Orientation,
|
||||
private val mainAxisSign: Float,
|
||||
private val hapticFeedback: HapticFeedback,
|
||||
private val onEvent: (ReorderListEvent) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
@@ -109,27 +137,56 @@ class ReorderableListState internal constructor(
|
||||
internal var previousItemOffset = Animatable(0f)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Converts a pointer position within the list container into a position along the list's scrolling axis.
|
||||
*
|
||||
* Pointer positions are measured from the container edge while item offsets are measured from the content area, so any
|
||||
* before-content padding has to be taken out. That padding is what the layout reports as a negative viewport start
|
||||
* offset.
|
||||
*/
|
||||
private fun Offset.toMainAxisPosition(): Float {
|
||||
val positionInContainer = when {
|
||||
orientation == Orientation.Vertical -> y
|
||||
mainAxisSign > 0f -> x
|
||||
else -> state.layoutInfo.viewportSize.width - x
|
||||
}
|
||||
|
||||
return positionInContainer + state.layoutInfo.viewportStartOffset
|
||||
}
|
||||
|
||||
/** Converts a distance along the list's scrolling axis into the equivalent physical translation. */
|
||||
internal fun toPhysicalTranslation(mainAxisOffset: Float): Float = mainAxisSign * mainAxisOffset
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
val mainAxisPosition = offset.toMainAxisPosition()
|
||||
|
||||
state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item ->
|
||||
offset.y.toInt() in item.offset..(item.offset + item.size) &&
|
||||
mainAxisPosition.toInt() in item.offset..(item.offset + item.size) &&
|
||||
(!includeHeader || item.index != 0) &&
|
||||
(!includeFooter || item.index != (state.layoutInfo.totalItemsCount - 1))
|
||||
}
|
||||
?.also {
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragEnd() {
|
||||
val wasDragging = draggingItemIndex != null
|
||||
onDragInterrupted()
|
||||
onEvent(ReorderListEvent.ItemDropped)
|
||||
if (wasDragging) {
|
||||
onEvent(ReorderListEvent.ItemDropped)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragCancel() {
|
||||
val wasDragging = draggingItemIndex != null
|
||||
onDragInterrupted()
|
||||
onEvent(ReorderListEvent.DragCanceled)
|
||||
if (wasDragging) {
|
||||
onEvent(ReorderListEvent.DragCanceled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onDragInterrupted() {
|
||||
@@ -159,7 +216,7 @@ class ReorderableListState internal constructor(
|
||||
|
||||
change.consume()
|
||||
|
||||
draggingItemDraggedDelta += offset.y
|
||||
draggingItemDraggedDelta += mainAxisSign * if (orientation == Orientation.Vertical) offset.y else offset.x
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo
|
||||
val isDraggingItemOffScreen = draggingItem == null
|
||||
@@ -199,32 +256,33 @@ class ReorderableListState internal constructor(
|
||||
?.let { targetItem -> performSwap(draggingItem, targetItem) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The item to trade places with, if any: the one the dragged item's center is currently over, once that center has
|
||||
* passed the midpoint of it. The same midpoint rule applies in both directions so that dragging backwards takes no
|
||||
* more travel than dragging forwards, and the gap between items counts towards its neighbours so there is no dead
|
||||
* zone where nothing swaps.
|
||||
*/
|
||||
private fun findSwapTarget(draggingItem: LazyListItemInfo, startOffset: Float, endOffset: Float): LazyListItemInfo? {
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
val draggedCenter = startOffset + (endOffset - startOffset) / 2f
|
||||
val halfSpacing = state.layoutInfo.mainAxisItemSpacing / 2f
|
||||
|
||||
return state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
val itemCenter = item.offset + item.size / 2f
|
||||
|
||||
when {
|
||||
item.index == draggingItem.index -> false
|
||||
includeHeader && item.index == 0 -> false
|
||||
includeFooter && item.index == (state.layoutInfo.totalItemsCount - 1) -> false
|
||||
|
||||
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
|
||||
}
|
||||
draggedCenter < item.offset - halfSpacing || draggedCenter > item.offsetEnd + halfSpacing -> false
|
||||
item.index > draggingItem.index -> draggedCenter >= itemCenter
|
||||
else -> draggedCenter <= itemCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performSwap(draggingItem: LazyListItemInfo, targetItem: LazyListItemInfo) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
|
||||
|
||||
if (includeHeader) {
|
||||
onEvent.invoke(ItemMoved(fromIndex = draggingItem.index - 1, toIndex = targetItem.index - 1))
|
||||
} else {
|
||||
@@ -260,22 +318,24 @@ sealed interface ReorderListEvent {
|
||||
* Enables drag-to-reorder functionality within a container.
|
||||
*
|
||||
* @param reorderableListState The state managing the drag operation.
|
||||
* @param dragHandleWidth Width of the draggable area (positioned at the end of the container).
|
||||
* @param dragHandleWidth Width of the draggable area (positioned at the end of each row), or null to allow drags to start
|
||||
* anywhere in the container after a long press.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.reorderableList(
|
||||
reorderableListState: ReorderableListState,
|
||||
dragHandleWidth: Dp
|
||||
dragHandleWidth: Dp? = null
|
||||
): Modifier {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
return pointerInput(reorderableListState, dragHandleWidth, isRtl) {
|
||||
val containerWidthPx = size.width.toFloat()
|
||||
val handleWidthPx = dragHandleWidth.toPx()
|
||||
|
||||
val dragHandleXRange = if (isRtl) {
|
||||
0f..handleWidthPx
|
||||
} else {
|
||||
(containerWidthPx - handleWidthPx)..containerWidthPx
|
||||
val dragHandleXRange = dragHandleWidth?.toPx()?.let { handleWidthPx ->
|
||||
if (isRtl) {
|
||||
0f..handleWidthPx
|
||||
} else {
|
||||
(containerWidthPx - handleWidthPx)..containerWidthPx
|
||||
}
|
||||
}
|
||||
|
||||
detectDragGestures(
|
||||
@@ -293,20 +353,33 @@ fun LazyItemScope.ReorderableItem(
|
||||
reorderableListState: ReorderableListState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||
content: @Composable (isDragging: Boolean) -> Unit
|
||||
) {
|
||||
val dragging = index == reorderableListState.draggingItemIndex
|
||||
val draggingModifier =
|
||||
if (dragging) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer { translationY = reorderableListState.draggingItemOffset }
|
||||
.graphicsLayer { translateMainAxis(reorderableListState, reorderableListState.draggingItemOffset) }
|
||||
} else if (index == reorderableListState.previousIndexOfDraggedItem) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer { translationY = reorderableListState.previousItemOffset.value }
|
||||
.graphicsLayer { translateMainAxis(reorderableListState, reorderableListState.previousItemOffset.value) }
|
||||
} else {
|
||||
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||
}
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
|
||||
if (reorderableListState.orientation == Orientation.Horizontal) {
|
||||
Row(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
} else {
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun GraphicsLayerScope.translateMainAxis(reorderableListState: ReorderableListState, mainAxisOffset: Float) {
|
||||
if (reorderableListState.orientation == Orientation.Horizontal) {
|
||||
translationX = reorderableListState.toPhysicalTranslation(mainAxisOffset)
|
||||
} else {
|
||||
translationY = mainAxisOffset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose.list
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
|
||||
/**
|
||||
* Holds the order a drag is building up so that the backing state only has to be updated once, when the item is
|
||||
* dropped, rather than on every swap. Anything else driven by that state - a pager, a database write, an adjacent list
|
||||
* - would otherwise churn on every swap for the length of the drag.
|
||||
*
|
||||
* Render [items] and hand [onReorderListEvent][ReorderBuffer.onReorderListEvent] to [rememberReorderableListState].
|
||||
*/
|
||||
@Composable
|
||||
fun <T> rememberReorderBuffer(
|
||||
items: List<T>,
|
||||
onReorder: (fromIndex: Int, toIndex: Int) -> Unit
|
||||
): ReorderBuffer<T> {
|
||||
val currentItems by rememberUpdatedState(items)
|
||||
val currentOnReorder by rememberUpdatedState(onReorder)
|
||||
|
||||
val buffer = remember {
|
||||
ReorderBuffer(
|
||||
source = { currentItems },
|
||||
onReorder = { fromIndex, toIndex -> currentOnReorder(fromIndex, toIndex) }
|
||||
)
|
||||
}
|
||||
|
||||
// Hold the dragged order until the reorder lands in the backing list, otherwise the list snaps back to its pre-drag
|
||||
// order for the frame between dropping an item and the new state arriving.
|
||||
LaunchedEffect(items) {
|
||||
buffer.onSourceChanged()
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
class ReorderBuffer<T> internal constructor(
|
||||
private val source: () -> List<T>,
|
||||
private val onReorder: (fromIndex: Int, toIndex: Int) -> Unit
|
||||
) {
|
||||
private var dragOrder: List<T>? by mutableStateOf(null)
|
||||
private var dragStartIndex: Int? = null
|
||||
private var dragEndIndex: Int? = null
|
||||
|
||||
/** The order to render: the order the drag has built up while one is in progress, otherwise the backing list. */
|
||||
val items: List<T>
|
||||
get() = dragOrder ?: source()
|
||||
|
||||
/**
|
||||
* Whether a reorder is still in flight, either because the item is being dragged or because it has been dropped but
|
||||
* the resulting order has not reached the backing list yet. Anything that reacts to the list settling should wait for
|
||||
* this to clear rather than for the drag to end, or it will act on the pre-drag order first and correct itself after.
|
||||
*/
|
||||
val isReordering: Boolean
|
||||
get() = dragOrder != null
|
||||
|
||||
fun onReorderListEvent(event: ReorderListEvent) {
|
||||
when (event) {
|
||||
is ReorderListEvent.ItemMoved -> onItemMoved(event.fromIndex, event.toIndex)
|
||||
ReorderListEvent.ItemDropped, ReorderListEvent.DragCanceled -> onDragFinished()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onItemMoved(fromIndex: Int, toIndex: Int) {
|
||||
val current = items.toMutableList()
|
||||
if (fromIndex !in current.indices || toIndex !in current.indices) {
|
||||
return
|
||||
}
|
||||
|
||||
current.add(toIndex, current.removeAt(fromIndex))
|
||||
|
||||
dragOrder = current
|
||||
dragStartIndex = dragStartIndex ?: fromIndex
|
||||
dragEndIndex = toIndex
|
||||
}
|
||||
|
||||
private fun onDragFinished() {
|
||||
val fromIndex = dragStartIndex
|
||||
val toIndex = dragEndIndex
|
||||
|
||||
dragStartIndex = null
|
||||
dragEndIndex = null
|
||||
|
||||
if (fromIndex != null && toIndex != null && fromIndex != toIndex) {
|
||||
onReorder(fromIndex, toIndex)
|
||||
} else {
|
||||
dragOrder = null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onSourceChanged() {
|
||||
if (dragStartIndex == null) {
|
||||
dragOrder = null
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user