Improve touch detection.

This commit is contained in:
Benoit Marty 2026-05-25 21:16:12 +02:00
parent 935506230b
commit edfdda36d6
4 changed files with 128 additions and 26 deletions

View file

@ -276,7 +276,7 @@ class AttachmentsPreviewPresenter(
imageEditorState = AttachmentImageEditorState( imageEditorState = AttachmentImageEditorState(
localMedia = originalLocalMedia, localMedia = originalLocalMedia,
edits = appliedImageEdits, edits = appliedImageEdits,
forceDrawGuidelines = false, previewDebug = false,
) )
} }
} }

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.attachments.preview.imageeditor package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.annotation.FloatRange
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -18,7 +19,7 @@ data class AttachmentImageEditorState(
val localMedia: LocalMedia, val localMedia: LocalMedia,
val edits: AttachmentImageEdits, val edits: AttachmentImageEdits,
// For preview only // For preview only
val forceDrawGuidelines: Boolean, val previewDebug: Boolean,
) )
@Immutable @Immutable
@ -51,10 +52,10 @@ data class AttachmentImageEdits(
@Immutable @Immutable
data class NormalizedCropRect( data class NormalizedCropRect(
val left: Float, @FloatRange(from = 0.0, to = 1.0) val left: Float,
val top: Float, @FloatRange(from = 0.0, to = 1.0) val top: Float,
val right: Float, @FloatRange(from = 0.0, to = 1.0) val right: Float,
val bottom: Float, @FloatRange(from = 0.0, to = 1.0) val bottom: Float,
) { ) {
init { init {
require(left in 0f..1f) require(left in 0f..1f)
@ -71,7 +72,11 @@ data class NormalizedCropRect(
val height: Float val height: Float
get() = bottom - top get() = bottom - top
fun applyChange(dragTarget: CropDragTarget, deltaX: Float, deltaY: Float): NormalizedCropRect = when (dragTarget) { fun applyChange(
dragTarget: CropDragTarget,
deltaX: Float,
deltaY: Float,
): NormalizedCropRect = when (dragTarget) {
is CropDragTarget.Move -> translate(deltaX, deltaY) is CropDragTarget.Move -> translate(deltaX, deltaY)
is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY) is CropDragTarget.Corner -> dragWithCorner(dragTarget, deltaX, deltaY)
is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY) is CropDragTarget.Edge -> dragWithEdge(dragTarget, deltaX, deltaY)
@ -88,7 +93,11 @@ data class NormalizedCropRect(
) )
} }
private fun dragWithCorner(dragTarget: CropDragTarget.Corner, deltaX: Float, deltaY: Float) = when (dragTarget) { private fun dragWithCorner(
dragTarget: CropDragTarget.Corner,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Corner.TopLeft -> copy( CropDragTarget.Corner.TopLeft -> copy(
left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE), left = (left + deltaX).coerceIn(0f, right - MIN_CROP_SIZE),
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
@ -107,7 +116,11 @@ data class NormalizedCropRect(
) )
} }
private fun dragWithEdge(dragTarget: CropDragTarget.Edge, deltaX: Float, deltaY: Float) = when (dragTarget) { private fun dragWithEdge(
dragTarget: CropDragTarget.Edge,
deltaX: Float,
deltaY: Float,
) = when (dragTarget) {
CropDragTarget.Edge.Top -> copy( CropDragTarget.Edge.Top -> copy(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE), top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
) )

View file

@ -44,13 +44,37 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
edits = AttachmentImageEdits( edits = AttachmentImageEdits(
cropRect = caterpillarCrop, cropRect = caterpillarCrop,
), ),
forceDrawGuidelines = true, previewDebug = true,
), ),
anAttachmentImageEditorState( anAttachmentImageEditorState(
edits = AttachmentImageEdits( edits = AttachmentImageEdits(
cropRect = caterpillarCrop, cropRect = caterpillarCrop,
).rotateAntiClockwise(), ).rotateAntiClockwise(),
), ),
// Small crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.3f,
top = 0.6f,
right = 0.4f,
bottom = 0.7f,
),
),
previewDebug = true,
),
// Big crop
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = NormalizedCropRect(
left = 0.05f,
top = 0.05f,
right = 0.95f,
bottom = 0.95f,
),
),
previewDebug = true,
),
) )
} }
@ -60,9 +84,9 @@ internal fun anAttachmentImageEditorState(
info = anImageMediaInfo(), info = anImageMediaInfo(),
), ),
edits: AttachmentImageEdits = AttachmentImageEdits(), edits: AttachmentImageEdits = AttachmentImageEdits(),
forceDrawGuidelines: Boolean = false, previewDebug: Boolean = false,
) = AttachmentImageEditorState( ) = AttachmentImageEditorState(
localMedia = localMedia, localMedia = localMedia,
edits = edits, edits = edits,
forceDrawGuidelines = forceDrawGuidelines, previewDebug = previewDebug,
) )

View file

