Add flip actions to image edition

This commit is contained in:
Benoit Marty
2026-05-28 15:00:03 +02:00
parent 1a73ab0094
commit a9ecf3c5dc
10 changed files with 219 additions and 37 deletions
@@ -17,6 +17,8 @@ sealed interface AttachmentsPreviewEvent {
data object OpenImageEditor : AttachmentsPreviewEvent data object OpenImageEditor : AttachmentsPreviewEvent
data object CloseImageEditor : AttachmentsPreviewEvent data object CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object FlipImageHorizontally : AttachmentsPreviewEvent
data object FlipImageVertically : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
@@ -295,6 +295,18 @@ class AttachmentsPreviewPresenter(
edits = pendingState.edits.rotateAntiClockwise() edits = pendingState.edits.rotateAntiClockwise()
) )
} }
AttachmentsPreviewEvent.FlipImageHorizontally -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipHorizontally()
)
}
AttachmentsPreviewEvent.FlipImageVertically -> {
val pendingState = imageEditorState ?: return
imageEditorState = pendingState.copy(
edits = pendingState.edits.flipVertically()
)
}
AttachmentsPreviewEvent.ResetImageEdits -> { AttachmentsPreviewEvent.ResetImageEdits -> {
imageEditorState = imageEditorState?.copy( imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits() edits = AttachmentImageEdits()
@@ -136,6 +136,8 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect)) state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
}, },
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) }, onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) },
onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) },
onCancelClick = ::postCloseImageEditor, onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor, onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits, onDoneClick = ::postApplyImageEdits,
@@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor(
decodedBitmap.recycle() decodedBitmap.recycle()
} }
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns) val transformedBitmap = normalizedBitmap.applyEdits(edits)
if (rotatedBitmap !== normalizedBitmap) { if (transformedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle() normalizedBitmap.recycle()
} }
val cropRect = edits.cropRect.toPixelRect( val cropRect = edits.cropRect.toPixelRect(
imageWidth = rotatedBitmap.width, imageWidth = transformedBitmap.width,
imageHeight = rotatedBitmap.height, imageHeight = transformedBitmap.height,
) )
val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 && val isCropUnchanged = cropRect.left == 0 && cropRect.top == 0 &&
cropRect.width() == rotatedBitmap.width && cropRect.height() == rotatedBitmap.height cropRect.width() == transformedBitmap.width && cropRect.height() == transformedBitmap.height
val croppedBitmap = if (isCropUnchanged) { val croppedBitmap = if (isCropUnchanged) {
rotatedBitmap transformedBitmap
} else { } else {
Bitmap.createBitmap( Bitmap.createBitmap(
rotatedBitmap, transformedBitmap,
cropRect.left, cropRect.left,
cropRect.top, cropRect.top,
cropRect.width(), cropRect.width(),
cropRect.height(), cropRect.height(),
) )
} }
if (croppedBitmap !== rotatedBitmap) { if (croppedBitmap !== transformedBitmap) {
rotatedBitmap.recycle() transformedBitmap.recycle()
} }
val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() } val editedMediaDir = File(context.cacheDir, EDITED_MEDIA_DIR_NAME).apply { mkdirs() }
@@ -141,11 +141,22 @@ internal fun exportedMimeTypeFor(sourceMimeType: String?): String {
} }
} }
private fun Bitmap.rotateQuarterTurns(quarterTurns: Int): Bitmap { private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap {
val normalizedTurns = (quarterTurns % 4 + 4) % 4 val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4
if (normalizedTurns == 0) return this if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) {
return this
}
val centerX = width / 2f
val centerY = height / 2f
val matrix = Matrix().apply { val matrix = Matrix().apply {
postRotate(normalizedTurns * 90f) val scaleX = if (edits.isFlippedHorizontally) -1f else 1f
val scaleY = if (edits.isFlippedVertically) -1f else 1f
if (scaleX < 0f || scaleY < 0f) {
postScale(scaleX, scaleY, centerX, centerY)
}
if (normalizedTurns != 0) {
postRotate(normalizedTurns * 90f, centerX, centerY)
}
} }
return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true) return Bitmap.createBitmap(this, 0, 0, width, height, matrix, true)
} }
@@ -26,6 +26,8 @@ data class AttachmentImageEditorState(
data class AttachmentImageEdits( data class AttachmentImageEdits(
val cropRect: NormalizedCropRect = NormalizedCropRect.default(), val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
val rotationQuarterTurns: Int = 0, val rotationQuarterTurns: Int = 0,
val isFlippedHorizontally: Boolean = false,
val isFlippedVertically: Boolean = false,
) { ) {
val normalizedRotationQuarterTurns: Int val normalizedRotationQuarterTurns: Int
get() = rotationQuarterTurns % 4 get() = rotationQuarterTurns % 4
@@ -34,18 +36,32 @@ data class AttachmentImageEdits(
get() = normalizedRotationQuarterTurns * 90 get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0 get() = cropRect != NormalizedCropRect.default() ||
normalizedRotationQuarterTurns != 0 ||
isFlippedHorizontally ||
isFlippedVertically
fun rotateAntiClockwise(): AttachmentImageEdits { fun rotateAntiClockwise(): AttachmentImageEdits {
return copy( return copy(
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4, rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
// Also update the crop rect to keep the same selected area // Also update the crop rect to keep the same selected area
cropRect = NormalizedCropRect( cropRect = cropRect.rotateAntiClockwise()
left = cropRect.top, )
top = 1f - cropRect.right, }
right = cropRect.bottom,
bottom = 1f - cropRect.left, fun flipHorizontally(): AttachmentImageEdits {
) return copy(
isFlippedHorizontally = !isFlippedHorizontally,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipHorizontally(),
)
}
fun flipVertically(): AttachmentImageEdits {
return copy(
isFlippedVertically = !isFlippedVertically,
// Also update the crop rect to keep the same selected area
cropRect = cropRect.flipVertically(),
) )
} }
} }
@@ -135,6 +151,23 @@ data class NormalizedCropRect(
) )
} }
fun rotateAntiClockwise() = copy(
left = top,
top = 1f - right,
right = bottom,
bottom = 1f - left,
)
fun flipHorizontally() = copy(
left = 1f - right,
right = 1f - left,
)
fun flipVertically() = copy(
top = 1f - bottom,
bottom = 1f - top,
)
companion object { companion object {
fun default() = NormalizedCropRect( fun default() = NormalizedCropRect(
left = DEFAULT_CROP_MARGIN, left = DEFAULT_CROP_MARGIN,
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
), ),
previewDebug = true, previewDebug = true,
), ),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipHorizontally(),
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).flipVertically(),
),
) )
} }
@@ -10,7 +10,6 @@ package io.element.android.features.messages.impl.attachments.preview.imageedito
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
state: AttachmentImageEditorState, state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit, onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit, onRotateClick: () -> Unit,
onFlipHorizontallyClick: () -> Unit,
onFlipVerticallyClick: () -> Unit,
onResetClick: () -> Unit, onResetClick: () -> Unit,
onCancelClick: () -> Unit, onCancelClick: () -> Unit,
onDoneClick: () -> Unit, onDoneClick: () -> Unit,
@@ -101,8 +101,18 @@ fun AttachmentImageEditorView(
state.edits.rotationDegrees, state.edits.rotationDegrees,
state.edits.rotationDegrees, state.edits.rotationDegrees,
) )
val rotateButtonBackground = ElementTheme.colors.bgCanvasDefault val flipHorizontalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally)
val flipHorizontalState = if (state.edits.isFlippedHorizontally) {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_horizontally_state_original)
}
val flipVerticalLabel = stringResource(R.string.screen_image_edition_a11y_flip_image_vertically)
val flipVerticalState = if (state.edits.isFlippedVertically) {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_flipped)
} else {
stringResource(R.string.screen_image_edition_a11y_flip_image_vertically_state_original)
}
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
@@ -158,37 +168,57 @@ fun AttachmentImageEditorView(
onClick = onResetClick, onClick = onResetClick,
) )
} }
Box( Row(
modifier = Modifier.weight(1f), modifier = Modifier.weight(2f),
contentAlignment = Alignment.Center, // Center the content horizontally
horizontalArrangement = Arrangement.Center,
) { ) {
IconButton(
onClick = onFlipHorizontallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipHorizontalLabel
stateDescription = flipHorizontalState
}
) {
Icon(
imageVector = CompoundIcons.FlipHorizontal(),
contentDescription = null,
)
}
IconButton( IconButton(
onClick = onRotateClick, onClick = onRotateClick,
modifier = Modifier modifier = Modifier
.background(
color = rotateButtonBackground,
shape = CircleShape,
)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
.clearAndSetSemantics { .clearAndSetSemantics {
contentDescription = rotateContentDescription contentDescription = rotateContentDescription
stateDescription = rotationStateDescription stateDescription = rotationStateDescription
} }
) { ) {
Icon( Icon(
modifier = Modifier
.size(22.dp),
imageVector = CompoundIcons.RotateLeft(), imageVector = CompoundIcons.RotateLeft(),
contentDescription = null, contentDescription = null,
) )
} }
IconButton(
onClick = onFlipVerticallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipVerticalLabel
stateDescription = flipVerticalState
}
) {
Icon(
imageVector = CompoundIcons.FlipVertical(),
contentDescription = null,
)
}
} }
Box( Box(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd, contentAlignment = Alignment.CenterEnd,
) { ) {
TextButton( TextButton(
text = stringResource(CommonStrings.action_done), text = stringResource(CommonStrings.action_save),
onClick = onDoneClick, onClick = onDoneClick,
) )
} }
@@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas(
) { ) {
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) } var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns val rotationQuarterTurns = state.edits.normalizedRotationQuarterTurns
val flipScaleX = if (state.edits.isFlippedHorizontally) -1f else 1f
val flipScaleY = if (state.edits.isFlippedVertically) -1f else 1f
var imageRect by remember { mutableStateOf(Rect.Zero) } var imageRect by remember { mutableStateOf(Rect.Zero) }
@@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, .graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
@@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = stringResource(CommonStrings.common_image), contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp) .requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f }, .graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
onState = { painterState -> onState = { painterState ->
@@ -642,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview(
state = state, state = state,
onCropRectChange = {}, onCropRectChange = {},
onRotateClick = {}, onRotateClick = {},
onFlipHorizontallyClick = {},
onFlipVerticallyClick = {},
onResetClick = {}, onResetClick = {},
onCancelClick = {}, onCancelClick = {},
onDoneClick = {}, onDoneClick = {},
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Travel &amp; Places"</string> <string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</string> <string name="emoji_picker_category_recent">"Recent emojis"</string>
<string name="emoji_picker_category_symbols">"Symbols"</string> <string name="emoji_picker_category_symbols">"Symbols"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
<string name="screen_image_edition_a11y_flip_image_vertically">"Flip image vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_flipped">"Flipped vertically"</string>
<string name="screen_image_edition_a11y_flip_image_vertically_state_original">"Original"</string>
<string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string> <string name="screen_image_edition_a11y_rotate_to_the_left">"Rotate the image to the left"</string>
<plurals name="screen_image_edition_a11y_rotation_state"> <plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degree"</item> <item quantity="one">"%1$d degree"</item>
@@ -624,7 +624,11 @@ class AttachmentsPreviewPresenterTest {
val croppedState = awaitItem() val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
val rotatedState = awaitItem() val rotatedState = awaitItem()
rotatedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits) rotatedState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
val flippedHorizontallyState = awaitItem()
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
val flippedState = awaitItem()
flippedState.eventSink(AttachmentsPreviewEvent.ApplyImageEdits)
val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last() val appliedState = consumeItemsUntilPredicate { !it.isApplyingImageEdits && it.imageEditorState == null }.last()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri) assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
@@ -638,9 +642,36 @@ class AttachmentsPreviewPresenterTest {
right = cropRect.bottom, right = cropRect.bottom,
bottom = 1f - cropRect.left, bottom = 1f - cropRect.left,
) )
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(rotatedCropRect) val flippedCropRect = NormalizedCropRect(
left = 1f - rotatedCropRect.right,
top = 1f - rotatedCropRect.bottom,
right = 1f - rotatedCropRect.left,
bottom = 1f - rotatedCropRect.top,
)
reopenedState.imageEditorState.edits.cropRect.assertIsSimilarTo(flippedCropRect)
assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3) assertThat(reopenedState.imageEditorState.edits.rotationQuarterTurns).isEqualTo(3)
assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270) assertThat(reopenedState.imageEditorState.edits.rotationDegrees).isEqualTo(270)
assertThat(reopenedState.imageEditorState.edits.isFlippedHorizontally).isTrue()
assertThat(reopenedState.imageEditorState.edits.isFlippedVertically).isTrue()
}
}
@Test
fun `present - image editor flip events update edits`() = runTest {
val presenter = createAttachmentsPreviewPresenter(displayMediaQualitySelectorViews = true)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(AttachmentsPreviewEvent.OpenImageEditor)
val editorState = consumeItemsUntilPredicate { it.imageEditorState != null }.last()
editorState.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally)
val flippedHorizontallyState = awaitItem()
assertThat(flippedHorizontallyState.imageEditorState?.edits?.isFlippedHorizontally).isTrue()
flippedHorizontallyState.eventSink(AttachmentsPreviewEvent.FlipImageVertically)
val flippedState = awaitItem()
assertThat(flippedState.imageEditorState?.edits?.isFlippedVertically).isTrue()
} }
} }
@@ -42,4 +42,37 @@ class AttachmentImageEditsTest {
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f) assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.8f)
assertThat(result.hasChanges).isTrue() assertThat(result.hasChanges).isTrue()
} }
@Test
fun `flip horizontally updates crop and change tracking`() {
val sut = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.1f,
top = 0.3f,
right = 0.6f,
bottom = 0.9f,
)
)
val result = sut.flipHorizontally()
assertThat(result.isFlippedHorizontally).isTrue()
assertThat(result.cropRect.left).isWithin(0.0001f).of(0.4f)
assertThat(result.cropRect.right).isWithin(0.0001f).of(0.9f)
assertThat(result.cropRect.top).isWithin(0.0001f).of(0.3f)
assertThat(result.cropRect.bottom).isWithin(0.0001f).of(0.9f)
assertThat(result.hasChanges).isTrue()
}
@Test
fun `flip vertical twice resets to default state`() {
val edits = AttachmentImageEdits().flipVertically().flipVertically()
assertThat(edits.isFlippedVertically).isFalse()
assertThat(edits.hasChanges).isFalse()
}
@Test
fun `flip horizontally twice resets to default state`() {
val edits = AttachmentImageEdits().flipHorizontally().flipHorizontally()
assertThat(edits.isFlippedVertically).isFalse()
assertThat(edits.hasChanges).isFalse()
}
} }