diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java index 31db75d8a6..6f381b1a04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.MultiLineTextRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.TrashRenderer; /** * ImageEditorView @@ -231,6 +232,7 @@ public final class ImageEditorView extends FrameLayout { editSession = startEdit(inverse, point, selected); if (editSession != null) { + checkTrashIntersect(point); notifyDragStart(editSession.getSelected()); } @@ -260,7 +262,7 @@ public final class ImageEditorView extends FrameLayout { } model.moving(editSession.getSelected()); invalidate(); - notifyDragMove(editSession.getSelected(), event); + notifyDragMove(editSession.getSelected(), checkTrashIntersect(getPoint(event))); return true; } break; @@ -304,7 +306,7 @@ public final class ImageEditorView extends FrameLayout { if (editSession != null) { editSession.commit(); dragDropRelease(false); - notifyDragEnd(editSession.getSelected()); + notifyDragEnd(editSession.getSelected(), checkTrashIntersect(getPoint(event))); editSession = null; model.postEdit(moreThanOnePointerUsedInSession); @@ -320,21 +322,35 @@ public final class ImageEditorView extends FrameLayout { return super.onTouchEvent(event); } + private boolean checkTrashIntersect(@NonNull PointF point) { + if (mode == Mode.Draw || mode == Mode.Blur) { + return false; + } + + if (model.checkTrashIntersectsPoint(point, viewMatrix)) { + ((TrashRenderer) model.getTrash().getRenderer()).expand(); + return true; + } else { + ((TrashRenderer) model.getTrash().getRenderer()).shrink(); + return false; + } + } + private void notifyDragStart(@Nullable EditorElement editorElement) { if (dragListener != null) { dragListener.onDragStarted(editorElement); } } - private void notifyDragMove(@Nullable EditorElement editorElement, @NonNull MotionEvent event) { + private void notifyDragMove(@Nullable EditorElement editorElement, boolean isInTrashHitZone) { if (dragListener != null) { - dragListener.onDragMoved(editorElement, event); + dragListener.onDragMoved(editorElement, isInTrashHitZone); } } - private void notifyDragEnd(@Nullable EditorElement editorElement) { + private void notifyDragEnd(@Nullable EditorElement editorElement, boolean isInTrashHitZone) { if (dragListener != null) { - dragListener.onDragEnded(editorElement); + dragListener.onDragEnded(editorElement, isInTrashHitZone); } } @@ -511,8 +527,8 @@ public final class ImageEditorView extends FrameLayout { public interface DragListener { void onDragStarted(@Nullable EditorElement editorElement); - void onDragMoved(@Nullable EditorElement editorElement, @NonNull MotionEvent event); - void onDragEnded(@Nullable EditorElement editorElement); + void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone); + void onDragEnded(@Nullable EditorElement editorElement, boolean isInTrashHitZone); } public interface TapListener { diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java index b363087e09..bac2ebff32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorElementHierarchy.java @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.FillRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer; import org.thoughtcrime.securesms.imageeditor.renderers.OvalGuideRenderer; +import org.thoughtcrime.securesms.imageeditor.renderers.TrashRenderer; /** * Creates and handles a strict EditorElement Hierarchy. @@ -71,6 +72,7 @@ final class EditorElementHierarchy { private final EditorElement cropEditorElement; private final EditorElement blackout; private final EditorElement fade; + private final EditorElement trash; private final EditorElement thumbs; private EditorElementHierarchy(@NonNull EditorElement root) { @@ -84,6 +86,7 @@ final class EditorElementHierarchy { this.blackout = this.cropEditorElement.getChild(0); this.thumbs = this.cropEditorElement.getChild(1); this.fade = this.cropEditorElement.getChild(2); + this.trash = this.cropEditorElement.getChild(3); } private enum CropStyle { @@ -141,6 +144,14 @@ final class EditorElementHierarchy { .persist(); cropEditorElement.addElement(fade); + EditorElement trash = new EditorElement(new TrashRenderer(), EditorModel.Z_TRASH); + trash.getFlags() + .setSelectable(false) + .setEditable(false) + .setVisible(false) + .persist(); + cropEditorElement.addElement(trash); + EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000)); blackout.getFlags() @@ -212,6 +223,10 @@ final class EditorElementHierarchy { return imageRoot; } + EditorElement getTrash() { + return trash; + } + /** * The main image, null if not yet set. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java index eb2486deae..6d71d41a35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/model/EditorModel.java @@ -41,6 +41,7 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { public static final int Z_STICKERS = 0; public static final int Z_FADE = 1; public static final int Z_TEXT = 2; + public static final int Z_TRASH = 3; private static final Runnable NULL_RUNNABLE = () -> { }; @@ -183,6 +184,25 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix); } + public boolean checkTrashIntersectsPoint(@NonNull PointF point, @NonNull Matrix viewMatrix) { + EditorElement trash = editorElementHierarchy.getTrash(); + if (trash.getFlags().isVisible()) { + trash.getFlags() + .setSelectable(true) + .persist(); + + boolean isIntersecting = trash.findElementAt(point.x, point.y, viewMatrix, new Matrix()) != null; + + trash.getFlags() + .setSelectable(false) + .persist(); + + return isIntersecting; + } else { + return false; + } + } + private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) { return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element; } @@ -880,6 +900,10 @@ public final class EditorModel implements Parcelable, RendererContext.Ready { return editorElementHierarchy.getRoot(); } + public EditorElement getTrash() { + return editorElementHierarchy.getTrash(); + } + public @Nullable EditorElement getMainImage() { return editorElementHierarchy.getMainImage(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/TrashRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/TrashRenderer.kt new file mode 100644 index 0000000000..9ef35c0a7d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/imageeditor/renderers/TrashRenderer.kt @@ -0,0 +1,158 @@ +package org.thoughtcrime.securesms.imageeditor.renderers + +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.os.Parcel +import android.os.Parcelable +import androidx.appcompat.content.res.AppCompatResources +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.imageeditor.Bounds +import org.thoughtcrime.securesms.imageeditor.Renderer +import org.thoughtcrime.securesms.imageeditor.RendererContext +import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations +import org.thoughtcrime.securesms.util.ViewUtil +import kotlin.math.pow +import kotlin.math.sqrt + +internal class TrashRenderer : InvalidateableRenderer, Renderer, Parcelable { + + private val outlinePaint = Paint().apply { + isAntiAlias = true + color = Color.WHITE + style = Paint.Style.STROKE + strokeWidth = ViewUtil.dpToPx(15) / 10f + } + + private val dst = RectF() + + private val diameterSmall = ViewUtil.dpToPx(41) + private val diameterLarge = ViewUtil.dpToPx(54) + private val trashSize = ViewUtil.dpToPx(24) + private val padBottom = ViewUtil.dpToPx(16) + + private var startTime = 0L + + private var isExpanding = false + + private val origin = FloatArray(2) + private val x = FloatArray(2) + private val a = FloatArray(2) + private val b = FloatArray(2) + + constructor() {} + + override fun render(rendererContext: RendererContext) { + super.render(rendererContext) + + val frameRenderTime = System.currentTimeMillis() + + val trash: Drawable = requireNotNull(AppCompatResources.getDrawable(rendererContext.context, R.drawable.ic_trash_white_24)) + trash.setBounds(0, 0, trashSize, trashSize) + + val diameter = getInterpolatedDiameter(frameRenderTime - startTime) + + rendererContext.canvas.save() + rendererContext.mapRect(dst, Bounds.FULL_BOUNDS) + + rendererContext.canvasMatrix.setToIdentity() + + rendererContext.canvasMatrix.mapPoints(origin, floatArrayOf(0f, 0f)) + rendererContext.canvasMatrix.mapPoints(x, floatArrayOf(diameterLarge.toFloat(), 0f)) + rendererContext.canvasMatrix.mapPoints(a, floatArrayOf(0f, diameterLarge.toFloat() / 2f)) + rendererContext.canvasMatrix.mapPoints(b, floatArrayOf(0f, padBottom.toFloat())) + + rendererContext.canvas.drawCircle(dst.centerX(), dst.bottom - diameterLarge / 2f - padBottom, diameter / 2f, outlinePaint) + rendererContext.canvas.translate(dst.centerX(), dst.bottom - diameterLarge / 2f - padBottom) + rendererContext.canvas.translate(- (trashSize / 2f), - (trashSize / 2f)) + trash.draw(rendererContext.canvas) + rendererContext.canvas.restore() + + if (frameRenderTime - DURATION < startTime) { + invalidate() + } + } + + private fun distance(a: FloatArray, b: FloatArray): Float { + return sqrt((b[1] - a[1]).toDouble().pow(2.0) + (b[0] - a[0]).toDouble().pow(2.0)).toFloat() + } + + private fun getInterpolatedDiameter(timeElapsed: Long): Float { + return if (timeElapsed >= DURATION) { + if (isExpanding) { + diameterLarge.toFloat() + } else { + diameterSmall.toFloat() + } + } else { + val interpolatedFraction = MediaAnimations.interpolator.getInterpolation(timeElapsed / DURATION.toFloat()) + if (isExpanding) { + interpolateFromFraction(interpolatedFraction) + } else { + interpolateFromFraction(1 - interpolatedFraction) + } + } + } + + private fun interpolateFromFraction(fraction: Float): Float { + return diameterSmall + (diameterLarge - diameterSmall) * fraction + } + + fun expand() { + if (isExpanding) { + return + } + + isExpanding = true + startTime = System.currentTimeMillis() + invalidate() + } + + fun shrink() { + if (!isExpanding) { + return + } + + isExpanding = false + startTime = System.currentTimeMillis() + invalidate() + } + + private constructor(inParcel: Parcel?) + + override fun hitTest(x: Float, y: Float): Boolean { + val xDistance = distance(origin, this.x) + val isXInRange = -xDistance <= x && x <= xDistance + + if (!isXInRange) { + return false + } + + val yDistanceStart = dst.bottom - dst.centerY() - distance(origin, a) - distance(origin, b) + + return y >= yDistanceStart + } + + override fun describeContents(): Int { + return 0 + } + + override fun writeToParcel(dest: Parcel, flags: Int) {} + + companion object { + + private const val DURATION = 150L + + @JvmField + val CREATOR: Parcelable.Creator = object : Parcelable.Creator { + override fun createFromParcel(`in`: Parcel): TrashRenderer { + return TrashRenderer(`in`) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 333498f269..a829fcbcf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -12,6 +12,7 @@ import android.graphics.Point; import android.graphics.RectF; import android.net.Uri; import android.os.Bundle; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; @@ -131,6 +132,8 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu private ImageEditorHudV2 imageEditorHud; private ImageEditorView imageEditorView; private boolean hasMadeAnEditThisSession; + private boolean wasInTrashHitZone; + public static ImageEditorFragment newInstanceForAvatarCapture(@NonNull Uri imageUri) { ImageEditorFragment fragment = newInstance(imageUri); @@ -407,6 +410,12 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu imageEditorView.getModel().doneCrop(); } + imageEditorView.getModel() + .getTrash() + .getFlags() + .setVisible(mode == ImageEditorHudV2.Mode.DELETE) + .persist(); + switch (mode) { case CROP: { imageEditorView.getModel().startCrop(); @@ -838,27 +847,39 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu } @Override - public void onDragMoved(@Nullable EditorElement editorElement, @NonNull MotionEvent event) { + public void onDragMoved(@Nullable EditorElement editorElement, boolean isInTrashHitZone) { if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP || editorElement == null) { return; } - imageEditorHud.onMoved(event); - if (imageEditorHud.isInDeleteRect()) { - deleteFadeDebouncer.publish(() -> editorElement.animatePartialFadeOut(imageEditorView::invalidate)); + if (isInTrashHitZone) { + deleteFadeDebouncer.publish(() -> { + if (!wasInTrashHitZone) { + wasInTrashHitZone = true; + if (imageEditorHud.isHapticFeedbackEnabled()) { + imageEditorHud.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP); + } + } + + editorElement.animatePartialFadeOut(imageEditorView::invalidate); + }); } else { - deleteFadeDebouncer.publish(() -> editorElement.animatePartialFadeIn(imageEditorView::invalidate)); + deleteFadeDebouncer.publish(() -> { + wasInTrashHitZone = false; + editorElement.animatePartialFadeIn(imageEditorView::invalidate); + }); } } @Override - public void onDragEnded(@Nullable EditorElement editorElement) { + public void onDragEnded(@Nullable EditorElement editorElement, boolean isInTrashHitZone) { + wasInTrashHitZone = false; imageEditorHud.animate().alpha(1f); if (imageEditorHud.getMode() == ImageEditorHudV2.Mode.CROP) { return; } - if (imageEditorHud.isInDeleteRect()) { + if (isInTrashHitZone) { deleteFadeDebouncer.clear(); onDelete(); setCurrentSelection(null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt index 4d2ad97471..d48f180995 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorHudV2.kt @@ -6,9 +6,7 @@ import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Color -import android.graphics.Rect import android.util.AttributeSet -import android.view.HapticFeedbackConstants import android.view.MotionEvent import android.view.View import android.widget.FrameLayout @@ -28,7 +26,6 @@ import org.thoughtcrime.securesms.scribbles.HSVColorSlider.getColor import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setColor import org.thoughtcrime.securesms.scribbles.HSVColorSlider.setUpForColor import org.thoughtcrime.securesms.util.Debouncer -import org.thoughtcrime.securesms.util.ThrottledDebouncer import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.visible @@ -65,8 +62,6 @@ class ImageEditorHudV2 @JvmOverloads constructor( private val blurToast: View = findViewById(R.id.image_editor_hud_blur_toast) private val blurHelpText: View = findViewById(R.id.image_editor_hud_blur_help_text) private val colorIndicator: ImageView = findViewById(R.id.image_editor_hud_color_indicator) - private val delete: FrameLayout = findViewById(R.id.image_editor_hud_delete) - private val deleteBackground: View = findViewById(R.id.image_editor_hud_delete_bg) private val bottomGuideline: Guideline = findViewById(R.id.image_editor_bottom_guide) private val brushPreview: BrushWidthPreviewView = findViewById(R.id.image_editor_hud_brush_preview) @@ -78,23 +73,15 @@ class ImageEditorHudV2 @JvmOverloads constructor( private val drawButtonRow: Set = setOf(cancelButton, doneButton, drawButton, textButton, stickerButton, blurButton) private val cropButtonRow: Set = setOf(cancelButton, doneButton, cropRotateButton, cropFlipButton, cropAspectLockButton) - private val allModeTools: Set = drawTools + blurTools + drawButtonRow + cropButtonRow + delete + private val allModeTools: Set = drawTools + blurTools + drawButtonRow + cropButtonRow private val viewsToSlide: Set = drawButtonRow + cropButtonRow private val toastDebouncer = Debouncer(3000) private var colorIndicatorAlphaAnimator: Animator? = null - private val deleteDebouncer = ThrottledDebouncer(500) - - private val rect = Rect() - private var modeAnimatorSet: AnimatorSet? = null private var undoAnimatorSet: AnimatorSet? = null - private var deleteSizeAnimatorSet: AnimatorSet? = null - - var isInDeleteRect: Boolean = false - private set init { initializeViews() @@ -287,47 +274,6 @@ class ImageEditorHudV2 @JvmOverloads constructor( fun getMode(): Mode = currentMode - fun onMoved(motionEvent: MotionEvent) { - delete.getHitRect(rect) - if (rect.contains(motionEvent.x.toInt(), motionEvent.y.toInt())) { - isInDeleteRect = true - deleteDebouncer.publish { scaleDeleteUp() } - } else { - isInDeleteRect = false - deleteDebouncer.publish { scaleDeleteDown() } - } - } - - private fun scaleDeleteUp() { - if (delete.isHapticFeedbackEnabled && deleteBackground.scaleX < 1.365f) { - delete.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP) - } - - deleteSizeAnimatorSet?.cancel() - deleteSizeAnimatorSet = AnimatorSet().apply { - playTogether( - ObjectAnimator.ofFloat(deleteBackground, "scaleX", deleteBackground.scaleX, 1.365f), - ObjectAnimator.ofFloat(deleteBackground, "scaleY", deleteBackground.scaleY, 1.365f), - ) - duration = ANIMATION_DURATION - interpolator = MediaAnimations.interpolator - start() - } - } - - private fun scaleDeleteDown() { - deleteSizeAnimatorSet?.cancel() - deleteSizeAnimatorSet = AnimatorSet().apply { - playTogether( - ObjectAnimator.ofFloat(deleteBackground, "scaleX", deleteBackground.scaleX, 1f), - ObjectAnimator.ofFloat(deleteBackground, "scaleY", deleteBackground.scaleY, 1f), - ) - duration = ANIMATION_DURATION - interpolator = MediaAnimations.interpolator - start() - } - } - fun setUndoAvailability(undoAvailability: Boolean) { this.undoAvailability = undoAvailability @@ -440,7 +386,6 @@ class ImageEditorHudV2 @JvmOverloads constructor( private fun presentModeDelete() { animateModeChange( - inSet = setOf(delete), outSet = allModeTools ) animateOutUndoTools() diff --git a/app/src/main/res/layout/v2_media_image_editor_hud.xml b/app/src/main/res/layout/v2_media_image_editor_hud.xml index 2488ac0c14..2a1ce63e85 100644 --- a/app/src/main/res/layout/v2_media_image_editor_hud.xml +++ b/app/src/main/res/layout/v2_media_image_editor_hud.xml @@ -374,35 +374,6 @@ android:orientation="horizontal" app:layout_constraintGuide_end="72dp" /> - - - - - - - -