Implement radial dial.
Co-authored-by: Alan Evans <alan@signal.org>
This commit is contained in:
parent
ce2c2002c6
commit
7bcc338a49
9 changed files with 491 additions and 14 deletions
|
@ -95,6 +95,9 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
private int imageMaxWidth;
|
||||
|
||||
private final ThrottledDebouncer deleteFadeDebouncer = new ThrottledDebouncer(500);
|
||||
private float initialDialImageDegrees;
|
||||
private float initialDialScale;
|
||||
private float minDialScaleDown;
|
||||
|
||||
public static class Data {
|
||||
private final Bundle bundle;
|
||||
|
@ -133,7 +136,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
private boolean hasMadeAnEditThisSession;
|
||||
private boolean wasInTrashHitZone;
|
||||
|
||||
|
||||
public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) {
|
||||
ImageEditorFragment fragment = newInstance(imageUri);
|
||||
fragment.requireArguments().putString(KEY_MODE, Mode.AVATAR_CAPTURE.code);
|
||||
|
@ -422,6 +424,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
.setVisible(mode == ImageEditorHudV2.Mode.DELETE)
|
||||
.persist();
|
||||
|
||||
updateHudDialRotation();
|
||||
|
||||
switch (mode) {
|
||||
case CROP: {
|
||||
imageEditorView.getModel().startCrop();
|
||||
|
@ -561,6 +565,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
@Override
|
||||
public void onClearAll() {
|
||||
imageEditorView.getModel().clearUndoStack();
|
||||
updateHudDialRotation();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -586,6 +591,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
public void onUndo() {
|
||||
imageEditorView.getModel().undo();
|
||||
imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer());
|
||||
updateHudDialRotation();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -641,6 +647,32 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
controller.onDoneEditing();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialRotationGestureStarted() {
|
||||
float localScaleX = imageEditorView.getModel().getMainImage().getLocalScaleX();
|
||||
minDialScaleDown = initialDialScale / localScaleX;
|
||||
imageEditorView.getModel().pushUndoPoint();
|
||||
imageEditorView.getModel().updateUndoRedoAvailabilityState();
|
||||
initialDialImageDegrees = (float) Math.toDegrees(imageEditorView.getModel().getMainImage().getLocalRotationAngle());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialRotationGestureFinished() {
|
||||
imageEditorView.getModel().getMainImage().commitEditorMatrix();
|
||||
imageEditorView.getModel().postEdit(true);
|
||||
imageEditorView.invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDialRotationChanged(float degrees) {
|
||||
imageEditorView.setMainImageEditorMatrixRotation(degrees - initialDialImageDegrees, minDialScaleDown);
|
||||
}
|
||||
|
||||
private void updateHudDialRotation() {
|
||||
imageEditorHud.setDialRotation(getRotationDegreesRounded(imageEditorView.getModel().getMainImage()));
|
||||
initialDialScale = imageEditorView.getModel().getMainImage().getLocalScaleX();
|
||||
}
|
||||
|
||||
private ResizeAnimation resizeAnimation;
|
||||
|
||||
private void scaleViewPortForDrawing(int orientation) {
|
||||
|
@ -738,6 +770,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
private void onDrawingChanged(boolean stillTouching, boolean isUserEdit) {
|
||||
if (isUserEdit) {
|
||||
hasMadeAnEditThisSession = true;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -832,10 +865,18 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
}
|
||||
};
|
||||
|
||||
public float getRotationDegreesRounded(@Nullable EditorElement editorElement) {
|
||||
if (editorElement == null) {
|
||||
return 0f;
|
||||
}
|
||||
return Math.round(Math.toDegrees(editorElement.getLocalRotationAngle()));
|
||||
}
|
||||
|
||||
private final ImageEditorView.DragListener dragListener = new ImageEditorView.DragListener() {
|
||||
@Override
|
||||
public void onDragStarted(@Nullable EditorElement editorElement) {
|
||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
||||
updateHudDialRotation();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -855,6 +896,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
@Override
|
||||
public void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
|
||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP || editorElement == null) {
|
||||
updateHudDialRotation();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -882,6 +924,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
wasInTrashHitZone = false;
|
||||
imageEditorHud.animate().alpha(1f);
|
||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
||||
updateHudDialRotation();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -961,6 +1004,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
|||
if (editorElement != null && editorElement.getRenderer() instanceof SelectableRenderer) {
|
||||
((SelectableRenderer) editorElement.getRenderer()).onSelected(selected);
|
||||
}
|
||||
imageEditorView.getModel().setSelected(selected ? editorElement : null);
|
||||
}
|
||||
|
||||
private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) {
|
||||
|
|
|
@ -65,16 +65,18 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
|||
private val bottomGuideline: Guideline = findViewById(R.id.image_editor_bottom_guide)
|
||||
private val brushPreview: BrushWidthPreviewView = findViewById(R.id.image_editor_hud_brush_preview)
|
||||
private val textStyleToggle: ImageView = findViewById(R.id.image_editor_hud_text_style_button)
|
||||
private val rotationDial: RotationDialView = findViewById(R.id.image_editor_hud_crop_rotation_dial)
|
||||
|
||||
private val selectableSet: Set<View> = setOf(drawButton, textButton, stickerButton, blurButton)
|
||||
|
||||
private val undoTools: Set<View> = setOf(undoButton, clearAllButton)
|
||||
private val drawTools: Set<View> = setOf(brushToggle, drawSeekBar, widthSeekBar)
|
||||
private val blurTools: Set<View> = setOf(blurToggleContainer, blurHelpText, widthSeekBar)
|
||||
private val cropTools: Set<View> = setOf(rotationDial)
|
||||
private val drawButtonRow: Set<View> = setOf(cancelButton, doneButton, drawButton, textButton, stickerButton, blurButton)
|
||||
private val cropButtonRow: Set<View> = setOf(cancelButton, doneButton, cropRotateButton, cropFlipButton, cropAspectLockButton)
|
||||
|
||||
private val allModeTools: Set<View> = drawTools + blurTools + drawButtonRow + cropButtonRow + textStyleToggle
|
||||
private val allModeTools: Set<View> = drawTools + blurTools + drawButtonRow + cropButtonRow + textStyleToggle + cropTools
|
||||
|
||||
private val viewsToSlide: Set<View> = drawButtonRow + cropButtonRow
|
||||
|
||||
|
@ -150,6 +152,24 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
|||
blurToggle.setOnCheckedChangeListener { _, enabled -> listener?.onBlurFacesToggled(enabled) }
|
||||
|
||||
setupWidthSeekBar()
|
||||
|
||||
rotationDial.listener = object : RotationDialView.Listener {
|
||||
override fun onDegreeChanged(degrees: Float) {
|
||||
listener?.onDialRotationChanged(degrees)
|
||||
}
|
||||
|
||||
override fun onGestureStart() {
|
||||
listener?.onDialRotationGestureStarted()
|
||||
}
|
||||
|
||||
override fun onGestureEnd() {
|
||||
listener?.onDialRotationGestureFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setDialRotation(degrees: Float) {
|
||||
rotationDial.setDegrees(degrees)
|
||||
}
|
||||
|
||||
fun setBottomOfImageEditorView(bottom: Int) {
|
||||
|
@ -326,7 +346,7 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
|||
|
||||
private fun presentModeCrop() {
|
||||
animateModeChange(
|
||||
inSet = cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(),
|
||||
inSet = cropTools + cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(),
|
||||
outSet = allModeTools
|
||||
)
|
||||
animateInUndoTools()
|
||||
|
@ -523,6 +543,9 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
|||
fun onRotate90AntiClockwise()
|
||||
fun onCropAspectLock()
|
||||
fun onTextStyleToggle()
|
||||
fun onDialRotationGestureStarted()
|
||||
fun onDialRotationChanged(degrees: Float)
|
||||
fun onDialRotationGestureFinished()
|
||||
val isCropAspectLocked: Boolean
|
||||
|
||||
fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean)
|
||||
|
|
|
@ -0,0 +1,297 @@
|
|||
package org.thoughtcrime.securesms.scribbles
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.util.AttributeSet
|
||||
import android.view.GestureDetector
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.Dimension
|
||||
import androidx.annotation.Px
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class RotationDialView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val canvasBounds = Rect()
|
||||
private val centerMostIndicatorRect = RectF()
|
||||
private val indicatorRect = RectF()
|
||||
private val dimensions = Dimensions()
|
||||
|
||||
private var snapDegrees: Float = 0f
|
||||
private var degrees: Float = 0f
|
||||
private var isInGesture: Boolean = false
|
||||
|
||||
private val gestureDetector: GestureDetector = GestureDetector(context, GestureListener())
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
private val textPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
textSize = ViewUtil.spToPx(15f).toFloat()
|
||||
typeface = Typeface.DEFAULT
|
||||
color = Colors.textColor
|
||||
style = Paint.Style.FILL
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
|
||||
private val angleIndicatorPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = Color.WHITE
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
|
||||
fun setDegrees(degrees: Float) {
|
||||
if (degrees != this.degrees) {
|
||||
this.degrees = degrees
|
||||
this.snapDegrees = calculateSnapDegrees()
|
||||
|
||||
if (isInGesture) {
|
||||
listener?.onDegreeChanged(snapDegrees)
|
||||
}
|
||||
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
if (event.actionIndex != 0) return false
|
||||
|
||||
isInGesture = gestureDetector.onTouchEvent(event)
|
||||
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> listener?.onGestureStart()
|
||||
MotionEvent.ACTION_UP -> listener?.onGestureEnd()
|
||||
}
|
||||
|
||||
return isInGesture
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (isInEditMode) {
|
||||
canvas.drawColor(Color.BLACK)
|
||||
}
|
||||
|
||||
canvas.getClipBounds(canvasBounds)
|
||||
|
||||
val dialDegrees = getDialDegrees(snapDegrees)
|
||||
val bottom = canvasBounds.bottom
|
||||
val approximateCenterDegree = dialDegrees.roundToInt()
|
||||
var currentDegree = approximateCenterDegree
|
||||
val fractionalOffset = dialDegrees - approximateCenterDegree
|
||||
val dialOffset = dimensions.spaceBetweenAngleIndicators * fractionalOffset
|
||||
|
||||
val centerX = width / 2f
|
||||
centerMostIndicatorRect.set(
|
||||
centerX - dimensions.angleIndicatorWidth / 2f,
|
||||
bottom.toFloat() - dimensions.majorAngleIndicatorHeight,
|
||||
centerX + dimensions.angleIndicatorWidth / 2f,
|
||||
bottom.toFloat()
|
||||
)
|
||||
centerMostIndicatorRect.offset(-dialOffset, 0f)
|
||||
|
||||
indicatorRect.set(centerMostIndicatorRect)
|
||||
|
||||
angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree)
|
||||
indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree)
|
||||
canvas.drawRect(indicatorRect, angleIndicatorPaint)
|
||||
indicatorRect.offset(dimensions.spaceBetweenAngleIndicators.toFloat(), 0f)
|
||||
currentDegree += 1
|
||||
|
||||
while (indicatorRect.left < width && currentDegree <= ceil(MAX_DEGREES)) {
|
||||
angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree)
|
||||
indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree)
|
||||
canvas.drawRect(indicatorRect, angleIndicatorPaint)
|
||||
indicatorRect.offset(dimensions.spaceBetweenAngleIndicators.toFloat(), 0f)
|
||||
currentDegree += 1
|
||||
}
|
||||
|
||||
currentDegree = approximateCenterDegree
|
||||
indicatorRect.set(centerMostIndicatorRect)
|
||||
indicatorRect.offset(-dimensions.spaceBetweenAngleIndicators.toFloat(), 0f)
|
||||
currentDegree -= 1
|
||||
|
||||
while (indicatorRect.left >= 0 && currentDegree >= floor(MIN_DEGRESS)) {
|
||||
angleIndicatorPaint.color = Colors.colorForOtherDegree(currentDegree)
|
||||
indicatorRect.top = bottom.toFloat() - dimensions.getHeightForDegree(currentDegree)
|
||||
canvas.drawRect(indicatorRect, angleIndicatorPaint)
|
||||
indicatorRect.offset(-dimensions.spaceBetweenAngleIndicators.toFloat(), 0f)
|
||||
currentDegree -= 1
|
||||
}
|
||||
|
||||
centerMostIndicatorRect.offset(dialOffset, 0f)
|
||||
angleIndicatorPaint.color = Colors.colorForCenterDegree(approximateCenterDegree)
|
||||
canvas.drawRect(centerMostIndicatorRect, angleIndicatorPaint)
|
||||
|
||||
drawText(canvas)
|
||||
}
|
||||
|
||||
private fun drawText(canvas: Canvas) {
|
||||
val approximateDegrees = getDialDegrees(snapDegrees).roundToInt()
|
||||
canvas.drawText(
|
||||
"$approximateDegrees",
|
||||
width / 2f,
|
||||
canvasBounds.bottom - textPaint.descent() - dimensions.majorAngleIndicatorHeight - dimensions.textPaddingBottom,
|
||||
textPaint
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDialDegrees(degrees: Float): Float {
|
||||
val alpha: Float = degrees % 360f
|
||||
|
||||
if (alpha % 90 == 0f) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
val beta: Float = floor(alpha / 90f)
|
||||
val offset: Float = alpha - beta * 90f
|
||||
|
||||
return if (offset > 45f) {
|
||||
offset - 90f
|
||||
} else {
|
||||
offset
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateSnapDegrees(): Float {
|
||||
return if (isInGesture) {
|
||||
val dialDegrees = getDialDegrees(degrees)
|
||||
if (dialDegrees.roundToInt() == 0) {
|
||||
degrees - dialDegrees
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
} else {
|
||||
degrees
|
||||
}
|
||||
}
|
||||
|
||||
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDown(e: MotionEvent?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
|
||||
val degreeIncrement: Float = distanceX / dimensions.spaceBetweenAngleIndicators
|
||||
val prevDialDegrees = getDialDegrees(degrees)
|
||||
val newDialDegrees = getDialDegrees(degrees + degreeIncrement)
|
||||
|
||||
val offEndOfMax = prevDialDegrees >= MAX_DEGREES / 2f && newDialDegrees <= MIN_DEGRESS / 2f
|
||||
val offEndOfMin = newDialDegrees >= MAX_DEGREES / 2f && prevDialDegrees <= MIN_DEGRESS / 2f
|
||||
|
||||
if (prevDialDegrees.roundToInt() != newDialDegrees.roundToInt() && isHapticFeedbackEnabled) {
|
||||
performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
|
||||
}
|
||||
|
||||
when {
|
||||
offEndOfMax -> {
|
||||
val newIncrement = MAX_DEGREES - prevDialDegrees
|
||||
setDegrees(degrees + newIncrement)
|
||||
}
|
||||
offEndOfMin -> {
|
||||
val newIncrement = MAX_DEGREES - abs(prevDialDegrees)
|
||||
setDegrees(degrees - newIncrement)
|
||||
}
|
||||
else -> {
|
||||
setDegrees(degrees + degreeIncrement)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private class Dimensions {
|
||||
|
||||
@Px
|
||||
val spaceBetweenAngleIndicators: Int = ViewUtil.dpToPx(Dimensions.spaceBetweenAngleIndicators)
|
||||
|
||||
@Px
|
||||
val angleIndicatorWidth: Int = ViewUtil.dpToPx(Dimensions.angleIndicatorWidth)
|
||||
|
||||
@Px
|
||||
val minorAngleIndicatorHeight: Int = ViewUtil.dpToPx(Dimensions.minorAngleIndicatorHeight)
|
||||
|
||||
@Px
|
||||
val majorAngleIndicatorHeight: Int = ViewUtil.dpToPx(Dimensions.majorAngleIndicatorHeight)
|
||||
|
||||
@Px
|
||||
val textPaddingBottom: Int = ViewUtil.dpToPx(Dimensions.textPaddingBottom)
|
||||
|
||||
fun getHeightForDegree(degree: Int): Int {
|
||||
return if (degree == 0) {
|
||||
majorAngleIndicatorHeight
|
||||
} else {
|
||||
minorAngleIndicatorHeight
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val spaceBetweenAngleIndicators: Int = 12
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val angleIndicatorWidth: Int = 1
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val minorAngleIndicatorHeight: Int = 12
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val majorAngleIndicatorHeight: Int = 32
|
||||
|
||||
@Dimension(unit = Dimension.DP)
|
||||
private val textPaddingBottom: Int = 8
|
||||
}
|
||||
}
|
||||
|
||||
private object Colors {
|
||||
@ColorInt
|
||||
val textColor: Int = Color.WHITE
|
||||
|
||||
@ColorInt
|
||||
val majorAngleIndicatorColor: Int = 0xFF62E87A.toInt()
|
||||
|
||||
@ColorInt
|
||||
val modFiveIndicatorColor: Int = Color.WHITE
|
||||
|
||||
@ColorInt
|
||||
val minorAngleIndicatorColor: Int = 0x80FFFFFF.toInt()
|
||||
|
||||
fun colorForCenterDegree(degree: Int) = if (degree == 0) modFiveIndicatorColor else majorAngleIndicatorColor
|
||||
|
||||
fun colorForOtherDegree(degree: Int): Int {
|
||||
return when {
|
||||
degree % 5 == 0 -> modFiveIndicatorColor
|
||||
else -> minorAngleIndicatorColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_DEGREES: Float = 44.99999f
|
||||
private const val MIN_DEGRESS: Float = -44.99999f
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onDegreeChanged(degrees: Float)
|
||||
fun onGestureStart()
|
||||
fun onGestureEnd()
|
||||
}
|
||||
}
|
|
@ -331,6 +331,16 @@
|
|||
tools:translationY="0dp"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<org.thoughtcrime.securesms.scribbles.RotationDialView
|
||||
android:id="@+id/image_editor_hud_crop_rotation_dial"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:alpha="0"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toTopOf="@id/image_editor_hud_top_of_button_bar_spacing"
|
||||
tools:alpha="1"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<!-- endregion -->
|
||||
|
||||
<!-- region blur stuff -->
|
||||
|
|
|
@ -427,6 +427,11 @@ public final class ImageEditorView extends FrameLayout {
|
|||
this.mode = mode;
|
||||
}
|
||||
|
||||
public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) {
|
||||
model.setMainImageEditorMatrixRotation(angle, minScaleDown);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) {
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
package org.signal.imageeditor.core;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public final class MatrixUtils {
|
||||
|
||||
private static final ThreadLocal<float[]> tempMatrixValues = new ThreadLocal<>();
|
||||
|
||||
protected static @NonNull float[] getTempMatrixValues() {
|
||||
float[] floats = tempMatrixValues.get();
|
||||
if(floats == null) {
|
||||
floats = new float[9];
|
||||
tempMatrixValues.set(floats);
|
||||
}
|
||||
return floats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the angle from a matrix in radians.
|
||||
*/
|
||||
public static float getRotationAngle(@NonNull Matrix matrix) {
|
||||
float[] matrixValues = getTempMatrixValues();
|
||||
matrix.getValues(matrixValues);
|
||||
return (float) -Math.atan2(matrixValues[Matrix.MSKEW_X], matrixValues[Matrix.MSCALE_X]);
|
||||
}
|
||||
|
||||
/** Gets the scale on the X axis */
|
||||
public static float getScaleX(@NonNull Matrix matrix) {
|
||||
float[] matrixValues = getTempMatrixValues();
|
||||
matrix.getValues(matrixValues);
|
||||
float scaleX = matrixValues[Matrix.MSCALE_X];
|
||||
float skewX = matrixValues[Matrix.MSKEW_X];
|
||||
return (float) Math.sqrt(scaleX * scaleX + skewX * skewX);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import android.os.Parcelable;
|
|||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.imageeditor.core.MatrixUtils;
|
||||
import org.signal.imageeditor.core.Renderer;
|
||||
import org.signal.imageeditor.core.RendererContext;
|
||||
|
||||
|
@ -299,6 +300,14 @@ public final class EditorElement implements Parcelable {
|
|||
children.clear();
|
||||
}
|
||||
|
||||
public float getLocalRotationAngle() {
|
||||
return MatrixUtils.getRotationAngle(localMatrix);
|
||||
}
|
||||
|
||||
public float getLocalScaleX() {
|
||||
return MatrixUtils.getScaleX(localMatrix);
|
||||
}
|
||||
|
||||
public interface PerElementFunction {
|
||||
void apply(EditorElement element);
|
||||
}
|
||||
|
|
|
@ -221,6 +221,12 @@ final class EditorElementHierarchy {
|
|||
selectedElement = null;
|
||||
}
|
||||
|
||||
void updateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
|
||||
if (element == selectedElement) {
|
||||
setOrUpdateSelectionThumbsForElement(element, overlayMappingMatrix);
|
||||
}
|
||||
}
|
||||
|
||||
void setOrUpdateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
|
||||
if (selectedElement != element) {
|
||||
removeAllSelectionArtifacts();
|
||||
|
@ -433,7 +439,7 @@ final class EditorElementHierarchy {
|
|||
return dst;
|
||||
}
|
||||
|
||||
void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
void flipRotate(float degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
Matrix newLocal = new Matrix(flipRotate.getLocalMatrix());
|
||||
if (degrees != 0) {
|
||||
newLocal.postRotate(degrees);
|
||||
|
|
|
@ -76,6 +76,11 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
}
|
||||
}
|
||||
|
||||
public void updateSelectionThumbsIfSelected(@NonNull EditorElement editorElement) {
|
||||
Matrix overlayMappingMatrix = findRelativeMatrix(editorElement, editorElementHierarchy.getOverlay());
|
||||
editorElementHierarchy.updateSelectionThumbsForElement(editorElement, overlayMappingMatrix);
|
||||
}
|
||||
|
||||
public void setSelectionVisible(boolean visible) {
|
||||
editorElementHierarchy.getSelection()
|
||||
.getFlags()
|
||||
|
@ -145,6 +150,51 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||
}
|
||||
|
||||
/** Keeps the image within the crop bounds as it rotates */
|
||||
public void setMainImageEditorMatrixRotation(float angle, float minScaleDown) {
|
||||
setEditorMatrixToRotationMatrixAboutParentsOrigin(editorElementHierarchy.getMainImage(), angle);
|
||||
scaleMainImageEditorMatrixToFitInsideCropBounds(minScaleDown, 2f);
|
||||
}
|
||||
|
||||
private void scaleMainImageEditorMatrixToFitInsideCropBounds(float minScaleDown, float maxScaleUp) {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
Matrix mainImageLocalBackup = new Matrix(mainImage.getLocalMatrix());
|
||||
Matrix mainImageEditorBackup = new Matrix(mainImage.getEditorMatrix());
|
||||
|
||||
mainImage.commitEditorMatrix();
|
||||
Matrix combinedLocal = new Matrix(mainImage.getLocalMatrix());
|
||||
Matrix newLocal = Bisect.bisectToTest(mainImage,
|
||||
minScaleDown,
|
||||
maxScaleUp,
|
||||
this::cropIsWithinMainImageBounds,
|
||||
(matrix, scale) -> matrix.preScale(scale, scale));
|
||||
|
||||
Matrix invertLocal = new Matrix();
|
||||
if (newLocal != null && combinedLocal.invert(invertLocal)) {
|
||||
invertLocal.preConcat(newLocal); // L^-1 (L * Scale) -> Scale
|
||||
mainImageEditorBackup.preConcat(invertLocal); // add the scale to editor matrix to keep this image within crop
|
||||
}
|
||||
mainImage.getLocalMatrix().set(mainImageLocalBackup);
|
||||
mainImage.getEditorMatrix().set(mainImageEditorBackup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the editor matrix for the element to a rotation of the degrees but does so that we are rotating around the
|
||||
* parents elements origin.
|
||||
*/
|
||||
private void setEditorMatrixToRotationMatrixAboutParentsOrigin(@NonNull EditorElement element, float degrees) {
|
||||
Matrix localMatrix = element.getLocalMatrix();
|
||||
Matrix editorMatrix = element.getEditorMatrix();
|
||||
localMatrix.invert(editorMatrix);
|
||||
editorMatrix.preRotate(degrees);
|
||||
editorMatrix.preConcat(localMatrix);
|
||||
// Editor Matrix is then: Local^-1 * Rotate(degrees) * Local
|
||||
// So you end up with this overall for the element: Local * Local^-1 * Rotate(degrees) * Local
|
||||
// Meaning the rotate applies after existing effects of the local matrix
|
||||
// Where as simply setting the editor matrix rotate gives this: Local * Rotate(degrees)
|
||||
// which rotates around local origin first
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders tree with the following matrix:
|
||||
* <p>
|
||||
|
@ -233,6 +283,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
public void updateUndoRedoAvailabilityState() {
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||
}
|
||||
|
||||
public void clearUndoStack() {
|
||||
EditorElement root = editorElementHierarchy.getRoot();
|
||||
EditorElement original = root;
|
||||
|
@ -598,7 +652,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
*/
|
||||
public void moving(@NonNull EditorElement editorElement) {
|
||||
if (!isCropping()) {
|
||||
setSelected(editorElement);
|
||||
updateSelectionThumbsIfSelected(editorElement);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -902,10 +956,6 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
return null;
|
||||
}
|
||||
|
||||
public void rotate90clockwise() {
|
||||
flipRotate(90, 1, 1);
|
||||
}
|
||||
|
||||
public void rotate90anticlockwise() {
|
||||
flipRotate(-90, 1, 1);
|
||||
}
|
||||
|
@ -914,11 +964,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
|||
flipRotate(0, -1, 1);
|
||||
}
|
||||
|
||||
public void flipVertical() {
|
||||
flipRotate(0, 1, -1);
|
||||
}
|
||||
|
||||
private void flipRotate(int degrees, int scaleX, int scaleY) {
|
||||
private void flipRotate(float degrees, int scaleX, int scaleY) {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
|
||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||
|
|
Loading…
Add table
Reference in a new issue