@ -67,6 +67,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -75,6 +76,10 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.min
private val minHandleTouchRadius = 16.dp
private val maxHandleTouchRadius = 56.dp
/** /**
* Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539 * Ref: https://www.figma.com/design/zftpgS6LjiczobJZ1GUNpt/Updates-to-Media---File-Upload?node-id=51-3539
@ -274,11 +279,20 @@ private fun BoxScope.CropEditorCanvas(
) )
} }
} }
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
val touchRadius = 56.dp val maxHandleTouchRadiusPx = maxHandleTouchRadius.toPx()
val touchRadiusPx by rememberUpdatedState(
(min(
state.edits.cropRect.width * imageRect.width,
state.edits.cropRect.height * imageRect.height,
) / 4f).coerceIn(
minHandleTouchRadiusPx,
maxHandleTouchRadiusPx,
)
)
var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) } var dragTarget by remember { mutableStateOf<CropDragTarget?>(null) }
val latestCropRect by rememberUpdatedState(state.edits.cropRect) val latestCropRect by rememberUpdatedState(state.edits.cropRect)
val drawGuidelines = dragTarget == CropDragTarget.Move || state.forceDrawGuidelines val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -290,7 +304,7 @@ private fun BoxScope.CropEditorCanvas(
imageOffset = imageRect.topLeft, imageOffset = imageRect.topLeft,
cropRect = latestCropRect, cropRect = latestCropRect,
canvasSize = Size(imageRect.width, imageRect.height), canvasSize = Size(imageRect.width, imageRect.height),
handleTouchRadius = touchRadius.toPx(), handleTouchRadius = touchRadiusPx,
) )
}, },
onDragCancel = { onDragCancel = {
@ -317,6 +331,9 @@ private fun BoxScope.CropEditorCanvas(
imageSize = DpSize(displayedWidthDp, displayedHeightDp), imageSize = DpSize(displayedWidthDp, displayedHeightDp),
cropRect = state.edits.cropRect, cropRect = state.edits.cropRect,
drawGuidelines = drawGuidelines, drawGuidelines = drawGuidelines,
previewDebug = state.previewDebug,
touchRadiusPx = touchRadiusPx,
dragTarget = dragTarget,
) )
} }
} }
@ -327,6 +344,9 @@ private fun CropOverlay(
imageSize: DpSize, imageSize: DpSize,
cropRect: NormalizedCropRect, cropRect: NormalizedCropRect,
drawGuidelines: Boolean, drawGuidelines: Boolean,
previewDebug: Boolean,
touchRadiusPx: Float,
dragTarget: CropDragTarget?,
) { ) {
val borderColor = ElementTheme.colors.iconPrimary val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.iconPrimary val guideColor = ElementTheme.colors.iconPrimary
@ -459,6 +479,32 @@ private fun CropOverlay(
handleLength = handleLength, handleLength = handleLength,
color = handleColor, color = handleColor,
) )
if (previewDebug) {
// Draw disk around touchable area
listOf(
CropDragTarget.Edge.Top,
CropDragTarget.Edge.Right,
CropDragTarget.Edge.Bottom,
CropDragTarget.Edge.Left,
CropDragTarget.Corner.TopLeft,
CropDragTarget.Corner.TopRight,
CropDragTarget.Corner.BottomRight,
CropDragTarget.Corner.BottomLeft,
CropDragTarget.Move,
).forEach { target ->
val color = when (target) {
is CropDragTarget.Move -> Color.Red
is CropDragTarget.Corner -> Color.Blue
is CropDragTarget.Edge -> Color.Green
}.copy(alpha = if (dragTarget == target) 9f else 0.5f)
drawCircle(
color = color,
radius = touchRadiusPx,
center = computeOffset(target, cropRect, Size(size.width, size.height)),
)
}
}
} }
} }
@ -482,17 +528,20 @@ private fun detectDragTarget(
canvasSize: Size, canvasSize: Size,
handleTouchRadius: Float, handleTouchRadius: Float,
): CropDragTarget? { ): CropDragTarget? {
val corners = mapOf( // Give priority on Move (extra detection of the center of crop area)
CropDragTarget.Corner.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height), // to ensure that user can move a small crop, then to corners and at last to edges.
CropDragTarget.Edge.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height), val handlesArea = mapOf(
CropDragTarget.Corner.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height), CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
CropDragTarget.Edge.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
CropDragTarget.Corner.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height), CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
CropDragTarget.Edge.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height), CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height), CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f), CropDragTarget.Edge.Top to computeOffset(CropDragTarget.Edge.Top, cropRect, canvasSize),
CropDragTarget.Edge.Right to computeOffset(CropDragTarget.Edge.Right, cropRect, canvasSize),
CropDragTarget.Edge.Bottom to computeOffset(CropDragTarget.Edge.Bottom, cropRect, canvasSize),
CropDragTarget.Edge.Left to computeOffset(CropDragTarget.Edge.Left, cropRect, canvasSize),
) )
corners.forEach { (target, corner) -> handlesArea.forEach { (target, corner) ->
if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) { if ((corner - touchPoint + imageOffset).getDistance() <= handleTouchRadius) {
return target return target
} }
@ -508,6 +557,22 @@ private fun detectDragTarget(
} }
} }
private fun computeOffset(
target: CropDragTarget,
cropRect: NormalizedCropRect,
canvasSize: Size,
) = when (target) {
CropDragTarget.Move -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.TopLeft -> Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Top -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height)
CropDragTarget.Corner.TopRight -> Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height)
CropDragTarget.Edge.Right -> Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
CropDragTarget.Corner.BottomRight -> Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Bottom -> Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height)
CropDragTarget.Corner.BottomLeft -> Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height)
CropDragTarget.Edge.Left -> Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f)
}
// x and y are the coordinates of the corner // x and y are the coordinates of the corner
private fun DrawScope.drawCornerHandle( private fun DrawScope.drawCornerHandle(
x: Float, x: Float,