Allow media select reorder.

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