mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-13 12:07:57 +01:00
Media send feedback.
This commit is contained in:
@@ -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) {
|
||||
|
||||
+98
-7
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-16
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
+96
-7
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+40
@@ -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) {
|
||||
|
||||
+42
-22
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
)
|
||||
|
||||
+192
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user