Improve trash can using in-renderer object.

This commit is contained in:
Alex Hart 2021-09-08 14:32:01 -03:00 committed by Greyson Parrelli
parent e7833df539
commit 1f7b1d91c4
7 changed files with 250 additions and 100 deletions

View file

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

View file

@ -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.
*/

View file

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

View file

@ -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<TrashRenderer> = object : Parcelable.Creator<TrashRenderer> {
override fun createFromParcel(`in`: Parcel): TrashRenderer {
return TrashRenderer(`in`)
}
override fun newArray(size: Int): Array<TrashRenderer?> {
return arrayOfNulls(size)
}
}
}
}

View file

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

View file

@ -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<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 + delete
private val allModeTools: Set<View> = drawTools + blurTools + drawButtonRow + cropButtonRow
private val viewsToSlide: Set<View> = 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()

View file

@ -374,35 +374,6 @@
android:orientation="horizontal"
app:layout_constraintGuide_end="72dp" />
<FrameLayout
android:id="@+id/image_editor_hud_delete"
android:layout_width="72dp"
android:layout_height="72dp"
android:alpha="0"
android:contentDescription="@string/ImageEditorHud__delete"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/image_editor_bottom_guide"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageView
android:id="@+id/image_editor_hud_delete_bg"
android:layout_width="41dp"
android:layout_height="41dp"
android:layout_gravity="center"
android:background="@drawable/image_editor_hud_delete_background"
android:importantForAccessibility="no" />
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:scaleType="centerInside"
app:srcCompat="@drawable/ic_trash_white_24" />
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<org.thoughtcrime.securesms.scribbles.BrushWidthPreviewView