Improve touch detection.
This commit is contained in:
parent
935506230b
commit
edfdda36d6
4 changed files with 128 additions and 26 deletions
|
|
@ -276,7 +276,7 @@ class AttachmentsPreviewPresenter(
|
||||||
imageEditorState = AttachmentImageEditorState(
|
imageEditorState = AttachmentImageEditorState(
|
||||||
localMedia = originalLocalMedia,
|
localMedia = originalLocalMedia,
|
||||||
edits = appliedImageEdits,
|
edits = appliedImageEdits,
|
||||||
forceDrawGuidelines = false,
|
previewDebug = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue