mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 05:14:50 +01:00
Media send flow controls fade for video editing.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user