mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-19 20:58:11 +01:00
Allow media select reorder.
This commit is contained in:
@@ -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