Add new ImageEditor compose component and wire in crop and drawing tools.

This commit is contained in:
Alex Hart
2026-04-13 09:19:22 -03:00
committed by Cody Henthorne
parent 629b96dd20
commit c2d927029a
10 changed files with 864 additions and 33 deletions

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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))
}
)
}
}

View 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>