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 CloseImageEditor : AttachmentsPreviewEvent
data object RotateImageToTheLeft : AttachmentsPreviewEvent
data object FlipImageHorizontally : AttachmentsPreviewEvent
data object FlipImageVertically : AttachmentsPreviewEvent
data object ApplyImageEdits : AttachmentsPreviewEvent
data object ResetImageEdits : AttachmentsPreviewEvent
data class UpdateImageCropRect(val cropRect: NormalizedCropRect) : AttachmentsPreviewEvent
@@ -295,6 +295,18 @@ class AttachmentsPreviewPresenter(
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 -> {
imageEditorState = imageEditorState?.copy(
edits = AttachmentImageEdits()
@@ -136,6 +136,8 @@ fun AttachmentsPreviewView(
state.eventSink(AttachmentsPreviewEvent.UpdateImageCropRect(cropRect))
},
onRotateClick = { state.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft) },
onFlipHorizontallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageHorizontally) },
onFlipVerticallyClick = { state.eventSink(AttachmentsPreviewEvent.FlipImageVertically) },
onCancelClick = ::postCloseImageEditor,
onResetClick = ::postResetImageEditor,
onDoneClick = ::postApplyImageEdits,
@@ -87,30 +87,30 @@ class DefaultAttachmentImageEditor(
decodedBitmap.recycle()
}
val rotatedBitmap = normalizedBitmap.rotateQuarterTurns(edits.rotationQuarterTurns)
if (rotatedBitmap !== normalizedBitmap) {
val transformedBitmap = normalizedBitmap.applyEdits(edits)
if (transformedBitmap !== normalizedBitmap) {
normalizedBitmap.recycle()
}
val cropRect = edits.cropRect.toPixelRect(
imageWidth = rotatedBitmap.width,
imageHeight = rotatedBitmap.height,
imageWidth = transformedBitmap.width,
imageHeight = transformedBitmap.height,
)
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) {
rotatedBitmap
transformedBitmap
} else {
Bitmap.createBitmap(
rotatedBitmap,
transformedBitmap,
cropRect.left,
cropRect.top,
cropRect.width(),
cropRect.height(),
)
}
if (croppedBitmap !== rotatedBitmap) {
rotatedBitmap.recycle()
if (croppedBitmap !== transformedBitmap) {
transformedBitmap.recycle()
}
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 {
val normalizedTurns = (quarterTurns % 4 + 4) % 4
if (normalizedTurns == 0) return this
private fun Bitmap.applyEdits(edits: AttachmentImageEdits): Bitmap {
val normalizedTurns = (edits.rotationQuarterTurns % 4 + 4) % 4
if (normalizedTurns == 0 && !edits.isFlippedHorizontally && !edits.isFlippedVertically) {
return this
}
val centerX = width / 2f
val centerY = height / 2f
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)
}
@@ -26,6 +26,8 @@ data class AttachmentImageEditorState(
data class AttachmentImageEdits(
val cropRect: NormalizedCropRect = NormalizedCropRect.default(),
val rotationQuarterTurns: Int = 0,
val isFlippedHorizontally: Boolean = false,
val isFlippedVertically: Boolean = false,
) {
val normalizedRotationQuarterTurns: Int
get() = rotationQuarterTurns % 4
@@ -34,18 +36,32 @@ data class AttachmentImageEdits(
get() = normalizedRotationQuarterTurns * 90
val hasChanges: Boolean
get() = cropRect != NormalizedCropRect.default() || normalizedRotationQuarterTurns != 0
get() = cropRect != NormalizedCropRect.default() ||
normalizedRotationQuarterTurns != 0 ||
isFlippedHorizontally ||
isFlippedVertically
fun rotateAntiClockwise(): AttachmentImageEdits {
return copy(
rotationQuarterTurns = (normalizedRotationQuarterTurns + 3) % 4,
// Also update the crop rect to keep the same selected area
cropRect = NormalizedCropRect(
left = cropRect.top,
top = 1f - cropRect.right,
right = cropRect.bottom,
bottom = 1f - cropRect.left,
)
cropRect = cropRect.rotateAntiClockwise()
)
}
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 {
fun default() = NormalizedCropRect(
left = DEFAULT_CROP_MARGIN,
@@ -75,6 +75,16 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
),
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.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
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.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -90,6 +88,8 @@ fun AttachmentImageEditorView(
state: AttachmentImageEditorState,
onCropRectChange: (NormalizedCropRect) -> Unit,
onRotateClick: () -> Unit,
onFlipHorizontallyClick: () -> Unit,
onFlipVerticallyClick: () -> Unit,
onResetClick: () -> Unit,
onCancelClick: () -> Unit,
onDoneClick: () -> Unit,
@@ -101,8 +101,18 @@ fun AttachmentImageEditorView(
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(
modifier = modifier.fillMaxSize(),
topBar = {
@@ -158,37 +168,57 @@ fun AttachmentImageEditorView(
onClick = onResetClick,
)
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center,
Row(
modifier = Modifier.weight(2f),
// Center the content horizontally
horizontalArrangement = Arrangement.Center,
) {
IconButton(
onClick = onFlipHorizontallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipHorizontalLabel
stateDescription = flipHorizontalState
}
) {
Icon(
imageVector = CompoundIcons.FlipHorizontal(),
contentDescription = null,
)
}
IconButton(
onClick = onRotateClick,
modifier = Modifier
.background(
color = rotateButtonBackground,
shape = CircleShape,
)
.border(1.dp, ElementTheme.colors.borderInteractiveSecondary, CircleShape)
.clearAndSetSemantics {
contentDescription = rotateContentDescription
stateDescription = rotationStateDescription
}
) {
Icon(
modifier = Modifier
.size(22.dp),
imageVector = CompoundIcons.RotateLeft(),
contentDescription = null,
)
}
IconButton(
onClick = onFlipVerticallyClick,
modifier = Modifier
.clearAndSetSemantics {
contentDescription = flipVerticalLabel
stateDescription = flipVerticalState
}
) {
Icon(
imageVector = CompoundIcons.FlipVertical(),
contentDescription = null,
)
}
}
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterEnd,
) {
TextButton(
text = stringResource(CommonStrings.action_done),
text = stringResource(CommonStrings.action_save),
onClick = onDoneClick,
)
}
@@ -204,6 +234,8 @@ private fun BoxScope.CropEditorCanvas(
) {
var imageSize by remember(state.localMedia.uri) { mutableStateOf(IntSize.Zero) }
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) }
@@ -257,6 +289,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = null,
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
)
@@ -266,6 +302,10 @@ private fun BoxScope.CropEditorCanvas(
contentDescription = stringResource(CommonStrings.common_image),
modifier = Modifier
.requiredSize(imageLayoutWidthDp, imageLayoutHeightDp)
.graphicsLayer {
scaleX = flipScaleX
scaleY = flipScaleY
}
.graphicsLayer { rotationZ = rotationQuarterTurns * 90f },
contentScale = ContentScale.Fit,
onState = { painterState ->
@@ -642,6 +682,8 @@ internal fun AttachmentImageEditorViewPreview(
state = state,
onCropRectChange = {},
onRotateClick = {},
onFlipHorizontallyClick = {},
onFlipVerticallyClick = {},
onResetClick = {},
onCancelClick = {},
onDoneClick = {},
@@ -16,6 +16,12 @@
<string name="emoji_picker_category_places">"Travel &amp; Places"</string>
<string name="emoji_picker_category_recent">"Recent emojis"</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>
<plurals name="screen_image_edition_a11y_rotation_state">
<item quantity="one">"%1$d degree"</item>
@@ -624,7 +624,11 @@ class AttachmentsPreviewPresenterTest {
val croppedState = awaitItem()
croppedState.eventSink(AttachmentsPreviewEvent.RotateImageToTheLeft)
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()
assertThat((appliedState.attachment as Attachment.Media).localMedia.uri).isEqualTo(editedUri)
@@ -638,9 +642,36 @@ class AttachmentsPreviewPresenterTest {
right = cropRect.bottom,
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.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.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()
}
}