From 34ecc7ab9c4fd69ab2d57b0e899702c62b7be59a Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 13 Aug 2026 15:36:47 -0300 Subject: [PATCH] Media send feedback. --- .../core/ui/navigation/TransitionSpecs.kt | 19 ++ .../signal/mediasend/MediaSendNavigation.kt | 6 +- .../mediasend/screens/edit/ImageController.kt | 105 +++++++++- .../mediasend/screens/edit/MediaEditScreen.kt | 38 ++-- .../screens/edit/MediaEditorToolbar.kt | 9 +- .../screens/edit/image/ImageEditor.kt | 103 +++++++++- .../edit/image/ImageEditorClearAllButton.kt | 8 +- .../screens/edit/image/ImageEditorState.kt | 40 ++++ .../screens/edit/image/ImageEditorToolbar.kt | 64 ++++-- .../edit/image/ImageEditorUndoRedoButtons.kt | 8 +- .../screens/edit/ImageControllerZoomTest.kt | 192 ++++++++++++++++++ 11 files changed, 528 insertions(+), 64 deletions(-) create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/screens/edit/ImageControllerZoomTest.kt diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt index c7ea9a9811..0c60d97798 100644 --- a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt @@ -57,6 +57,25 @@ object TransitionSpecs { override val predictivePopTransitionSpec: ContentTransform = Transition.NONE } + /** + * Screens fade in place, without any directional movement. + */ + object Fade : Transition { + private const val DURATION = 200 + + override val transitionSpec: ContentTransform = + ( + fadeIn(animationSpec = tween(DURATION)) + ) togetherWith + ( + fadeOut(animationSpec = tween(DURATION)) + ) + + override val popTransitionSpec: ContentTransform = transitionSpec + + override val predictivePopTransitionSpec: ContentTransform = transitionSpec + } + /** * Screens fade and zoom in place, without any directional movement. */ diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt index b5aedfb4f0..13f8b611f5 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavigation.kt @@ -69,9 +69,9 @@ internal fun MediaSendNavigation( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator() ), - transitionSpec = { TransitionSpecs.FadeScale.transitionSpec }, - popTransitionSpec = { TransitionSpecs.FadeScale.popTransitionSpec }, - predictivePopTransitionSpec = { TransitionSpecs.FadeScale.predictivePopTransitionSpec } + transitionSpec = { TransitionSpecs.Fade.transitionSpec }, + popTransitionSpec = { TransitionSpecs.Fade.popTransitionSpec }, + predictivePopTransitionSpec = { TransitionSpecs.Fade.predictivePopTransitionSpec } ) { key -> when (key) { is MediaSendRoute.Capture -> NavEntry(MediaSendRoute.Capture.Chrome) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt index 066e220113..9f31bb0c3f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/ImageController.kt @@ -47,7 +47,7 @@ internal class ImageController( private const val STICKER_SCALE = 0.4f } - val isUserInEdit: Boolean by derivedStateOf { mode != Mode.NONE } + val isUserInEdit: Boolean by derivedStateOf { mode.isEditing } val imageEditorState = ImageEditorState(editorModel).also { it.onGestureCompleted = { drawSessionDirty = true } @@ -56,6 +56,14 @@ internal class ImageController( var mode: Mode by mutableStateOf(Mode.NONE) private set + /** Whether the chrome that [Mode.ZOOM] fades has been tapped back into view. */ + var isChromeRevealed: Boolean by mutableStateOf(false) + private set + + /** Whether the canvas is easing back to its fit, which nothing else may zoom or pan out from under. */ + var isSettlingZoom: Boolean by mutableStateOf(false) + private set + var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked) private set @@ -157,11 +165,14 @@ internal class ImageController( } } + /** Whether the chrome is out of the way of a zoomed image, waiting on a tap to come back. */ + val isChromeFadedForZoom: Boolean by derivedStateOf { mode == Mode.ZOOM && !isChromeRevealed } + /** * Backs out of the current mode into the one before it. Draw and crop sessions go through [requestCancelEdit] so that * backing out of a dirty one asks first rather than silently throwing the work away. */ - fun onBackPressed() { + suspend fun onBackPressed() { // A selection is a level of its own: back gives that up before it gives up the mode. if (selectedElement != null) { clearSelection() @@ -171,6 +182,7 @@ internal class ImageController( when (mode) { Mode.TEXT -> finishTextEditing() Mode.DRAW, Mode.HIGHLIGHT, Mode.BLUR, Mode.CROP -> requestCancelEdit() + Mode.ZOOM -> exitZoomMode() Mode.NONE, Mode.INSERT_STICKER, Mode.DELETE -> Unit } } @@ -239,13 +251,81 @@ internal class ImageController( } private fun transitionTo(newMode: Mode) { + // A zoom only survives in Mode.ZOOM: every other mode has spent two fingers on something else, leaving no way back + // out of one. + if (mode == Mode.ZOOM && newMode != Mode.ZOOM) { + isChromeRevealed = false + imageEditorState.clearZoom() + } + if (!newMode.isTransient) { - restingMode = newMode + restingMode = newMode.asRestingMode } mode = newMode } + /** + * The mode a transient one hands back to. Never [Mode.ZOOM]: leaving it dropped the zoom, so coming back would leave + * the pager locked around an image sitting at its fit scale. + */ + private val Mode.asRestingMode: Mode + get() = if (this == Mode.ZOOM) Mode.NONE else this + + /** + * Pinches the canvas, opening [Mode.ZOOM] on the way up from the fit scale and closing it again on the way back down, + * so a pinch that undoes itself hands the pager and the selection back without needing a double tap. + */ + fun zoomBy(focusX: Float, focusY: Float, scaleFactor: Float, panX: Float, panY: Float) { + if (isSettlingZoom || (mode != Mode.NONE && mode != Mode.ZOOM)) { + return + } + + imageEditorState.zoomBy(focusX, focusY, scaleFactor, panX, panY) + + when { + imageEditorState.isZoomed && mode == Mode.NONE -> enterZoomMode() + !imageEditorState.isZoomed && mode == Mode.ZOOM -> transitionTo(Mode.NONE) + } + } + + fun panBy(panX: Float, panY: Float) { + if (isSettlingZoom || mode != Mode.ZOOM) { + return + } + + imageEditorState.panBy(panX, panY) + } + + private fun enterZoomMode() { + clearSelection() + transitionTo(Mode.ZOOM) + } + + /** + * Eases the canvas back to its fit and then leaves zoom mode. The chrome is revealed up front so that it fades back in + * alongside the image rather than waiting for it to land. + */ + suspend fun exitZoomMode() { + if (mode != Mode.ZOOM || isSettlingZoom) { + return + } + + isSettlingZoom = true + isChromeRevealed = true + + try { + imageEditorState.animateZoomToFit() + } finally { + isSettlingZoom = false + transitionTo(Mode.NONE) + } + } + + fun toggleChromeRevealed() { + isChromeRevealed = !isChromeRevealed + } + /** Ends a transient mode by returning to the session the user was in before it. */ private fun returnToRestingMode() { when (restingMode) { @@ -384,8 +464,6 @@ internal class ImageController( } fun enterCropMode() { - // Two fingers belong to the crop from here, so there would be no way back out of a zoom. - imageEditorState.clearZoom() editorModel.startCrop() initialDialScale = editorModel.mainImage?.localScaleX ?: 1f transitionTo(Mode.CROP) @@ -470,6 +548,10 @@ internal class ImageController( } fun onEntityDown(element: EditorElement?) { + if (mode == Mode.ZOOM) { + return + } + if (element != null && element.renderer is SelectableRenderer) { selectElement(element) } else { @@ -478,6 +560,10 @@ internal class ImageController( } fun onEntitySingleTap(element: EditorElement?) { + if (mode == Mode.ZOOM) { + return + } + val tappable = element?.renderer as? TappableRenderer ?: return tappable.onTapped() @@ -486,7 +572,7 @@ internal class ImageController( /** Re-opens an existing text element, which is how the user gets back to its color and style controls. */ fun onEntityDoubleTap(element: EditorElement?) { - if (mode == Mode.CROP || element == null || element.renderer !is MultiLineTextRenderer) { + if (mode == Mode.CROP || mode == Mode.ZOOM || element == null || element.renderer !is MultiLineTextRenderer) { return } @@ -523,7 +609,7 @@ internal class ImageController( fun enterStickerMode() { // Re-opening the picker must not record INSERT_STICKER as the mode to come back to. if (mode != Mode.INSERT_STICKER) { - modeBeforeStickerInsertion = mode + modeBeforeStickerInsertion = mode.asRestingMode } transitionTo(Mode.INSERT_STICKER) @@ -648,6 +734,7 @@ internal class ImageController( enum class Mode { NONE, + ZOOM, CROP, TEXT, DRAW, @@ -659,6 +746,10 @@ internal class ImageController( /** Whether this is something done within a session rather than a session in its own right. */ internal val isTransient: Boolean get() = this == TEXT || this == DELETE || this == INSERT_STICKER + + /** Whether an editing session is open. [NONE] and [ZOOM] are ways of looking at the image rather than changing it. */ + internal val isEditing: Boolean + get() = this != NONE && this != ZOOM } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt index f1b9f3f66a..ea1b45fcf4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditScreen.kt @@ -163,18 +163,22 @@ internal fun MediaEditScreen( // Composed after the camera-first handler so it wins while an editor mode is open: back should step out of that mode // rather than leave the screen. BackHandler(enabled = imageController?.canHandleBack == true) { - imageController?.onBackPressed() + scope.launch { imageController?.onBackPressed() } } var isVideoInteracting by remember(focusedUri) { mutableStateOf(false) } var isAdjustingBrushWidth by remember(focusedUri) { mutableStateOf(false) } val isImageEditing = imageController?.isUserInEdit == true - val isInteracting = isImageEditing || isVideoInteracting + val isZooming = imageController?.mode == ImageController.Mode.ZOOM + val isInteracting = isImageEditing || isVideoInteracting || isZooming // Drags of the media itself, which every piece of chrome gets out of the way for. Sliders are excluded -- they are // chrome themselves, and clearing the screen would hide what they adjust. val isDragging = imageController?.imageEditorState?.isGestureActive == true || isVideoInteracting + // A zoomed image gets the screen to itself in the same way a drag does, until a tap asks for the chrome back. + val isChromeFaded = isDragging || imageController?.isChromeFadedForZoom == true + val isAtRest = !isImageEditing && !isVideoInteracting val isAtRestState by rememberUpdatedState(isAtRest) val focusedChromeKind by rememberUpdatedState(focusedEditorState.chromeKind()) @@ -312,7 +316,7 @@ internal fun MediaEditScreen( ) MediaEditControl( - faded = isDragging, + faded = isChromeFaded, modifier = Modifier.align(Alignment.CenterStart) ) { BrushWidthBar( @@ -362,7 +366,7 @@ internal fun MediaEditScreen( } if (state.selectedMedia.size > 1 && isAddMediaVisible(state, focusedEditorState)) { - MediaEditControl(visible = !isImageEditing, faded = isDragging) { + MediaEditControl(visible = !isImageEditing, faded = isChromeFaded) { ThumbnailRow( selectedMedia = state.selectedMedia, pagerState = pagerState, @@ -394,13 +398,13 @@ internal fun MediaEditScreen( } if (controller.isUserDrawing) { - MediaEditControl(faded = isDragging) { + MediaEditControl(faded = isChromeFaded) { DrawModeColorBar(imageEditorController = controller) } } if (controller.isUserBlurring) { - MediaEditControl(faded = isDragging) { + MediaEditControl(faded = isChromeFaded) { BlurFacesBar( checked = controller.isBlurringFaces, onCheckedChange = { onEvent(MediaEditScreenEvents.ToggleBlurFaces(it)) } @@ -417,14 +421,14 @@ internal fun MediaEditScreen( onEvent = onEvent, imageController = imageController, isTextEditing = isTextEditing, - isDragging = isDragging + faded = isChromeFaded ) } } MediaEditControl( visible = !isImageEditing, - faded = isDragging, + faded = isChromeFaded, enter = MediaSendMetrics.SlidingControlEnterTransition, exit = MediaSendMetrics.SlidingControlExitTransition ) { @@ -465,7 +469,7 @@ internal fun MediaEditScreen( onEvent = onEvent, imageController = imageController, isTextEditing = isTextEditing, - isDragging = isDragging + faded = isChromeFaded ) } } @@ -482,7 +486,7 @@ internal fun MediaEditScreen( .reportChromeInset(chromeInsets, ChromeSlot.TOP_BAND, ChromeEdge.TOP) ) { MediaEditControl( - faded = isDragging || isImageEditing, + faded = isChromeFaded || isImageEditing, modifier = Modifier .align(Alignment.TopCenter) .padding(top = 10.dp) @@ -500,7 +504,7 @@ internal fun MediaEditScreen( ImageEditorUndoRedoButtons( imageEditorController = imageController, - isDragging = isDragging, + faded = isChromeFaded, modifier = Modifier .align(Alignment.TopStart) .padding(top = 12.dp, start = 16.dp) @@ -508,7 +512,7 @@ internal fun MediaEditScreen( ImageEditorClearAllButton( imageEditorController = imageController, - isDragging = isDragging, + faded = isChromeFaded, modifier = Modifier .align(Alignment.TopEnd) .padding(top = 12.dp, end = 16.dp) @@ -534,7 +538,7 @@ private fun MediaToolbar( focusedEditorState: EditorState?, imageController: ImageController?, isTextEditing: Boolean, - isDragging: Boolean, + faded: Boolean, modifier: Modifier = Modifier ) { if (focusedUri == null || focusedEditorState == null) { @@ -546,7 +550,7 @@ private fun MediaToolbar( return } - MediaEditControl(faded = isDragging, modifier = modifier) { + MediaEditControl(faded = faded, modifier = modifier) { when (focusedEditorState) { is EditorState.Image -> { val breakpoint = rememberWindowBreakpoint() @@ -565,7 +569,8 @@ private fun MediaToolbar( editorState = focusedEditorState, onEvent = onEvent, modifier = modifier - .then(if (isTextEditing) Modifier.imePadding() else Modifier) + .then(if (isTextEditing) Modifier.imePadding() else Modifier), + enabled = !faded ) } } @@ -574,7 +579,8 @@ private fun MediaToolbar( MediaEditorToolbarSharedButtons( state = state, editorState = focusedEditorState, - onEvent = onEvent + onEvent = onEvent, + enabled = !faded ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt index efbe34d6f5..a047a73db4 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/MediaEditorToolbar.kt @@ -89,11 +89,13 @@ internal fun MediaEditorToolbarButton( imageVector: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, contentDescription: String? = null, colors: IconButtonColors = IconButtons.iconButtonColors() ) { IconButtons.IconButton( onClick = onClick, + enabled = enabled, colors = colors, modifier = modifier ) { @@ -105,7 +107,8 @@ internal fun MediaEditorToolbarButton( internal fun MediaEditorToolbarSharedButtons( state: MediaEditState, editorState: EditorState, - onEvent: (MediaEditScreenEvents) -> Unit + onEvent: (MediaEditScreenEvents) -> Unit, + enabled: Boolean = true ) { if (isQualityVisible(state, editorState)) { var isSelectingQuality by rememberSaveable { mutableStateOf(false) } @@ -125,6 +128,7 @@ internal fun MediaEditorToolbarSharedButtons( SignalIcons.QualityHighSlash.imageVector }, onClick = { isSelectingQuality = true }, + enabled = enabled, modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_QUALITY_BUTTON) ) } @@ -133,6 +137,7 @@ internal fun MediaEditorToolbarSharedButtons( MediaEditorToolbarButton( imageVector = if (editorState.videoTrimData.isMuted) SignalIcons.SpeakerSlash.imageVector else SignalIcons.Speaker.imageVector, onClick = { onEvent(MediaEditScreenEvents.ToggleVideoMuted) }, + enabled = enabled, modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_MUTE_BUTTON) ) } @@ -141,6 +146,7 @@ internal fun MediaEditorToolbarSharedButtons( MediaEditorToolbarButton( imageVector = SignalIcons.Save.imageVector, onClick = { onEvent(MediaEditScreenEvents.SaveMedia) }, + enabled = enabled, modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON) ) } @@ -149,6 +155,7 @@ internal fun MediaEditorToolbarSharedButtons( MediaEditorToolbarButton( imageVector = SignalIcons.AlbumPlus.imageVector, onClick = { onEvent(MediaEditScreenEvents.NavigateToGallery) }, + enabled = enabled, modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON) ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt index 5518ab58fb..fd12e61efe 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditor.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -32,6 +33,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.changedToDown import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged @@ -42,6 +44,8 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.signal.imageeditor.core.ImageEditorTouchHandler import org.signal.imageeditor.core.model.EditorElement import org.signal.imageeditor.core.renderers.MultiLineTextRenderer @@ -57,6 +61,7 @@ internal fun ImageEditor( val context = LocalContext.current val state = controller.imageEditorState val hapticFeedback = LocalHapticFeedback.current + val scope = rememberCoroutineScope() // Masks everything outside the image, so it has to match the surface the editor is drawn on to read as a background. val blackoutColor = MaterialTheme.colorScheme.surface.toArgb() @@ -82,7 +87,7 @@ internal fun ImageEditor( .matchParentSize() .clipToBounds() .onSizeChanged { state.setCanvasSize(it.width.toFloat(), it.height.toFloat()) } - .imageEditorPointerInput(state, controller) + .imageEditorPointerInput(state, controller, scope) ) { state.revision @@ -146,7 +151,7 @@ private fun HiddenTextInput(controller: ImageController) { /** How far a finger travels before a touch on an element counts as a drag. */ private const val MAX_MOVE_SQUARED_BEFORE_DRAG = 10f -private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController): Modifier { +private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController, scope: CoroutineScope): Modifier { return this.pointerInput(controller, controller.textEditingElement) { val touchHandler = ImageEditorTouchHandler() @@ -154,9 +159,17 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller var lastTapElement: EditorElement? = null var lastTapUptimeMillis = 0L + // The equivalent for the canvas itself, which only zoom mode taps. + var lastCanvasTapUptimeMillis = 0L + awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = true) + if (controller.mode == ImageController.Mode.ZOOM) { + lastCanvasTapUptimeMillis = zoomGesture(controller, down, lastCanvasTapUptimeMillis, scope) + return@awaitEachGesture + } + if (state.textEditingElement != null) { // During text editing, a tap on the canvas finishes editing down.consume() @@ -180,9 +193,14 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller controller.onEntityDown(hitElement) } + val hasNoSession = !touchHandler.hasActiveSession() + + // Only at rest: every other mode has already spent two fingers on the elements or on the crop frame. + val canZoomCanvas = hasNoSession && controller.mode == ImageController.Mode.NONE + // Crop is excluded: two fingers there scale the image inside the crop frame. - val canZoomCanvas = !touchHandler.hasActiveSession() && controller.mode != ImageController.Mode.CROP - if (canZoomCanvas && !awaitSecondPointer()) { + val canWaitForSecondPointer = hasNoSession && controller.mode != ImageController.Mode.CROP + if (canWaitForSecondPointer && !awaitSecondPointer()) { // In NONE mode the pager took the swipe; anywhere else it was a tap on nothing, which still deselects. if (controller.mode != ImageController.Mode.NONE) { touchHandler.onUp(state.editorModel) @@ -250,16 +268,18 @@ private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller // Deliberately outside didPinch: zooming is not an edit and must not mark the model dirty. zoomPointers = if (currentCount == 2) { val current = currentPressed[0].position to currentPressed[1].position - zoomPointers?.let { state.zoomBetween(it, current) } + zoomPointers?.let { controller.zoomBetween(it, current) } current } else { null } } else if (currentCount == 2 && previousPointerCount < 2) { - didPinch = true val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last() val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1) touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex) + + // Only an edit if it found something to scale: two fingers on empty space must not arm the discard prompt. + didPinch = touchHandler.hasActiveSession() } else if (currentCount == 1 && previousPointerCount == 2) { val released = event.changes.firstOrNull { !it.pressed && it.previousPressed } val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0 @@ -308,7 +328,76 @@ private suspend fun AwaitPointerEventScope.awaitSecondPointer(): Boolean { } } -private fun ImageEditorState.zoomBetween(previous: Pair, current: Pair) { +/** + * Owns the whole gesture while the canvas is zoomed: one finger pans, two pinch, a tap toggles the chrome, and a double + * tap eases the image back to its fit scale. Nothing reaches the pager from here -- a swipe is a pan. + * + * The settle runs on [scope] rather than here, so gesture detection is back up before it finishes. + * + * Returns the uptime a following tap has to beat to count as a double tap, or zero when this gesture was not a tap. + */ +private suspend fun AwaitPointerEventScope.zoomGesture( + controller: ImageController, + down: PointerInputChange, + lastTapUptimeMillis: Long, + scope: CoroutineScope +): Long { + down.consume() + controller.imageEditorState.isGestureActive = true + + val isSecondTap = down.uptimeMillis - lastTapUptimeMillis <= viewConfiguration.doubleTapTimeoutMillis + var isTap = true + var zoomPointers: Pair? = null + + try { + while (true) { + val event = awaitPointerEvent() + val pressed = event.changes.filter { it.pressed } + event.changes.forEach { it.consume() } + + if (pressed.isEmpty()) { + break + } + + if (pressed.size >= 2) { + isTap = false + val current = pressed[0].position to pressed[1].position + zoomPointers?.let { controller.zoomBetween(it, current) } + zoomPointers = current + continue + } + + // The pinch is over, so the finger left behind starts panning from wherever it is rather than from the midpoint. + zoomPointers = null + + val change = pressed.first() + if (isTap && (change.position - down.position).getDistance() > viewConfiguration.touchSlop) { + isTap = false + } + + if (!isTap) { + val pan = change.position - change.previousPosition + controller.panBy(pan.x, pan.y) + } + } + } finally { + controller.imageEditorState.isGestureActive = false + } + + if (!isTap) { + return 0L + } + + if (isSecondTap) { + scope.launch { controller.exitZoomMode() } + return 0L + } + + controller.toggleChromeRevealed() + return down.uptimeMillis +} + +private fun ImageController.zoomBetween(previous: Pair, current: Pair) { val previousSpread = (previous.first - previous.second).getDistance() val currentSpread = (current.first - current.second).getDistance() if (previousSpread <= 0f || currentSpread <= 0f) return diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt index d77f2cddee..f9d8d9ff9c 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorClearAllButton.kt @@ -25,13 +25,13 @@ import org.signal.mediasend.screens.edit.MediaEditControl @Composable internal fun ImageEditorClearAllButton( imageEditorController: ImageController?, - isDragging: Boolean, + faded: Boolean, modifier: Modifier = Modifier, canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false ) { MediaEditControl( - visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && canUndo, - faded = isDragging, + visible = imageEditorController != null && imageEditorController.isUserInEdit && canUndo, + faded = faded, modifier = modifier ) { Buttons.MediumTonal( @@ -55,7 +55,7 @@ private fun ImageEditorClearAllButtonPreview() { Box(modifier = Modifier.fillMaxSize()) { ImageEditorClearAllButton( imageEditorController = remember { ImageController(EditorModel.create(0x0)).apply { enterDrawMode() } }, - isDragging = false, + faded = false, canUndo = true ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt index 1d8d35bd7b..eedf186b09 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorState.kt @@ -12,6 +12,9 @@ import android.graphics.Paint import android.graphics.Point import android.graphics.RectF import android.graphics.Typeface +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -31,6 +34,9 @@ private const val MIN_CONTENT_VIEW_PORT = 100f private const val MIN_ZOOM = 1f private const val MAX_ZOOM = 8f +/** How long the canvas takes to ease back to its fit, rather than snapping there. */ +private const val ZOOM_SETTLE_DURATION_MILLIS = 250 + /** * Compose-observable wrapper around [EditorModel]. * @@ -56,6 +62,10 @@ internal class ImageEditorState( var isGestureActive: Boolean by mutableStateOf(false) internal set + /** Whether the canvas is scaled past its fit. */ + var isZoomed: Boolean by mutableStateOf(false) + private set + var textEditingElement: EditorElement? = null var isDrawing: Boolean = false var isBlur: Boolean = false @@ -139,6 +149,34 @@ internal class ImageEditorState( applyZoom() } + /** Drags the zoomed canvas under the finger. A no-op at the fit scale, where there is nothing to pan to. */ + fun panBy(panX: Float, panY: Float) { + zoomBy(focusX = 0f, focusY = 0f, scaleFactor = 1f, panX = panX, panY = panY) + } + + /** + * Eases the canvas back to its fit and leaves the zoom cleared. Scale and translation are driven off one fraction, + * which is what keeps every frame inside [constrainZoom]'s bounds without having to re-clamp along the way. + */ + suspend fun animateZoomToFit() { + val fromScale = zoomScale + val fromTranslateX = zoomTranslateX + val fromTranslateY = zoomTranslateY + + animate( + initialValue = 1f, + targetValue = 0f, + animationSpec = tween(durationMillis = ZOOM_SETTLE_DURATION_MILLIS, easing = FastOutSlowInEasing) + ) { fraction, _ -> + zoomScale = MIN_ZOOM + (fromScale - MIN_ZOOM) * fraction + zoomTranslateX = fromTranslateX * fraction + zoomTranslateY = fromTranslateY * fraction + applyZoom() + } + + clearZoom() + } + fun clearZoom() { if (zoomScale == 1f && zoomTranslateX == 0f && zoomTranslateY == 0f) return @@ -158,6 +196,8 @@ internal class ImageEditorState( viewMatrix.postScale(zoomScale, zoomScale) viewMatrix.postTranslate(zoomTranslateX, zoomTranslateY) revision++ + + isZoomed = zoomScale > MIN_ZOOM } fun setContentInsets(insets: ChromeInsets) { diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt index 7633d92cbf..7856b16978 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorToolbar.kt @@ -45,7 +45,8 @@ internal fun ImageEditorToolbar( state: MediaEditState, editorState: EditorState.Image, onEvent: (MediaEditScreenEvents) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { when { imageEditorController.shouldDisplayTextColorBar -> { @@ -54,14 +55,14 @@ internal fun ImageEditorToolbar( modifier = modifier ) } - imageEditorController.mode == ImageController.Mode.NONE -> { - ImageEditorNoneStateToolbar(imageEditorController, state, editorState, onEvent, modifier) + !imageEditorController.isUserInEdit -> { + ImageEditorNoneStateToolbar(imageEditorController, state, editorState, onEvent, modifier, enabled) } imageEditorController.mode == ImageController.Mode.CROP -> { - ImageEditorCropAndResizeToolbar(imageEditorController, modifier) + ImageEditorCropAndResizeToolbar(imageEditorController, modifier, enabled) } else -> { - ImageEditorDrawStateToolbar(imageEditorController, onEvent, modifier) + ImageEditorDrawStateToolbar(imageEditorController, onEvent, modifier, enabled) } } } @@ -75,23 +76,27 @@ private fun ImageEditorNoneStateToolbar( state: MediaEditState, editorState: EditorState.Image, onEvent: (MediaEditScreenEvents) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { MediaEditorToolbar(modifier) { MediaEditorToolbarButton( imageVector = SignalIcons.CropRotate.imageVector, - onClick = imageEditorController::beginCropAndRotateEdit + onClick = imageEditorController::beginCropAndRotateEdit, + enabled = enabled ) MediaEditorToolbarButton( imageVector = SignalIcons.BrushPen.imageVector, - onClick = imageEditorController::beginDrawEdit + onClick = imageEditorController::beginDrawEdit, + enabled = enabled ) MediaEditorToolbarSharedButtons( state = state, editorState = editorState, - onEvent = onEvent + onEvent = onEvent, + enabled = enabled ) } } @@ -100,20 +105,22 @@ private fun ImageEditorNoneStateToolbar( private fun ImageEditorDrawStateToolbar( imageEditorController: ImageController, onEvent: (MediaEditScreenEvents) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { MediaEditorToolbar( modifier = modifier, leading = { - DiscardButton(imageEditorController) + DiscardButton(imageEditorController, enabled) }, trailing = { - CommitButton(imageEditorController) + CommitButton(imageEditorController, enabled) } ) { ImageEditorToggleButton( imageVector = SignalIcons.Draw.imageVector, checked = imageEditorController.isUserDrawing, + enabled = enabled, onCheckChanged = { if (!imageEditorController.isUserDrawing) { imageEditorController.enterDrawMode() @@ -124,6 +131,7 @@ private fun ImageEditorDrawStateToolbar( ImageEditorToggleButton( imageVector = SignalIcons.Text.imageVector, checked = imageEditorController.isUserEnteringText, + enabled = enabled, onCheckChanged = { if (!imageEditorController.isUserEnteringText) { imageEditorController.enterTextMode() @@ -134,6 +142,7 @@ private fun ImageEditorDrawStateToolbar( ImageEditorToggleButton( imageVector = SignalIcons.Sticker.imageVector, checked = imageEditorController.isUserInsertingSticker, + enabled = enabled, onCheckChanged = { // Unconditional: if a previous pick never delivered a result the mode is still INSERT_STICKER, and gating on it // would leave the button dead. @@ -145,6 +154,7 @@ private fun ImageEditorDrawStateToolbar( ImageEditorToggleButton( imageVector = SignalIcons.Blur.imageVector, checked = imageEditorController.isUserBlurring, + enabled = enabled, onCheckChanged = { if (!imageEditorController.isUserBlurring) { imageEditorController.enterBlurMode() @@ -157,25 +167,28 @@ private fun ImageEditorDrawStateToolbar( @Composable private fun ImageEditorCropAndResizeToolbar( imageEditorController: ImageController, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + enabled: Boolean = true ) { MediaEditorToolbar( modifier = modifier, leading = { - DiscardButton(imageEditorController) + DiscardButton(imageEditorController, enabled) }, trailing = { - CommitButton(imageEditorController) + CommitButton(imageEditorController, enabled) } ) { MediaEditorToolbarButton( imageVector = SignalIcons.CropRotate.imageVector, - onClick = imageEditorController::rotate + onClick = imageEditorController::rotate, + enabled = enabled ) MediaEditorToolbarButton( imageVector = SignalIcons.Flip.imageVector, - onClick = imageEditorController::flip + onClick = imageEditorController::flip, + enabled = enabled ) val cropLockImageVector = SignalIcons.CropLock.imageVector @@ -184,6 +197,7 @@ private fun ImageEditorCropAndResizeToolbar( val cropUnlockContentDescription = stringResource(R.string.ImageEditorToolbar__aspect_ratio_unlocked) IconCrossfadeToggleButton( + enabled = enabled, target = if (imageEditorController.isCropAspectRatioLocked) CropLock.LOCKED else CropLock.UNLOCKED, setTarget = { target -> when (target) { @@ -212,10 +226,11 @@ private fun ImageEditorCropAndResizeToolbar( } @Composable -private fun CommitButton(imageEditorController: ImageController) { +private fun CommitButton(imageEditorController: ImageController, enabled: Boolean) { MediaEditorToolbarButton( imageVector = SignalIcons.Check.imageVector, onClick = imageEditorController::commitEdit, + enabled = enabled, colors = IconButtons.iconButtonColors( containerColor = MaterialTheme.colorScheme.primaryContainer ) @@ -223,7 +238,7 @@ private fun CommitButton(imageEditorController: ImageController) { } @Composable -private fun DiscardButton(imageEditorController: ImageController) { +private fun DiscardButton(imageEditorController: ImageController, enabled: Boolean) { if (imageEditorController.showDiscardDialog) { MediaEditScreenDialogs.DiscardEditsConfirmationDialog( onDiscard = imageEditorController::confirmDiscardEdit, @@ -234,6 +249,7 @@ private fun DiscardButton(imageEditorController: ImageController) { MediaEditorToolbarButton( imageVector = SignalIcons.X.imageVector, onClick = imageEditorController::requestCancelEdit, + enabled = enabled, colors = IconButtons.iconButtonColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ) @@ -245,10 +261,12 @@ private inline fun > IconCrossfadeToggleButton( target: E, crossinline setTarget: (E) -> Unit, targetToImageMap: EnumMap, - targetToContentDescriptionMap: EnumMap + targetToContentDescriptionMap: EnumMap, + enabled: Boolean = true ) { IconButtons.IconButton( - onClick = { setTarget(target.next()) } + onClick = { setTarget(target.next()) }, + enabled = enabled ) { Crossfade(target) { enumValue -> Icon( @@ -265,11 +283,13 @@ private fun ImageEditorToggleButton( imageVector: ImageVector, checked: Boolean, onCheckChanged: (Boolean) -> Unit, - contentDescription: String? = null + contentDescription: String? = null, + enabled: Boolean = true ) { IconButtons.IconToggleButton( checked = checked, onCheckedChange = onCheckChanged, + enabled = enabled, colors = iconToggleButtonColors( checkedContentColor = MaterialTheme.colorScheme.onSurface, checkedContainerColor = SignalTheme.colors.colorTransparentInverse2 diff --git a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt index 401b705e0d..266620c3d2 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/screens/edit/image/ImageEditorUndoRedoButtons.kt @@ -29,14 +29,14 @@ import org.signal.mediasend.screens.edit.MediaEditControl @Composable internal fun ImageEditorUndoRedoButtons( imageEditorController: ImageController?, - isDragging: Boolean, + faded: Boolean, modifier: Modifier = Modifier, canUndo: Boolean = imageEditorController?.imageEditorState?.undoAvailable ?: false, canRedo: Boolean = imageEditorController?.imageEditorState?.redoAvailable ?: false ) { MediaEditControl( - visible = imageEditorController != null && imageEditorController.mode != ImageController.Mode.NONE && (canUndo || canRedo), - faded = isDragging, + visible = imageEditorController != null && imageEditorController.isUserInEdit && (canUndo || canRedo), + faded = faded, modifier = modifier ) { Row(horizontalArrangement = spacedBy(8.dp)) { @@ -80,7 +80,7 @@ private fun ImageEditorUndoRedoButtonsPreview() { enterDrawMode() } }, - isDragging = false, + faded = false, canUndo = true, canRedo = true ) diff --git a/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/ImageControllerZoomTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/ImageControllerZoomTest.kt new file mode 100644 index 0000000000..7c17db563f --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/screens/edit/ImageControllerZoomTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.screens.edit + +import android.app.Application +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.TestMonotonicFrameClock +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.signal.imageeditor.core.model.EditorElement +import org.signal.imageeditor.core.model.EditorModel + +/** + * Zoom is a mode rather than a transform sitting on top of one: it opens on the first pinch away from the fit scale and + * closes again whenever the canvas goes back to it, taking the selection and the pager lock with it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +// The editor hierarchy builds an inverse-fill Path for the crop mask, which the legacy graphics shadows cannot do. +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ImageControllerZoomTest { + + private val controller = ImageController(EditorModel.create(0)) + + @Test + fun `Given the editor at rest, when the canvas is pinched, then it enters zoom mode`() { + controller.pinch(scaleFactor = 2f) + + assertThat(controller.mode).isEqualTo(ImageController.Mode.ZOOM) + assertThat(controller.imageEditorState.isZoomed).isTrue() + assertThat(controller.isUserInEdit).isFalse() + } + + @Test + fun `Given a zoomed canvas, when it is pinched back to its fit, then it returns to rest`() { + controller.pinch(scaleFactor = 2f) + + controller.pinch(scaleFactor = 0.5f) + + assertThat(controller.mode).isEqualTo(ImageController.Mode.NONE) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given a zoomed canvas, when back is pressed, then the zoom is dropped and the editor is back at rest`() = runZoomTest { + controller.pinch(scaleFactor = 2f) + + controller.onBackPressed() + + assertThat(controller.mode).isEqualTo(ImageController.Mode.NONE) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given a zoomed canvas, when zoom mode is exited, then the zoom is dropped`() = runZoomTest { + controller.pinch(scaleFactor = 2f) + + controller.exitZoomMode() + + assertThat(controller.mode).isEqualTo(ImageController.Mode.NONE) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given a zoomed canvas, when zoom mode is exited, then the canvas eases back rather than snapping`() = runZoomTest { + controller.pinch(scaleFactor = 4f) + + val settle = launch { controller.exitZoomMode() } + delay(50) + + assertThat(controller.isSettlingZoom).isTrue() + assertThat(controller.imageEditorState.isZoomed).isTrue() + assertThat(controller.mode).isEqualTo(ImageController.Mode.ZOOM) + + settle.join() + + assertThat(controller.isSettlingZoom).isFalse() + assertThat(controller.mode).isEqualTo(ImageController.Mode.NONE) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given an easing canvas, when it is panned, then the settle is left alone`() = runZoomTest { + controller.pinch(scaleFactor = 4f) + + val settle = launch { controller.exitZoomMode() } + delay(50) + controller.panBy(100f, 100f) + settle.join() + + assertThat(controller.mode).isEqualTo(ImageController.Mode.NONE) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given a zoomed canvas, when an edit mode is entered, then the zoom does not survive into it`() { + controller.pinch(scaleFactor = 2f) + + controller.beginDrawEdit() + + assertThat(controller.mode).isEqualTo(ImageController.Mode.DRAW) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given an edit mode, when the canvas is pinched, then nothing zooms`() { + controller.beginCropAndRotateEdit() + + controller.pinch(scaleFactor = 2f) + + assertThat(controller.mode).isEqualTo(ImageController.Mode.CROP) + assertThat(controller.imageEditorState.isZoomed).isFalse() + } + + @Test + fun `Given a selected element, when the canvas is pinched, then the selection is given up`() { + val element = controller.selectNewTextElement() + assertThat(controller.selectedElement).isNotNull() + + controller.pinch(scaleFactor = 2f) + + assertThat(controller.selectedElement).isNull() + assertThat(controller.mode).isEqualTo(ImageController.Mode.ZOOM) + + controller.onEntityDown(element) + + assertThat(controller.selectedElement).isNull() + } + + @Test + fun `Given a zoomed canvas, when it is tapped, then the chrome comes back until it is tapped again`() { + controller.pinch(scaleFactor = 2f) + assertThat(controller.isChromeFadedForZoom).isTrue() + + controller.toggleChromeRevealed() + assertThat(controller.isChromeFadedForZoom).isFalse() + + controller.toggleChromeRevealed() + assertThat(controller.isChromeFadedForZoom).isTrue() + } + + @Test + fun `Given revealed chrome, when zoom mode is left, then the chrome goes back to following the mode`() = runZoomTest { + controller.pinch(scaleFactor = 2f) + controller.toggleChromeRevealed() + + controller.exitZoomMode() + controller.pinch(scaleFactor = 2f) + + assertThat(controller.isChromeFadedForZoom).isTrue() + } + + /** The zoom settle animation needs a frame clock, which the test scheduler drives through virtual time. */ + @OptIn(ExperimentalTestApi::class) + private fun runZoomTest(testBody: suspend CoroutineScope.() -> Unit) = runTest { + withContext(TestMonotonicFrameClock(this)) { + testBody() + } + } + + private fun ImageController.pinch(scaleFactor: Float) { + zoomBy(focusX = 0f, focusY = 0f, scaleFactor = scaleFactor, panX = 0f, panY = 0f) + } + + /** Leaves a real, model-backed element selected the way a tap on it at rest would. */ + private fun ImageController.selectNewTextElement(): EditorElement { + enterTextMode() + onTextChanged("hello") + val element = textEditingElement!! + finishTextEditing() + onEntityDown(element) + + return element + } +}