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
}
}
}
@@ -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<Int, Int> = 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
@@ -174,6 +174,9 @@ fun MediaEditScreen(
scope.launch {
pagerState.animateScrollToPage(index)
}
},
onReorder = { fromIndex, toIndex ->
onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex))
}
)
}
@@ -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
@@ -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<Media>,
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) }
)
}
}
}
}
@@ -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<Media>,
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)
)
}
}
@@ -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
}