Media send feedback.

This commit is contained in:
Alex Hart
2026-08-13 15:37:02 -03:00
parent f8d84ff254
commit 34ecc7ab9c
11 changed files with 528 additions and 64 deletions
@@ -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.
*/
@@ -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) {
@@ -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
}
/**
@@ -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
)
}
}
@@ -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)
)
}
@@ -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<Offset, Offset>, current: Pair<Offset, Offset>) {
/**
* 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<Offset, Offset>? = 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<Offset, Offset>, current: Pair<Offset, Offset>) {
val previousSpread = (previous.first - previous.second).getDistance()
val currentSpread = (current.first - current.second).getDistance()
if (previousSpread <= 0f || currentSpread <= 0f) return
@@ -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
)
}
@@ -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) {
@@ -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 <reified E : Enum<E>> IconCrossfadeToggleButton(
target: E,
crossinline setTarget: (E) -> Unit,
targetToImageMap: EnumMap<E, ImageVector>,
targetToContentDescriptionMap: EnumMap<E, String>
targetToContentDescriptionMap: EnumMap<E, String>,
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
@@ -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
)
@@ -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
}
}