mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 05:14:50 +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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user