Media send flow controls fade for video editing.

This commit is contained in:
Alex Hart
2026-07-30 16:02:40 -03:00
parent 9e12fd7e39
commit 14a245bceb
7 changed files with 176 additions and 94 deletions
@@ -7,9 +7,12 @@ package org.signal.mediasend
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
@@ -19,4 +22,11 @@ object MediaSendMetrics {
val ControlEnterTransition: EnterTransition = fadeIn()
val ControlExitTransition: ExitTransition = fadeOut()
/**
* For the bottom-most control of a bottom-aligned stack: the control slides off the bottom edge while giving up its
* layout space at the same rate, so everything above it slides along with it as a unit.
*/
val SlidingControlEnterTransition: EnterTransition = ControlEnterTransition + expandVertically(expandFrom = Alignment.Top, clip = false)
val SlidingControlExitTransition: ExitTransition = ControlExitTransition + shrinkVertically(shrinkTowards = Alignment.Top, clip = false)
}
@@ -242,6 +242,7 @@ class MediaSendViewModel(
is MediaSelectScreenEvent.SetFocusedMedia -> setFocusedMedia(mediaSelectScreenEvent.media)
is MediaSelectScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaSelectScreenEvent.fromIndex, mediaSelectScreenEvent.toIndex)
MediaSelectScreenEvent.NavigateToEdit -> backStack.goToEdit()
MediaSelectScreenEvent.NavigateToCamera -> backStack.goToCamera()
}
}
@@ -251,8 +252,8 @@ class MediaSendViewModel(
MediaCaptureScreenEvent.ShowTextStory -> backStack.goToTextStory()
is MediaCaptureScreenEvent.Camera -> onCameraXScreenEvent(mediaCaptureScreenEvent.event)
MediaCaptureScreenEvent.NextClicked -> backStack.goToEdit()
MediaCaptureScreenEvent.CycleTextStoryBackgroundColor -> Unit // TODO [media-send]
MediaCaptureScreenEvent.AddLinkToTextStory -> Unit // TODO [media-send]
MediaCaptureScreenEvent.CycleTextStoryBackgroundColor -> error("Handled directly in the fragment.")
MediaCaptureScreenEvent.AddLinkToTextStory -> error("Handled directly in the fragment.")
}
}
@@ -5,8 +5,13 @@
package org.signal.mediasend.edit
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.layout.Arrangement.spacedBy
@@ -51,6 +56,7 @@ import org.signal.glide.decryptableuri.DecryptableUri
import org.signal.imageeditor.core.model.EditorModel
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.MediaSendMetrics
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.edit.document.DocumentPage
import org.signal.mediasend.edit.image.BrushWidthBar
@@ -113,7 +119,8 @@ fun MediaEditScreen(
var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) }
var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) }
val isInteracting = imageController?.isUserInEdit == true || isVideoInteracting
val isImageEditing = imageController?.isUserInEdit == true
val isInteracting = isImageEditing || isVideoInteracting
HorizontalPager(
state = pagerState,
@@ -218,7 +225,6 @@ fun MediaEditScreen(
val isTextEditing = imageController?.textEditingElement != null
Column(
verticalArrangement = spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -226,95 +232,71 @@ fun MediaEditScreen(
.navigationBarsPadding()
.then(if (isTextEditing) Modifier.imePadding() else Modifier)
) {
if (state.selectedMedia.size > 1 && !isInteracting) {
ThumbnailRow(
selectedMedia = state.selectedMedia,
pagerState = pagerState,
onFocusedMediaChange = {
onEvent(MediaEditScreenEvent.FocusedMediaChanged(it))
},
onThumbnailClick = { index ->
if (pagerState.currentPage == index) {
onEvent(MediaEditScreenEvent.RemoveMedia(state.selectedMedia[index]))
} else {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
},
onReorder = { fromIndex, toIndex ->
onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex))
}
)
}
when (focusedEditorState) {
is EditorState.Image -> {
imageController?.let { controller ->
if (controller.mode == ImageController.Mode.CROP) {
RotationDial(
imageEditorController = controller,
modifier = Modifier
.widthIn(max = 380.dp)
.padding(horizontal = 16.dp)
)
}
if (controller.isUserDrawing) {
DrawModeColorBar(imageEditorController = controller)
}
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent)
}
}
}
is EditorState.VideoTrim -> {
val playbackPositionUs by produceState(focusedEditorState.videoTrimData.startTimeUs, focusedUri) {
videoEditorViewModel.events(focusedUri).collect { event ->
if (event is VideoEditorViewModel.Event.ActualPositionChanged) {
value = event.positionUs
}
}
}
VideoEditorToolbar(
Column(
verticalArrangement = spacedBy(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (focusedEditorState is EditorState.VideoTrim) {
VideoTrimTimeline(
videoUri = focusedUri,
mediaInputFactory = MediaSendDependencies.mediaInputFactory,
videoTrimData = focusedEditorState.videoTrimData,
maxSelectableDurationUs = focusedEditorState.maxDurationUs,
playbackPositionUs = playbackPositionUs,
onEvent = { event ->
when (event) {
is MediaEditScreenEvent.VideoTrimChanged -> {
isVideoInteracting = !event.editingComplete
onEvent(event)
}
is MediaEditScreenEvent.VideoSeek -> {
isVideoInteracting = !event.editingComplete
videoEditorViewModel.sendCommand(
focusedUri,
if (event.editingComplete) {
VideoEditorViewModel.Command.EndPositionDrag(event.positionUs)
} else {
VideoEditorViewModel.Command.PositionDrag(event.positionUs)
}
)
}
else -> onEvent(event)
}
}
editorState = focusedEditorState,
videoEditorViewModel = videoEditorViewModel,
onInteractingChange = { isVideoInteracting = it },
onEvent = onEvent
)
}
is EditorState.Document, EditorState.VideoGif, EditorState.Gif, null -> Unit
if (state.selectedMedia.size > 1) {
MediaEditControl(visible = !isImageEditing, faded = isVideoInteracting) {
ThumbnailRow(
selectedMedia = state.selectedMedia,
pagerState = pagerState,
enabled = !isInteracting,
onFocusedMediaChange = {
onEvent(MediaEditScreenEvent.FocusedMediaChanged(it))
},
onThumbnailClick = { index ->
if (pagerState.currentPage == index) {
onEvent(MediaEditScreenEvent.RemoveMedia(state.selectedMedia[index]))
} else {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
},
onReorder = { fromIndex, toIndex ->
onEvent(MediaEditScreenEvent.ReorderSelectedMedia(fromIndex, toIndex))
}
)
}
}
imageController?.let { controller ->
if (controller.mode == ImageController.Mode.CROP) {
RotationDial(
imageEditorController = controller,
modifier = Modifier
.widthIn(max = 380.dp)
.padding(horizontal = 16.dp)
)
}
if (controller.isUserDrawing) {
DrawModeColorBar(imageEditorController = controller)
}
if (isSmallWindowBreakpoint) {
ImageEditorToolbar(imageEditorController = controller, state = state, onEvent = onEvent)
}
}
}
val showAddMessageRow = !(isInteracting && focusedEditorState !is EditorState.VideoTrim)
if (showAddMessageRow) {
MediaEditControl(
visible = !isImageEditing,
faded = isVideoInteracting,
enter = MediaSendMetrics.SlidingControlEnterTransition,
exit = MediaSendMetrics.SlidingControlExitTransition
) {
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
message = state.message,
@@ -323,8 +305,8 @@ fun MediaEditScreen(
modifier = Modifier
.widthIn(max = 624.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
.alpha(if (isInteracting) 0f else 1f)
// Own padding rather than the stack's arrangement so the gap collapses along with the slide.
.padding(top = 20.dp, bottom = 16.dp)
)
}
}
@@ -377,6 +359,82 @@ fun MediaEditScreen(
}
}
/**
* A control in the bottom stack, and the two ways it gets out of the user's way. Going not-[visible] gives up its layout
* space, letting the rest of the stack settle into it, while [faded] holds onto the space -- releasing it mid-gesture
* would move whatever the user is dragging out from under their finger.
*/
@Composable
private fun MediaEditControl(
visible: Boolean,
faded: Boolean,
enter: EnterTransition = MediaSendMetrics.ControlEnterTransition,
exit: ExitTransition = MediaSendMetrics.ControlExitTransition,
content: @Composable () -> Unit
) {
val alpha by animateFloatAsState(targetValue = if (faded) 0f else 1f)
AnimatedVisibility(
visible = visible,
enter = enter,
exit = exit,
modifier = Modifier.alpha(alpha)
) {
content()
}
}
/**
* Trim/scrub timeline for the focused video. Drag state is reported through [onInteractingChange] so the rest of the
* stack can get out of the way, and seeks are translated into player commands rather than screen events.
*/
@Composable
private fun VideoTrimTimeline(
videoUri: Uri,
editorState: EditorState.VideoTrim,
videoEditorViewModel: VideoEditorViewModel,
onInteractingChange: (Boolean) -> Unit,
onEvent: (MediaEditScreenEvent) -> Unit
) {
val playbackPositionUs by produceState(editorState.videoTrimData.startTimeUs, videoUri) {
videoEditorViewModel.events(videoUri).collect { event ->
if (event is VideoEditorViewModel.Event.ActualPositionChanged) {
value = event.positionUs
}
}
}
VideoEditorToolbar(
videoUri = videoUri,
mediaInputFactory = MediaSendDependencies.mediaInputFactory,
videoTrimData = editorState.videoTrimData,
maxSelectableDurationUs = editorState.maxDurationUs,
playbackPositionUs = playbackPositionUs,
onEvent = { event ->
when (event) {
is MediaEditScreenEvent.VideoTrimChanged -> {
onInteractingChange(!event.editingComplete)
onEvent(event)
}
is MediaEditScreenEvent.VideoSeek -> {
onInteractingChange(!event.editingComplete)
videoEditorViewModel.sendCommand(
videoUri,
if (event.editingComplete) {
VideoEditorViewModel.Command.EndPositionDrag(event.positionUs)
} else {
VideoEditorViewModel.Command.PositionDrag(event.positionUs)
}
)
}
else -> onEvent(event)
}
}
)
}
@Composable
private fun rememberVideoEditorViewModel(): VideoEditorViewModel {
return if (LocalInspectionMode.current) {
@@ -74,7 +74,8 @@ internal fun ThumbnailRow(
pagerState: PagerState,
onFocusedMediaChange: (Media) -> Unit,
onThumbnailClick: (Int) -> Unit = {},
onReorder: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }
onReorder: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
enabled: Boolean = true
) {
val density = LocalDensity.current
val scope = rememberCoroutineScope()
@@ -159,7 +160,7 @@ internal fun ThumbnailRow(
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
enabled = !isReordering,
enabled = enabled && !isReordering,
onDragStopped = { velocity ->
scope.launch {
val targetPage = when {
@@ -187,7 +188,7 @@ internal fun ThumbnailRow(
contentPadding = PaddingValues(start = startPadding, end = endPadding),
state = listState,
userScrollEnabled = false,
modifier = Modifier.reorderableList(reorderableListState)
modifier = if (enabled) Modifier.reorderableList(reorderableListState) else Modifier
) {
itemsIndexed(reorderBuffer.items, key = { _, media -> media.uri }) { index, media ->
val padding by remember(index) {
@@ -210,7 +211,7 @@ internal fun ThumbnailRow(
media = media,
modifier = Modifier
.padding(horizontal = padding)
.clickable { onThumbnailClick(index) }
.clickable(enabled = enabled) { onThumbnailClick(index) }
)
}
}
@@ -38,6 +38,7 @@ 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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
@@ -68,6 +69,7 @@ import org.signal.core.ui.compose.Buttons
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.SignalIcons
import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight
import org.signal.core.ui.compose.list.ReorderableItem
import org.signal.core.ui.compose.list.rememberReorderBuffer
@@ -96,7 +98,14 @@ internal fun MediaSelectScreen(
is MediaSelectScreenState.Files -> state.selectedMediaFolder.title
},
navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24),
onNavigationClick = { backDispatcher?.onBackPressed() }
onNavigationClick = { backDispatcher?.onBackPressed() },
actions = {
IconButton(onClick = {
onEvent(MediaSelectScreenEvent.NavigateToCamera)
}) {
Icon(imageVector = SignalIcons.Camera.imageVector, contentDescription = stringResource(R.string.MediaSelectScreen__go_to_camera))
}
}
) { paddingValues ->
Column(
modifier = Modifier
@@ -14,4 +14,5 @@ sealed interface MediaSelectScreenEvent {
data class SetFocusedMedia(val media: Media) : MediaSelectScreenEvent
data class ReorderSelectedMedia(val fromIndex: Int, val toIndex: Int) : MediaSelectScreenEvent
data object NavigateToEdit : MediaSelectScreenEvent
data object NavigateToCamera : MediaSelectScreenEvent
}
@@ -32,6 +32,8 @@
<string name="MediaSelectScreen__gallery">Gallery</string>
<!-- Accessibility description for the button that advances from media selection to the next step in the send flow. -->
<string name="MediaSelectScreen__next">Next</string>
<!-- Accessibility description for the button that leaves media selection and opens the camera. -->
<string name="MediaSelectScreen__go_to_camera">Go to camera</string>
<!-- Label for the button that switches the capture screen to the camera. -->
<string name="MediaCaptureScreen__camera">Camera</string>