Support rotation snapping.

Closes signalapp/Signal-Android#14810
This commit is contained in:
fm-sys
2026-09-02 16:11:27 -03:00
committed by Alex Hart
parent 7a27b6e40f
commit 25de8d686d
8 changed files with 119 additions and 22 deletions
@@ -87,7 +87,9 @@ internal fun ImageEditor(
.matchParentSize()
.clipToBounds()
.onSizeChanged { state.setCanvasSize(it.width.toFloat(), it.height.toFloat()) }
.imageEditorPointerInput(state, controller, scope)
.imageEditorPointerInput(state, controller, scope) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
) {
state.revision
@@ -151,9 +153,9 @@ private fun HiddenTextInput(controller: ImageController) {
/** How far a finger travels before a touch on an element counts as a drag. */
private const val MAX_MOVE_SQUARED_BEFORE_DRAG = 10f
private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController, scope: CoroutineScope): Modifier {
private fun Modifier.imageEditorPointerInput(state: ImageEditorState, controller: ImageController, scope: CoroutineScope, onRotationSnap: () -> Unit): Modifier {
return this.pointerInput(controller, controller.textEditingElement) {
val touchHandler = ImageEditorTouchHandler()
val touchHandler = ImageEditorTouchHandler(onRotationSnap)
// The tap that a second one has to land on, and beat the timeout from, to count as a double tap.
var lastTapElement: EditorElement? = null
@@ -9,14 +9,17 @@ import org.signal.imageeditor.core.model.EditorElement;
final class ElementDragEditSession extends ElementEditSession {
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
private final RotationSnapListener rotationSnapListener;
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull RotationSnapListener rotationSnapListener) {
super(selected, inverseMatrix);
this.rotationSnapListener = rotationSnapListener;
}
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) {
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point, @NonNull RotationSnapListener rotationSnapListener) {
if (!selected.getFlags().isEditable()) return null;
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix);
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix, rotationSnapListener);
elementDragEditSession.setScreenStartPoint(0, point);
elementDragEditSession.setScreenEndPoint(0, point);
@@ -33,7 +36,7 @@ final class ElementDragEditSession extends ElementEditSession {
@Override
public EditSession newPoint(@NonNull Matrix newInverse, @NonNull PointF point, int p) {
return ElementScaleEditSession.startScale(this, newInverse, point, p);
return ElementScaleEditSession.startScale(this, newInverse, point, p, rotationSnapListener);
}
@Override
@@ -1,3 +1,8 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.imageeditor.core;
import android.graphics.Matrix;
@@ -9,13 +14,19 @@ import org.signal.imageeditor.core.model.EditorElement;
final class ElementScaleEditSession extends ElementEditSession {
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
private final RotationSnapListener rotationSnapListener;
private final float initialRotationRadians;
private boolean wasRotationSnapped = true; // skip triggering listener on start of session
private ElementScaleEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull RotationSnapListener rotationSnapListener, float initialRotationRadians) {
super(selected, inverseMatrix);
this.rotationSnapListener = rotationSnapListener;
this.initialRotationRadians = initialRotationRadians;
}
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) {
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p, @NonNull RotationSnapListener rotationSnapListener) {
session.commit();
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix);
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix, rotationSnapListener, session.selected.getLocalRotationAngle());
newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]);
newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]);
newSession.setScreenStartPoint(p, point);
@@ -38,9 +49,15 @@ final class ElementScaleEditSession extends ElementEditSession {
editorMatrix.postScale(scale, scale);
double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]);
RotationSnapResult rotationSnapResult = RotationSnap.snapToAngle(initialRotationRadians, angle);
angle = rotationSnapResult.getAngleRadians();
if (!selected.getFlags().isRotateLocked()) {
editorMatrix.postRotate((float) Math.toDegrees(angle));
if (rotationSnapResult.getSnapped() && !wasRotationSnapped) {
rotationSnapListener.onRotationSnap();
}
wasRotationSnapped = rotationSnapResult.getSnapped();
}
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
@@ -71,7 +88,7 @@ final class ElementScaleEditSession extends ElementEditSession {
}
private ElementDragEditSession convertToDrag(int p, @NonNull Matrix inverse) {
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p], rotationSnapListener);
}
/**
@@ -24,7 +24,9 @@ import org.signal.imageeditor.core.renderers.TrashRenderer
* Usage: call the on* methods in order as pointer events arrive. The handler manages
* edit session state internally.
*/
class ImageEditorTouchHandler {
class ImageEditorTouchHandler(
private val rotationSnapListener: RotationSnapListener
) {
private var drawing: Boolean = false
private var blur: Boolean = false
@@ -159,7 +161,7 @@ class ImageEditorTouchHandler {
point: PointF,
selected: EditorElement?
): EditSession? {
val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected)
val session = startMoveAndResizeSession(model, viewMatrix, inverse, point, selected, rotationSnapListener)
if (session == null && drawing) {
drawingSession = true
return startDrawingSession(model, viewMatrix, point)
@@ -191,7 +193,8 @@ class ImageEditorTouchHandler {
viewMatrix: Matrix,
inverse: Matrix,
point: PointF,
selected: EditorElement?
selected: EditorElement?,
rotationSnapListener: RotationSnapListener
): EditSession? {
if (selected == null) return null
@@ -209,14 +212,15 @@ class ImageEditorTouchHandler {
elementInverseMatrix,
thumbContainerRelativeMatrix,
thumb.controlPoint,
point
point,
rotationSnapListener
)
} else {
null
}
}
return ElementDragEditSession.startDrag(selected, inverse, point)
return ElementDragEditSession.startDrag(selected, inverse, point, rotationSnapListener)
}
}
}
@@ -473,13 +473,13 @@ public final class ImageEditorView extends FrameLayout {
elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
if (elementInverseMatrix != null) {
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point);
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumbContainerRelativeMatrix, thumb.getControlPoint(), point, () -> {});
} else {
return null;
}
}
return ElementDragEditSession.startDrag(selected, inverse, point);
return ElementDragEditSession.startDrag(selected, inverse, point, () -> {});
}
@NonNull
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.imageeditor.core
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.round
internal data class RotationSnapResult(
val angleRadians: Double,
val snapped: Boolean
)
internal object RotationSnap {
private const val SNAP_ANGLE_RADIANS = PI / 2.0 // 90 degrees in radians
private val SNAP_THRESHOLD_RADIANS = Math.toRadians(5.0)
private fun snapToAngle(angleRadians: Double): RotationSnapResult {
val snappedAngle: Double = round(angleRadians / SNAP_ANGLE_RADIANS) * SNAP_ANGLE_RADIANS
return if (isCloseEnoughToSnap(angleRadians, snappedAngle)) {
RotationSnapResult(snappedAngle, true)
} else {
RotationSnapResult(angleRadians, false)
}
}
private fun isCloseEnoughToSnap(angleRadians: Double, snappedAngle: Double): Boolean {
return abs(angleRadians - snappedAngle) <= SNAP_THRESHOLD_RADIANS
}
@JvmStatic
fun snapToAngle(baseAngleRadians: Double, relativeAngleRadians: Double): RotationSnapResult {
val absoluteAngle = baseAngleRadians + relativeAngleRadians
val snappedAbsoluteAngle = snapToAngle(absoluteAngle)
return RotationSnapResult(snappedAbsoluteAngle.angleRadians - baseAngleRadians, snappedAbsoluteAngle.snapped)
}
}
@@ -0,0 +1,9 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.imageeditor.core
fun interface RotationSnapListener {
fun onRotationSnap()
}
@@ -1,3 +1,8 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.imageeditor.core;
import android.graphics.Matrix;
@@ -14,6 +19,10 @@ class ThumbDragEditSession extends ElementEditSession {
private final float[] oppositeControlPointOnControlParent = new float[2];
private final float[] oppositeControlPointOnElement = new float[2];
private final RotationSnapListener rotationSnapListener;
private final float initialRotationRadians;
private boolean wasRotationSnapped = true; // skip triggering listener on start of session
@NonNull
private final ThumbRenderer.ControlPoint controlPoint;
@NonNull private final Matrix thumbContainerRelativeMatrix;
@@ -21,22 +30,27 @@ class ThumbDragEditSession extends ElementEditSession {
private ThumbDragEditSession(@NonNull EditorElement selected,
@NonNull ThumbRenderer.ControlPoint controlPoint,
@NonNull Matrix inverseMatrix,
@NonNull Matrix thumbContainerRelativeMatrix)
@NonNull Matrix thumbContainerRelativeMatrix,
@NonNull RotationSnapListener rotationSnapListener,
float initialRotationRadians)
{
super(selected, inverseMatrix);
this.controlPoint = controlPoint;
this.thumbContainerRelativeMatrix = thumbContainerRelativeMatrix;
this.rotationSnapListener = rotationSnapListener;
this.initialRotationRadians = initialRotationRadians;
}
static EditSession startDrag(@NonNull EditorElement selected,
@NonNull Matrix inverseViewModelMatrix,
@NonNull Matrix thumbContainerRelativeMatrix,
@NonNull ThumbRenderer.ControlPoint controlPoint,
@NonNull PointF point)
@NonNull PointF point,
@NonNull RotationSnapListener rotationSnapListener)
{
if (!selected.getFlags().isEditable()) return null;
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix);
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix, thumbContainerRelativeMatrix, rotationSnapListener, selected.getLocalRotationAngle());
elementDragEditSession.setScreenStartPoint(0, point);
elementDragEditSession.setScreenEndPoint(0, point);
return elementDragEditSession;
@@ -72,7 +86,15 @@ class ThumbDragEditSession extends ElementEditSession {
editorMatrix.postTranslate(-oppositeControlPoint.x, -oppositeControlPoint.y);
editorMatrix.postScale(scale, scale);
double angle = angle(endPointElement[0], oppositeControlPoint) - angle(startPointElement[0], oppositeControlPoint);
RotationSnapResult rotationSnapResult = RotationSnap.snapToAngle(initialRotationRadians, angle);
angle = rotationSnapResult.getAngleRadians();
rotate(editorMatrix, angle);
if (rotationSnapResult.getSnapped() && !wasRotationSnapped) {
rotationSnapListener.onRotationSnap();
}
wasRotationSnapped = rotationSnapResult.getSnapped();
editorMatrix.postTranslate(oppositeControlPoint.x, oppositeControlPoint.y);
} else {
// 8 point controls, where edges scale in just one dimension and corners scale in both, optionally fixed aspect ratio
@@ -142,4 +164,4 @@ class ThumbDragEditSession extends ElementEditSession {
float dy = a.y - b.y;
return dx * dx + dy * dy;
}
}
}