diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/list/DragGestureDetector.kt b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragGestureDetector.kt index cdb561a25e..e08b097508 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/list/DragGestureDetector.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragGestureDetector.kt @@ -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, + dragHandleXRange: ClosedFloatingPointRange?, 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) diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToReorder.kt b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToReorder.kt index b05faf0a49..b4d5bbfa6d 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToReorder.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/list/DragToReorder.kt @@ -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(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 + } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/list/ReorderBuffer.kt b/core/ui/src/main/java/org/signal/core/ui/compose/list/ReorderBuffer.kt new file mode 100644 index 0000000000..8021de4ac6 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/list/ReorderBuffer.kt @@ -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 rememberReorderBuffer( + items: List, + onReorder: (fromIndex: Int, toIndex: Int) -> Unit +): ReorderBuffer { + 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 internal constructor( + private val source: () -> List, + private val onReorder: (fromIndex: Int, toIndex: Int) -> Unit +) { + private var dragOrder: List? 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 + 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 + } + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index 95d62945f3..0a7b46472f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -60,7 +60,6 @@ import org.signal.mediasend.select.MediaSelectScreenEvent import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import java.io.FileInputStream import java.io.IOException -import java.util.Collections import kotlin.coroutines.resume import kotlin.time.Duration import kotlin.time.Duration.Companion.microseconds @@ -146,9 +145,6 @@ class MediaSendViewModel( .map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 } .distinctUntilChanged() - /** Tracks drag state for media reordering. */ - private var lastMediaDrag: Pair = Pair(0, 0) - init { // Matches legacy behavior: VM subscribes to connectivity updates and derives // isPreUploadEnabled from metered state. @@ -234,6 +230,7 @@ class MediaSendViewModel( is MediaSelectScreenEvent.FolderClick -> onFolderClick(mediaSelectScreenEvent.mediaFolder) is MediaSelectScreenEvent.MediaClick -> onMediaClick(mediaSelectScreenEvent.media) is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media) + is MediaSelectScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaSelectScreenEvent.fromIndex, mediaSelectScreenEvent.toIndex) MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit() } } @@ -265,6 +262,7 @@ class MediaSendViewModel( override fun onMediaEditScreenEvent(mediaEditScreenEvent: MediaEditScreenEvent) { when (mediaEditScreenEvent) { is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media) + is MediaEditScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaEditScreenEvent.fromIndex, mediaEditScreenEvent.toIndex) MediaEditScreenEvent.NextClick -> { if (state.value.isContactSelectionRequired) { backStack.goToSend() @@ -767,56 +765,24 @@ class MediaSendViewModel( //region Drag/Reordering - fun swapMedia(originalStart: Int, end: Int): Boolean { - var start = originalStart + /** Moves the media at [fromIndex] to [toIndex]. Called once per drag, once the item has been dropped. */ + private fun reorderMedia(fromIndex: Int, toIndex: Int) { + val selectedMedia = state.value.selectedMedia - if (lastMediaDrag.first == start && lastMediaDrag.second == end) { - return true - } else if (lastMediaDrag.first == start) { - start = lastMediaDrag.second + if (fromIndex == toIndex || fromIndex !in selectedMedia.indices || toIndex !in selectedMedia.indices) { + return } - val snapshot = state.value + val reordered = selectedMedia.toMutableList().apply { add(toIndex, removeAt(fromIndex)) } - if (end >= snapshot.selectedMedia.size || - end < 0 || - start >= snapshot.selectedMedia.size || - start < 0 - ) { - return false - } - - lastMediaDrag = Pair(originalStart, end) - - val newMediaList = snapshot.selectedMedia.toMutableList() - - if (start < end) { - for (i in start until end) { - Collections.swap(newMediaList, i, i + 1) - } - } else { - for (i in start downTo end + 1) { - Collections.swap(newMediaList, i, i - 1) - } - } - - updateState { copy(selectedMedia = newMediaList) } - return true - } - - fun isValidMediaDragPosition(position: Int): Boolean { - return position >= 0 && position < internalState.value.selectedMedia.size + updateState { copy(selectedMedia = reordered) } + preUploadController.updateDisplayOrder(reordered) } private fun isNonGifVideo(media: Media): Boolean { return ContentTypeUtil.isVideo(media.contentType) && !media.isVideoGif } - fun onMediaDragFinished() { - lastMediaDrag = Pair(0, 0) - preUploadController.updateDisplayOrder(internalState.value.selectedMedia) - } - //endregion //region Editor State diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index fc4e773d82..568e6ddbdf 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -174,6 +174,9 @@ fun MediaEditScreen( scope.launch { pagerState.animateScrollToPage(index) } + }, + onReorder = { fromIndex, toIndex -> + onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex)) } ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index 5673519639..6827180afc 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -10,6 +10,7 @@ import org.signal.mediasend.edit.video.VideoTrimData sealed interface MediaEditScreenEvent { data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent + data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaEditScreenEvent data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent data object NextClick : MediaEditScreenEvent data object NavigateBack : MediaEditScreenEvent diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt index 65de243863..56c73904c2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -46,6 +47,10 @@ import kotlinx.coroutines.launch import org.signal.core.models.media.Media import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.list.ReorderableItem +import org.signal.core.ui.compose.list.rememberReorderBuffer +import org.signal.core.ui.compose.list.rememberReorderableListState +import org.signal.core.ui.compose.list.reorderableList import org.signal.core.util.ContentTypeUtil import org.signal.glide.compose.GlideImage import org.signal.mediasend.MediaSendMetrics @@ -66,7 +71,8 @@ internal fun ThumbnailRow( selectedMedia: List, pagerState: PagerState, onFocusedMediaChange: (Media) -> Unit, - onThumbnailClick: (Int) -> Unit = {} + onThumbnailClick: (Int) -> Unit = {}, + onReorder: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> } ) { val density = LocalDensity.current val scope = rememberCoroutineScope() @@ -77,35 +83,71 @@ internal fun ThumbnailRow( val pagerPageSize = pagerState.layoutInfo.pageSize.takeIf { it > 0 } ?: 1 val listState = rememberLazyListState() + val reorderBuffer = rememberReorderBuffer(selectedMedia, onReorder) + val reorderableListState = rememberReorderableListState( + lazyListState = listState, + includeHeader = false, + includeFooter = false, + orientation = Orientation.Horizontal, + autoScroll = false, + onEvent = reorderBuffer::onReorderListEvent + ) + val isReordering = reorderableListState.draggingItemIndex != null + val draggableState = rememberDraggableState { delta -> val scaledDelta = delta * (pagerPageSize.toFloat() / itemStride) pagerState.dispatchRawDelta(-scaledDelta) } + // Read through the latest selection rather than the one captured when the effect started, since reordering changes + // which media a settled page refers to without restarting the effect. + val currentSelectedMedia by rememberUpdatedState(selectedMedia) + LaunchedEffect(pagerState) { snapshotFlow { pagerState.isScrollInProgress } .filter { !it } .drop(1) .collectLatest { val settledPage = pagerState.currentPage - if (settledPage in selectedMedia.indices) { - onFocusedMediaChange(selectedMedia[settledPage]) + if (settledPage in currentSelectedMedia.indices) { + onFocusedMediaChange(currentSelectedMedia[settledPage]) } } } + // The rail's scroll position belongs to the drag rather than the pager from the moment an item is picked up until the + // reorder it produced has landed in state. Resuming any earlier means syncing to the pre-drag order and then having to + // correct once the new order arrives. The single catch-up afterwards is animated so the rail glides to the dropped + // item's slot rather than snapping to it. LaunchedEffect(pagerState, itemStride, selectedMedia.size) { if (selectedMedia.isEmpty()) return@LaunchedEffect - snapshotFlow { pagerState.currentPage + pagerState.currentPageOffsetFraction } + var isCatchingUp = false + + snapshotFlow { + val isReordering = reorderableListState.draggingItemIndex != null || reorderBuffer.isReordering + (pagerState.currentPage + pagerState.currentPageOffsetFraction) to isReordering + } .distinctUntilChanged() - .collectLatest { position -> + .collectLatest { (position, isReordering) -> + if (isReordering) { + isCatchingUp = true + return@collectLatest + } + val clampedPosition = position.coerceIn(0f, selectedMedia.lastIndex.toFloat()) val baseIndex = floor(clampedPosition.toDouble()).toInt() val fraction = (clampedPosition - baseIndex).coerceIn(0f, 1f) val scrollOffsetPx = (fraction * itemStride).roundToInt() - listState.scrollToItem(baseIndex, scrollOffsetPx) + if (isCatchingUp) { + // Left set until the animation actually finishes: if the pager retargets midway, collectLatest cancels this + // and the next pass animates on from wherever the rail got to instead of snapping. + listState.animateScrollToItem(baseIndex, scrollOffsetPx) + isCatchingUp = false + } else { + listState.scrollToItem(baseIndex, scrollOffsetPx) + } } } @@ -113,6 +155,7 @@ internal fun ThumbnailRow( modifier = Modifier.fillMaxWidth().draggable( state = draggableState, orientation = Orientation.Horizontal, + enabled = !isReordering, onDragStopped = { velocity -> scope.launch { val targetPage = when { @@ -139,9 +182,10 @@ internal fun ThumbnailRow( horizontalArrangement = spacedBy(BASE_SPACING), contentPadding = PaddingValues(start = startPadding, end = endPadding), state = listState, - userScrollEnabled = false + userScrollEnabled = false, + modifier = Modifier.reorderableList(reorderableListState) ) { - itemsIndexed(selectedMedia, key = { _, media -> media.uri }) { index, media -> + itemsIndexed(reorderBuffer.items, key = { _, media -> media.uri }) { index, media -> val padding by remember(index) { derivedStateOf { val currentPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction @@ -150,12 +194,14 @@ internal fun ThumbnailRow( } } - Thumbnail( - media = media, - modifier = Modifier - .padding(horizontal = padding) - .clickable { onThumbnailClick(index) } - ) + ReorderableItem(reorderableListState, index) { + Thumbnail( + media = media, + modifier = Modifier + .padding(horizontal = padding) + .clickable { onThumbnailClick(index) } + ) + } } } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt index 06b923c1fa..a6804ad23d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -33,7 +34,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -67,6 +69,10 @@ import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight +import org.signal.core.ui.compose.list.ReorderableItem +import org.signal.core.ui.compose.list.rememberReorderBuffer +import org.signal.core.ui.compose.list.rememberReorderableListState +import org.signal.core.ui.compose.list.reorderableList import org.signal.glide.compose.GlideImage import org.signal.mediasend.MediaSendMetrics import org.signal.mediasend.R @@ -137,19 +143,14 @@ internal fun MediaSelectScreen( .background(color = MaterialTheme.colorScheme.surface) .padding(vertical = gridConfiguration.bottomBarVerticalPadding, horizontal = gridConfiguration.bottomBarHorizontalPadding) ) { - LazyRow( + SelectedMediaRow( + selectedMedia = state.selectedMedia, + alignment = gridConfiguration.bottomBarAlignment, + onEvent = onEvent, modifier = Modifier .weight(1f) - .padding(end = 16.dp), - horizontalArrangement = spacedBy(space = 12.dp, alignment = gridConfiguration.bottomBarAlignment) - ) { - items(state.selectedMedia, key = { it.uri }) { media -> - MediaThumbnail(media, modifier = Modifier.animateItem()) { - onEvent(MediaSelectScreenEvent.SetFocusedMedia(media)) - onEvent(MediaSelectScreenEvent.NavigateToEdit) - } - } - } + .padding(end = 16.dp) + ) NextButton(state.selectedMedia.size) { onEvent(MediaSelectScreenEvent.NavigateToEdit) @@ -376,6 +377,44 @@ private fun NextButton(mediaSelectionCount: Int, onClick: () -> Unit) { } } +/** + * The rail of currently selected media. Items can be long pressed and dragged to change the order they'll be sent in. + */ +@Composable +private fun SelectedMediaRow( + selectedMedia: List, + alignment: Alignment.Horizontal, + onEvent: (MediaSelectScreenEvent) -> Unit, + modifier: Modifier = Modifier +) { + val listState = rememberLazyListState() + val reorderBuffer = rememberReorderBuffer(selectedMedia) { fromIndex, toIndex -> + onEvent(MediaSelectScreenEvent.ReorderSelectedMedia(fromIndex, toIndex)) + } + val reorderableListState = rememberReorderableListState( + lazyListState = listState, + includeHeader = false, + includeFooter = false, + orientation = Orientation.Horizontal, + onEvent = reorderBuffer::onReorderListEvent + ) + + LazyRow( + state = listState, + modifier = modifier.reorderableList(reorderableListState), + horizontalArrangement = spacedBy(space = 12.dp, alignment = alignment) + ) { + itemsIndexed(reorderBuffer.items, key = { _, media -> media.uri }) { index, media -> + ReorderableItem(reorderableListState, index) { + MediaThumbnail(media) { + onEvent(MediaSelectScreenEvent.SetFocusedMedia(media)) + onEvent(MediaSelectScreenEvent.NavigateToEdit) + } + } + } + } +} + @Composable private fun MediaThumbnail( media: Media, @@ -387,6 +426,7 @@ private fun MediaThumbnail( modifier = modifier .size(MediaSendMetrics.SelectedMediaPreviewSize) .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(8.dp)) + .clickable(onClick = onClick, onClickLabel = media.fileName, role = Role.Button) ) } else { GlideImage( @@ -395,6 +435,7 @@ private fun MediaThumbnail( modifier = modifier .size(MediaSendMetrics.SelectedMediaPreviewSize) .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick, onClickLabel = media.fileName, role = Role.Button) ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt index 62b0269c9d..03d30677e1 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreenEvent.kt @@ -12,5 +12,6 @@ sealed interface MediaSelectScreenEvent { data class FolderClick(val mediaFolder: MediaFolder?) : MediaSelectScreenEvent data class MediaClick(val media: Media) : MediaSelectScreenEvent data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent + data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent data object NavigateToEdit : MediaSelectScreenEvent }