Improve touch detection.

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

View file

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

View file

@ -7,6 +7,7 @@
package io.element.android.features.messages.impl.attachments.preview.imageeditor
import androidx.annotation.FloatRange
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -18,7 +19,7 @@ data class AttachmentImageEditorState(
val localMedia: LocalMedia,
val edits: AttachmentImageEdits,
// For preview only
val forceDrawGuidelines: Boolean,
val previewDebug: Boolean,
)
@Immutable
@ -51,10 +52,10 @@ data class AttachmentImageEdits(
@Immutable
data class NormalizedCropRect(
val left: Float,
val top: Float,
val right: Float,
val bottom: Float,
@FloatRange(from = 0.0, to = 1.0) val left: Float,
@FloatRange(from = 0.0, to = 1.0) val top: Float,
@FloatRange(from = 0.0, to = 1.0) val right: Float,
@FloatRange(from = 0.0, to = 1.0) val bottom: Float,
) {
init {
require(left in 0f..1f)
@ -71,7 +72,11 @@ data class NormalizedCropRect(
val height: Float
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.Corner -> dragWithCorner(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(
left = (left + deltaX).coerceIn(0f, right - 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(
top = (top + deltaY).coerceIn(0f, bottom - MIN_CROP_SIZE),
)

View file

@ -44,13 +44,37 @@ open class AttachmentImageEditorStateProvider : PreviewParameterProvider<Attachm
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
),
forceDrawGuidelines = true,
previewDebug = true,
),
anAttachmentImageEditorState(
edits = AttachmentImageEdits(
cropRect = caterpillarCrop,
).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(),
),
edits: AttachmentImageEdits = AttachmentImageEdits(),
forceDrawGuidelines: Boolean = false,
previewDebug: Boolean = false,
) = AttachmentImageEditorState(
localMedia = localMedia,
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.libraries.designsystem.components.button.BackButton
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.IconButton
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.utils.CommonDrawables
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
@ -274,11 +279,20 @@ private fun BoxScope.CropEditorCanvas(
)
}
}
val touchRadius = 56.dp
val minHandleTouchRadiusPx = minHandleTouchRadius.toPx()
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) }
val latestCropRect by rememberUpdatedState(state.edits.cropRect)
val drawGuidelines = dragTarget == CropDragTarget.Move || state.forceDrawGuidelines
val drawGuidelines = dragTarget == CropDragTarget.Move || state.previewDebug
Box(
modifier = Modifier
.fillMaxSize()
@ -290,7 +304,7 @@ private fun BoxScope.CropEditorCanvas(
imageOffset = imageRect.topLeft,
cropRect = latestCropRect,
canvasSize = Size(imageRect.width, imageRect.height),
handleTouchRadius = touchRadius.toPx(),
handleTouchRadius = touchRadiusPx,
)
},
onDragCancel = {
@ -317,6 +331,9 @@ private fun BoxScope.CropEditorCanvas(
imageSize = DpSize(displayedWidthDp, displayedHeightDp),
cropRect = state.edits.cropRect,
drawGuidelines = drawGuidelines,
previewDebug = state.previewDebug,
touchRadiusPx = touchRadiusPx,
dragTarget = dragTarget,
)
}
}
@ -327,6 +344,9 @@ private fun CropOverlay(
imageSize: DpSize,
cropRect: NormalizedCropRect,
drawGuidelines: Boolean,
previewDebug: Boolean,
touchRadiusPx: Float,
dragTarget: CropDragTarget?,
) {
val borderColor = ElementTheme.colors.iconPrimary
val guideColor = ElementTheme.colors.iconPrimary
@ -459,6 +479,32 @@ private fun CropOverlay(
handleLength = handleLength,
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,
handleTouchRadius: Float,
): CropDragTarget? {
val corners = mapOf(
CropDragTarget.Corner.TopLeft to Offset(cropRect.left * canvasSize.width, cropRect.top * canvasSize.height),
CropDragTarget.Edge.Top to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.top * canvasSize.height),
CropDragTarget.Corner.TopRight to Offset(cropRect.right * canvasSize.width, cropRect.top * canvasSize.height),
CropDragTarget.Edge.Right to Offset(cropRect.right * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f),
CropDragTarget.Corner.BottomRight to Offset(cropRect.right * canvasSize.width, cropRect.bottom * canvasSize.height),
CropDragTarget.Edge.Bottom to Offset((cropRect.left + cropRect.right) * canvasSize.width / 2f, cropRect.bottom * canvasSize.height),
CropDragTarget.Corner.BottomLeft to Offset(cropRect.left * canvasSize.width, cropRect.bottom * canvasSize.height),
CropDragTarget.Edge.Left to Offset(cropRect.left * canvasSize.width, (cropRect.top + cropRect.bottom) * canvasSize.height / 2f),
// Give priority on Move (extra detection of the center of crop area)
// to ensure that user can move a small crop, then to corners and at last to edges.
val handlesArea = mapOf(
CropDragTarget.Move to computeOffset(CropDragTarget.Move, cropRect, canvasSize),
CropDragTarget.Corner.TopLeft to computeOffset(CropDragTarget.Corner.TopLeft, cropRect, canvasSize),
CropDragTarget.Corner.TopRight to computeOffset(CropDragTarget.Corner.TopRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomRight to computeOffset(CropDragTarget.Corner.BottomRight, cropRect, canvasSize),
CropDragTarget.Corner.BottomLeft to computeOffset(CropDragTarget.Corner.BottomLeft, cropRect, canvasSize),
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) {
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
private fun DrawScope.drawCornerHandle(
x: Float,