Allow media select reorder.

This commit is contained in:
Alex Hart
2026-07-28 13:09:52 -03:00
parent 361b767a3f
commit e177f8a590
9 changed files with 352 additions and 112 deletions
@@ -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
}
}
}