Implement radial dial.

Co-authored-by: Alan Evans <alan@signal.org>
This commit is contained in:
Alex Hart
2021-09-17 13:09:13 -03:00
committed by GitHub
parent ce2c2002c6
commit 7bcc338a49
9 changed files with 491 additions and 14 deletions

View File

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

View File

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

View File

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