mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-28 04:34:21 +01:00
Add new ImageEditor compose component and wire in crop and drawing tools.
This commit is contained in:
committed by
Cody Henthorne
parent
629b96dd20
commit
c2d927029a
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.annotation.RememberInComposition
|
||||
@@ -19,7 +20,7 @@ sealed interface EditorController {
|
||||
|
||||
@Stable
|
||||
class Container @RememberInComposition constructor() {
|
||||
val controllers = SnapshotStateMap<Uri, EditorController>()
|
||||
private val controllers = SnapshotStateMap<Uri, EditorController>()
|
||||
|
||||
fun getOrCreateImageController(uri: Uri, editorModel: EditorModel): Image {
|
||||
return controllers.getOrPut(uri) { Image(editorModel) } as Image
|
||||
@@ -32,11 +33,51 @@ sealed interface EditorController {
|
||||
override val isUserInEdit: Boolean
|
||||
get() = mode != Mode.NONE
|
||||
|
||||
val imageEditorState = ImageEditorState(editorModel).also {
|
||||
it.onGestureCompleted = { drawSessionDirty = true }
|
||||
}
|
||||
|
||||
var mode: Mode by mutableStateOf(Mode.NONE)
|
||||
|
||||
var isCropLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
|
||||
var isCropAspectRatioLocked: Boolean by mutableStateOf(editorModel.isCropAspectLocked)
|
||||
private set
|
||||
|
||||
val dialRotation: Float
|
||||
get() = editorModel.mainImage?.let { Math.toDegrees(it.localRotationAngle.toDouble()).toFloat() } ?: 0f
|
||||
|
||||
private var initialDialScale: Float = editorModel.mainImage?.localScaleX ?: 1f
|
||||
private var initialDialImageDegrees: Float = 0f
|
||||
private var minDialScaleDown: Float = 1f
|
||||
private var drawSessionSnapshot: ByteArray? = null
|
||||
private var drawSessionDirty: Boolean = false
|
||||
|
||||
var showDiscardDialog: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
val hasUnsavedChanges: Boolean
|
||||
get() = when {
|
||||
mode == Mode.CROP -> imageEditorState.undoAvailable
|
||||
isInDrawSession -> drawSessionDirty
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun requestCancelEdit() {
|
||||
if (hasUnsavedChanges) {
|
||||
showDiscardDialog = true
|
||||
} else {
|
||||
cancelEdit()
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissDiscardDialog() {
|
||||
showDiscardDialog = false
|
||||
}
|
||||
|
||||
fun confirmDiscardEdit() {
|
||||
showDiscardDialog = false
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
val isUserDrawing: Boolean
|
||||
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT
|
||||
|
||||
@@ -58,26 +99,78 @@ sealed interface EditorController {
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
mode = Mode.NONE
|
||||
when {
|
||||
mode == Mode.CROP -> {
|
||||
editorModel.clearUndoStack()
|
||||
editorModel.doneCrop()
|
||||
}
|
||||
isInDrawSession -> {
|
||||
drawSessionSnapshot?.let { editorModel.restoreFromSnapshot(it) }
|
||||
}
|
||||
}
|
||||
exitEditMode()
|
||||
}
|
||||
|
||||
fun commitEdit() {
|
||||
if (mode == Mode.CROP) {
|
||||
editorModel.doneCrop()
|
||||
}
|
||||
exitEditMode()
|
||||
}
|
||||
|
||||
private fun exitEditMode() {
|
||||
drawSessionSnapshot = null
|
||||
drawSessionDirty = false
|
||||
mode = Mode.NONE
|
||||
imageEditorState.isDrawing = false
|
||||
imageEditorState.isBlur = false
|
||||
}
|
||||
|
||||
fun enterDrawMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.DRAW
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
fun enterHighlightMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.HIGHLIGHT
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
fun enterBlurMode() {
|
||||
snapshotIfNewDrawSession()
|
||||
mode = Mode.BLUR
|
||||
syncDrawingState()
|
||||
}
|
||||
|
||||
private fun snapshotIfNewDrawSession() {
|
||||
if (!isInDrawSession) {
|
||||
drawSessionSnapshot = editorModel.createSnapshot()
|
||||
drawSessionDirty = false
|
||||
}
|
||||
}
|
||||
|
||||
fun setDrawColor(color: Int) {
|
||||
imageEditorState.drawColor = color
|
||||
}
|
||||
|
||||
fun setDrawThickness(thickness: Float) {
|
||||
imageEditorState.drawThickness = thickness
|
||||
}
|
||||
|
||||
private val isInDrawSession: Boolean
|
||||
get() = mode == Mode.DRAW || mode == Mode.HIGHLIGHT || mode == Mode.BLUR
|
||||
|
||||
private fun syncDrawingState() {
|
||||
imageEditorState.isDrawing = true
|
||||
imageEditorState.isBlur = mode == Mode.BLUR
|
||||
imageEditorState.drawCap = if (mode == Mode.HIGHLIGHT) Paint.Cap.SQUARE else Paint.Cap.ROUND
|
||||
}
|
||||
|
||||
fun enterCropMode() {
|
||||
editorModel.startCrop()
|
||||
initialDialScale = editorModel.mainImage?.localScaleX ?: 1f
|
||||
mode = Mode.CROP
|
||||
}
|
||||
|
||||
@@ -91,12 +184,12 @@ sealed interface EditorController {
|
||||
|
||||
fun lockCrop() {
|
||||
editorModel.setCropAspectLock(true)
|
||||
isCropLocked = true
|
||||
isCropAspectRatioLocked = true
|
||||
}
|
||||
|
||||
fun unlockCrop() {
|
||||
editorModel.setCropAspectLock(false)
|
||||
isCropLocked = false
|
||||
isCropAspectRatioLocked = false
|
||||
}
|
||||
|
||||
fun flip() {
|
||||
@@ -107,6 +200,26 @@ sealed interface EditorController {
|
||||
editorModel.rotate90anticlockwise()
|
||||
}
|
||||
|
||||
fun onDialGestureStart() {
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
initialDialScale = mainImage.localScaleX
|
||||
minDialScaleDown = 1f
|
||||
editorModel.pushUndoPoint()
|
||||
editorModel.updateUndoRedoAvailabilityState()
|
||||
initialDialImageDegrees = Math.toDegrees(mainImage.localRotationAngle.toDouble()).toFloat()
|
||||
}
|
||||
|
||||
fun onDialRotationChanged(degrees: Float) {
|
||||
editorModel.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown)
|
||||
}
|
||||
|
||||
fun onDialGestureEnd() {
|
||||
val mainImage = editorModel.mainImage ?: return
|
||||
mainImage.commitEditorMatrix()
|
||||
editorModel.postEdit(true)
|
||||
initialDialScale = mainImage.localScaleX
|
||||
}
|
||||
|
||||
fun toggleImageQuality() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@@ -5,39 +5,98 @@
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import android.graphics.PointF
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.signal.imageeditor.core.ImageEditorView
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.changedToDown
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import org.signal.imageeditor.core.ImageEditorTouchHandler
|
||||
|
||||
@Composable
|
||||
fun ImageEditor(
|
||||
controller: EditorController.Image,
|
||||
state: ImageEditorState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context -> ImageEditorView(context) },
|
||||
update = { view ->
|
||||
view.model = controller.editorModel
|
||||
view.mode = mapMode(controller.mode)
|
||||
},
|
||||
onReset = { },
|
||||
modifier = modifier.clipToBounds()
|
||||
)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
private fun mapMode(mode: EditorController.Image.Mode): ImageEditorView.Mode {
|
||||
return when (mode) {
|
||||
EditorController.Image.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.DRAW -> ImageEditorView.Mode.Draw
|
||||
EditorController.Image.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
|
||||
EditorController.Image.Mode.BLUR -> ImageEditorView.Mode.Blur
|
||||
EditorController.Image.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
|
||||
EditorController.Image.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
|
||||
DisposableEffect(state) {
|
||||
state.attach()
|
||||
onDispose { state.detach() }
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.onSizeChanged { state.updateViewMatrix(it.width.toFloat(), it.height.toFloat()) }
|
||||
.imageEditorPointerInput(state)
|
||||
) {
|
||||
state.revision
|
||||
|
||||
val nativeCanvas = drawContext.canvas.nativeCanvas
|
||||
val rendererContext = state.getOrCreateRendererContext(context, nativeCanvas)
|
||||
rendererContext.save()
|
||||
try {
|
||||
rendererContext.canvasMatrix.initial(state.viewMatrix)
|
||||
state.editorModel.draw(rendererContext, null)
|
||||
} finally {
|
||||
rendererContext.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.imageEditorPointerInput(state: ImageEditorState): Modifier {
|
||||
return this.pointerInput(state) {
|
||||
val touchHandler = ImageEditorTouchHandler()
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = true)
|
||||
down.consume()
|
||||
touchHandler.setDrawing(state.isDrawing, state.isBlur)
|
||||
touchHandler.setDrawingBrush(state.drawColor, state.drawThickness, state.drawCap)
|
||||
touchHandler.onDown(state.editorModel, state.viewMatrix, down.position.toPointF())
|
||||
|
||||
var previousPointerCount = 1
|
||||
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val currentPressed = event.changes.filter { it.pressed }
|
||||
val currentCount = currentPressed.size
|
||||
|
||||
if (currentCount == 0) {
|
||||
event.changes.forEach { it.consume() }
|
||||
touchHandler.onUp(state.editorModel)
|
||||
state.onGestureCompleted?.invoke()
|
||||
break
|
||||
}
|
||||
|
||||
if (currentCount == 2 && previousPointerCount < 2) {
|
||||
val newPointer = event.changes.firstOrNull { it.changedToDown() } ?: currentPressed.last()
|
||||
val pointerIndex = event.changes.indexOf(newPointer).coerceIn(0, 1)
|
||||
touchHandler.onSecondPointerDown(state.editorModel, state.viewMatrix, newPointer.position.toPointF(), pointerIndex)
|
||||
} else if (currentCount == 1 && previousPointerCount == 2) {
|
||||
val released = event.changes.firstOrNull { !it.pressed && it.previousPressed }
|
||||
val releasedIndex = if (released != null) event.changes.indexOf(released).coerceIn(0, 1) else 0
|
||||
touchHandler.onSecondPointerUp(state.editorModel, state.viewMatrix, releasedIndex)
|
||||
} else if (touchHandler.hasActiveSession()) {
|
||||
val pointers = currentPressed.take(2).map { it.position.toPointF() }.toTypedArray()
|
||||
touchHandler.onMove(state.editorModel, pointers)
|
||||
state.invalidate()
|
||||
}
|
||||
|
||||
event.changes.forEach { it.consume() }
|
||||
previousPointerCount = currentCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.toPointF(): PointF = PointF(x, y)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Point
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import org.signal.imageeditor.core.Bounds
|
||||
import org.signal.imageeditor.core.Renderer
|
||||
import org.signal.imageeditor.core.RendererContext
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
|
||||
/**
|
||||
* Compose-observable wrapper around [EditorModel].
|
||||
*
|
||||
* Hooks into the model's invalidation callback so that a revision counter (read during the
|
||||
* Canvas draw phase) triggers redraws whenever the model changes. This gives us unidirectional
|
||||
* flow: mutations go into the model, the composable only reads and renders.
|
||||
*/
|
||||
@Stable
|
||||
class ImageEditorState(
|
||||
val editorModel: EditorModel
|
||||
) {
|
||||
|
||||
var revision: Long by mutableLongStateOf(0L)
|
||||
private set
|
||||
|
||||
var undoAvailable: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var redoAvailable: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var isDrawing: Boolean = false
|
||||
var isBlur: Boolean = false
|
||||
var drawColor: Int = 0xff000000.toInt()
|
||||
var drawThickness: Float = 0.02f
|
||||
var drawCap: Paint.Cap = Paint.Cap.ROUND
|
||||
var onGestureCompleted: (() -> Unit)? = null
|
||||
|
||||
val viewMatrix: Matrix = Matrix()
|
||||
val visibleViewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
|
||||
private val viewPort: RectF = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
private val screen: RectF = RectF()
|
||||
|
||||
private var rendererContext: RendererContext? = null
|
||||
|
||||
private val rendererReady = RendererContext.Ready { renderer: Renderer, cropMatrix: Matrix?, size: Point? ->
|
||||
editorModel.onReady(renderer, cropMatrix, size)
|
||||
revision++
|
||||
}
|
||||
|
||||
private val rendererInvalidate = RendererContext.Invalidate { _: Renderer ->
|
||||
revision++
|
||||
}
|
||||
|
||||
private val defaultTypefaceProvider = RendererContext.TypefaceProvider { _: Context, _: Renderer, _: RendererContext.Invalidate ->
|
||||
Typeface.DEFAULT
|
||||
}
|
||||
|
||||
/** Manually triggers a Canvas redraw. Call after touch moves that modify the model directly. */
|
||||
fun invalidate() {
|
||||
revision++
|
||||
}
|
||||
|
||||
/** Hooks into the [EditorModel]'s invalidation and undo/redo callbacks. Call in [DisposableEffect]. */
|
||||
fun attach() {
|
||||
editorModel.setInvalidate { revision++ }
|
||||
editorModel.setUndoRedoStackListener { undo, redo ->
|
||||
undoAvailable = undo
|
||||
redoAvailable = redo
|
||||
}
|
||||
}
|
||||
|
||||
/** Unhooks from the [EditorModel]. Call in [DisposableEffect]'s onDispose. */
|
||||
fun detach() {
|
||||
editorModel.setInvalidate(null)
|
||||
editorModel.setUndoRedoStackListener(null)
|
||||
}
|
||||
|
||||
/** Recomputes the view matrix to map the editor's coordinate space to the given pixel dimensions. */
|
||||
fun updateViewMatrix(width: Float, height: Float) {
|
||||
screen.set(0f, 0f, width, height)
|
||||
viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL)
|
||||
|
||||
val values = FloatArray(9)
|
||||
viewMatrix.getValues(values)
|
||||
val scale = values[0] / values[4]
|
||||
|
||||
val tempViewPort = RectF(Bounds.LEFT, Bounds.TOP, Bounds.RIGHT, Bounds.BOTTOM)
|
||||
if (scale < 1) {
|
||||
tempViewPort.top /= scale
|
||||
tempViewPort.bottom /= scale
|
||||
} else {
|
||||
tempViewPort.left *= scale
|
||||
tempViewPort.right *= scale
|
||||
}
|
||||
|
||||
visibleViewPort.set(tempViewPort)
|
||||
viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER)
|
||||
editorModel.setVisibleViewPort(visibleViewPort)
|
||||
revision++
|
||||
}
|
||||
|
||||
/** Returns a cached [RendererContext], recreating it only when the canvas instance changes. */
|
||||
fun getOrCreateRendererContext(context: Context, canvas: Canvas): RendererContext {
|
||||
val current = rendererContext
|
||||
if (current != null && current.canvas === canvas) return current
|
||||
return RendererContext(context, canvas, rendererReady, rendererInvalidate, defaultTypefaceProvider).also {
|
||||
rendererContext = it
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ private fun ImageEditorCropAndResizeToolbar(
|
||||
val cropUnlockImageVector = SignalIcons.CropUnlock.imageVector
|
||||
|
||||
IconCrossfadeToggleButton(
|
||||
target = if (imageEditorController.isCropLocked) CropLock.LOCKED else CropLock.UNLOCKED,
|
||||
target = if (imageEditorController.isCropAspectRatioLocked) CropLock.LOCKED else CropLock.UNLOCKED,
|
||||
setTarget = { target ->
|
||||
when (target) {
|
||||
CropLock.LOCKED -> imageEditorController.lockCrop()
|
||||
@@ -208,9 +208,16 @@ private fun CommitButton(imageEditorController: EditorController.Image) {
|
||||
|
||||
@Composable
|
||||
private fun DiscardButton(imageEditorController: EditorController.Image) {
|
||||
if (imageEditorController.showDiscardDialog) {
|
||||
MediaSendDialogs.DiscardEditsConfirmationDialog(
|
||||
onDiscard = imageEditorController::confirmDiscardEdit,
|
||||
onDismiss = imageEditorController::dismissDiscardDialog
|
||||
)
|
||||
}
|
||||
|
||||
ImageEditorButton(
|
||||
imageVector = SignalIcons.X.imageVector,
|
||||
onClick = imageEditorController::cancelEdit,
|
||||
onClick = imageEditorController::requestCancelEdit,
|
||||
colors = IconButtons.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ fun MediaEditScreen(
|
||||
when (val editorState = state.editorStateMap[uri]) {
|
||||
is EditorState.Image -> {
|
||||
ImageEditor(
|
||||
controller = controllers.getOrCreateImageController(uri, editorState.model),
|
||||
state = controllers.getOrCreateImageController(uri, editorState.model).imageEditorState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
@@ -118,6 +118,14 @@ fun MediaEditScreen(
|
||||
|
||||
when (currentController) {
|
||||
is EditorController.Image -> {
|
||||
if (currentController.mode == EditorController.Image.Mode.CROP) {
|
||||
RotationDial(
|
||||
imageEditorController = currentController,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 380.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
if (isSmallWindowBreakpoint) {
|
||||
ImageEditorToolbar(imageEditorController = currentController)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import org.signal.core.ui.compose.Dialogs
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.mediasend.R
|
||||
|
||||
object MediaSendDialogs {
|
||||
|
||||
@Composable
|
||||
fun DiscardEditsConfirmationDialog(
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = stringResource(R.string.MediaSendDialogs__discard_changes),
|
||||
body = stringResource(R.string.MediaSendDialogs__youll_lose_any_changes),
|
||||
confirm = stringResource(R.string.MediaSendDialogs__discard),
|
||||
onConfirm = onDiscard,
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DiscardEditsConfirmationDialogPreview() {
|
||||
Previews.Preview {
|
||||
MediaSendDialogs.DiscardEditsConfirmationDialog(
|
||||
onDiscard = {},
|
||||
onDismiss = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.drag
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.positionChange
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val MAX_DEGREES: Float = 44.99999f
|
||||
private const val MIN_DEGREES: Float = -44.99999f
|
||||
|
||||
private val SPACE_BETWEEN_INDICATORS = 12.dp
|
||||
private val INDICATOR_WIDTH = 1.dp
|
||||
private val MINOR_INDICATOR_HEIGHT = 12.dp
|
||||
private val MAJOR_INDICATOR_HEIGHT = 24.dp
|
||||
|
||||
@Composable
|
||||
fun RotationDial(
|
||||
imageEditorController: EditorController.Image,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val spaceBetweenIndicatorsPx = with(LocalDensity.current) { SPACE_BETWEEN_INDICATORS.toPx() }
|
||||
|
||||
var degrees by remember { mutableFloatStateOf(imageEditorController.dialRotation) }
|
||||
var isInGesture by remember { mutableStateOf(false) }
|
||||
|
||||
val snapDegrees = calculateSnapDegrees(degrees, isInGesture)
|
||||
val dialDegrees = getDialDegrees(snapDegrees)
|
||||
val displayDegree = dialDegrees.roundToInt()
|
||||
|
||||
val modFiveColor = MaterialTheme.colorScheme.onSurface
|
||||
val minorColor = MaterialTheme.colorScheme.outline
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown()
|
||||
down.consume()
|
||||
|
||||
isInGesture = true
|
||||
degrees = imageEditorController.dialRotation
|
||||
imageEditorController.onDialGestureStart()
|
||||
|
||||
val dragged = drag(down.id) { change ->
|
||||
val dragAmount = change.positionChange().x
|
||||
change.consume()
|
||||
|
||||
val degreeIncrement = -dragAmount / spaceBetweenIndicatorsPx
|
||||
val prevDialDegrees = getDialDegrees(degrees)
|
||||
val newDialDegrees = getDialDegrees(degrees + degreeIncrement)
|
||||
|
||||
val offEndOfMax = prevDialDegrees >= MAX_DEGREES / 2f && newDialDegrees <= MIN_DEGREES / 2f
|
||||
val offEndOfMin = newDialDegrees >= MAX_DEGREES / 2f && prevDialDegrees <= MIN_DEGREES / 2f
|
||||
|
||||
if (prevDialDegrees.roundToInt() != newDialDegrees.roundToInt()) {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
}
|
||||
|
||||
val newDegrees = when {
|
||||
offEndOfMax -> degrees + (MAX_DEGREES - prevDialDegrees)
|
||||
offEndOfMin -> degrees - (MAX_DEGREES - abs(prevDialDegrees))
|
||||
else -> degrees + degreeIncrement
|
||||
}
|
||||
|
||||
degrees = newDegrees
|
||||
val newSnapDegrees = calculateSnapDegrees(newDegrees, true)
|
||||
imageEditorController.onDialRotationChanged(newSnapDegrees)
|
||||
}
|
||||
|
||||
isInGesture = false
|
||||
val finalSnapDegrees = calculateSnapDegrees(degrees, false)
|
||||
imageEditorController.onDialRotationChanged(finalSnapDegrees)
|
||||
imageEditorController.onDialGestureEnd()
|
||||
}
|
||||
}
|
||||
) {
|
||||
// Tick marks layer (full width, centered on pill center)
|
||||
Canvas(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val centerY = canvasHeight / 2f
|
||||
val indicatorWidthPx = INDICATOR_WIDTH.toPx()
|
||||
val minorHeightPx = MINOR_INDICATOR_HEIGHT.toPx()
|
||||
val majorHeightPx = MAJOR_INDICATOR_HEIGHT.toPx()
|
||||
val spacingPx = SPACE_BETWEEN_INDICATORS.toPx()
|
||||
|
||||
val approximateCenterDegree = dialDegrees.roundToInt()
|
||||
val fractionalOffset = dialDegrees - approximateCenterDegree
|
||||
val dialOffset = spacingPx * fractionalOffset
|
||||
|
||||
val centerX = canvasWidth / 2f
|
||||
val startX = centerX - dialOffset
|
||||
|
||||
fun heightForDegree(degree: Int): Float = if (degree == 0) majorHeightPx else minorHeightPx
|
||||
|
||||
// Draw rightward from center
|
||||
var currentDegree = approximateCenterDegree
|
||||
var x = startX
|
||||
while (x < canvasWidth && currentDegree <= ceil(MAX_DEGREES).toInt()) {
|
||||
val h = heightForDegree(currentDegree)
|
||||
val color = if (currentDegree % 5 == 0) modFiveColor else minorColor
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f),
|
||||
size = Size(indicatorWidthPx, h)
|
||||
)
|
||||
x += spacingPx
|
||||
currentDegree += 1
|
||||
}
|
||||
|
||||
// Draw leftward from center
|
||||
currentDegree = approximateCenterDegree - 1
|
||||
x = startX - spacingPx
|
||||
while (x >= 0 && currentDegree >= floor(MIN_DEGREES).toInt()) {
|
||||
val h = heightForDegree(currentDegree)
|
||||
val color = if (currentDegree % 5 == 0) modFiveColor else minorColor
|
||||
drawRect(
|
||||
color = color,
|
||||
topLeft = Offset(x - indicatorWidthPx / 2f, centerY - h / 2f),
|
||||
size = Size(indicatorWidthPx, h)
|
||||
)
|
||||
x -= spacingPx
|
||||
currentDegree -= 1
|
||||
}
|
||||
|
||||
// Draw the center-most indicator on top
|
||||
val centerHeight = heightForDegree(approximateCenterDegree)
|
||||
drawRect(
|
||||
color = modFiveColor,
|
||||
topLeft = Offset(centerX - indicatorWidthPx / 2f, centerY - centerHeight / 2f),
|
||||
size = Size(indicatorWidthPx, centerHeight)
|
||||
)
|
||||
}
|
||||
|
||||
// Degree text overlay (on top of ticks, with background to cover them)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxHeight()
|
||||
) {
|
||||
Text(
|
||||
text = "$displayDegree",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(start = 16.dp, end = 24.dp)
|
||||
)
|
||||
|
||||
Dividers.Vertical(
|
||||
thickness = Dp.Hairline,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.fillMaxHeight().padding(vertical = 22.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDialDegrees(degrees: Float): Float {
|
||||
val alpha = degrees % 360f
|
||||
|
||||
if (alpha % 90 == 0f) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
val beta = floor(alpha / 90f)
|
||||
val offset = alpha - beta * 90f
|
||||
|
||||
return if (offset > 45f) {
|
||||
offset - 90f
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSnapDegrees(degrees: Float, isInGesture: Boolean): Float {
|
||||
return if (isInGesture) {
|
||||
val dialDegrees = getDialDegrees(degrees)
|
||||
if (dialDegrees.roundToInt() == 0) {
|
||||
degrees - dialDegrees
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun RotationDialPreview() {
|
||||
Previews.Preview {
|
||||
RotationDial(
|
||||
imageEditorController = remember {
|
||||
EditorController.Image(EditorModel.create(0))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
6
feature/media-send/src/main/res/values/strings.xml
Normal file
6
feature/media-send/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="MediaSendDialogs__discard_changes">Discard changes?</string>
|
||||
<string name="MediaSendDialogs__youll_lose_any_changes">You\'ll lose any changes you\'ve made to this photo.</string>
|
||||
<string name="MediaSendDialogs__discard">Discard</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user