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 int imageMaxWidth;
|
||||||
|
|
||||||
private final ThrottledDebouncer deleteFadeDebouncer = new ThrottledDebouncer(500);
|
private final ThrottledDebouncer deleteFadeDebouncer = new ThrottledDebouncer(500);
|
||||||
|
private float initialDialImageDegrees;
|
||||||
|
private float initialDialScale;
|
||||||
|
private float minDialScaleDown;
|
||||||
|
|
||||||
public static class Data {
|
public static class Data {
|
||||||
private final Bundle bundle;
|
private final Bundle bundle;
|
||||||
|
@ -133,7 +136,6 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
private boolean hasMadeAnEditThisSession;
|
private boolean hasMadeAnEditThisSession;
|
||||||
private boolean wasInTrashHitZone;
|
private boolean wasInTrashHitZone;
|
||||||
|
|
||||||
|
|
||||||
public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) {
|
public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) {
|
||||||
ImageEditorFragment fragment = newInstance(imageUri);
|
ImageEditorFragment fragment = newInstance(imageUri);
|
||||||
fragment.requireArguments().putString(KEY_MODE, Mode.AVATAR_CAPTURE.code);
|
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)
|
.setVisible(mode == ImageEditorHudV2.Mode.DELETE)
|
||||||
.persist();
|
.persist();
|
||||||
|
|
||||||
|
updateHudDialRotation();
|
||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case CROP: {
|
case CROP: {
|
||||||
imageEditorView.getModel().startCrop();
|
imageEditorView.getModel().startCrop();
|
||||||
|
@ -561,6 +565,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
@Override
|
@Override
|
||||||
public void onClearAll() {
|
public void onClearAll() {
|
||||||
imageEditorView.getModel().clearUndoStack();
|
imageEditorView.getModel().clearUndoStack();
|
||||||
|
updateHudDialRotation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -586,6 +591,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
public void onUndo() {
|
public void onUndo() {
|
||||||
imageEditorView.getModel().undo();
|
imageEditorView.getModel().undo();
|
||||||
imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer());
|
imageEditorHud.setBlurFacesToggleEnabled(imageEditorView.getModel().hasFaceRenderer());
|
||||||
|
updateHudDialRotation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -641,6 +647,32 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
controller.onDoneEditing();
|
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 ResizeAnimation resizeAnimation;
|
||||||
|
|
||||||
private void scaleViewPortForDrawing(int orientation) {
|
private void scaleViewPortForDrawing(int orientation) {
|
||||||
|
@ -738,6 +770,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
private void onDrawingChanged(boolean stillTouching, boolean isUserEdit) {
|
private void onDrawingChanged(boolean stillTouching, boolean isUserEdit) {
|
||||||
if (isUserEdit) {
|
if (isUserEdit) {
|
||||||
hasMadeAnEditThisSession = true;
|
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() {
|
private final ImageEditorView.DragListener dragListener = new ImageEditorView.DragListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onDragStarted(@Nullable EditorElement editorElement) {
|
public void onDragStarted(@Nullable EditorElement editorElement) {
|
||||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
||||||
|
updateHudDialRotation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -855,6 +896,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
@Override
|
@Override
|
||||||
public void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
|
public void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone) {
|
||||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP || editorElement == null) {
|
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP || editorElement == null) {
|
||||||
|
updateHudDialRotation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -882,6 +924,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
wasInTrashHitZone = false;
|
wasInTrashHitZone = false;
|
||||||
imageEditorHud.animate().alpha(1f);
|
imageEditorHud.animate().alpha(1f);
|
||||||
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) {
|
||||||
|
updateHudDialRotation();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -961,6 +1004,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
|
||||||
if (editorElement != null && editorElement.getRenderer() instanceof SelectableRenderer) {
|
if (editorElement != null && editorElement.getRenderer() instanceof SelectableRenderer) {
|
||||||
((SelectableRenderer) editorElement.getRenderer()).onSelected(selected);
|
((SelectableRenderer) editorElement.getRenderer()).onSelected(selected);
|
||||||
}
|
}
|
||||||
|
imageEditorView.getModel().setSelected(selected ? editorElement : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(false) {
|
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 bottomGuideline: Guideline = findViewById(R.id.image_editor_bottom_guide)
|
||||||
private val brushPreview: BrushWidthPreviewView = findViewById(R.id.image_editor_hud_brush_preview)
|
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 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 selectableSet: Set<View> = setOf(drawButton, textButton, stickerButton, blurButton)
|
||||||
|
|
||||||
private val undoTools: Set<View> = setOf(undoButton, clearAllButton)
|
private val undoTools: Set<View> = setOf(undoButton, clearAllButton)
|
||||||
private val drawTools: Set<View> = setOf(brushToggle, drawSeekBar, widthSeekBar)
|
private val drawTools: Set<View> = setOf(brushToggle, drawSeekBar, widthSeekBar)
|
||||||
private val blurTools: Set<View> = setOf(blurToggleContainer, blurHelpText, 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 drawButtonRow: Set<View> = setOf(cancelButton, doneButton, drawButton, textButton, stickerButton, blurButton)
|
||||||
private val cropButtonRow: Set<View> = setOf(cancelButton, doneButton, cropRotateButton, cropFlipButton, cropAspectLockButton)
|
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
|
private val viewsToSlide: Set<View> = drawButtonRow + cropButtonRow
|
||||||
|
|
||||||
|
@ -150,6 +152,24 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
||||||
blurToggle.setOnCheckedChangeListener { _, enabled -> listener?.onBlurFacesToggled(enabled) }
|
blurToggle.setOnCheckedChangeListener { _, enabled -> listener?.onBlurFacesToggled(enabled) }
|
||||||
|
|
||||||
setupWidthSeekBar()
|
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) {
|
fun setBottomOfImageEditorView(bottom: Int) {
|
||||||
|
@ -326,7 +346,7 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
||||||
|
|
||||||
private fun presentModeCrop() {
|
private fun presentModeCrop() {
|
||||||
animateModeChange(
|
animateModeChange(
|
||||||
inSet = cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(),
|
inSet = cropTools + cropButtonRow - if (isAvatarEdit) setOf(cropAspectLockButton) else setOf(),
|
||||||
outSet = allModeTools
|
outSet = allModeTools
|
||||||
)
|
)
|
||||||
animateInUndoTools()
|
animateInUndoTools()
|
||||||
|
@ -523,6 +543,9 @@ class ImageEditorHudV2 @JvmOverloads constructor(
|
||||||
fun onRotate90AntiClockwise()
|
fun onRotate90AntiClockwise()
|
||||||
fun onCropAspectLock()
|
fun onCropAspectLock()
|
||||||
fun onTextStyleToggle()
|
fun onTextStyleToggle()
|
||||||
|
fun onDialRotationGestureStarted()
|
||||||
|
fun onDialRotationChanged(degrees: Float)
|
||||||
|
fun onDialRotationGestureFinished()
|
||||||
val isCropAspectLocked: Boolean
|
val isCropAspectLocked: Boolean
|
||||||
|
|
||||||
fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: 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:translationY="0dp"
|
||||||
tools:visibility="visible" />
|
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 -->
|
<!-- endregion -->
|
||||||
|
|
||||||
<!-- region blur stuff -->
|
<!-- region blur stuff -->
|
||||||
|
|
|
@ -427,6 +427,11 @@ public final class ImageEditorView extends FrameLayout {
|
||||||
this.mode = mode;
|
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) {
|
public void startDrawing(float thickness, @NonNull Paint.Cap cap, boolean blur) {
|
||||||
this.thickness = thickness;
|
this.thickness = thickness;
|
||||||
this.cap = cap;
|
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.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.signal.imageeditor.core.MatrixUtils;
|
||||||
import org.signal.imageeditor.core.Renderer;
|
import org.signal.imageeditor.core.Renderer;
|
||||||
import org.signal.imageeditor.core.RendererContext;
|
import org.signal.imageeditor.core.RendererContext;
|
||||||
|
|
||||||
|
@ -299,6 +300,14 @@ public final class EditorElement implements Parcelable {
|
||||||
children.clear();
|
children.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public float getLocalRotationAngle() {
|
||||||
|
return MatrixUtils.getRotationAngle(localMatrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public float getLocalScaleX() {
|
||||||
|
return MatrixUtils.getScaleX(localMatrix);
|
||||||
|
}
|
||||||
|
|
||||||
public interface PerElementFunction {
|
public interface PerElementFunction {
|
||||||
void apply(EditorElement element);
|
void apply(EditorElement element);
|
||||||
}
|
}
|
||||||
|
|
|
@ -221,6 +221,12 @@ final class EditorElementHierarchy {
|
||||||
selectedElement = null;
|
selectedElement = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
|
||||||
|
if (element == selectedElement) {
|
||||||
|
setOrUpdateSelectionThumbsForElement(element, overlayMappingMatrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void setOrUpdateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
|
void setOrUpdateSelectionThumbsForElement(@NonNull EditorElement element, @Nullable Matrix overlayMappingMatrix) {
|
||||||
if (selectedElement != element) {
|
if (selectedElement != element) {
|
||||||
removeAllSelectionArtifacts();
|
removeAllSelectionArtifacts();
|
||||||
|
@ -433,7 +439,7 @@ final class EditorElementHierarchy {
|
||||||
return dst;
|
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());
|
Matrix newLocal = new Matrix(flipRotate.getLocalMatrix());
|
||||||
if (degrees != 0) {
|
if (degrees != 0) {
|
||||||
newLocal.postRotate(degrees);
|
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) {
|
public void setSelectionVisible(boolean visible) {
|
||||||
editorElementHierarchy.getSelection()
|
editorElementHierarchy.getSelection()
|
||||||
.getFlags()
|
.getFlags()
|
||||||
|
@ -145,6 +150,51 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
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:
|
* Renders tree with the following matrix:
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -233,6 +283,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||||
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
|
getActiveUndoRedoStacks(cropping).pushState(editorElementHierarchy.getRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void updateUndoRedoAvailabilityState() {
|
||||||
|
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||||
|
}
|
||||||
|
|
||||||
public void clearUndoStack() {
|
public void clearUndoStack() {
|
||||||
EditorElement root = editorElementHierarchy.getRoot();
|
EditorElement root = editorElementHierarchy.getRoot();
|
||||||
EditorElement original = root;
|
EditorElement original = root;
|
||||||
|
@ -598,7 +652,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||||
*/
|
*/
|
||||||
public void moving(@NonNull EditorElement editorElement) {
|
public void moving(@NonNull EditorElement editorElement) {
|
||||||
if (!isCropping()) {
|
if (!isCropping()) {
|
||||||
setSelected(editorElement);
|
updateSelectionThumbsIfSelected(editorElement);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -902,10 +956,6 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void rotate90clockwise() {
|
|
||||||
flipRotate(90, 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void rotate90anticlockwise() {
|
public void rotate90anticlockwise() {
|
||||||
flipRotate(-90, 1, 1);
|
flipRotate(-90, 1, 1);
|
||||||
}
|
}
|
||||||
|
@ -914,11 +964,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||||
flipRotate(0, -1, 1);
|
flipRotate(0, -1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void flipVertical() {
|
private void flipRotate(float degrees, int scaleX, int scaleY) {
|
||||||
flipRotate(0, 1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void flipRotate(int degrees, int scaleX, int scaleY) {
|
|
||||||
pushUndoPoint();
|
pushUndoPoint();
|
||||||
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
|
editorElementHierarchy.flipRotate(degrees, scaleX, scaleY, visibleViewPort, invalidate);
|
||||||
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
updateUndoRedoAvailableState(getActiveUndoRedoStacks(isCropping()));
|
||||||
|
|
Loading…
Add table
Reference in a new issue