mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-24 03:35:58 +00:00
Replace image editor.
This commit is contained in:
29
src/org/thoughtcrime/securesms/imageeditor/Bounds.java
Normal file
29
src/org/thoughtcrime/securesms/imageeditor/Bounds.java
Normal file
@@ -0,0 +1,29 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.RectF;
|
||||
|
||||
/**
|
||||
* The local extent of a {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}.
|
||||
* i.e. all {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement}s have a bounding rectangle from:
|
||||
* <p>
|
||||
* {@link #LEFT} to {@link #RIGHT} and from {@link #TOP} to {@link #BOTTOM}.
|
||||
*/
|
||||
public final class Bounds {
|
||||
|
||||
public static final float LEFT = -1000f;
|
||||
public static final float RIGHT = 1000f;
|
||||
|
||||
public static final float TOP = -1000f;
|
||||
public static final float BOTTOM = 1000f;
|
||||
|
||||
public static final float CENTRE_X = (LEFT + RIGHT) / 2f;
|
||||
public static final float CENTRE_Y = (TOP + BOTTOM) / 2f;
|
||||
|
||||
public static final float[] CENTRE = new float[]{ CENTRE_X, CENTRE_Y };
|
||||
|
||||
static RectF newFullBounds() {
|
||||
return new RectF(LEFT, TOP, RIGHT, BOTTOM);
|
||||
}
|
||||
|
||||
public static RectF FULL_BOUNDS = newFullBounds();
|
||||
}
|
||||
78
src/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java
Normal file
78
src/org/thoughtcrime/securesms/imageeditor/CanvasMatrix.java
Normal file
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.RectF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Tracks the current matrix for a canvas.
|
||||
* <p>
|
||||
* This is because you cannot reliably call {@link Canvas#setMatrix(Matrix)}.
|
||||
* {@link Canvas#getMatrix()} provides this hint in its documentation:
|
||||
* "track relevant transform state outside of the canvas."
|
||||
* <p>
|
||||
* To achieve this, any changes to the canvas matrix must be done via this class, including save and
|
||||
* restore operations where the matrix was altered in between.
|
||||
*/
|
||||
public final class CanvasMatrix {
|
||||
|
||||
private final static int STACK_HEIGHT_LIMIT = 16;
|
||||
|
||||
private final Canvas canvas;
|
||||
private final Matrix canvasMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final Matrix[] stack = new Matrix[STACK_HEIGHT_LIMIT];
|
||||
private int stackHeight;
|
||||
|
||||
CanvasMatrix(Canvas canvas) {
|
||||
this.canvas = canvas;
|
||||
for (int i = 0; i < stack.length; i++) {
|
||||
stack[i] = new Matrix();
|
||||
}
|
||||
}
|
||||
|
||||
public void concat(@NonNull Matrix matrix) {
|
||||
canvas.concat(matrix);
|
||||
canvasMatrix.preConcat(matrix);
|
||||
}
|
||||
|
||||
void save() {
|
||||
canvas.save();
|
||||
if (stackHeight == STACK_HEIGHT_LIMIT) {
|
||||
throw new AssertionError("Not enough space on stack");
|
||||
}
|
||||
stack[stackHeight++].set(canvasMatrix);
|
||||
}
|
||||
|
||||
void restore() {
|
||||
canvas.restore();
|
||||
canvasMatrix.set(stack[--stackHeight]);
|
||||
}
|
||||
|
||||
void getCurrent(@NonNull Matrix into) {
|
||||
into.set(canvasMatrix);
|
||||
}
|
||||
|
||||
public void setToIdentity() {
|
||||
if (canvasMatrix.invert(temp)) {
|
||||
concat(temp);
|
||||
}
|
||||
}
|
||||
|
||||
public void initial(Matrix viewMatrix) {
|
||||
concat(viewMatrix);
|
||||
}
|
||||
|
||||
boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
|
||||
return canvasMatrix.mapRect(dst, src);
|
||||
}
|
||||
|
||||
public void mapPoints(float[] dst, float[] src) {
|
||||
canvasMatrix.mapPoints(dst, src);
|
||||
}
|
||||
|
||||
public void copyTo(@NonNull Matrix matrix) {
|
||||
matrix.set(canvasMatrix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.support.annotation.ColorInt;
|
||||
|
||||
/**
|
||||
* A renderer that can have its color changed.
|
||||
* <p>
|
||||
* For example, Lines and Text can change color.
|
||||
*/
|
||||
public interface ColorableRenderer extends Renderer {
|
||||
|
||||
@ColorInt
|
||||
int getColor();
|
||||
|
||||
void setColor(@ColorInt int color);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.BezierDrawingRenderer;
|
||||
|
||||
/**
|
||||
* Passes touch events into a {@link BezierDrawingRenderer}.
|
||||
*/
|
||||
class DrawingSession extends ElementEditSession {
|
||||
|
||||
private final BezierDrawingRenderer renderer;
|
||||
|
||||
private DrawingSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix, @NonNull BezierDrawingRenderer renderer) {
|
||||
super(selected, inverseMatrix);
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
public static EditSession start(EditorElement element, BezierDrawingRenderer renderer, Matrix inverseMatrix, PointF point) {
|
||||
DrawingSession drawingSession = new DrawingSession(element, inverseMatrix, renderer);
|
||||
drawingSession.setScreenStartPoint(0, point);
|
||||
renderer.setFirstPoint(drawingSession.startPointElement[0]);
|
||||
return drawingSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
if (p != 0) return;
|
||||
setScreenEndPoint(p, point);
|
||||
renderer.addNewPoint(endPointElement[0]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
30
src/org/thoughtcrime/securesms/imageeditor/EditSession.java
Normal file
30
src/org/thoughtcrime/securesms/imageeditor/EditSession.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
|
||||
/**
|
||||
* Represents an underway edit of the image.
|
||||
* <p>
|
||||
* Accepts new touch positions, new touch points, released touch points and when complete can commit the edit.
|
||||
* <p>
|
||||
* Examples of edit session implementations are, Drag, Draw, Resize:
|
||||
* <p>
|
||||
* {@link ElementDragEditSession} for dragging with a single finger.
|
||||
* {@link ElementScaleEditSession} for resize/dragging with two fingers.
|
||||
* {@link DrawingSession} for drawing with a single finger.
|
||||
*/
|
||||
interface EditSession {
|
||||
|
||||
void movePoint(int p, PointF point);
|
||||
|
||||
EditorElement getSelected();
|
||||
|
||||
EditSession newPoint(Matrix newInverse, PointF point, int p);
|
||||
|
||||
EditSession removePoint(Matrix newInverse, int p);
|
||||
|
||||
void commit();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
|
||||
final class ElementDragEditSession extends ElementEditSession {
|
||||
|
||||
private ElementDragEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementDragEditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull PointF point) {
|
||||
if (!selected.getFlags().isEditable()) return null;
|
||||
|
||||
ElementDragEditSession elementDragEditSession = new ElementDragEditSession(selected, inverseViewModelMatrix);
|
||||
elementDragEditSession.setScreenStartPoint(0, point);
|
||||
elementDragEditSession.setScreenEndPoint(0, point);
|
||||
|
||||
return elementDragEditSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
|
||||
selected.getEditorMatrix()
|
||||
.setTranslate(endPointElement[0].x - startPointElement[0].x, endPointElement[0].y - startPointElement[0].y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
|
||||
return ElementScaleEditSession.startScale(this, newInverse, point, p);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(Matrix newInverse, int p) {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
|
||||
abstract class ElementEditSession implements EditSession {
|
||||
|
||||
private final Matrix inverseMatrix;
|
||||
|
||||
final EditorElement selected;
|
||||
|
||||
final PointF[] startPointElement = newTwoPointArray();
|
||||
final PointF[] endPointElement = newTwoPointArray();
|
||||
final PointF[] startPointScreen = newTwoPointArray();
|
||||
final PointF[] endPointScreen = newTwoPointArray();
|
||||
|
||||
ElementEditSession(@NonNull EditorElement selected, @NonNull Matrix inverseMatrix) {
|
||||
this.selected = selected;
|
||||
this.inverseMatrix = inverseMatrix;
|
||||
}
|
||||
|
||||
void setScreenStartPoint(int p, @NonNull PointF point) {
|
||||
startPointScreen[p] = point;
|
||||
mapPoint(startPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
void setScreenEndPoint(int p, @NonNull PointF point) {
|
||||
endPointScreen[p] = point;
|
||||
mapPoint(endPointElement[p], inverseMatrix, point);
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract void movePoint(int p, @NonNull PointF point);
|
||||
|
||||
@Override
|
||||
public void commit() {
|
||||
selected.commitEditorMatrix();
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorElement getSelected() {
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static PointF[] newTwoPointArray() {
|
||||
PointF[] array = new PointF[2];
|
||||
for (int i = 0; i < array.length; i++) {
|
||||
array[i] = new PointF();
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map src to dst using the matrix.
|
||||
*
|
||||
* @param dst Output point.
|
||||
* @param matrix Matrix to transform point with.
|
||||
* @param src Input point.
|
||||
*/
|
||||
static void mapPoint(@NonNull PointF dst, @NonNull Matrix matrix, @NonNull PointF src) {
|
||||
float[] in = { src.x, src.y };
|
||||
float[] out = new float[2];
|
||||
matrix.mapPoints(out, in);
|
||||
dst.set(out[0], out[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
|
||||
final class ElementScaleEditSession extends ElementEditSession {
|
||||
|
||||
private ElementScaleEditSession(EditorElement selected, Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
}
|
||||
|
||||
static ElementScaleEditSession startScale(@NonNull ElementDragEditSession session, @NonNull Matrix inverseMatrix, @NonNull PointF point, int p) {
|
||||
session.commit();
|
||||
ElementScaleEditSession newSession = new ElementScaleEditSession(session.selected, inverseMatrix);
|
||||
newSession.setScreenStartPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenEndPoint(1 - p, session.endPointScreen[0]);
|
||||
newSession.setScreenStartPoint(p, point);
|
||||
newSession.setScreenEndPoint(p, point);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
Matrix editorMatrix = selected.getEditorMatrix();
|
||||
|
||||
editorMatrix.reset();
|
||||
|
||||
if (selected.getFlags().isAspectLocked()) {
|
||||
|
||||
float scale = (float) findScale(startPointElement, endPointElement);
|
||||
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
editorMatrix.postScale(scale, scale);
|
||||
|
||||
double angle = angle(endPointElement[0], endPointElement[1]) - angle(startPointElement[0], startPointElement[1]);
|
||||
|
||||
if (!selected.getFlags().isRotateLocked()) {
|
||||
editorMatrix.postRotate((float) Math.toDegrees(angle));
|
||||
}
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
} else {
|
||||
editorMatrix.postTranslate(-startPointElement[0].x, -startPointElement[0].y);
|
||||
|
||||
float scaleX = (endPointElement[1].x - endPointElement[0].x) / (startPointElement[1].x - startPointElement[0].x);
|
||||
float scaleY = (endPointElement[1].y - endPointElement[0].y) / (startPointElement[1].y - startPointElement[0].y);
|
||||
|
||||
editorMatrix.postScale(scaleX, scaleY);
|
||||
|
||||
editorMatrix.postTranslate(endPointElement[0].x, endPointElement[0].y);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(Matrix newInverse, int p) {
|
||||
return convertToDrag(p, newInverse);
|
||||
}
|
||||
|
||||
private static double angle(PointF a, PointF b) {
|
||||
return Math.atan2(a.y - b.y, a.x - b.x);
|
||||
}
|
||||
|
||||
private ElementDragEditSession convertToDrag(int p, Matrix inverse) {
|
||||
return ElementDragEditSession.startDrag(selected, inverse, endPointScreen[1 - p]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relative distance between an old and new set of Points.
|
||||
*
|
||||
* @param from Pair of points.
|
||||
* @param to New pair of points.
|
||||
* @return Scale
|
||||
*/
|
||||
private static double findScale(@NonNull PointF[] from, @NonNull PointF[] to) {
|
||||
float originalD2 = getDistanceSquared(from[0], from[1]);
|
||||
float newD2 = getDistanceSquared(to[0], to[1]);
|
||||
return Math.sqrt(newD2 / originalD2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance between two points squared.
|
||||
*/
|
||||
private static float getDistanceSquared(@NonNull PointF a, @NonNull PointF b) {
|
||||
float dx = a.x - b.x;
|
||||
float dy = a.y - b.y;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
}
|
||||
136
src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java
Normal file
136
src/org/thoughtcrime/securesms/imageeditor/HiddenEditText.java
Normal file
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Rect;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.InputType;
|
||||
import android.util.TypedValue;
|
||||
import android.view.Gravity;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer;
|
||||
|
||||
/**
|
||||
* Invisible {@link android.widget.EditText} that is used during in-image text editing.
|
||||
*/
|
||||
final class HiddenEditText extends android.support.v7.widget.AppCompatEditText {
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
private static final int INCOGNITO_KEYBOARD_IME = EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING;
|
||||
|
||||
@Nullable
|
||||
private TextRenderer currentTextEntity;
|
||||
|
||||
@Nullable
|
||||
private Runnable onEndEdit;
|
||||
|
||||
public HiddenEditText(Context context) {
|
||||
super(context);
|
||||
setAlpha(0);
|
||||
setLayoutParams(new FrameLayout.LayoutParams(1, 1, Gravity.TOP | Gravity.START));
|
||||
setClickable(false);
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
setBackgroundColor(Color.TRANSPARENT);
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 1);
|
||||
setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
setImeOptions(EditorInfo.IME_ACTION_DONE);
|
||||
clearFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
|
||||
super.onTextChanged(text, start, lengthBefore, lengthAfter);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setText(text.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEditorAction(int actionCode) {
|
||||
super.onEditorAction(actionCode);
|
||||
if (actionCode == EditorInfo.IME_ACTION_DONE && currentTextEntity != null) {
|
||||
currentTextEntity.setFocused(false);
|
||||
endEdit();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
|
||||
super.onFocusChanged(focused, direction, previouslyFocusedRect);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setFocused(focused);
|
||||
if (!focused) {
|
||||
endEdit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void endEdit() {
|
||||
if (onEndEdit != null) {
|
||||
onEndEdit.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable TextRenderer getCurrentTextEntity() {
|
||||
return currentTextEntity;
|
||||
}
|
||||
|
||||
void setCurrentTextEntity(@Nullable TextRenderer currentTextEntity) {
|
||||
if (this.currentTextEntity != currentTextEntity) {
|
||||
if (this.currentTextEntity != null) {
|
||||
this.currentTextEntity.setFocused(false);
|
||||
}
|
||||
this.currentTextEntity = currentTextEntity;
|
||||
if (currentTextEntity != null) {
|
||||
String text = currentTextEntity.getText();
|
||||
setText(text);
|
||||
setSelection(text.length());
|
||||
} else {
|
||||
setText("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
if (currentTextEntity != null) {
|
||||
currentTextEntity.setSelection(selStart, selEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
|
||||
boolean focus = super.requestFocus(direction, previouslyFocusedRect);
|
||||
|
||||
if (currentTextEntity != null && focus) {
|
||||
currentTextEntity.setFocused(true);
|
||||
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT);
|
||||
if (!imm.isAcceptingText()) {
|
||||
imm.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
}
|
||||
}
|
||||
|
||||
return focus;
|
||||
}
|
||||
|
||||
public void hideKeyboard() {
|
||||
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(getWindowToken(), InputMethodManager.HIDE_IMPLICIT_ONLY);
|
||||
}
|
||||
|
||||
public void setIncognitoKeyboardEnabled(boolean incognitoKeyboardEnabled) {
|
||||
setImeOptions(incognitoKeyboardEnabled ? getImeOptions() | INCOGNITO_KEYBOARD_IME
|
||||
: getImeOptions() & ~INCOGNITO_KEYBOARD_IME);
|
||||
}
|
||||
|
||||
public void setOnEndEdit(@Nullable Runnable onEndEdit) {
|
||||
this.onEndEdit = onEndEdit;
|
||||
}
|
||||
}
|
||||
426
src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java
Normal file
426
src/org/thoughtcrime/securesms/imageeditor/ImageEditorView.java
Normal file
@@ -0,0 +1,426 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.support.annotation.ColorInt;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.view.GestureDetectorCompat;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.MotionEvent;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
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.TextRenderer;
|
||||
|
||||
/**
|
||||
* ImageEditorView
|
||||
* <p>
|
||||
* Android {@link android.view.View} that allows manipulation of a base image, rotate/flip/crop and
|
||||
* addition and manipulation of text/drawing/and other image layers that move with the base image.
|
||||
* <p>
|
||||
* Drawing
|
||||
* <p>
|
||||
* Drawing is achieved by setting the {@link #color} and putting the view in {@link Mode#Draw}.
|
||||
* Touch events are then passed to a new {@link BezierDrawingRenderer} on a new {@link EditorElement}.
|
||||
* <p>
|
||||
* New images
|
||||
* <p>
|
||||
* To add new images to the base image add via the {@link EditorModel#addElementCentered(EditorElement, float)}
|
||||
* which centers the new item in the current crop area.
|
||||
*/
|
||||
public final class ImageEditorView extends FrameLayout {
|
||||
|
||||
private HiddenEditText editText;
|
||||
|
||||
@NonNull
|
||||
private Mode mode = Mode.MoveAndResize;
|
||||
|
||||
@ColorInt
|
||||
private int color = 0xff000000;
|
||||
|
||||
private float thickness = 0.02f;
|
||||
|
||||
@NonNull
|
||||
private Paint.Cap cap = Paint.Cap.ROUND;
|
||||
|
||||
private EditorModel model;
|
||||
|
||||
private GestureDetectorCompat doubleTap;
|
||||
|
||||
@Nullable
|
||||
private DrawingChangedListener drawingChangedListener;
|
||||
|
||||
private final Matrix viewMatrix = new Matrix();
|
||||
private final RectF viewPort = Bounds.newFullBounds();
|
||||
private final RectF visibleViewPort = Bounds.newFullBounds();
|
||||
private final RectF screen = new RectF();
|
||||
|
||||
private TapListener tapListener;
|
||||
private RendererContext rendererContext;
|
||||
|
||||
@Nullable
|
||||
private EditSession editSession;
|
||||
|
||||
public ImageEditorView(Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public ImageEditorView(Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
|
||||
public ImageEditorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init();
|
||||
}
|
||||
|
||||
private void init() {
|
||||
setWillNotDraw(false);
|
||||
setModel(new EditorModel());
|
||||
|
||||
editText = createAHiddenTextEntryField();
|
||||
|
||||
doubleTap = new GestureDetectorCompat(getContext(), new DoubleTapGestureListener());
|
||||
|
||||
setOnTouchListener((v, event) -> doubleTap.onTouchEvent(event));
|
||||
}
|
||||
|
||||
private HiddenEditText createAHiddenTextEntryField() {
|
||||
HiddenEditText editText = new HiddenEditText(getContext());
|
||||
addView(editText);
|
||||
editText.clearFocus();
|
||||
editText.setOnEndEdit(this::doneTextEditing);
|
||||
return editText;
|
||||
}
|
||||
|
||||
public void startTextEditing(@NonNull EditorElement editorElement, boolean incognitoKeyboardEnabled, boolean selectAll) {
|
||||
Renderer renderer = editorElement.getRenderer();
|
||||
if (renderer instanceof TextRenderer) {
|
||||
TextRenderer textRenderer = (TextRenderer) renderer;
|
||||
|
||||
editText.setIncognitoKeyboardEnabled(incognitoKeyboardEnabled);
|
||||
editText.setCurrentTextEntity(textRenderer);
|
||||
if (selectAll) {
|
||||
editText.selectAll();
|
||||
}
|
||||
editText.requestFocus();
|
||||
|
||||
getModel().zoomTo(editorElement, Bounds.TOP / 2, true);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isTextEditing() {
|
||||
return editText.getCurrentTextEntity() != null;
|
||||
}
|
||||
|
||||
public void doneTextEditing() {
|
||||
getModel().zoomOut();
|
||||
if (editText.getCurrentTextEntity() != null) {
|
||||
editText.setCurrentTextEntity(null);
|
||||
editText.hideKeyboard();
|
||||
if (tapListener != null) {
|
||||
tapListener.onEntityDown(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (rendererContext == null || rendererContext.canvas != canvas) {
|
||||
rendererContext = new RendererContext(getContext(), canvas, rendererReady, rendererInvalidate);
|
||||
}
|
||||
rendererContext.save();
|
||||
try {
|
||||
rendererContext.canvasMatrix.initial(viewMatrix);
|
||||
model.draw(rendererContext);
|
||||
} finally {
|
||||
rendererContext.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private final RendererContext.Ready rendererReady = new RendererContext.Ready() {
|
||||
@Override
|
||||
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
|
||||
model.onReady(renderer, cropMatrix, size);
|
||||
invalidate();
|
||||
}
|
||||
};
|
||||
|
||||
private final RendererContext.Invalidate rendererInvalidate = renderer -> invalidate();
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
updateViewMatrix();
|
||||
}
|
||||
|
||||
private void updateViewMatrix() {
|
||||
screen.right = getWidth();
|
||||
screen.bottom = getHeight();
|
||||
|
||||
viewMatrix.setRectToRect(viewPort, screen, Matrix.ScaleToFit.FILL);
|
||||
|
||||
float[] values = new float[9];
|
||||
viewMatrix.getValues(values);
|
||||
|
||||
float scale = values[0] / values[4];
|
||||
|
||||
RectF tempViewPort = Bounds.newFullBounds();
|
||||
if (scale < 1) {
|
||||
tempViewPort.top /= scale;
|
||||
tempViewPort.bottom /= scale;
|
||||
} else {
|
||||
tempViewPort.left *= scale;
|
||||
tempViewPort.right *= scale;
|
||||
}
|
||||
|
||||
visibleViewPort.set(tempViewPort);
|
||||
|
||||
viewMatrix.setRectToRect(visibleViewPort, screen, Matrix.ScaleToFit.CENTER);
|
||||
|
||||
model.setVisibleViewPort(visibleViewPort);
|
||||
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void setModel(@NonNull EditorModel model) {
|
||||
if (this.model != model) {
|
||||
if (this.model != null) {
|
||||
this.model.setInvalidate(null);
|
||||
}
|
||||
this.model = model;
|
||||
this.model.setInvalidate(this::invalidate);
|
||||
this.model.setVisibleViewPort(visibleViewPort);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
switch (event.getActionMasked()) {
|
||||
case MotionEvent.ACTION_DOWN: {
|
||||
Matrix inverse = new Matrix();
|
||||
PointF point = getPoint(event);
|
||||
EditorElement selected = model.findElementAtPoint(point, viewMatrix, inverse);
|
||||
|
||||
model.pushUndoPoint();
|
||||
editSession = startEdit(inverse, point, selected);
|
||||
|
||||
if (tapListener != null && allowTaps()) {
|
||||
if (editSession != null) {
|
||||
tapListener.onEntityDown(editSession.getSelected());
|
||||
} else {
|
||||
tapListener.onEntityDown(null);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
case MotionEvent.ACTION_MOVE: {
|
||||
if (editSession != null) {
|
||||
for (int p = 0; p < Math.min(2, event.getPointerCount()); p++) {
|
||||
editSession.movePoint(p, getPoint(event, p));
|
||||
}
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_POINTER_DOWN: {
|
||||
if (editSession != null && event.getPointerCount() == 2) {
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
editSession = editSession.newPoint(newInverse, getPoint(event, event.getActionIndex()), event.getActionIndex());
|
||||
if (editSession == null) {
|
||||
dragDropRelease();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_POINTER_UP: {
|
||||
if (editSession != null && event.getActionIndex() < 2) {
|
||||
editSession.commit();
|
||||
model.pushUndoPoint();
|
||||
dragDropRelease();
|
||||
|
||||
Matrix newInverse = model.findElementInverseMatrix(editSession.getSelected(), viewMatrix);
|
||||
editSession = editSession.removePoint(newInverse, event.getActionIndex());
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MotionEvent.ACTION_UP: {
|
||||
if (editSession != null) {
|
||||
editSession.commit();
|
||||
dragDropRelease();
|
||||
|
||||
editSession = null;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return super.onTouchEvent(event);
|
||||
}
|
||||
|
||||
private @Nullable EditSession startEdit(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
||||
if (mode == Mode.Draw) {
|
||||
return startADrawingSession(point);
|
||||
} else {
|
||||
return startAMoveAndResizeSession(inverse, point, selected);
|
||||
}
|
||||
}
|
||||
|
||||
private EditSession startADrawingSession(@NonNull PointF point) {
|
||||
BezierDrawingRenderer renderer = new BezierDrawingRenderer(color, thickness * Bounds.FULL_BOUNDS.width(), cap, model.findCropRelativeToRoot());
|
||||
EditorElement element = new EditorElement(renderer);
|
||||
model.addElementCentered(element, 1);
|
||||
|
||||
Matrix elementInverseMatrix = model.findElementInverseMatrix(element, viewMatrix);
|
||||
|
||||
return DrawingSession.start(element, renderer, elementInverseMatrix, point);
|
||||
}
|
||||
|
||||
private EditSession startAMoveAndResizeSession(@NonNull Matrix inverse, @NonNull PointF point, @Nullable EditorElement selected) {
|
||||
Matrix elementInverseMatrix;
|
||||
if (selected == null) return null;
|
||||
|
||||
if (selected.getRenderer() instanceof ThumbRenderer) {
|
||||
ThumbRenderer thumb = (ThumbRenderer) selected.getRenderer();
|
||||
|
||||
selected = getModel().findById(thumb.getElementToControl());
|
||||
|
||||
if (selected == null) return null;
|
||||
|
||||
elementInverseMatrix = model.findElementInverseMatrix(selected, viewMatrix);
|
||||
if (elementInverseMatrix != null) {
|
||||
return ThumbDragEditSession.startDrag(selected, elementInverseMatrix, thumb.getControlPoint(), point);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return ElementDragEditSession.startDrag(selected, inverse, point);
|
||||
}
|
||||
|
||||
public void setMode(@NonNull Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public void startDrawing(float thickness, @NonNull Paint.Cap cap) {
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
setMode(Mode.Draw);
|
||||
}
|
||||
|
||||
public void setDrawingBrushColor(int color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
private void dragDropRelease() {
|
||||
model.dragDropRelease();
|
||||
if (drawingChangedListener != null) {
|
||||
drawingChangedListener.onDrawingChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static PointF getPoint(MotionEvent event) {
|
||||
return getPoint(event, 0);
|
||||
}
|
||||
|
||||
private static PointF getPoint(MotionEvent event, int p) {
|
||||
return new PointF(event.getX(p), event.getY(p));
|
||||
}
|
||||
|
||||
public EditorModel getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setDrawingChangedListener(@Nullable DrawingChangedListener drawingChangedListener) {
|
||||
this.drawingChangedListener = drawingChangedListener;
|
||||
}
|
||||
|
||||
public void setTapListener(TapListener tapListener) {
|
||||
this.tapListener = tapListener;
|
||||
}
|
||||
|
||||
public void deleteElement(@Nullable EditorElement editorElement) {
|
||||
if (editorElement != null) {
|
||||
model.pushUndoPoint();
|
||||
model.delete(editorElement);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private final class DoubleTapGestureListener extends GestureDetector.SimpleOnGestureListener {
|
||||
|
||||
@Override
|
||||
public boolean onDoubleTap(MotionEvent e) {
|
||||
if (tapListener != null && editSession != null && allowTaps()) {
|
||||
tapListener.onEntityDoubleTap(editSession.getSelected());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e) {}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
if (tapListener != null && allowTaps()) {
|
||||
if (editSession != null) {
|
||||
EditorElement selected = editSession.getSelected();
|
||||
model.indicateSelected(selected);
|
||||
tapListener.onEntitySingleTap(selected);
|
||||
} else {
|
||||
tapListener.onEntitySingleTap(null);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allowTaps() {
|
||||
return !model.isCropping() && mode != Mode.Draw;
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
MoveAndResize,
|
||||
Draw
|
||||
}
|
||||
|
||||
public interface DrawingChangedListener {
|
||||
void onDrawingChanged();
|
||||
}
|
||||
|
||||
public interface TapListener {
|
||||
|
||||
void onEntityDown(@Nullable EditorElement editorElement);
|
||||
|
||||
void onEntitySingleTap(@Nullable EditorElement editorElement);
|
||||
|
||||
void onEntityDoubleTap(@NonNull EditorElement editorElement);
|
||||
}
|
||||
}
|
||||
26
src/org/thoughtcrime/securesms/imageeditor/Renderer.java
Normal file
26
src/org/thoughtcrime/securesms/imageeditor/Renderer.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
/**
|
||||
* Responsible for rendering a single {@link org.thoughtcrime.securesms.imageeditor.model.EditorElement} to the canvas.
|
||||
* <p>
|
||||
* Because it knows the most about the whereabouts of the image it is also responsible for hit detection.
|
||||
*/
|
||||
public interface Renderer extends Parcelable {
|
||||
|
||||
/**
|
||||
* Draw self to the context.
|
||||
*
|
||||
* @param rendererContext The context to draw to.
|
||||
*/
|
||||
void render(@NonNull RendererContext rendererContext);
|
||||
|
||||
/**
|
||||
* @param x Local coordinate X
|
||||
* @param y Local coordinate Y
|
||||
* @return true iff hit.
|
||||
*/
|
||||
boolean hitTest(float x, float y);
|
||||
}
|
||||
114
src/org/thoughtcrime/securesms/imageeditor/RendererContext.java
Normal file
114
src/org/thoughtcrime/securesms/imageeditor/RendererContext.java
Normal file
@@ -0,0 +1,114 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.RectF;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Contains all of the information required for a {@link Renderer} to do its job.
|
||||
* <p>
|
||||
* Includes a {@link #canvas}, preconfigured with the correct matrix.
|
||||
* <p>
|
||||
* The {@link #canvasMatrix} should further matrix manipulation be required.
|
||||
*/
|
||||
public final class RendererContext {
|
||||
|
||||
@NonNull
|
||||
public final Context context;
|
||||
|
||||
@NonNull
|
||||
public final Canvas canvas;
|
||||
|
||||
@NonNull
|
||||
public final CanvasMatrix canvasMatrix;
|
||||
|
||||
@NonNull
|
||||
public final Ready rendererReady;
|
||||
|
||||
@NonNull
|
||||
public final Invalidate invalidate;
|
||||
|
||||
private boolean blockingLoad;
|
||||
|
||||
private float fade = 1f;
|
||||
|
||||
private boolean isEditing = true;
|
||||
|
||||
public RendererContext(@NonNull Context context, @NonNull Canvas canvas, @NonNull Ready rendererReady, @NonNull Invalidate invalidate) {
|
||||
this.context = context;
|
||||
this.canvas = canvas;
|
||||
this.canvasMatrix = new CanvasMatrix(canvas);
|
||||
this.rendererReady = rendererReady;
|
||||
this.invalidate = invalidate;
|
||||
}
|
||||
|
||||
public void setBlockingLoad(boolean blockingLoad) {
|
||||
this.blockingLoad = blockingLoad;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link Renderer}s generally run in the foreground but can load any data they require in the background.
|
||||
* <p>
|
||||
* If they do so, they can use the {@link #invalidate} callback when ready to inform the view it needs to be redrawn.
|
||||
* <p>
|
||||
* However, when isBlockingLoad is true, the renderer is running in the background for the final render
|
||||
* and must load the data immediately and block the render until done so.
|
||||
*/
|
||||
public boolean isBlockingLoad() {
|
||||
return blockingLoad;
|
||||
}
|
||||
|
||||
public boolean mapRect(@NonNull RectF dst, @NonNull RectF src) {
|
||||
return canvasMatrix.mapRect(dst, src);
|
||||
}
|
||||
|
||||
public void setIsEditing(boolean isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
}
|
||||
|
||||
public boolean isEditing() {
|
||||
return isEditing;
|
||||
}
|
||||
|
||||
public void setFade(float fade) {
|
||||
this.fade = fade;
|
||||
}
|
||||
|
||||
public int getAlpha(int alpha) {
|
||||
return Math.max(0, Math.min(255, (int) (fade * alpha)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current state on to a stack, must be complimented by a call to {@link #restore()}.
|
||||
*/
|
||||
public void save() {
|
||||
canvasMatrix.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the current state from the stack, must match a call to {@link #save()}.
|
||||
*/
|
||||
public void restore() {
|
||||
canvasMatrix.restore();
|
||||
}
|
||||
|
||||
public interface Ready {
|
||||
|
||||
Ready NULL = (renderer, cropMatrix, size) -> {
|
||||
};
|
||||
|
||||
void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size);
|
||||
}
|
||||
|
||||
public interface Invalidate {
|
||||
|
||||
Invalidate NULL = (renderer) -> {
|
||||
};
|
||||
|
||||
void onInvalidate(@NonNull Renderer renderer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.imageeditor;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.ThumbRenderer;
|
||||
|
||||
class ThumbDragEditSession extends ElementEditSession {
|
||||
|
||||
@NonNull
|
||||
private final ThumbRenderer.ControlPoint controlPoint;
|
||||
|
||||
private ThumbDragEditSession(@NonNull EditorElement selected, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull Matrix inverseMatrix) {
|
||||
super(selected, inverseMatrix);
|
||||
this.controlPoint = controlPoint;
|
||||
}
|
||||
|
||||
static EditSession startDrag(@NonNull EditorElement selected, @NonNull Matrix inverseViewModelMatrix, @NonNull ThumbRenderer.ControlPoint controlPoint, @NonNull PointF point) {
|
||||
if (!selected.getFlags().isEditable()) return null;
|
||||
|
||||
ElementEditSession elementDragEditSession = new ThumbDragEditSession(selected, controlPoint, inverseViewModelMatrix);
|
||||
elementDragEditSession.setScreenStartPoint(0, point);
|
||||
elementDragEditSession.setScreenEndPoint(0, point);
|
||||
return elementDragEditSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void movePoint(int p, @NonNull PointF point) {
|
||||
setScreenEndPoint(p, point);
|
||||
|
||||
Matrix editorMatrix = selected.getEditorMatrix();
|
||||
|
||||
editorMatrix.reset();
|
||||
|
||||
float x = controlPoint.opposite().getX();
|
||||
float y = controlPoint.opposite().getY();
|
||||
|
||||
editorMatrix.postTranslate(-x, -y);
|
||||
|
||||
boolean aspectLocked = selected.getFlags().isAspectLocked();
|
||||
|
||||
float defaultScale = aspectLocked ? 2 : 1;
|
||||
|
||||
float scaleX = controlPoint.isVerticalCenter() ? defaultScale : (endPointElement[0].x - x) / (startPointElement[0].x - x);
|
||||
float scaleY = controlPoint.isHorizontalCenter() ? defaultScale : (endPointElement[0].y - y) / (startPointElement[0].y - y);
|
||||
|
||||
if (aspectLocked) {
|
||||
float minScale = Math.min(scaleX, scaleY);
|
||||
editorMatrix.postScale(minScale, minScale);
|
||||
} else {
|
||||
editorMatrix.postScale(scaleX, scaleY);
|
||||
}
|
||||
|
||||
editorMatrix.postTranslate(x, y);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession newPoint(Matrix newInverse, PointF point, int p) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditSession removePoint(Matrix newInverse, int p) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.view.animation.Interpolator;
|
||||
import android.view.animation.LinearInterpolator;
|
||||
|
||||
final class AlphaAnimation {
|
||||
|
||||
private final static Interpolator interpolator = new LinearInterpolator();
|
||||
|
||||
final static AlphaAnimation NULL_1 = new AlphaAnimation(1);
|
||||
|
||||
private final float from;
|
||||
private final float to;
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private float animatedFraction;
|
||||
|
||||
private AlphaAnimation(float from, float to, @Nullable Runnable invalidate) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = invalidate != null;
|
||||
}
|
||||
|
||||
private AlphaAnimation(float fixed) {
|
||||
this(fixed, fixed, null);
|
||||
}
|
||||
|
||||
static AlphaAnimation animate(float from, float to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
|
||||
if (from != to) {
|
||||
AlphaAnimation animationMatrix = new AlphaAnimation(from, to, invalidate);
|
||||
animationMatrix.start();
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return new AlphaAnimation(to);
|
||||
}
|
||||
}
|
||||
|
||||
private void start() {
|
||||
if (canAnimate && invalidate != null) {
|
||||
ValueAnimator animator = ValueAnimator.ofFloat(from, to);
|
||||
animator.setDuration(200);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
float getValue() {
|
||||
if (!canAnimate) return to;
|
||||
|
||||
return animatedFraction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.graphics.Matrix;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.view.animation.CycleInterpolator;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.view.animation.Interpolator;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.CanvasMatrix;
|
||||
|
||||
/**
|
||||
* Animation Matrix provides a matrix that animates over time down to the identity matrix.
|
||||
*/
|
||||
final class AnimationMatrix {
|
||||
|
||||
private final static float[] iValues = new float[9];
|
||||
private final static Interpolator interpolator = new DecelerateInterpolator();
|
||||
private final static Interpolator pulseInterpolator = inverse(new CycleInterpolator(0.5f));
|
||||
|
||||
static AnimationMatrix NULL = new AnimationMatrix();
|
||||
|
||||
static {
|
||||
new Matrix().getValues(iValues);
|
||||
}
|
||||
|
||||
private final Runnable invalidate;
|
||||
private final boolean canAnimate;
|
||||
private final float[] undoValues = new float[9];
|
||||
|
||||
private final Matrix temp = new Matrix();
|
||||
private final float[] tempValues = new float[9];
|
||||
|
||||
private ValueAnimator animator;
|
||||
private float animatedFraction;
|
||||
|
||||
private AnimationMatrix(@NonNull Matrix undo, @NonNull Runnable invalidate) {
|
||||
this.invalidate = invalidate;
|
||||
this.canAnimate = true;
|
||||
undo.getValues(undoValues);
|
||||
}
|
||||
|
||||
private AnimationMatrix() {
|
||||
canAnimate = false;
|
||||
invalidate = null;
|
||||
}
|
||||
|
||||
static @NonNull AnimationMatrix animate(@NonNull Matrix from, @NonNull Matrix to, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
Matrix undo = new Matrix();
|
||||
boolean inverted = to.invert(undo);
|
||||
if (inverted) {
|
||||
undo.preConcat(from);
|
||||
}
|
||||
if (inverted && !undo.isIdentity()) {
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(undo, invalidate);
|
||||
animationMatrix.start(interpolator);
|
||||
return animationMatrix;
|
||||
} else {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate applying a matrix and then animate removing.
|
||||
*/
|
||||
static @NonNull AnimationMatrix singlePulse(@NonNull Matrix pulse, @Nullable Runnable invalidate) {
|
||||
if (invalidate == null) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
AnimationMatrix animationMatrix = new AnimationMatrix(pulse, invalidate);
|
||||
animationMatrix.start(pulseInterpolator);
|
||||
|
||||
return animationMatrix;
|
||||
}
|
||||
|
||||
private void start(@NonNull Interpolator interpolator) {
|
||||
if (canAnimate) {
|
||||
animator = ValueAnimator.ofFloat(1, 0);
|
||||
animator.setDuration(250);
|
||||
animator.setInterpolator(interpolator);
|
||||
animator.addUpdateListener(animation -> {
|
||||
animatedFraction = (float) animation.getAnimatedValue();
|
||||
invalidate.run();
|
||||
});
|
||||
animator.start();
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
ValueAnimator animator = this.animator;
|
||||
if (animator != null) animator.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull Matrix onTo) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
onTo.preConcat(buildTemp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the current animation value.
|
||||
*/
|
||||
void preConcatValueTo(@NonNull CanvasMatrix canvasMatrix) {
|
||||
if (!canAnimate) return;
|
||||
|
||||
canvasMatrix.concat(buildTemp());
|
||||
}
|
||||
|
||||
private Matrix buildTemp() {
|
||||
if (!canAnimate) {
|
||||
temp.reset();
|
||||
return temp;
|
||||
}
|
||||
|
||||
final float fractionCompliment = 1f - animatedFraction;
|
||||
for (int i = 0; i < 9; i++) {
|
||||
tempValues[i] = fractionCompliment * iValues[i] + animatedFraction * undoValues[i];
|
||||
}
|
||||
|
||||
temp.setValues(tempValues);
|
||||
return temp;
|
||||
}
|
||||
|
||||
private static Interpolator inverse(@NonNull Interpolator interpolator) {
|
||||
return input -> 1f - interpolator.getInterpolation(input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Hit tests a circle that is {@link R.dimen#crop_area_renderer_edge_size} in radius on the screen.
|
||||
* <p>
|
||||
* Does not draw anything.
|
||||
*/
|
||||
class CropThumbRenderer implements Renderer, ThumbRenderer {
|
||||
|
||||
private final ControlPoint controlPoint;
|
||||
private final UUID toControl;
|
||||
|
||||
private final float[] centreOnScreen = new float[2];
|
||||
private final Matrix matrix = new Matrix();
|
||||
private int size;
|
||||
|
||||
CropThumbRenderer(@NonNull ControlPoint controlPoint, @NonNull UUID toControl) {
|
||||
this.controlPoint = controlPoint;
|
||||
this.toControl = toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ControlPoint getControlPoint() {
|
||||
return controlPoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UUID getElementToControl() {
|
||||
return toControl;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvasMatrix.mapPoints(centreOnScreen, Bounds.CENTRE);
|
||||
rendererContext.canvasMatrix.copyTo(matrix);
|
||||
size = rendererContext.context.getResources().getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
float[] hitPointOnScreen = new float[2];
|
||||
matrix.mapPoints(hitPointOnScreen, new float[]{ x, y });
|
||||
|
||||
float dx = centreOnScreen[0] - hitPointOnScreen[0];
|
||||
float dy = centreOnScreen[1] - hitPointOnScreen[1];
|
||||
|
||||
return dx * dx + dy * dy < size * size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<CropThumbRenderer> CREATOR = new Creator<CropThumbRenderer>() {
|
||||
@Override
|
||||
public CropThumbRenderer createFromParcel(Parcel in) {
|
||||
return new CropThumbRenderer(ControlPoint.values()[in.readInt()], ParcelUtils.readUUID(in));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CropThumbRenderer[] newArray(int size) {
|
||||
return new CropThumbRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(controlPoint.ordinal());
|
||||
ParcelUtils.writeUUID(dest, toControl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* An image consists of a tree of {@link EditorElement}s.
|
||||
* <p>
|
||||
* Each element has some persisted state:
|
||||
* - An optional {@link Renderer} so that it can draw itself.
|
||||
* - A list of child elements that make the tree possible.
|
||||
* - Its own transformation matrix, which applies to itself and all its children.
|
||||
* - A set of flags controlling visibility, selectablity etc.
|
||||
* <p>
|
||||
* Then some temporary state.
|
||||
* - A editor matrix for displaying as yet uncommitted edits.
|
||||
* - An animation matrix for animating from one matrix to another.
|
||||
* - Deleted children to allow them to fade out on delete.
|
||||
* - Temporary flags, for temporary visibility, selectablity etc.
|
||||
*/
|
||||
public final class EditorElement implements Parcelable {
|
||||
|
||||
private final UUID id;
|
||||
private final EditorFlags flags;
|
||||
private final Matrix localMatrix = new Matrix();
|
||||
private final Matrix editorMatrix = new Matrix();
|
||||
|
||||
@Nullable
|
||||
private final Renderer renderer;
|
||||
|
||||
private final Matrix temp = new Matrix();
|
||||
|
||||
private final Matrix tempMatrix = new Matrix();
|
||||
|
||||
private final List<EditorElement> children = new LinkedList<>();
|
||||
private final List<EditorElement> deletedChildren = new LinkedList<>();
|
||||
|
||||
@NonNull
|
||||
private AnimationMatrix animationMatrix = AnimationMatrix.NULL;
|
||||
|
||||
@NonNull
|
||||
private AlphaAnimation alphaAnimation = AlphaAnimation.NULL_1;
|
||||
|
||||
public EditorElement(@Nullable Renderer renderer) {
|
||||
this.id = UUID.randomUUID();
|
||||
this.flags = new EditorFlags();
|
||||
this.renderer = renderer;
|
||||
}
|
||||
|
||||
private EditorElement(Parcel in) {
|
||||
id = ParcelUtils.readUUID(in);
|
||||
flags = new EditorFlags(in.readInt());
|
||||
ParcelUtils.readMatrix(localMatrix, in);
|
||||
renderer = in.readParcelable(Renderer.class.getClassLoader());
|
||||
in.readTypedList(children, EditorElement.CREATOR);
|
||||
}
|
||||
|
||||
UUID getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public @Nullable Renderer getRenderer() {
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iff Visible,
|
||||
* Renders tree with the following localMatrix:
|
||||
* <p>
|
||||
* viewModelMatrix * localMatrix * editorMatrix * animationMatrix
|
||||
* <p>
|
||||
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * localMatrix * editorMatrix * animationMatrix
|
||||
*
|
||||
* @param rendererContext Canvas to draw on to.
|
||||
*/
|
||||
void draw(@NonNull RendererContext rendererContext) {
|
||||
if (!flags.isVisible() && !flags.isChildrenVisible()) return;
|
||||
|
||||
rendererContext.save();
|
||||
|
||||
rendererContext.canvasMatrix.concat(localMatrix);
|
||||
|
||||
if (rendererContext.isEditing()) {
|
||||
rendererContext.canvasMatrix.concat(editorMatrix);
|
||||
animationMatrix.preConcatValueTo(rendererContext.canvasMatrix);
|
||||
}
|
||||
|
||||
if (flags.isVisible()) {
|
||||
float alpha = alphaAnimation.getValue();
|
||||
if (alpha > 0) {
|
||||
rendererContext.setFade(alpha);
|
||||
drawSelf(rendererContext);
|
||||
rendererContext.setFade(1f);
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.isChildrenVisible()) {
|
||||
drawChildren(children, rendererContext);
|
||||
drawChildren(deletedChildren, rendererContext);
|
||||
}
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
private void drawSelf(@NonNull RendererContext rendererContext) {
|
||||
if (renderer == null) return;
|
||||
renderer.render(rendererContext);
|
||||
}
|
||||
|
||||
private static void drawChildren(@NonNull List<EditorElement> children, @NonNull RendererContext rendererContext) {
|
||||
for (EditorElement element : children) {
|
||||
element.draw(rendererContext);
|
||||
}
|
||||
}
|
||||
|
||||
public void addElement(@NonNull EditorElement element) {
|
||||
children.add(element);
|
||||
}
|
||||
|
||||
public Matrix getLocalMatrix() {
|
||||
return localMatrix;
|
||||
}
|
||||
|
||||
public Matrix getEditorMatrix() {
|
||||
return editorMatrix;
|
||||
}
|
||||
|
||||
EditorElement findElement(@NonNull EditorElement toFind, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return findElement(viewMatrix, outInverseModelMatrix, (element, inverseMatrix) -> toFind == element);
|
||||
}
|
||||
|
||||
EditorElement findElementAt(float x, float y, @NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
final float[] dst = new float[2];
|
||||
final float[] src = { x, y };
|
||||
|
||||
return findElement(viewModelMatrix, outInverseModelMatrix, (element, inverseMatrix) -> {
|
||||
Renderer renderer = element.renderer;
|
||||
if (renderer == null) return false;
|
||||
inverseMatrix.mapPoints(dst, src);
|
||||
return element.flags.isSelectable() && renderer.hitTest(dst[0], dst[1]);
|
||||
});
|
||||
}
|
||||
|
||||
public EditorElement findElement(@NonNull Matrix viewModelMatrix, @NonNull Matrix outInverseModelMatrix, @NonNull FindElementPredicate predicate) {
|
||||
temp.set(viewModelMatrix);
|
||||
|
||||
temp.preConcat(localMatrix);
|
||||
temp.preConcat(editorMatrix);
|
||||
|
||||
if (temp.invert(tempMatrix)) {
|
||||
|
||||
for (int i = children.size() - 1; i >= 0; i--) {
|
||||
EditorElement elementAt = children.get(i).findElement(temp, outInverseModelMatrix, predicate);
|
||||
if (elementAt != null) {
|
||||
return elementAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (predicate.test(this, tempMatrix)) {
|
||||
outInverseModelMatrix.set(tempMatrix);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public EditorFlags getFlags() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
int getChildCount() {
|
||||
return children.size();
|
||||
}
|
||||
|
||||
EditorElement getChild(int i) {
|
||||
return children.get(i);
|
||||
}
|
||||
|
||||
void forAllInTree(@NonNull PerElementFunction function) {
|
||||
function.apply(this);
|
||||
for (EditorElement child : children) {
|
||||
child.forAllInTree(function);
|
||||
}
|
||||
}
|
||||
|
||||
void deleteChild(@NonNull EditorElement editorElement, @Nullable Runnable invalidate) {
|
||||
Iterator<EditorElement> iterator = children.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
if (iterator.next() == editorElement) {
|
||||
iterator.remove();
|
||||
addDeletedChildFadingOut(editorElement, invalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void addDeletedChildFadingOut(@NonNull EditorElement fromElement, @Nullable Runnable invalidate) {
|
||||
deletedChildren.add(fromElement);
|
||||
fromElement.animateFadeOut(invalidate);
|
||||
}
|
||||
|
||||
private void animateFadeOut(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(1, 0, invalidate);
|
||||
}
|
||||
|
||||
void animateFadeIn(@Nullable Runnable invalidate) {
|
||||
alphaAnimation = AlphaAnimation.animate(0, 1, invalidate);
|
||||
}
|
||||
|
||||
@Nullable EditorElement parentOf(@NonNull EditorElement element) {
|
||||
if (children.contains(element)) {
|
||||
return this;
|
||||
}
|
||||
for (EditorElement child : children) {
|
||||
EditorElement parent = child.parentOf(element);
|
||||
if (parent != null) {
|
||||
return parent;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void singleScalePulse(@Nullable Runnable invalidate) {
|
||||
Matrix scale = new Matrix();
|
||||
scale.setScale(1.2f, 1.2f);
|
||||
|
||||
animationMatrix = AnimationMatrix.singlePulse(scale, invalidate);
|
||||
}
|
||||
|
||||
public interface PerElementFunction {
|
||||
void apply(EditorElement element);
|
||||
}
|
||||
|
||||
public interface FindElementPredicate {
|
||||
boolean test(EditorElement element, Matrix inverseMatrix);
|
||||
}
|
||||
|
||||
public void commitEditorMatrix() {
|
||||
if (flags.isEditable()) {
|
||||
localMatrix.preConcat(editorMatrix);
|
||||
editorMatrix.reset();
|
||||
} else {
|
||||
rollbackEditorMatrix(null);
|
||||
}
|
||||
}
|
||||
|
||||
void rollbackEditorMatrix(@Nullable Runnable invalidate) {
|
||||
animateEditorTo(new Matrix(), invalidate);
|
||||
}
|
||||
|
||||
void buildMap(Map<UUID, EditorElement> map) {
|
||||
map.put(id, this);
|
||||
for (EditorElement child : children) {
|
||||
child.buildMap(map);
|
||||
}
|
||||
}
|
||||
|
||||
void animateFrom(@NonNull Matrix oldMatrix, @Nullable Runnable invalidate) {
|
||||
Matrix oldMatrixCopy = new Matrix(oldMatrix);
|
||||
animationMatrix.stop();
|
||||
animationMatrix.preConcatValueTo(oldMatrixCopy);
|
||||
animationMatrix = AnimationMatrix.animate(oldMatrixCopy, localMatrix, invalidate);
|
||||
}
|
||||
|
||||
void animateEditorTo(@NonNull Matrix newEditorMatrix, @Nullable Runnable invalidate) {
|
||||
setMatrixWithAnimation(editorMatrix, newEditorMatrix, invalidate);
|
||||
}
|
||||
|
||||
void animateLocalTo(@NonNull Matrix newLocalMatrix, @Nullable Runnable invalidate) {
|
||||
setMatrixWithAnimation(localMatrix, newLocalMatrix, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param destination Matrix to change
|
||||
* @param source Matrix value to set
|
||||
* @param invalidate Callback to allow animation
|
||||
*/
|
||||
private void setMatrixWithAnimation(@NonNull Matrix destination, @NonNull Matrix source, @Nullable Runnable invalidate) {
|
||||
Matrix old = new Matrix(destination);
|
||||
animationMatrix.stop();
|
||||
animationMatrix.preConcatValueTo(old);
|
||||
destination.set(source);
|
||||
animationMatrix = AnimationMatrix.animate(old, destination, invalidate);
|
||||
}
|
||||
|
||||
Matrix getLocalMatrixAnimating() {
|
||||
Matrix matrix = new Matrix(localMatrix);
|
||||
animationMatrix.preConcatValueTo(matrix);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
void stopAnimation() {
|
||||
animationMatrix.stop();
|
||||
}
|
||||
|
||||
public static final Creator<EditorElement> CREATOR = new Creator<EditorElement>() {
|
||||
@Override
|
||||
public EditorElement createFromParcel(Parcel in) {
|
||||
return new EditorElement(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorElement[] newArray(int size) {
|
||||
return new EditorElement[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
ParcelUtils.writeUUID(dest, id);
|
||||
dest.writeInt(this.flags.asInt());
|
||||
ParcelUtils.writeMatrix(dest, localMatrix);
|
||||
dest.writeParcelable(renderer, flags);
|
||||
dest.writeTypedList(children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.CropAreaRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.InverseFillRenderer;
|
||||
|
||||
/**
|
||||
* Creates and handles a strict EditorElement Hierarchy.
|
||||
* <p>
|
||||
* root - always square, contains only temporary zooms for editing. e.g. when the whole editor zooms out for cropping
|
||||
* |
|
||||
* |- view - contains persisted adjustments for crops
|
||||
* | |
|
||||
* | |- flipRotate - contains persisted adjustments for flip and rotate operations, ensures operations are centered within the current view
|
||||
* | |
|
||||
* | |- imageRoot
|
||||
* | | |- mainImage
|
||||
* | | |- stickers/drawings/text
|
||||
* | |
|
||||
* | |- overlay - always square
|
||||
* | | |- imageCrop - a crop to match the aspect of the main image
|
||||
* | | | |- cropEditorElement - user crop, not always square, but upright, the area of the view
|
||||
* | | | | | All children do not move/scale or rotate.
|
||||
* | | | | |- blackout
|
||||
* | | | | |- thumbs
|
||||
* | | | | | |- Center left thumb
|
||||
* | | | | | |- Center right thumb
|
||||
* | | | | | |- Top center thumb
|
||||
* | | | | | |- Bottom center thumb
|
||||
* | | | | | |- Top left thumb
|
||||
* | | | | | |- Top right thumb
|
||||
* | | | | | |- Bottom left thumb
|
||||
* | | | | | |- Bottom right thumb
|
||||
*/
|
||||
final class EditorElementHierarchy {
|
||||
|
||||
static @NonNull EditorElementHierarchy create() {
|
||||
return new EditorElementHierarchy(createRoot());
|
||||
}
|
||||
|
||||
static @NonNull EditorElementHierarchy create(@Nullable EditorElement root) {
|
||||
if (root == null) {
|
||||
return create();
|
||||
} else {
|
||||
return new EditorElementHierarchy(root);
|
||||
}
|
||||
}
|
||||
|
||||
private final EditorElement root;
|
||||
private final EditorElement view;
|
||||
private final EditorElement flipRotate;
|
||||
private final EditorElement imageRoot;
|
||||
private final EditorElement overlay;
|
||||
private final EditorElement imageCrop;
|
||||
private final EditorElement cropEditorElement;
|
||||
private final EditorElement blackout;
|
||||
private final EditorElement thumbs;
|
||||
|
||||
private EditorElementHierarchy(@NonNull EditorElement root) {
|
||||
this.root = root;
|
||||
this.view = this.root.getChild(0);
|
||||
this.flipRotate = this.view.getChild(0);
|
||||
this.imageRoot = this.flipRotate.getChild(0);
|
||||
this.overlay = this.flipRotate.getChild(1);
|
||||
this.imageCrop = this.overlay.getChild(0);
|
||||
this.cropEditorElement = this.imageCrop.getChild(0);
|
||||
this.blackout = this.cropEditorElement.getChild(0);
|
||||
this.thumbs = this.cropEditorElement.getChild(1);
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement createRoot() {
|
||||
EditorElement root = new EditorElement(null);
|
||||
|
||||
EditorElement imageRoot = new EditorElement(null);
|
||||
root.addElement(imageRoot);
|
||||
|
||||
EditorElement flipRotate = new EditorElement(null);
|
||||
imageRoot.addElement(flipRotate);
|
||||
|
||||
EditorElement image = new EditorElement(null);
|
||||
flipRotate.addElement(image);
|
||||
|
||||
EditorElement overlay = new EditorElement(null);
|
||||
flipRotate.addElement(overlay);
|
||||
|
||||
EditorElement imageCrop = new EditorElement(null);
|
||||
overlay.addElement(imageCrop);
|
||||
|
||||
EditorElement cropEditorElement = new EditorElement(new CropAreaRenderer(R.color.crop_area_renderer_outer_color));
|
||||
|
||||
cropEditorElement.getFlags()
|
||||
.setRotateLocked(true)
|
||||
.setAspectLocked(true)
|
||||
.setSelectable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
|
||||
imageCrop.addElement(cropEditorElement);
|
||||
|
||||
EditorElement blackout = new EditorElement(new InverseFillRenderer(0xff000000));
|
||||
|
||||
blackout.getFlags()
|
||||
.setSelectable(false)
|
||||
.setEditable(false)
|
||||
.persist();
|
||||
|
||||
cropEditorElement.addElement(blackout);
|
||||
|
||||
cropEditorElement.addElement(createThumbs(cropEditorElement));
|
||||
return root;
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement createThumbs(EditorElement cropEditorElement) {
|
||||
EditorElement thumbs = new EditorElement(null);
|
||||
|
||||
thumbs.getFlags()
|
||||
.setChildrenVisible(false)
|
||||
.setSelectable(false)
|
||||
.setVisible(false)
|
||||
.persist();
|
||||
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.CENTER_RIGHT));
|
||||
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_CENTER));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_CENTER));
|
||||
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.TOP_RIGHT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_LEFT));
|
||||
thumbs.addElement(newThumb(cropEditorElement, ThumbRenderer.ControlPoint.BOTTOM_RIGHT));
|
||||
|
||||
return thumbs;
|
||||
}
|
||||
|
||||
private static @NonNull EditorElement newThumb(@NonNull EditorElement toControl, @NonNull ThumbRenderer.ControlPoint controlPoint) {
|
||||
EditorElement element = new EditorElement(new CropThumbRenderer(controlPoint, toControl.getId()));
|
||||
|
||||
element.getFlags()
|
||||
.setSelectable(false)
|
||||
.persist();
|
||||
|
||||
element.getLocalMatrix().preTranslate(controlPoint.getX(), controlPoint.getY());
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
EditorElement getRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
EditorElement getImageRoot() {
|
||||
return imageRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main image, null if not yet set.
|
||||
*/
|
||||
@Nullable EditorElement getMainImage() {
|
||||
return imageRoot.getChildCount() > 0 ? imageRoot.getChild(0) : null;
|
||||
}
|
||||
|
||||
EditorElement getCropEditorElement() {
|
||||
return cropEditorElement;
|
||||
}
|
||||
|
||||
EditorElement getImageCrop() {
|
||||
return imageCrop;
|
||||
}
|
||||
|
||||
EditorElement getOverlay() {
|
||||
return overlay;
|
||||
}
|
||||
|
||||
EditorElement getFlipRotate() {
|
||||
return flipRotate;
|
||||
}
|
||||
|
||||
void startCrop(@NonNull Runnable invalidate) {
|
||||
Matrix editor = new Matrix();
|
||||
float scaleInForCrop = 0.8f;
|
||||
|
||||
editor.postScale(scaleInForCrop, scaleInForCrop);
|
||||
root.animateEditorTo(editor, invalidate);
|
||||
|
||||
cropEditorElement.getFlags()
|
||||
.setVisible(true);
|
||||
|
||||
blackout.getFlags()
|
||||
.setVisible(false);
|
||||
|
||||
thumbs.getFlags()
|
||||
.setChildrenVisible(true);
|
||||
|
||||
thumbs.forAllInTree(element -> element.getFlags().setSelectable(true));
|
||||
|
||||
imageRoot.forAllInTree(element -> element.getFlags().setSelectable(false));
|
||||
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
mainImage.getFlags().setSelectable(true);
|
||||
}
|
||||
|
||||
invalidate.run();
|
||||
}
|
||||
|
||||
void doneCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
|
||||
root.rollbackEditorMatrix(invalidate);
|
||||
|
||||
root.forAllInTree(element -> element.getFlags().reset());
|
||||
}
|
||||
|
||||
void updateViewToCrop(@NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
RectF dst = new RectF();
|
||||
|
||||
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
|
||||
Matrix temp = new Matrix();
|
||||
temp.setRectToRect(dst, visibleViewPort, Matrix.ScaleToFit.CENTER);
|
||||
view.animateLocalTo(temp, invalidate);
|
||||
}
|
||||
|
||||
private @NonNull Matrix getCropFinalMatrix() {
|
||||
Matrix matrix = new Matrix(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(imageCrop.getLocalMatrix());
|
||||
matrix.preConcat(cropEditorElement.getLocalMatrix());
|
||||
return matrix;
|
||||
}
|
||||
|
||||
void dragDropRelease(@NonNull RectF visibleViewPort, @NonNull Runnable invalidate) {
|
||||
if (cropEditorElement.getFlags().isVisible()) {
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
RectF getCropRect() {
|
||||
RectF dst = new RectF();
|
||||
getCropFinalMatrix().mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
return dst;
|
||||
}
|
||||
|
||||
void flipRotate(int degrees, int scaleX, int scaleY, @NonNull RectF visibleViewPort, @Nullable Runnable invalidate) {
|
||||
Matrix newLocal = new Matrix(flipRotate.getLocalMatrix());
|
||||
if (degrees != 0) {
|
||||
newLocal.postRotate(degrees);
|
||||
}
|
||||
newLocal.postScale(scaleX, scaleY);
|
||||
flipRotate.animateLocalTo(newLocal, invalidate);
|
||||
updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
/**
|
||||
* The full matrix for the {@link #getMainImage()} from {@link #root} down.
|
||||
*/
|
||||
Matrix getMainImageFullMatrix() {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(view.getLocalMatrix());
|
||||
matrix.preConcat(getMainImageFullMatrixFromFlipRotate());
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full matrix for the {@link #getMainImage()} from {@link #flipRotate} down.
|
||||
*/
|
||||
Matrix getMainImageFullMatrixFromFlipRotate() {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(imageRoot.getLocalMatrix());
|
||||
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
matrix.preConcat(mainImage.getLocalMatrix());
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the exact output size based upon the crops/rotates and zooms in the hierarchy.
|
||||
*
|
||||
* @param inputSize Main image size
|
||||
* @return Size after applying all zooms/rotates and crops
|
||||
*/
|
||||
PointF getOutputSize(@NonNull Point inputSize) {
|
||||
Matrix matrix = new Matrix();
|
||||
|
||||
matrix.preConcat(flipRotate.getLocalMatrix());
|
||||
matrix.preConcat(cropEditorElement.getLocalMatrix());
|
||||
EditorElement mainImage = getMainImage();
|
||||
if (mainImage != null) {
|
||||
float xScale = 1f / xScale(mainImage.getLocalMatrix());
|
||||
matrix.preScale(xScale, xScale);
|
||||
}
|
||||
|
||||
float[] dst = new float[4];
|
||||
matrix.mapPoints(dst, new float[]{ 0, 0, inputSize.x, inputSize.y });
|
||||
|
||||
float widthF = Math.abs(dst[0] - dst[2]);
|
||||
float heightF = Math.abs(dst[1] - dst[3]);
|
||||
|
||||
return new PointF(widthF, heightF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the x scale from a matrix, which is the length of the first column.
|
||||
*/
|
||||
static float xScale(@NonNull Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
matrix.getValues(values);
|
||||
return (float) Math.sqrt(values[0] * values[0] + values[3] * values[3]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
/**
|
||||
* Flags for an {@link EditorElement}.
|
||||
* <p>
|
||||
* Values you set are not persisted unless you call {@link #persist()}.
|
||||
* <p>
|
||||
* This allows temporary state for editing and an easy way to revert to the persisted state via {@link #reset()}.
|
||||
*/
|
||||
public final class EditorFlags {
|
||||
|
||||
private static final int ASPECT_LOCK = 1;
|
||||
private static final int ROTATE_LOCK = 2;
|
||||
private static final int SELECTABLE = 4;
|
||||
private static final int VISIBLE = 8;
|
||||
private static final int CHILDREN_VISIBLE = 16;
|
||||
private static final int EDITABLE = 32;
|
||||
|
||||
private int flags;
|
||||
private int persistedFlags;
|
||||
|
||||
EditorFlags() {
|
||||
this(ASPECT_LOCK | SELECTABLE | VISIBLE | CHILDREN_VISIBLE | EDITABLE);
|
||||
}
|
||||
|
||||
EditorFlags(int flags) {
|
||||
this.flags = flags;
|
||||
this.persistedFlags = flags;
|
||||
}
|
||||
|
||||
public EditorFlags setRotateLocked(boolean rotateLocked) {
|
||||
setFlag(ROTATE_LOCK, rotateLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isRotateLocked() {
|
||||
return isFlagSet(ROTATE_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setAspectLocked(boolean aspectLocked) {
|
||||
setFlag(ASPECT_LOCK, aspectLocked);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isAspectLocked() {
|
||||
return isFlagSet(ASPECT_LOCK);
|
||||
}
|
||||
|
||||
public EditorFlags setSelectable(boolean selectable) {
|
||||
setFlag(SELECTABLE, selectable);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isSelectable() {
|
||||
return isFlagSet(SELECTABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setEditable(boolean canEdit) {
|
||||
setFlag(EDITABLE, canEdit);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isEditable() {
|
||||
return isFlagSet(EDITABLE);
|
||||
}
|
||||
|
||||
public EditorFlags setVisible(boolean visible) {
|
||||
setFlag(VISIBLE, visible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return isFlagSet(VISIBLE);
|
||||
}
|
||||
|
||||
public EditorFlags setChildrenVisible(boolean childrenVisible) {
|
||||
setFlag(CHILDREN_VISIBLE, childrenVisible);
|
||||
return this;
|
||||
}
|
||||
|
||||
public boolean isChildrenVisible() {
|
||||
return isFlagSet(CHILDREN_VISIBLE);
|
||||
}
|
||||
|
||||
private void setFlag(int flag, boolean set) {
|
||||
if (set) {
|
||||
this.flags |= flag;
|
||||
} else {
|
||||
this.flags &= ~flag;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFlagSet(int flag) {
|
||||
return (flags & flag) != 0;
|
||||
}
|
||||
|
||||
int asInt() {
|
||||
return persistedFlags;
|
||||
}
|
||||
|
||||
int getCurrentState() {
|
||||
return flags;
|
||||
}
|
||||
|
||||
public void persist() {
|
||||
persistedFlags = flags;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
restoreState(persistedFlags);
|
||||
}
|
||||
|
||||
void restoreState(int flags) {
|
||||
this.flags = flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Contains a reference to the root {@link EditorElement}, maintains undo and redo stacks and has a
|
||||
* reference to the {@link EditorElementHierarchy}.
|
||||
* <p>
|
||||
* As such it is the entry point for all operations that change the image.
|
||||
*/
|
||||
public final class EditorModel implements Parcelable, RendererContext.Ready {
|
||||
|
||||
private static final Runnable NULL_RUNNABLE = () -> {
|
||||
};
|
||||
|
||||
private static final int MINIMUM_OUTPUT_WIDTH = 0;
|
||||
|
||||
@NonNull
|
||||
private Runnable invalidate = NULL_RUNNABLE;
|
||||
|
||||
private final ElementStack undoStack;
|
||||
private final ElementStack redoStack;
|
||||
|
||||
private EditorElementHierarchy editorElementHierarchy;
|
||||
|
||||
private final RectF visibleViewPort = new RectF();
|
||||
private final Point size;
|
||||
|
||||
public EditorModel() {
|
||||
this.size = new Point(1024, 1024);
|
||||
this.editorElementHierarchy = EditorElementHierarchy.create();
|
||||
this.undoStack = new ElementStack(50);
|
||||
this.redoStack = new ElementStack(50);
|
||||
}
|
||||
|
||||
private EditorModel(Parcel in) {
|
||||
ClassLoader classLoader = getClass().getClassLoader();
|
||||
this.size = new Point(in.readInt(), in.readInt());
|
||||
this.editorElementHierarchy = EditorElementHierarchy.create(in.readParcelable(classLoader));
|
||||
this.undoStack = in.readParcelable(classLoader);
|
||||
this.redoStack = in.readParcelable(classLoader);
|
||||
}
|
||||
|
||||
public void setInvalidate(@Nullable Runnable invalidate) {
|
||||
this.invalidate = invalidate != null ? invalidate : NULL_RUNNABLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders tree with the following matrix:
|
||||
* <p>
|
||||
* viewModelMatrix * matrix * editorMatrix
|
||||
* <p>
|
||||
* Child nodes are supplied with a viewModelMatrix' = viewModelMatrix * matrix * editorMatrix
|
||||
*
|
||||
* @param rendererContext Canvas to draw on to.
|
||||
*/
|
||||
public void draw(@NonNull RendererContext rendererContext) {
|
||||
editorElementHierarchy.getRoot().draw(rendererContext);
|
||||
}
|
||||
|
||||
public @Nullable Matrix findElementInverseMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
|
||||
Matrix inverse = new Matrix();
|
||||
if (findElement(element, viewMatrix, inverse)) {
|
||||
return inverse;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private @Nullable Matrix findElementMatrix(@NonNull EditorElement element, @NonNull Matrix viewMatrix) {
|
||||
Matrix inverse = findElementInverseMatrix(element, viewMatrix);
|
||||
if (inverse != null) {
|
||||
Matrix regular = new Matrix();
|
||||
inverse.invert(regular);
|
||||
return regular;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public EditorElement findElementAtPoint(@NonNull PointF point, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return editorElementHierarchy.getRoot().findElementAt(point.x, point.y, viewMatrix, outInverseModelMatrix);
|
||||
}
|
||||
|
||||
private boolean findElement(@NonNull EditorElement element, @NonNull Matrix viewMatrix, @NonNull Matrix outInverseModelMatrix) {
|
||||
return editorElementHierarchy.getRoot().findElement(element, viewMatrix, outInverseModelMatrix) == element;
|
||||
}
|
||||
|
||||
public void pushUndoPoint() {
|
||||
if (undoStack.tryPush(editorElementHierarchy.getRoot())) {
|
||||
redoStack.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public void undo() {
|
||||
undoRedo(undoStack, redoStack);
|
||||
}
|
||||
|
||||
public void redo() {
|
||||
undoRedo(redoStack, undoStack);
|
||||
}
|
||||
|
||||
private void undoRedo(@NonNull ElementStack fromStack, @NonNull ElementStack toStack) {
|
||||
final EditorElement popped = fromStack.pop();
|
||||
|
||||
if (popped != null) {
|
||||
EditorElement oldRootElement = editorElementHierarchy.getRoot();
|
||||
editorElementHierarchy = EditorElementHierarchy.create(popped);
|
||||
toStack.tryPush(oldRootElement);
|
||||
|
||||
restoreStateWithAnimations(oldRootElement, editorElementHierarchy.getRoot(), invalidate);
|
||||
invalidate.run();
|
||||
|
||||
// re-zoom image root as the view port might be different now
|
||||
editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static void restoreStateWithAnimations(@NonNull EditorElement fromRootElement, @NonNull EditorElement toRootElement, @NonNull Runnable onInvalidate) {
|
||||
Map<UUID, EditorElement> fromMap = getElementMap(fromRootElement);
|
||||
Map<UUID, EditorElement> toMap = getElementMap(toRootElement);
|
||||
|
||||
for (EditorElement fromElement : fromMap.values()) {
|
||||
fromElement.stopAnimation();
|
||||
EditorElement toElement = toMap.get(fromElement.getId());
|
||||
if (toElement != null) {
|
||||
toElement.animateFrom(fromElement.getLocalMatrixAnimating(), onInvalidate);
|
||||
} else {
|
||||
// element is removed
|
||||
EditorElement parentFrom = fromRootElement.parentOf(fromElement);
|
||||
if (parentFrom != null) {
|
||||
EditorElement toParent = toMap.get(parentFrom.getId());
|
||||
if (toParent != null) {
|
||||
toParent.addDeletedChildFadingOut(fromElement, onInvalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (EditorElement toElement : toMap.values()) {
|
||||
if (!fromMap.containsKey(toElement.getId())) {
|
||||
// new item
|
||||
toElement.animateFadeIn(onInvalidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<UUID, EditorElement> getElementMap(@NonNull EditorElement element) {
|
||||
final Map<UUID, EditorElement> result = new HashMap<>();
|
||||
element.buildMap(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void startCrop() {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.startCrop(invalidate);
|
||||
}
|
||||
|
||||
public void doneCrop() {
|
||||
editorElementHierarchy.doneCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public void setCropAspectLock(boolean locked) {
|
||||
EditorFlags flags = editorElementHierarchy.getCropEditorElement().getFlags();
|
||||
int currentState = flags.setAspectLocked(locked).getCurrentState();
|
||||
flags.reset();
|
||||
flags.setAspectLocked(locked).persist();
|
||||
flags.restoreState(currentState);
|
||||
}
|
||||
|
||||
public boolean isCropAspectLocked() {
|
||||
return editorElementHierarchy.getCropEditorElement().getFlags().isAspectLocked();
|
||||
}
|
||||
|
||||
public void dragDropRelease() {
|
||||
editorElementHierarchy.dragDropRelease(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public void setVisibleViewPort(@NonNull RectF visibleViewPort) {
|
||||
this.visibleViewPort.set(visibleViewPort);
|
||||
this.editorElementHierarchy.updateViewToCrop(visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public Set<Integer> getUniqueColorsIgnoringAlpha() {
|
||||
final Set<Integer> colors = new LinkedHashSet<>();
|
||||
|
||||
editorElementHierarchy.getRoot().forAllInTree(element -> {
|
||||
Renderer renderer = element.getRenderer();
|
||||
if (renderer instanceof ColorableRenderer) {
|
||||
colors.add(((ColorableRenderer) renderer).getColor() | 0xff000000);
|
||||
}
|
||||
});
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
public static final Creator<EditorModel> CREATOR = new Creator<EditorModel>() {
|
||||
@Override
|
||||
public EditorModel createFromParcel(Parcel in) {
|
||||
return new EditorModel(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EditorModel[] newArray(int size) {
|
||||
return new EditorModel[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(size.x);
|
||||
dest.writeInt(size.y);
|
||||
dest.writeParcelable(editorElementHierarchy.getRoot(), flags);
|
||||
dest.writeParcelable(undoStack, flags);
|
||||
dest.writeParcelable(redoStack, flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking render of the model.
|
||||
*/
|
||||
@WorkerThread
|
||||
public Bitmap render(@NonNull Context context) {
|
||||
EditorElement image = editorElementHierarchy.getFlipRotate();
|
||||
RectF cropRect = editorElementHierarchy.getCropRect();
|
||||
Point outputSize = getOutputSize();
|
||||
|
||||
Bitmap bitmap = Bitmap.createBitmap(outputSize.x, outputSize.y, Bitmap.Config.ARGB_8888);
|
||||
try {
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
RendererContext rendererContext = new RendererContext(context, canvas, RendererContext.Ready.NULL, RendererContext.Invalidate.NULL);
|
||||
|
||||
RectF bitmapArea = new RectF();
|
||||
bitmapArea.right = bitmap.getWidth();
|
||||
bitmapArea.bottom = bitmap.getHeight();
|
||||
|
||||
Matrix viewMatrix = new Matrix();
|
||||
viewMatrix.setRectToRect(cropRect, bitmapArea, Matrix.ScaleToFit.FILL);
|
||||
|
||||
rendererContext.setIsEditing(false);
|
||||
rendererContext.setBlockingLoad(true);
|
||||
|
||||
EditorElement overlay = editorElementHierarchy.getOverlay();
|
||||
overlay.getFlags().setVisible(false).setChildrenVisible(false);
|
||||
|
||||
try {
|
||||
rendererContext.canvasMatrix.initial(viewMatrix);
|
||||
image.draw(rendererContext);
|
||||
} finally {
|
||||
overlay.getFlags().reset();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
bitmap.recycle();
|
||||
throw e;
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private Point getOutputSize() {
|
||||
PointF outputSize = editorElementHierarchy.getOutputSize(size);
|
||||
|
||||
int width = (int) Math.max(MINIMUM_OUTPUT_WIDTH, outputSize.x);
|
||||
int height = (int) (width * outputSize.y / outputSize.x);
|
||||
|
||||
return new Point(width, height);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReady(@NonNull Renderer renderer, @Nullable Matrix cropMatrix, @Nullable Point size) {
|
||||
if (cropMatrix != null && size != null && isRendererOfMainImage(renderer)) {
|
||||
Matrix imageCropMatrix = editorElementHierarchy.getImageCrop().getLocalMatrix();
|
||||
this.size.set(size.x, size.y);
|
||||
if (imageCropMatrix.isIdentity()) {
|
||||
imageCropMatrix.set(cropMatrix);
|
||||
editorElementHierarchy.doneCrop(visibleViewPort, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRendererOfMainImage(@NonNull Renderer renderer) {
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
Renderer mainImageRenderer = mainImage != null ? mainImage.getRenderer() : null;
|
||||
return mainImageRenderer == renderer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new {@link EditorElement} centered in the current visible crop area.
|
||||
*
|
||||
* @param element New element to add.
|
||||
* @param scale Initial scale for new element.
|
||||
*/
|
||||
public void addElementCentered(@NonNull EditorElement element, float scale) {
|
||||
Matrix localMatrix = element.getLocalMatrix();
|
||||
|
||||
editorElementHierarchy.getMainImageFullMatrix().invert(localMatrix);
|
||||
|
||||
localMatrix.preScale(scale, scale);
|
||||
addElement(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an element to the main image, or if there is no main image, make the new element the main image.
|
||||
*
|
||||
* @param element New element to add.
|
||||
*/
|
||||
public void addElement(@NonNull EditorElement element) {
|
||||
pushUndoPoint();
|
||||
|
||||
EditorElement mainImage = editorElementHierarchy.getMainImage();
|
||||
EditorElement parent = mainImage != null ? mainImage : editorElementHierarchy.getImageRoot();
|
||||
|
||||
parent.addElement(element);
|
||||
|
||||
if (parent != mainImage) {
|
||||
undoStack.clear();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isChanged() {
|
||||
return !undoStack.isEmpty() || undoStack.isOverflowed();
|
||||
}
|
||||
|
||||
public RectF findCropRelativeToRoot() {
|
||||
return findCropRelativeTo(editorElementHierarchy.getRoot());
|
||||
}
|
||||
|
||||
private RectF findCropRelativeTo(EditorElement element) {
|
||||
return findRelativeBounds(editorElementHierarchy.getCropEditorElement(), element);
|
||||
}
|
||||
|
||||
private RectF findRelativeBounds(EditorElement from, EditorElement to) {
|
||||
Matrix matrix1 = findElementMatrix(from, new Matrix());
|
||||
Matrix matrix2 = findElementInverseMatrix(to, new Matrix());
|
||||
|
||||
RectF dst = new RectF(Bounds.FULL_BOUNDS);
|
||||
if (matrix1 != null) {
|
||||
matrix1.preConcat(matrix2);
|
||||
|
||||
matrix1.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
public void rotate90clockwise() {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(90, 1, 1, visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public void rotate90anticlockwise() {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(-90, 1, 1, visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public void flipHorizontal() {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(0, -1, 1, visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public void flipVerticle() {
|
||||
pushUndoPoint();
|
||||
editorElementHierarchy.flipRotate(0, 1, -1, visibleViewPort, invalidate);
|
||||
}
|
||||
|
||||
public EditorElement getRoot() {
|
||||
return editorElementHierarchy.getRoot();
|
||||
}
|
||||
|
||||
public void delete(@NonNull EditorElement editorElement) {
|
||||
editorElementHierarchy.getImageRoot().forAllInTree(element -> element.deleteChild(editorElement, invalidate));
|
||||
}
|
||||
|
||||
public @Nullable EditorElement findById(@NonNull UUID uuid) {
|
||||
return getElementMap(getRoot()).get(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the temporary view so that the element is centered in it.
|
||||
*
|
||||
* @param entity Entity to center on.
|
||||
* @param y An optional extra value to translate the view by to leave space for the keyboard for example.
|
||||
* @param doNotZoomOut Iff true, undoes any zoom out
|
||||
*/
|
||||
public void zoomTo(@NonNull EditorElement entity, float y, boolean doNotZoomOut) {
|
||||
Matrix elementInverseMatrix = findElementInverseMatrix(entity, new Matrix());
|
||||
if (elementInverseMatrix != null) {
|
||||
elementInverseMatrix.preConcat(editorElementHierarchy.getRoot().getEditorMatrix());
|
||||
|
||||
float xScale = EditorElementHierarchy.xScale(elementInverseMatrix);
|
||||
if (doNotZoomOut && xScale < 1) {
|
||||
elementInverseMatrix.postScale(1 / xScale, 1 / xScale);
|
||||
}
|
||||
|
||||
elementInverseMatrix.postTranslate(0, y);
|
||||
|
||||
editorElementHierarchy.getRoot().animateEditorTo(elementInverseMatrix, invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
public void zoomOut() {
|
||||
editorElementHierarchy.getRoot().rollbackEditorMatrix(invalidate);
|
||||
}
|
||||
|
||||
public void indicateSelected(@NonNull EditorElement selected) {
|
||||
selected.singleScalePulse(invalidate);
|
||||
}
|
||||
|
||||
public boolean isCropping() {
|
||||
return editorElementHierarchy.getCropEditorElement().getFlags().isVisible();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Stack;
|
||||
|
||||
/**
|
||||
* Contains a stack of elements for undo and redo stacks.
|
||||
* <p>
|
||||
* Elements are mutable, so this stack serializes the element and keeps a stack of serialized data.
|
||||
* <p>
|
||||
* The stack has a {@link #limit} and if it exceeds that limit the {@link #overflowed} flag is set.
|
||||
* So that when used as an undo stack, {@link #isEmpty()} and {@link #isOverflowed()} tell you if the image has ever changed.
|
||||
*/
|
||||
final class ElementStack implements Parcelable {
|
||||
|
||||
private final int limit;
|
||||
private final Stack<byte[]> stack = new Stack<>();
|
||||
private boolean overflowed;
|
||||
|
||||
ElementStack(int limit) {
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
private ElementStack(@NonNull Parcel in) {
|
||||
this(in.readInt());
|
||||
overflowed = in.readInt() != 0;
|
||||
final int count = in.readInt();
|
||||
for (int i = 0; i < count; i++) {
|
||||
stack.add(i, in.createByteArray());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes an element to the stack iff the element's serialized value is different to any found at
|
||||
* the top of the stack.
|
||||
*
|
||||
* @param element new editor element state.
|
||||
* @return true iff the pushed item was different to the top item.
|
||||
*/
|
||||
boolean tryPush(@NonNull EditorElement element) {
|
||||
Parcel parcel = Parcel.obtain();
|
||||
byte[] bytes;
|
||||
try {
|
||||
parcel.writeParcelable(element, 0);
|
||||
bytes = parcel.marshall();
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
boolean push = stack.isEmpty() || !Arrays.equals(bytes, stack.peek());
|
||||
if (push) {
|
||||
stack.push(bytes);
|
||||
if (stack.size() > limit) {
|
||||
stack.remove(0);
|
||||
overflowed = true;
|
||||
}
|
||||
}
|
||||
return push;
|
||||
}
|
||||
|
||||
@Nullable EditorElement pop() {
|
||||
if (stack.empty()) return null;
|
||||
|
||||
byte[] data = stack.pop();
|
||||
Parcel parcel = Parcel.obtain();
|
||||
try {
|
||||
parcel.unmarshall(data, 0, data.length);
|
||||
parcel.setDataPosition(0);
|
||||
return parcel.readParcelable(EditorElement.class.getClassLoader());
|
||||
} finally {
|
||||
parcel.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
stack.clear();
|
||||
}
|
||||
|
||||
public static final Creator<ElementStack> CREATOR = new Creator<ElementStack>() {
|
||||
@Override
|
||||
public ElementStack createFromParcel(Parcel in) {
|
||||
return new ElementStack(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ElementStack[] newArray(int size) {
|
||||
return new ElementStack[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(limit);
|
||||
dest.writeInt(overflowed ? 1 : 0);
|
||||
final int count = stack.size();
|
||||
dest.writeInt(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
dest.writeByteArray(stack.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return stack.isEmpty();
|
||||
}
|
||||
|
||||
boolean isOverflowed() {
|
||||
return overflowed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import android.graphics.Matrix;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
final class ParcelUtils {
|
||||
|
||||
private ParcelUtils() {
|
||||
}
|
||||
|
||||
static void writeMatrix(@NonNull Parcel dest, @NonNull Matrix matrix) {
|
||||
float[] values = new float[9];
|
||||
matrix.getValues(values);
|
||||
dest.writeFloatArray(values);
|
||||
}
|
||||
|
||||
static void readMatrix(@NonNull Matrix matrix, @NonNull Parcel in) {
|
||||
float[] values = new float[9];
|
||||
in.readFloatArray(values);
|
||||
matrix.setValues(values);
|
||||
}
|
||||
|
||||
static UUID readUUID(@NonNull Parcel in) {
|
||||
return new UUID(in.readLong(), in.readLong());
|
||||
}
|
||||
|
||||
static void writeUUID(@NonNull Parcel dest, @NonNull UUID uuid) {
|
||||
dest.writeLong(uuid.getMostSignificantBits());
|
||||
dest.writeLong(uuid.getLeastSignificantBits());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.model;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A special {@link Renderer} that controls another {@link EditorElement}.
|
||||
* <p>
|
||||
* It has a reference to the {@link EditorElement#getId()} and a {@link ControlPoint} which it is in control of.
|
||||
* <p>
|
||||
* The presence of this interface on the selected element is used to launch a ThumbDragEditSession.
|
||||
*/
|
||||
public interface ThumbRenderer extends Renderer {
|
||||
|
||||
enum ControlPoint {
|
||||
|
||||
CENTER_LEFT (Bounds.LEFT, Bounds.CENTRE_Y),
|
||||
CENTER_RIGHT (Bounds.RIGHT, Bounds.CENTRE_Y),
|
||||
|
||||
TOP_CENTER (Bounds.CENTRE_X, Bounds.TOP),
|
||||
BOTTOM_CENTER (Bounds.CENTRE_X, Bounds.BOTTOM),
|
||||
|
||||
TOP_LEFT (Bounds.LEFT, Bounds.TOP),
|
||||
TOP_RIGHT (Bounds.RIGHT, Bounds.TOP),
|
||||
BOTTOM_LEFT (Bounds.LEFT, Bounds.BOTTOM),
|
||||
BOTTOM_RIGHT (Bounds.RIGHT, Bounds.BOTTOM);
|
||||
|
||||
private final float x;
|
||||
private final float y;
|
||||
|
||||
ControlPoint(float x, float y) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
public float getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public float getY() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public ControlPoint opposite() {
|
||||
switch (this) {
|
||||
case CENTER_LEFT: return CENTER_RIGHT;
|
||||
case CENTER_RIGHT: return CENTER_LEFT;
|
||||
case TOP_CENTER: return BOTTOM_CENTER;
|
||||
case BOTTOM_CENTER: return TOP_CENTER;
|
||||
case TOP_LEFT: return BOTTOM_RIGHT;
|
||||
case TOP_RIGHT: return BOTTOM_LEFT;
|
||||
case BOTTOM_LEFT: return TOP_RIGHT;
|
||||
case BOTTOM_RIGHT: return TOP_LEFT;
|
||||
default:
|
||||
throw new RuntimeException();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isHorizontalCenter() {
|
||||
return this == ControlPoint.CENTER_LEFT || this == ControlPoint.CENTER_RIGHT;
|
||||
}
|
||||
|
||||
public boolean isVerticalCenter() {
|
||||
return this == ControlPoint.TOP_CENTER || this == ControlPoint.BOTTOM_CENTER;
|
||||
}
|
||||
}
|
||||
|
||||
ControlPoint getControlPoint();
|
||||
|
||||
UUID getElementToControl();
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Given points for a line to go though, automatically finds control points.
|
||||
* <p>
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
* <p>
|
||||
* Can then draw that line to a {@link Canvas} given a {@link Paint}.
|
||||
* <p>
|
||||
* Allocation efficient so that adding new points does not result in lots of array allocations.
|
||||
*/
|
||||
final class AutomaticControlPointBezierLine implements Parcelable {
|
||||
|
||||
private static final int INITIAL_CAPACITY = 256;
|
||||
|
||||
private float[] x;
|
||||
private float[] y;
|
||||
|
||||
// control points
|
||||
private float[] p1x;
|
||||
private float[] p1y;
|
||||
private float[] p2x;
|
||||
private float[] p2y;
|
||||
|
||||
private int count;
|
||||
|
||||
private final Path path = new Path();
|
||||
|
||||
private AutomaticControlPointBezierLine(@Nullable float[] x, @Nullable float[] y, int count) {
|
||||
this.count = count;
|
||||
this.x = x != null ? x : new float[INITIAL_CAPACITY];
|
||||
this.y = y != null ? y : new float[INITIAL_CAPACITY];
|
||||
allocControlPointsAndWorkingMemory(this.x.length);
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
AutomaticControlPointBezierLine() {
|
||||
this(null, null, 0);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
count = 0;
|
||||
path.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line but ignores points that are too close to the last.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
* @param thickness the maximum distance to allow, line thickness is recommended.
|
||||
*/
|
||||
void addPointFiltered(float x, float y, float thickness) {
|
||||
if (count > 0) {
|
||||
float dx = this.x[count - 1] - x;
|
||||
float dy = this.y[count - 1] - y;
|
||||
if (dx * dx + dy * dy < thickness * thickness) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
addPoint(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new point to the end of the line.
|
||||
*
|
||||
* @param x new x point
|
||||
* @param y new y point
|
||||
*/
|
||||
void addPoint(float x, float y) {
|
||||
if (this.x == null || count == this.x.length) {
|
||||
resize(this.x != null ? this.x.length << 1 : INITIAL_CAPACITY);
|
||||
}
|
||||
|
||||
this.x[count] = x;
|
||||
this.y[count] = y;
|
||||
count++;
|
||||
|
||||
recalculateControlPoints();
|
||||
}
|
||||
|
||||
private void resize(int newCapacity) {
|
||||
x = Arrays.copyOf(x, newCapacity);
|
||||
y = Arrays.copyOf(y, newCapacity);
|
||||
allocControlPointsAndWorkingMemory(newCapacity - 1);
|
||||
}
|
||||
|
||||
private void allocControlPointsAndWorkingMemory(int max) {
|
||||
p1x = new float[max];
|
||||
p1y = new float[max];
|
||||
p2x = new float[max];
|
||||
p2y = new float[max];
|
||||
|
||||
a = new float[max];
|
||||
b = new float[max];
|
||||
c = new float[max];
|
||||
r = new float[max];
|
||||
}
|
||||
|
||||
private void recalculateControlPoints() {
|
||||
path.reset();
|
||||
|
||||
if (count > 2) {
|
||||
computeControlPoints(x, p1x, p2x, count);
|
||||
computeControlPoints(y, p1y, p2y, count);
|
||||
}
|
||||
|
||||
path.moveTo(x[0], y[0]);
|
||||
switch (count) {
|
||||
case 1:
|
||||
path.lineTo(x[0], y[0]);
|
||||
break;
|
||||
case 2:
|
||||
path.lineTo(x[1], y[1]);
|
||||
break;
|
||||
default:
|
||||
for (int i = 1; i < count - 1; i++) {
|
||||
path.cubicTo(p1x[i], p1y[i], p2x[i], p2y[i], x[i + 1], y[i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the line.
|
||||
*
|
||||
* @param canvas The canvas to draw on.
|
||||
* @param paint The paint to use.
|
||||
*/
|
||||
void draw(@NonNull Canvas canvas, @NonNull Paint paint) {
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
// rhs vector for computeControlPoints method
|
||||
private float[] a;
|
||||
private float[] b;
|
||||
private float[] c;
|
||||
private float[] r;
|
||||
|
||||
/**
|
||||
* Based on http://www.particleincell.com/2012/bezier-splines/
|
||||
*
|
||||
* @param k knots x or y, must be at least 2 entries
|
||||
* @param p1 corresponding first control point x or y
|
||||
* @param p2 corresponding second control point x or y
|
||||
* @param count number of k to process
|
||||
*/
|
||||
private void computeControlPoints(float[] k, float[] p1, float[] p2, int count) {
|
||||
final int n = count - 1;
|
||||
|
||||
// left most segment
|
||||
a[0] = 0;
|
||||
b[0] = 2;
|
||||
c[0] = 1;
|
||||
r[0] = k[0] + 2 * k[1];
|
||||
|
||||
// internal segments
|
||||
for (int i = 1; i < n - 1; i++) {
|
||||
a[i] = 1;
|
||||
b[i] = 4;
|
||||
c[i] = 1;
|
||||
r[i] = 4 * k[i] + 2 * k[i + 1];
|
||||
}
|
||||
|
||||
// right segment
|
||||
a[n - 1] = 2;
|
||||
b[n - 1] = 7;
|
||||
c[n - 1] = 0;
|
||||
r[n - 1] = 8 * k[n - 1] + k[n];
|
||||
|
||||
// solves Ax=b with the Thomas algorithm
|
||||
for (int i = 1; i < n; i++) {
|
||||
float m = a[i] / b[i - 1];
|
||||
b[i] = b[i] - m * c[i - 1];
|
||||
r[i] = r[i] - m * r[i - 1];
|
||||
}
|
||||
|
||||
p1[n - 1] = r[n - 1] / b[n - 1];
|
||||
for (int i = n - 2; i >= 0; --i) {
|
||||
p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i];
|
||||
}
|
||||
|
||||
// we have p1, now compute p2
|
||||
for (int i = 0; i < n - 1; i++) {
|
||||
p2[i] = 2 * k[i + 1] - p1[i + 1];
|
||||
}
|
||||
|
||||
p2[n - 1] = 0.5f * (k[n] + p1[n - 1]);
|
||||
}
|
||||
|
||||
public static final Creator<AutomaticControlPointBezierLine> CREATOR = new Creator<AutomaticControlPointBezierLine>() {
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine createFromParcel(Parcel in) {
|
||||
float[] x = in.createFloatArray();
|
||||
float[] y = in.createFloatArray();
|
||||
return new AutomaticControlPointBezierLine(x, y, x != null ? x.length : 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutomaticControlPointBezierLine[] newArray(int size) {
|
||||
return new AutomaticControlPointBezierLine[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeFloatArray(Arrays.copyOfRange(x, 0, count));
|
||||
dest.writeFloatArray(Arrays.copyOfRange(y, 0, count));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a {@link AutomaticControlPointBezierLine} with {@link #thickness}, {@link #color} and {@link #cap} end type.
|
||||
*/
|
||||
public final class BezierDrawingRenderer extends InvalidateableRenderer implements ColorableRenderer {
|
||||
|
||||
private final Paint paint;
|
||||
private final AutomaticControlPointBezierLine bezierLine;
|
||||
private final Paint.Cap cap;
|
||||
|
||||
@Nullable
|
||||
private final RectF clipRect;
|
||||
|
||||
private int color;
|
||||
private float thickness;
|
||||
|
||||
private BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable AutomaticControlPointBezierLine bezierLine, @Nullable RectF clipRect) {
|
||||
this.paint = new Paint();
|
||||
this.color = color;
|
||||
this.thickness = thickness;
|
||||
this.cap = cap;
|
||||
this.clipRect = clipRect;
|
||||
this.bezierLine = bezierLine != null ? bezierLine : new AutomaticControlPointBezierLine();
|
||||
|
||||
updatePaint();
|
||||
}
|
||||
|
||||
public BezierDrawingRenderer(int color, float thickness, @NonNull Paint.Cap cap, @Nullable RectF clipRect) {
|
||||
this(color, thickness, cap,null, clipRect != null ? new RectF(clipRect) : null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColor(int color) {
|
||||
if (this.color != color) {
|
||||
this.color = color;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void setThickness(float thickness) {
|
||||
if (this.thickness != thickness) {
|
||||
this.thickness = thickness;
|
||||
updatePaint();
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void updatePaint() {
|
||||
paint.setColor(color);
|
||||
paint.setStrokeWidth(thickness);
|
||||
paint.setStyle(Paint.Style.STROKE);
|
||||
paint.setAntiAlias(true);
|
||||
paint.setStrokeCap(cap);
|
||||
}
|
||||
|
||||
public void setFirstPoint(PointF point) {
|
||||
bezierLine.reset();
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void addNewPoint(PointF point) {
|
||||
if (cap != Paint.Cap.ROUND) {
|
||||
bezierLine.addPointFiltered(point.x, point.y, thickness * 0.5f);
|
||||
} else {
|
||||
bezierLine.addPoint(point.x, point.y);
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
super.render(rendererContext);
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
canvas.save();
|
||||
if (clipRect != null) {
|
||||
canvas.clipRect(clipRect);
|
||||
}
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
bezierLine.draw(canvas, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final Creator<BezierDrawingRenderer> CREATOR = new Creator<BezierDrawingRenderer>() {
|
||||
@Override
|
||||
public BezierDrawingRenderer createFromParcel(Parcel in) {
|
||||
int color = in.readInt();
|
||||
float thickness = in.readFloat();
|
||||
Paint.Cap cap = Paint.Cap.values()[in.readInt()];
|
||||
AutomaticControlPointBezierLine bezierLine = in.readParcelable(AutomaticControlPointBezierLine.class.getClassLoader());
|
||||
RectF clipRect = in.readParcelable(RectF.class.getClassLoader());
|
||||
|
||||
return new BezierDrawingRenderer(color, thickness, cap, bezierLine, clipRect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public BezierDrawingRenderer[] newArray(int size) {
|
||||
return new BezierDrawingRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
dest.writeFloat(thickness);
|
||||
dest.writeInt(cap.ordinal());
|
||||
dest.writeParcelable(bezierLine, flags);
|
||||
dest.writeParcelable(clipRect, flags);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.ColorRes;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.v4.content.res.ResourcesCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a box outside of the current crop area using {@link R.color#crop_area_renderer_outer_color}
|
||||
* and around the edge it renders the markers for the thumbs using {@link R.color#crop_area_renderer_edge_color},
|
||||
* {@link R.dimen#crop_area_renderer_edge_thickness} and {@link R.dimen#crop_area_renderer_edge_size}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class CropAreaRenderer implements Renderer {
|
||||
|
||||
@ColorRes
|
||||
private final int color;
|
||||
|
||||
private final Path cropClipPath = new Path();
|
||||
private final Path screenClipPath = new Path();
|
||||
|
||||
private final RectF dst = new RectF();
|
||||
private final Paint paint = new Paint();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.save();
|
||||
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
Resources resources = rendererContext.context.getResources();
|
||||
|
||||
canvas.clipPath(cropClipPath);
|
||||
canvas.drawColor(ResourcesCompat.getColor(resources, color, null));
|
||||
|
||||
rendererContext.mapRect(dst, Bounds.FULL_BOUNDS);
|
||||
|
||||
final int thickness = resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_thickness);
|
||||
final int size = (int) Math.min(resources.getDimensionPixelSize(R.dimen.crop_area_renderer_edge_size), Math.min(dst.width(), dst.height()) / 3f - 10);
|
||||
|
||||
paint.setColor(ResourcesCompat.getColor(resources, R.color.crop_area_renderer_edge_color, null));
|
||||
|
||||
rendererContext.canvasMatrix.setToIdentity();
|
||||
screenClipPath.reset();
|
||||
screenClipPath.moveTo(dst.left, dst.top);
|
||||
screenClipPath.lineTo(dst.right, dst.top);
|
||||
screenClipPath.lineTo(dst.right, dst.bottom);
|
||||
screenClipPath.lineTo(dst.left, dst.bottom);
|
||||
screenClipPath.close();
|
||||
canvas.clipPath(screenClipPath);
|
||||
canvas.translate(dst.left, dst.top);
|
||||
|
||||
float halfDx = (dst.right - dst.left - size + thickness) / 2;
|
||||
float halfDy = (dst.bottom - dst.top - size + thickness) / 2;
|
||||
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(halfDx, 0);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(halfDx, 0);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, -halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(0, -halfDy);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
canvas.translate(-halfDx, 0);
|
||||
canvas.drawRect(-thickness, -thickness, size, size, paint);
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
public CropAreaRenderer(@ColorRes int color) {
|
||||
this.color = color;
|
||||
cropClipPath.toggleInverseFillType();
|
||||
cropClipPath.moveTo(Bounds.LEFT, Bounds.TOP);
|
||||
cropClipPath.lineTo(Bounds.RIGHT, Bounds.TOP);
|
||||
cropClipPath.lineTo(Bounds.RIGHT, Bounds.BOTTOM);
|
||||
cropClipPath.lineTo(Bounds.LEFT, Bounds.BOTTOM);
|
||||
cropClipPath.close();
|
||||
screenClipPath.toggleInverseFillType();
|
||||
}
|
||||
|
||||
private CropAreaRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.FULL_BOUNDS.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<CropAreaRenderer> CREATOR = new Creator<CropAreaRenderer>() {
|
||||
@Override
|
||||
public CropAreaRenderer createFromParcel(Parcel in) {
|
||||
return new CropAreaRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CropAreaRenderer[] newArray(int size) {
|
||||
return new CropAreaRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
/**
|
||||
* Maintains a weak reference to the an invalidate callback allowing future invalidation without memory leak risk.
|
||||
*/
|
||||
abstract class InvalidateableRenderer implements Renderer {
|
||||
|
||||
private WeakReference<RendererContext.Invalidate> invalidate = new WeakReference<>(null);
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
setInvalidate(rendererContext.invalidate);
|
||||
}
|
||||
|
||||
private void setInvalidate(RendererContext.Invalidate invalidate) {
|
||||
if (invalidate != this.invalidate.get()) {
|
||||
this.invalidate = new WeakReference<>(invalidate);
|
||||
}
|
||||
}
|
||||
|
||||
protected void invalidate() {
|
||||
RendererContext.Invalidate invalidate = this.invalidate.get();
|
||||
if (invalidate != null) {
|
||||
invalidate.onInvalidate(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.graphics.Path;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.ColorInt;
|
||||
import android.support.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders the {@link color} outside of the {@link Bounds}.
|
||||
* <p>
|
||||
* Hit tests outside of the bounds.
|
||||
*/
|
||||
public final class InverseFillRenderer implements Renderer {
|
||||
|
||||
private final int color;
|
||||
|
||||
private final Path path = new Path();
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
rendererContext.canvas.save();
|
||||
rendererContext.canvas.clipPath(path);
|
||||
rendererContext.canvas.drawColor(color);
|
||||
rendererContext.canvas.restore();
|
||||
}
|
||||
|
||||
public InverseFillRenderer(@ColorInt int color) {
|
||||
this.color = color;
|
||||
path.toggleInverseFillType();
|
||||
path.moveTo(Bounds.LEFT, Bounds.TOP);
|
||||
path.lineTo(Bounds.RIGHT, Bounds.TOP);
|
||||
path.lineTo(Bounds.RIGHT, Bounds.BOTTOM);
|
||||
path.lineTo(Bounds.LEFT, Bounds.BOTTOM);
|
||||
path.close();
|
||||
}
|
||||
|
||||
private InverseFillRenderer(Parcel in) {
|
||||
this(in.readInt());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return !Bounds.FULL_BOUNDS.contains(x, y);
|
||||
}
|
||||
|
||||
public static final Creator<InverseFillRenderer> CREATOR = new Creator<InverseFillRenderer>() {
|
||||
@Override
|
||||
public InverseFillRenderer createFromParcel(Parcel in) {
|
||||
return new InverseFillRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InverseFillRenderer[] newArray(int size) {
|
||||
return new InverseFillRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeInt(color);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package org.thoughtcrime.securesms.imageeditor.renderers;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.ColorInt;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
|
||||
/**
|
||||
* Renders a single line of {@link #text} in ths specified {@link #color}.
|
||||
* <p>
|
||||
* Scales down the text size to fit inside the {@link Bounds} width.
|
||||
*/
|
||||
public final class TextRenderer extends InvalidateableRenderer implements ColorableRenderer {
|
||||
|
||||
@NonNull
|
||||
private String text = "";
|
||||
|
||||
@ColorInt
|
||||
private int color;
|
||||
|
||||
private final Paint paint = new Paint();
|
||||
private final Paint selectionPaint = new Paint();
|
||||
private final RectF textBounds = new RectF();
|
||||
private final RectF selectionBounds = new RectF();
|
||||
private final RectF maxTextBounds = new RectF();
|
||||
private final Matrix projectionMatrix = new Matrix();
|
||||
private final Matrix inverseProjectionMatrix = new Matrix();
|
||||
|
||||
private final float textScale;
|
||||
|
||||
private float xForCentre;
|
||||
private int selStart;
|
||||
private int selEnd;
|
||||
private boolean hasFocus;
|
||||
|
||||
public TextRenderer(@Nullable String text, @ColorInt int color) {
|
||||
setColor(color);
|
||||
float regularTextSize = paint.getTextSize();
|
||||
paint.setAntiAlias(true);
|
||||
paint.setTextSize(100);
|
||||
paint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD));
|
||||
textScale = paint.getTextSize() / regularTextSize;
|
||||
selectionPaint.setAntiAlias(true);
|
||||
setText(text != null ? text : "");
|
||||
}
|
||||
|
||||
private TextRenderer(Parcel in) {
|
||||
this(in.readString(), in.readInt());
|
||||
}
|
||||
|
||||
public static final Creator<TextRenderer> CREATOR = new Creator<TextRenderer>() {
|
||||
@Override
|
||||
public TextRenderer createFromParcel(Parcel in) {
|
||||
return new TextRenderer(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TextRenderer[] newArray(int size) {
|
||||
return new TextRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
super.render(rendererContext);
|
||||
rendererContext.save();
|
||||
Canvas canvas = rendererContext.canvas;
|
||||
|
||||
rendererContext.canvasMatrix.concat(projectionMatrix);
|
||||
|
||||
canvas.clipRect(textBounds);
|
||||
|
||||
if (hasFocus) {
|
||||
canvas.drawRect(selectionBounds, selectionPaint);
|
||||
}
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
canvas.drawText(text, xForCentre, 0, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
|
||||
rendererContext.restore();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public void setText(@NonNull String text) {
|
||||
if (!this.text.equals(text)) {
|
||||
this.text = text;
|
||||
recalculate();
|
||||
}
|
||||
}
|
||||
|
||||
private void recalculate() {
|
||||
Rect temp = new Rect();
|
||||
|
||||
getTextBoundsWithoutTrim(text, 0, text.length(), temp);
|
||||
textBounds.set(temp);
|
||||
|
||||
maxTextBounds.set(textBounds);
|
||||
maxTextBounds.right = Math.max(150 * textScale, maxTextBounds.right);
|
||||
|
||||
xForCentre = maxTextBounds.centerX() - textBounds.centerX();
|
||||
|
||||
textBounds.left += xForCentre;
|
||||
textBounds.right += xForCentre;
|
||||
|
||||
if (selStart != selEnd) {
|
||||
getTextBoundsWithoutTrim(text, Math.min(text.length(), selStart), Math.min(text.length(), selEnd), temp);
|
||||
} else {
|
||||
Rect startTemp = new Rect();
|
||||
int start = Math.min(text.length(), selStart);
|
||||
String text = this.text.substring(0, start);
|
||||
|
||||
getTextBoundsWithoutTrim(text, 0, start, startTemp);
|
||||
paint.getTextBounds("|", 0, 1, temp);
|
||||
|
||||
int width = temp.width();
|
||||
|
||||
temp.left -= width;
|
||||
temp.right -= width;
|
||||
temp.left += startTemp.right;
|
||||
temp.right += startTemp.right;
|
||||
}
|
||||
selectionBounds.set(temp);
|
||||
selectionBounds.left += xForCentre;
|
||||
selectionBounds.right += xForCentre;
|
||||
|
||||
projectionMatrix.setRectToRect(new RectF(maxTextBounds), Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
|
||||
projectionMatrix.invert(inverseProjectionMatrix);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void getTextBoundsWithoutTrim(String text, int start, int end, Rect result) {
|
||||
Rect extra = new Rect();
|
||||
Rect xBounds = new Rect();
|
||||
String cannotBeTrimmed = "x" + text.substring(start, end) + "x";
|
||||
paint.getTextBounds(cannotBeTrimmed, 0, cannotBeTrimmed.length(), extra);
|
||||
paint.getTextBounds("x", 0, 1, xBounds);
|
||||
result.set(extra);
|
||||
result.right -= 2 * xBounds.width();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setColor(@ColorInt int color) {
|
||||
if (this.color != color) {
|
||||
this.color = color;
|
||||
paint.setColor(color);
|
||||
selectionPaint.setColor(color & ~0xff000000 | 0x7f000000);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
float[] dst = new float[2];
|
||||
inverseProjectionMatrix.mapPoints(dst, new float[]{ x, y });
|
||||
return textBounds.contains(dst[0], dst[1]);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(text);
|
||||
dest.writeInt(color);
|
||||
}
|
||||
|
||||
public void setSelection(int selStart, int selEnd) {
|
||||
this.selStart = selStart;
|
||||
this.selEnd = selEnd;
|
||||
recalculate();
|
||||
}
|
||||
|
||||
public void setFocused(boolean hasFocus) {
|
||||
if (this.hasFocus != hasFocus) {
|
||||
this.hasFocus = hasFocus;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.scribbles.ScribbleFragment;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -49,7 +49,7 @@ import java.util.Locale;
|
||||
public class MediaSendActivity extends PassphraseRequiredActionBarActivity implements MediaPickerFolderFragment.Controller,
|
||||
MediaPickerItemFragment.Controller,
|
||||
MediaSendFragment.Controller,
|
||||
ScribbleFragment.Controller,
|
||||
ImageEditorFragment.Controller,
|
||||
Camera1Fragment.Controller
|
||||
{
|
||||
private static final String TAG = MediaSendActivity.class.getSimpleName();
|
||||
@@ -446,4 +446,12 @@ public class MediaSendActivity extends PassphraseRequiredActionBarActivity imple
|
||||
|
||||
button.startAnimation(grow);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestFullScreen(boolean fullScreen) {
|
||||
MediaSendFragment sendFragment = (MediaSendFragment) getSupportFragmentManager().findFragmentByTag(TAG_SEND);
|
||||
if (sendFragment != null && sendFragment.isVisible()) {
|
||||
sendFragment.onRequestFullScreen(fullScreen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import android.view.WindowManager;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
@@ -43,7 +44,7 @@ import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.ScribbleView;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
@@ -51,6 +52,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
@@ -79,6 +81,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
|
||||
private static final String KEY_LOCALE = "locale";
|
||||
|
||||
private InputAwareLayout hud;
|
||||
private View captionAndRail;
|
||||
private SendButton sendButton;
|
||||
private ComposeText composeText;
|
||||
private ViewGroup composeContainer;
|
||||
@@ -140,6 +143,7 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
hud = view.findViewById(R.id.mediasend_hud);
|
||||
captionAndRail = view.findViewById(R.id.mediasend_caption_and_rail);
|
||||
sendButton = view.findViewById(R.id.mediasend_send_button);
|
||||
composeText = view.findViewById(R.id.mediasend_compose_text);
|
||||
composeContainer = view.findViewById(R.id.mediasend_compose_container);
|
||||
@@ -313,7 +317,9 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
|
||||
}
|
||||
|
||||
public void onTouchEventsNeeded(boolean needed) {
|
||||
fragmentPager.setEnabled(!needed);
|
||||
if (fragmentPager != null) {
|
||||
fragmentPager.setEnabled(!needed);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean handleBackPress() {
|
||||
@@ -423,8 +429,11 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
|
||||
for (Media media : mediaList) {
|
||||
Object state = savedState.get(media.getUri());
|
||||
|
||||
if (state instanceof ScribbleView.SavedState && !((ScribbleView.SavedState) state).isEmpty()) {
|
||||
futures.put(media, ScribbleView.renderImage(requireContext(), media.getUri(), (ScribbleView.SavedState) state, GlideApp.with(this)));
|
||||
if (state instanceof ImageEditorFragment.Data) {
|
||||
EditorModel model = ((ImageEditorFragment.Data) state).readModel();
|
||||
if (model != null && model.isChanged()) {
|
||||
futures.put(media, render(requireContext(), model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,6 +502,18 @@ public class MediaSendFragment extends Fragment implements ViewTreeObserver.OnGl
|
||||
}.execute();
|
||||
}
|
||||
|
||||
private static ListenableFuture<Bitmap> render(@NonNull Context context, @NonNull EditorModel model) {
|
||||
SettableFuture<Bitmap> future = new SettableFuture<>();
|
||||
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> future.set(model.render(context)));
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public void onRequestFullScreen(boolean fullScreen) {
|
||||
captionAndRail.setVisibility(fullScreen ? View.GONE : View.VISIBLE);
|
||||
}
|
||||
|
||||
private class FragmentPageChangeListener extends ViewPager.SimpleOnPageChangeListener {
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
|
||||
@@ -9,15 +9,12 @@ import android.support.v4.app.FragmentStatePagerAdapter;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.scribbles.ScribbleFragment;
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter {
|
||||
@@ -40,7 +37,7 @@ class MediaSendFragmentPagerAdapter extends FragmentStatePagerAdapter {
|
||||
if (MediaUtil.isGif(mediaItem.getMimeType())) {
|
||||
return MediaSendGifFragment.newInstance(mediaItem.getUri());
|
||||
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
|
||||
return ScribbleFragment.newInstance(mediaItem.getUri());
|
||||
return ImageEditorFragment.newInstance(mediaItem.getUri());
|
||||
} else if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
|
||||
return MediaSendVideoFragment.newInstance(mediaItem.getUri());
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.graphics.Paint;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.imageeditor.ColorableRenderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.ImageEditorView;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorElement;
|
||||
import org.thoughtcrime.securesms.imageeditor.model.EditorModel;
|
||||
import org.thoughtcrime.securesms.imageeditor.renderers.TextRenderer;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.mms.PushMediaConstraints;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
public final class ImageEditorFragment extends Fragment implements ImageEditorHud.EventListener,
|
||||
VerticalSlideColorPicker.OnColorChangeListener,
|
||||
MediaSendPageFragment {
|
||||
|
||||
private static final String TAG = Log.tag(ImageEditorFragment.class);
|
||||
|
||||
private static final String KEY_IMAGE_URI = "image_uri";
|
||||
|
||||
public static final int SELECT_STICKER_REQUEST_CODE = 123;
|
||||
|
||||
private EditorModel restoredModel;
|
||||
|
||||
@Nullable
|
||||
private EditorElement currentSelection;
|
||||
private int imageMaxHeight;
|
||||
private int imageMaxWidth;
|
||||
|
||||
public static class Data {
|
||||
private final Bundle bundle;
|
||||
|
||||
Data(Bundle bundle) {
|
||||
this.bundle = bundle;
|
||||
}
|
||||
|
||||
public Data() {
|
||||
this(new Bundle());
|
||||
}
|
||||
|
||||
void writeModel(@NonNull EditorModel model) {
|
||||
byte[] bytes = ParcelUtil.serialize(model);
|
||||
bundle.putByteArray("MODEL", bytes);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public EditorModel readModel() {
|
||||
byte[] bytes = bundle.getByteArray("MODEL");
|
||||
if (bytes == null) {
|
||||
return null;
|
||||
}
|
||||
return ParcelUtil.deserialize(bytes, EditorModel.CREATOR);
|
||||
}
|
||||
}
|
||||
|
||||
private Uri imageUri;
|
||||
private Controller controller;
|
||||
private ImageEditorHud imageEditorHud;
|
||||
private ImageEditorView imageEditorView;
|
||||
|
||||
public static ImageEditorFragment newInstance(@NonNull Uri imageUri) {
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(KEY_IMAGE_URI, imageUri);
|
||||
|
||||
ImageEditorFragment fragment = new ImageEditorFragment();
|
||||
fragment.setArguments(args);
|
||||
fragment.setUri(imageUri);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement Controller interface.");
|
||||
}
|
||||
controller = (Controller) getActivity();
|
||||
Bundle arguments = getArguments();
|
||||
if (arguments != null) {
|
||||
imageUri = arguments.getParcelable(KEY_IMAGE_URI);
|
||||
}
|
||||
|
||||
if (imageUri == null) {
|
||||
throw new AssertionError("No KEY_IMAGE_URI supplied");
|
||||
}
|
||||
|
||||
MediaConstraints mediaConstraints = new PushMediaConstraints();
|
||||
|
||||
imageMaxWidth = mediaConstraints.getImageMaxWidth(requireContext());
|
||||
imageMaxHeight = mediaConstraints.getImageMaxHeight(requireContext());
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.image_editor_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
imageEditorHud = view.findViewById(R.id.scribble_hud);
|
||||
imageEditorView = view.findViewById(R.id.image_editor_view);
|
||||
|
||||
imageEditorHud.setEventListener(this);
|
||||
|
||||
imageEditorView.setTapListener(selectionListener);
|
||||
imageEditorView.setDrawingChangedListener(this::refreshUniqueColors);
|
||||
|
||||
EditorModel editorModel = null;
|
||||
|
||||
if (restoredModel != null) {
|
||||
editorModel = restoredModel;
|
||||
restoredModel = null;
|
||||
} else if (savedInstanceState != null) {
|
||||
editorModel = new Data(savedInstanceState).readModel();
|
||||
}
|
||||
|
||||
if (editorModel == null) {
|
||||
editorModel = new EditorModel();
|
||||
EditorElement image = new EditorElement(new UriGlideRenderer(imageUri, true, imageMaxWidth, imageMaxHeight));
|
||||
image.getFlags().setSelectable(false).persist();
|
||||
editorModel.addElement(image);
|
||||
}
|
||||
|
||||
imageEditorView.setModel(editorModel);
|
||||
|
||||
refreshUniqueColors();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSaveInstanceState(@NonNull Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
new Data(outState).writeModel(imageEditorView.getModel());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUri(@NonNull Uri uri) {
|
||||
this.imageUri = uri;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Uri getUri() {
|
||||
return imageUri;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View getPlaybackControls() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object saveState() {
|
||||
Data data = new Data();
|
||||
data.writeModel(imageEditorView.getModel());
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restoreState(@NonNull Object state) {
|
||||
if (state instanceof Data) {
|
||||
|
||||
Data data = (Data) state;
|
||||
EditorModel model = data.readModel();
|
||||
|
||||
if (model != null) {
|
||||
if (imageEditorView != null) {
|
||||
imageEditorView.setModel(model);
|
||||
refreshUniqueColors();
|
||||
} else {
|
||||
this.restoredModel = model;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
private void changeEntityColor(int selectedColor) {
|
||||
if (currentSelection != null) {
|
||||
Renderer renderer = currentSelection.getRenderer();
|
||||
if (renderer instanceof ColorableRenderer) {
|
||||
((ColorableRenderer) renderer).setColor(selectedColor);
|
||||
refreshUniqueColors();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startTextEntityEditing(@NonNull EditorElement textElement, boolean selectAll) {
|
||||
imageEditorView.startTextEditing(textElement, TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext()), selectAll);
|
||||
}
|
||||
|
||||
protected void addText() {
|
||||
String initialText = requireContext().getString(R.string.ImageEditorFragment_initial_text);
|
||||
int color = imageEditorHud.getActiveColor();
|
||||
TextRenderer renderer = new TextRenderer(initialText, color);
|
||||
EditorElement element = new EditorElement(renderer);
|
||||
|
||||
imageEditorView.getModel().addElementCentered(element, 1);
|
||||
imageEditorView.invalidate();
|
||||
|
||||
currentSelection = element;
|
||||
|
||||
startTextEntityEditing(element, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) {
|
||||
final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE);
|
||||
|
||||
UriGlideRenderer renderer = new UriGlideRenderer(Uri.parse("file:///android_asset/" + stickerFile), false, imageMaxWidth, imageMaxHeight);
|
||||
EditorElement element = new EditorElement(renderer);
|
||||
imageEditorView.getModel().addElementCentered(element, 0.2f);
|
||||
currentSelection = element;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModeStarted(@NonNull ImageEditorHud.Mode mode) {
|
||||
imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize);
|
||||
imageEditorView.doneTextEditing();
|
||||
|
||||
controller.onTouchEventsNeeded(mode != ImageEditorHud.Mode.NONE);
|
||||
|
||||
switch (mode) {
|
||||
case CROP:
|
||||
imageEditorView.getModel().startCrop();
|
||||
break;
|
||||
|
||||
case DRAW:
|
||||
imageEditorView.startDrawing(0.01f, Paint.Cap.ROUND);
|
||||
break;
|
||||
|
||||
case HIGHLIGHT:
|
||||
imageEditorView.startDrawing(0.03f, Paint.Cap.SQUARE);
|
||||
break;
|
||||
|
||||
case TEXT:
|
||||
addText();
|
||||
break;
|
||||
|
||||
case MOVE_DELETE:
|
||||
Intent intent = new Intent(getContext(), StickerSelectActivity.class);
|
||||
startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE);
|
||||
break;
|
||||
|
||||
case NONE:
|
||||
imageEditorView.getModel().doneCrop();
|
||||
currentSelection = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorChange(int color) {
|
||||
imageEditorView.setDrawingBrushColor(color);
|
||||
changeEntityColor(color);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUndo() {
|
||||
imageEditorView.getModel().undo();
|
||||
refreshUniqueColors();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete() {
|
||||
imageEditorView.deleteElement(currentSelection);
|
||||
refreshUniqueColors();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFlipHorizontal() {
|
||||
imageEditorView.getModel().flipHorizontal();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotate90AntiClockwise() {
|
||||
imageEditorView.getModel().rotate90anticlockwise();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCropAspectLock(boolean locked) {
|
||||
imageEditorView.getModel().setCropAspectLock(locked);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCropAspectLocked() {
|
||||
return imageEditorView.getModel().isCropAspectLocked();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestFullScreen(boolean fullScreen) {
|
||||
controller.onRequestFullScreen(fullScreen);
|
||||
}
|
||||
|
||||
private void refreshUniqueColors() {
|
||||
imageEditorHud.setColorPalette(imageEditorView.getModel().getUniqueColorsIgnoringAlpha());
|
||||
}
|
||||
|
||||
private final ImageEditorView.TapListener selectionListener = new ImageEditorView.TapListener() {
|
||||
|
||||
@Override
|
||||
public void onEntityDown(@Nullable EditorElement editorElement) {
|
||||
if (editorElement != null) {
|
||||
controller.onTouchEventsNeeded(true);
|
||||
} else {
|
||||
currentSelection = null;
|
||||
controller.onTouchEventsNeeded(false);
|
||||
imageEditorHud.enterMode(ImageEditorHud.Mode.NONE);
|
||||
imageEditorView.doneTextEditing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEntitySingleTap(@Nullable EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
if (currentSelection != null) {
|
||||
if (editorElement.getRenderer() instanceof TextRenderer) {
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), imageEditorView.isTextEditing());
|
||||
} else {
|
||||
imageEditorHud.enterMode(ImageEditorHud.Mode.MOVE_DELETE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEntityDoubleTap(@NonNull EditorElement editorElement) {
|
||||
currentSelection = editorElement;
|
||||
if (editorElement.getRenderer() instanceof TextRenderer) {
|
||||
setTextElement(editorElement, (ColorableRenderer) editorElement.getRenderer(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setTextElement(@NonNull EditorElement editorElement,
|
||||
@NonNull ColorableRenderer colorableRenderer,
|
||||
boolean startEditing)
|
||||
{
|
||||
int color = colorableRenderer.getColor();
|
||||
imageEditorHud.enterMode(ImageEditorHud.Mode.TEXT);
|
||||
imageEditorHud.setActiveColor(color);
|
||||
if (startEditing) {
|
||||
startTextEntityEditing(editorElement, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public interface Controller {
|
||||
void onTouchEventsNeeded(boolean needed);
|
||||
|
||||
void onRequestFullScreen(boolean fullScreen);
|
||||
}
|
||||
}
|
||||
274
src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java
Normal file
274
src/org/thoughtcrime/securesms/scribbles/ImageEditorHud.java
Normal file
@@ -0,0 +1,274 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The HUD (heads-up display) that contains all of the tools for interacting with
|
||||
* {@link org.thoughtcrime.securesms.imageeditor.ImageEditorView}
|
||||
*/
|
||||
public final class ImageEditorHud extends LinearLayout {
|
||||
|
||||
private View cropButton;
|
||||
private View cropFlipButton;
|
||||
private View cropRotateButton;
|
||||
private ImageView cropAspectLock;
|
||||
private View drawButton;
|
||||
private View highlightButton;
|
||||
private View textButton;
|
||||
private View stickerButton;
|
||||
private View undoButton;
|
||||
private View deleteButton;
|
||||
private View confirmButton;
|
||||
private VerticalSlideColorPicker colorPicker;
|
||||
private RecyclerView colorPalette;
|
||||
|
||||
@NonNull
|
||||
private EventListener eventListener = NULL_EVENT_LISTENER;
|
||||
@Nullable
|
||||
private ColorPaletteAdapter colorPaletteAdapter;
|
||||
|
||||
private final Map<Mode, Set<View>> visibilityModeMap = new HashMap<>();
|
||||
private final Set<View> allViews = new HashSet<>();
|
||||
|
||||
public ImageEditorHud(@NonNull Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ImageEditorHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
inflate(getContext(), R.layout.image_editor_hud, this);
|
||||
setOrientation(VERTICAL);
|
||||
|
||||
cropButton = findViewById(R.id.scribble_crop_button);
|
||||
cropFlipButton = findViewById(R.id.scribble_crop_flip);
|
||||
cropRotateButton = findViewById(R.id.scribble_crop_rotate);
|
||||
cropAspectLock = findViewById(R.id.scribble_crop_aspect_lock);
|
||||
colorPalette = findViewById(R.id.scribble_color_palette);
|
||||
drawButton = findViewById(R.id.scribble_draw_button);
|
||||
highlightButton = findViewById(R.id.scribble_highlight_button);
|
||||
textButton = findViewById(R.id.scribble_text_button);
|
||||
stickerButton = findViewById(R.id.scribble_sticker_button);
|
||||
undoButton = findViewById(R.id.scribble_undo_button);
|
||||
deleteButton = findViewById(R.id.scribble_delete_button);
|
||||
confirmButton = findViewById(R.id.scribble_confirm_button);
|
||||
colorPicker = findViewById(R.id.scribble_color_picker);
|
||||
|
||||
cropAspectLock.setOnClickListener(v -> {
|
||||
eventListener.onCropAspectLock(!eventListener.isCropAspectLocked());
|
||||
updateCropAspectLockImage(eventListener.isCropAspectLocked());
|
||||
});
|
||||
|
||||
initializeViews();
|
||||
initializeVisibilityMap();
|
||||
setMode(Mode.NONE);
|
||||
}
|
||||
|
||||
private void updateCropAspectLockImage(boolean cropAspectLocked) {
|
||||
cropAspectLock.setImageDrawable(getResources().getDrawable(cropAspectLocked ? R.drawable.ic_crop_lock_32 : R.drawable.ic_crop_unlock_32));
|
||||
}
|
||||
|
||||
private void initializeVisibilityMap() {
|
||||
setVisibleViewsWhenInMode(Mode.NONE, drawButton, highlightButton, textButton, stickerButton, cropButton, undoButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.DRAW, confirmButton, undoButton, colorPicker, colorPalette);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.HIGHLIGHT, confirmButton, undoButton, colorPicker, colorPalette);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.TEXT, confirmButton, deleteButton, colorPicker, colorPalette);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.MOVE_DELETE, confirmButton, deleteButton);
|
||||
|
||||
setVisibleViewsWhenInMode(Mode.CROP, confirmButton, cropFlipButton, cropRotateButton, cropAspectLock);
|
||||
|
||||
for (Set<View> views : visibilityModeMap.values()) {
|
||||
allViews.addAll(views);
|
||||
}
|
||||
}
|
||||
|
||||
private void setVisibleViewsWhenInMode(Mode mode, View... views) {
|
||||
visibilityModeMap.put(mode, new HashSet<>(Arrays.asList(views)));
|
||||
}
|
||||
|
||||
private void initializeViews() {
|
||||
undoButton.setOnClickListener(v -> eventListener.onUndo());
|
||||
|
||||
deleteButton.setOnClickListener(v -> {
|
||||
eventListener.onDelete();
|
||||
setMode(Mode.NONE);
|
||||
});
|
||||
|
||||
cropButton.setOnClickListener(v -> setMode(Mode.CROP));
|
||||
cropFlipButton.setOnClickListener(v -> eventListener.onFlipHorizontal());
|
||||
cropRotateButton.setOnClickListener(v -> eventListener.onRotate90AntiClockwise());
|
||||
|
||||
confirmButton.setOnClickListener(v -> setMode(Mode.NONE));
|
||||
|
||||
colorPaletteAdapter = new ColorPaletteAdapter();
|
||||
colorPaletteAdapter.setEventListener(colorPicker::setActiveColor);
|
||||
|
||||
colorPalette.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
colorPalette.setAdapter(colorPaletteAdapter);
|
||||
|
||||
drawButton.setOnClickListener(v -> setMode(Mode.DRAW));
|
||||
highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT));
|
||||
textButton.setOnClickListener(v -> setMode(Mode.TEXT));
|
||||
stickerButton.setOnClickListener(v -> setMode(Mode.MOVE_DELETE));
|
||||
}
|
||||
|
||||
public void setColorPalette(@NonNull Set<Integer> colors) {
|
||||
if (colorPaletteAdapter != null) {
|
||||
colorPaletteAdapter.setColors(colors);
|
||||
}
|
||||
}
|
||||
|
||||
public int getActiveColor() {
|
||||
return colorPicker.getActiveColor();
|
||||
}
|
||||
|
||||
public void setActiveColor(int color) {
|
||||
colorPicker.setActiveColor(color);
|
||||
}
|
||||
|
||||
public void setEventListener(@Nullable EventListener eventListener) {
|
||||
this.eventListener = eventListener != null ? eventListener : NULL_EVENT_LISTENER;
|
||||
}
|
||||
|
||||
public void enterMode(@NonNull Mode mode) {
|
||||
setMode(mode, false);
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode) {
|
||||
setMode(mode, true);
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode, boolean notify) {
|
||||
Set<View> visibleButtons = visibilityModeMap.get(mode);
|
||||
for (View button : allViews) {
|
||||
button.setVisibility(visibleButtons != null && visibleButtons.contains(button) ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case CROP: presentModeCrop(); break;
|
||||
case DRAW: presentModeDraw(); break;
|
||||
case HIGHLIGHT: presentModeHighlight(); break;
|
||||
case TEXT: presentModeText(); break;
|
||||
}
|
||||
|
||||
if (notify) {
|
||||
eventListener.onModeStarted(mode);
|
||||
}
|
||||
eventListener.onRequestFullScreen(mode != Mode.NONE);
|
||||
}
|
||||
|
||||
private void presentModeCrop() {
|
||||
updateCropAspectLockImage(eventListener.isCropAspectLocked());
|
||||
}
|
||||
|
||||
private void presentModeDraw() {
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.RED);
|
||||
}
|
||||
|
||||
private void presentModeHighlight() {
|
||||
colorPicker.setOnColorChangeListener(highlightOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.YELLOW);
|
||||
}
|
||||
|
||||
private void presentModeText() {
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.WHITE);
|
||||
}
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = selectedColor -> eventListener.onColorChange(selectedColor);
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = selectedColor -> eventListener.onColorChange(replaceAlphaWith128(selectedColor));
|
||||
|
||||
private static int replaceAlphaWith128(int color) {
|
||||
return color & ~0xff000000 | 0x80000000;
|
||||
}
|
||||
|
||||
public enum Mode {
|
||||
NONE, DRAW, HIGHLIGHT, TEXT, MOVE_DELETE, CROP
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onModeStarted(@NonNull Mode mode);
|
||||
void onColorChange(int color);
|
||||
void onUndo();
|
||||
void onDelete();
|
||||
void onFlipHorizontal();
|
||||
void onRotate90AntiClockwise();
|
||||
void onCropAspectLock(boolean locked);
|
||||
boolean isCropAspectLocked();
|
||||
void onRequestFullScreen(boolean fullScreen);
|
||||
}
|
||||
|
||||
private static final EventListener NULL_EVENT_LISTENER = new EventListener() {
|
||||
|
||||
@Override
|
||||
public void onModeStarted(@NonNull Mode mode) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorChange(int color) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUndo() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFlipHorizontal() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRotate90AntiClockwise() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCropAspectLock(boolean locked) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCropAspectLocked() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestFullScreen(boolean fullScreen) {
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.PointF;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.app.Fragment;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.MediaSendPageFragment;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider;
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.Font;
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.Layer;
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.TextLayer;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.MotionView;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.ScribbleView;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.ImageEntity;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static android.app.Activity.RESULT_OK;
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
||||
public class ScribbleFragment extends Fragment implements ScribbleHud.EventListener,
|
||||
VerticalSlideColorPicker.OnColorChangeListener,
|
||||
MediaSendPageFragment
|
||||
{
|
||||
|
||||
private static final String TAG = ScribbleFragment.class.getSimpleName();
|
||||
|
||||
private static final String KEY_IMAGE_URI = "image_uri";
|
||||
|
||||
public static final int SELECT_STICKER_REQUEST_CODE = 123;
|
||||
|
||||
private Controller controller;
|
||||
private ScribbleHud scribbleHud;
|
||||
private ScribbleView scribbleView;
|
||||
private GlideRequests glideRequests;
|
||||
private Uri imageUri;
|
||||
|
||||
private ScribbleView.SavedState savedState;
|
||||
|
||||
public static ScribbleFragment newInstance(@NonNull Uri imageUri) {
|
||||
Bundle args = new Bundle();
|
||||
args.putParcelable(KEY_IMAGE_URI, imageUri);
|
||||
|
||||
ScribbleFragment fragment = new ScribbleFragment();
|
||||
fragment.setArguments(args);
|
||||
fragment.setUri(imageUri);
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (!(getActivity() instanceof Controller)) {
|
||||
throw new IllegalStateException("Parent activity must implement Controller interface.");
|
||||
}
|
||||
controller = (Controller) getActivity();
|
||||
imageUri = getArguments().getParcelable(KEY_IMAGE_URI);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
return inflater.inflate(R.layout.scribble_fragment, container, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
this.glideRequests = GlideApp.with(this);
|
||||
this.scribbleHud = view.findViewById(R.id.scribble_hud);
|
||||
this.scribbleView = view.findViewById(R.id.scribble_view);
|
||||
|
||||
scribbleHud.setEventListener(this);
|
||||
|
||||
scribbleView.setMotionViewCallback(motionViewCallback);
|
||||
scribbleView.setDrawingChangedListener(() -> scribbleHud.setColorPalette(scribbleView.getUniqueColors()));
|
||||
scribbleView.setDrawingMode(false);
|
||||
scribbleView.setImage(glideRequests, imageUri);
|
||||
|
||||
if (savedState != null) {
|
||||
scribbleView.restoreState(savedState);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUri(@NonNull Uri uri) {
|
||||
this.imageUri = uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Uri getUri() {
|
||||
return imageUri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View getPlaybackControls() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable Object saveState() {
|
||||
return scribbleView.saveState();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restoreState(@NonNull Object state) {
|
||||
if (state instanceof ScribbleView.SavedState) {
|
||||
savedState = (ScribbleView.SavedState) state;
|
||||
|
||||
if (scribbleView != null) {
|
||||
scribbleView.restoreState(savedState);
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Received a bad saved state. Received class: " + state.getClass().getName());
|
||||
}
|
||||
}
|
||||
|
||||
private void addSticker(final Bitmap pica) {
|
||||
Util.runOnMain(() -> {
|
||||
Layer layer = new Layer();
|
||||
ImageEntity entity = new ImageEntity(layer, pica, scribbleView.getWidth(), scribbleView.getHeight());
|
||||
|
||||
scribbleView.addEntityAndPosition(entity);
|
||||
});
|
||||
}
|
||||
|
||||
private void changeTextEntityColor(int selectedColor) {
|
||||
TextEntity textEntity = currentTextEntity();
|
||||
|
||||
if (textEntity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
textEntity.getLayer().getFont().setColor(selectedColor);
|
||||
textEntity.updateEntity();
|
||||
scribbleView.invalidate();
|
||||
scribbleHud.setColorPalette(scribbleView.getUniqueColors());
|
||||
}
|
||||
|
||||
private void startTextEntityEditing() {
|
||||
TextEntity textEntity = currentTextEntity();
|
||||
if (textEntity != null) {
|
||||
scribbleView.startEditing(textEntity);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private TextEntity currentTextEntity() {
|
||||
if (scribbleView != null && scribbleView.getSelectedEntity() instanceof TextEntity) {
|
||||
return ((TextEntity) scribbleView.getSelectedEntity());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected void addTextSticker() {
|
||||
TextLayer textLayer = createTextLayer();
|
||||
TextEntity textEntity = new TextEntity(textLayer, scribbleView.getWidth(), scribbleView.getHeight());
|
||||
scribbleView.addEntityAndPosition(textEntity);
|
||||
|
||||
PointF center = textEntity.absoluteCenter();
|
||||
center.y = center.y * 0.5F;
|
||||
textEntity.moveCenterTo(center);
|
||||
|
||||
scribbleView.invalidate();
|
||||
|
||||
startTextEntityEditing();
|
||||
changeTextEntityColor(scribbleHud.getActiveColor());
|
||||
}
|
||||
|
||||
private TextLayer createTextLayer() {
|
||||
TextLayer textLayer = new TextLayer();
|
||||
Font font = new Font();
|
||||
|
||||
font.setColor(scribbleHud.getActiveColor());
|
||||
font.setSize(TextLayer.Limits.INITIAL_FONT_SIZE);
|
||||
|
||||
textLayer.setFont(font);
|
||||
|
||||
return textLayer;
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
@Override
|
||||
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
if (resultCode == RESULT_OK && requestCode == SELECT_STICKER_REQUEST_CODE && data != null) {
|
||||
final String stickerFile = data.getStringExtra(StickerSelectActivity.EXTRA_STICKER_FILE);
|
||||
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
try {
|
||||
return BitmapFactory.decodeStream(getContext().getAssets().open(stickerFile));
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return null;
|
||||
}
|
||||
}, bitmap -> {
|
||||
if (bitmap != null) {
|
||||
addSticker(bitmap);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onModeStarted(@NonNull ScribbleHud.Mode mode) {
|
||||
switch (mode) {
|
||||
case DRAW:
|
||||
controller.onTouchEventsNeeded(true);
|
||||
scribbleView.setDrawingMode(true);
|
||||
scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH);
|
||||
break;
|
||||
|
||||
case HIGHLIGHT:
|
||||
controller.onTouchEventsNeeded(true);
|
||||
scribbleView.setDrawingMode(true);
|
||||
scribbleView.setDrawingBrushWidth(ScribbleView.DEFAULT_BRUSH_WIDTH * 3);
|
||||
break;
|
||||
|
||||
case TEXT:
|
||||
controller.onTouchEventsNeeded(true);
|
||||
scribbleView.setDrawingMode(false);
|
||||
addTextSticker();
|
||||
break;
|
||||
|
||||
case STICKER:
|
||||
controller.onTouchEventsNeeded(true);
|
||||
scribbleView.setDrawingMode(false);
|
||||
Intent intent = new Intent(getContext(), StickerSelectActivity.class);
|
||||
startActivityForResult(intent, SELECT_STICKER_REQUEST_CODE);
|
||||
break;
|
||||
|
||||
case NONE:
|
||||
controller.onTouchEventsNeeded(false);
|
||||
scribbleView.clearSelection();
|
||||
scribbleView.setDrawingMode(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onColorChange(int color) {
|
||||
scribbleView.setDrawingBrushColor(color);
|
||||
changeTextEntityColor(color);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUndo() {
|
||||
scribbleView.undoDrawing();
|
||||
scribbleHud.setColorPalette(scribbleView.getUniqueColors());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDelete() {
|
||||
scribbleView.deleteSelected();
|
||||
scribbleHud.setColorPalette(scribbleView.getUniqueColors());
|
||||
}
|
||||
|
||||
private final MotionView.MotionViewCallback motionViewCallback = new MotionView.MotionViewCallback() {
|
||||
@Override
|
||||
public void onEntitySelected(@Nullable MotionEntity entity) {
|
||||
if (entity == null) {
|
||||
scribbleHud.enterMode(ScribbleHud.Mode.NONE);
|
||||
controller.onTouchEventsNeeded(false);
|
||||
} else if (entity instanceof TextEntity) {
|
||||
int textColor = ((TextEntity) entity).getLayer().getFont().getColor();
|
||||
|
||||
scribbleHud.enterMode(ScribbleHud.Mode.TEXT);
|
||||
scribbleHud.setActiveColor(textColor);
|
||||
controller.onTouchEventsNeeded(true);
|
||||
} else {
|
||||
scribbleHud.enterMode(ScribbleHud.Mode.STICKER);
|
||||
controller.onTouchEventsNeeded(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEntityDoubleTap(@NonNull MotionEntity entity) {
|
||||
startTextEntityEditing();
|
||||
}
|
||||
};
|
||||
|
||||
public interface Controller {
|
||||
void onTouchEventsNeeded(boolean needed);
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Rect;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v7.widget.LinearLayoutManager;
|
||||
import android.support.v7.widget.RecyclerView;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.components.ComposeText;
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout;
|
||||
import org.thoughtcrime.securesms.components.SendButton;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiDrawer;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.ColorPaletteAdapter;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.VerticalSlideColorPicker;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* The HUD (heads-up display) that contains all of the tools for interacting with
|
||||
* {@link org.thoughtcrime.securesms.scribbles.widget.ScribbleView}
|
||||
*/
|
||||
public class ScribbleHud extends LinearLayout {
|
||||
|
||||
private View drawButton;
|
||||
private View highlightButton;
|
||||
private View textButton;
|
||||
private View stickerButton;
|
||||
private View undoButton;
|
||||
private View deleteButton;
|
||||
private View confirmButton;
|
||||
private VerticalSlideColorPicker colorPicker;
|
||||
private RecyclerView colorPalette;
|
||||
|
||||
private EventListener eventListener;
|
||||
private ColorPaletteAdapter colorPaletteAdapter;
|
||||
|
||||
public ScribbleHud(@NonNull Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ScribbleHud(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public ScribbleHud(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
inflate(getContext(), R.layout.scribble_hud, this);
|
||||
setOrientation(VERTICAL);
|
||||
|
||||
drawButton = findViewById(R.id.scribble_draw_button);
|
||||
highlightButton = findViewById(R.id.scribble_highlight_button);
|
||||
textButton = findViewById(R.id.scribble_text_button);
|
||||
stickerButton = findViewById(R.id.scribble_sticker_button);
|
||||
undoButton = findViewById(R.id.scribble_undo_button);
|
||||
deleteButton = findViewById(R.id.scribble_delete_button);
|
||||
confirmButton = findViewById(R.id.scribble_confirm_button);
|
||||
colorPicker = findViewById(R.id.scribble_color_picker);
|
||||
colorPalette = findViewById(R.id.scribble_color_palette);
|
||||
|
||||
initializeViews();
|
||||
setMode(Mode.NONE);
|
||||
}
|
||||
|
||||
private void initializeViews() {
|
||||
undoButton.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onUndo();
|
||||
}
|
||||
});
|
||||
|
||||
deleteButton.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onDelete();
|
||||
}
|
||||
setMode(Mode.NONE);
|
||||
});
|
||||
|
||||
confirmButton.setOnClickListener(v -> setMode(Mode.NONE));
|
||||
|
||||
colorPaletteAdapter = new ColorPaletteAdapter();
|
||||
colorPaletteAdapter.setEventListener(colorPicker::setActiveColor);
|
||||
|
||||
colorPalette.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
colorPalette.setAdapter(colorPaletteAdapter);
|
||||
}
|
||||
|
||||
public void setColorPalette(@NonNull Set<Integer> colors) {
|
||||
colorPaletteAdapter.setColors(colors);
|
||||
}
|
||||
|
||||
public int getActiveColor() {
|
||||
return colorPicker.getActiveColor();
|
||||
}
|
||||
|
||||
public void setActiveColor(int color) {
|
||||
colorPicker.setActiveColor(color);
|
||||
}
|
||||
|
||||
public void setEventListener(@Nullable EventListener eventListener) {
|
||||
this.eventListener = eventListener;
|
||||
}
|
||||
|
||||
public void enterMode(@NonNull Mode mode) {
|
||||
setMode(mode, false);
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode) {
|
||||
setMode(mode, true);
|
||||
}
|
||||
|
||||
private void setMode(@NonNull Mode mode, boolean notify) {
|
||||
switch (mode) {
|
||||
case NONE: presentModeNone(); break;
|
||||
case DRAW: presentModeDraw(); break;
|
||||
case HIGHLIGHT: presentModeHighlight(); break;
|
||||
case TEXT: presentModeText(); break;
|
||||
case STICKER: presentModeSticker(); break;
|
||||
}
|
||||
|
||||
if (notify && eventListener != null) {
|
||||
eventListener.onModeStarted(mode);
|
||||
}
|
||||
}
|
||||
|
||||
private void presentModeNone() {
|
||||
drawButton.setVisibility(VISIBLE);
|
||||
highlightButton.setVisibility(VISIBLE);
|
||||
textButton.setVisibility(VISIBLE);
|
||||
stickerButton.setVisibility(VISIBLE);
|
||||
|
||||
undoButton.setVisibility(GONE);
|
||||
deleteButton.setVisibility(GONE);
|
||||
confirmButton.setVisibility(GONE);
|
||||
colorPicker.setVisibility(GONE);
|
||||
colorPalette.setVisibility(GONE);
|
||||
|
||||
drawButton.setOnClickListener(v -> setMode(Mode.DRAW));
|
||||
highlightButton.setOnClickListener(v -> setMode(Mode.HIGHLIGHT));
|
||||
textButton.setOnClickListener(v -> setMode(Mode.TEXT));
|
||||
stickerButton.setOnClickListener(v -> setMode(Mode.STICKER));
|
||||
}
|
||||
|
||||
private void presentModeDraw() {
|
||||
confirmButton.setVisibility(VISIBLE);
|
||||
undoButton.setVisibility(VISIBLE);
|
||||
colorPicker.setVisibility(VISIBLE);
|
||||
colorPalette.setVisibility(VISIBLE);
|
||||
|
||||
drawButton.setVisibility(GONE);
|
||||
highlightButton.setVisibility(GONE);
|
||||
textButton.setVisibility(GONE);
|
||||
stickerButton.setVisibility(GONE);
|
||||
deleteButton.setVisibility(GONE);
|
||||
|
||||
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.RED);
|
||||
}
|
||||
|
||||
private void presentModeHighlight() {
|
||||
confirmButton.setVisibility(VISIBLE);
|
||||
undoButton.setVisibility(VISIBLE);
|
||||
colorPicker.setVisibility(VISIBLE);
|
||||
colorPalette.setVisibility(VISIBLE);
|
||||
|
||||
drawButton.setVisibility(GONE);
|
||||
highlightButton.setVisibility(GONE);
|
||||
textButton.setVisibility(GONE);
|
||||
deleteButton.setVisibility(GONE);
|
||||
stickerButton.setVisibility(GONE);
|
||||
|
||||
colorPicker.setOnColorChangeListener(highlightOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.YELLOW);
|
||||
}
|
||||
|
||||
private void presentModeText() {
|
||||
confirmButton.setVisibility(VISIBLE);
|
||||
deleteButton.setVisibility(VISIBLE);
|
||||
colorPicker.setVisibility(VISIBLE);
|
||||
colorPalette.setVisibility(VISIBLE);
|
||||
|
||||
textButton.setVisibility(GONE);
|
||||
drawButton.setVisibility(GONE);
|
||||
highlightButton.setVisibility(GONE);
|
||||
stickerButton.setVisibility(GONE);
|
||||
undoButton.setVisibility(GONE);
|
||||
|
||||
colorPicker.setOnColorChangeListener(standardOnColorChangeListener);
|
||||
colorPicker.setActiveColor(Color.WHITE);
|
||||
}
|
||||
|
||||
private void presentModeSticker() {
|
||||
deleteButton.setVisibility(VISIBLE);
|
||||
confirmButton.setVisibility(VISIBLE);
|
||||
|
||||
drawButton.setVisibility(GONE);
|
||||
highlightButton.setVisibility(GONE);
|
||||
textButton.setVisibility(GONE);
|
||||
stickerButton.setVisibility(GONE);
|
||||
undoButton.setVisibility(GONE);
|
||||
colorPicker.setVisibility(GONE);
|
||||
colorPalette.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener standardOnColorChangeListener = new VerticalSlideColorPicker.OnColorChangeListener() {
|
||||
@Override
|
||||
public void onColorChange(int selectedColor) {
|
||||
if (eventListener != null) {
|
||||
eventListener.onColorChange(selectedColor);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final VerticalSlideColorPicker.OnColorChangeListener highlightOnColorChangeListener = new VerticalSlideColorPicker.OnColorChangeListener() {
|
||||
@Override
|
||||
public void onColorChange(int selectedColor) {
|
||||
if (eventListener != null) {
|
||||
int r = Color.red(selectedColor);
|
||||
int g = Color.green(selectedColor);
|
||||
int b = Color.blue(selectedColor);
|
||||
int a = 128;
|
||||
|
||||
eventListener.onColorChange(Color.argb(a, r, g, b));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public enum Mode {
|
||||
NONE, DRAW, HIGHLIGHT, TEXT, STICKER
|
||||
}
|
||||
|
||||
public interface EventListener {
|
||||
void onModeStarted(@NonNull Mode mode);
|
||||
void onColorChange(int color);
|
||||
void onUndo();
|
||||
void onDelete();
|
||||
}
|
||||
}
|
||||
186
src/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java
Normal file
186
src/org/thoughtcrime/securesms/scribbles/UriGlideRenderer.java
Normal file
@@ -0,0 +1,186 @@
|
||||
package org.thoughtcrime.securesms.scribbles;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Point;
|
||||
import android.graphics.RectF;
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.thoughtcrime.securesms.imageeditor.Bounds;
|
||||
import org.thoughtcrime.securesms.imageeditor.Renderer;
|
||||
import org.thoughtcrime.securesms.imageeditor.RendererContext;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
/**
|
||||
* Uses Glide to load an image and implements a {@link Renderer}.
|
||||
*
|
||||
* The image can be encrypted.
|
||||
*/
|
||||
final class UriGlideRenderer implements Renderer {
|
||||
|
||||
private final Uri imageUri;
|
||||
private final Paint paint = new Paint();
|
||||
private final Matrix imageProjectionMatrix = new Matrix();
|
||||
private final Matrix temp = new Matrix();
|
||||
private final boolean decryptable;
|
||||
private final int maxWidth;
|
||||
private final int maxHeight;
|
||||
|
||||
@Nullable
|
||||
private Bitmap bitmap;
|
||||
|
||||
UriGlideRenderer(@NonNull Uri imageUri, boolean decryptable, int maxWidth, int maxHeight) {
|
||||
this.imageUri = imageUri;
|
||||
this.decryptable = decryptable;
|
||||
this.maxWidth = maxWidth;
|
||||
this.maxHeight = maxHeight;
|
||||
paint.setAntiAlias(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NonNull RendererContext rendererContext) {
|
||||
if (getBitmap() == null) {
|
||||
if (rendererContext.isBlockingLoad()) {
|
||||
try {
|
||||
Bitmap bitmap = getBitmapGlideRequest(rendererContext.context).submit().get();
|
||||
setBitmap(rendererContext, bitmap);
|
||||
} catch (ExecutionException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
getBitmapGlideRequest(rendererContext.context).into(new SimpleTarget<Bitmap>() {
|
||||
@Override
|
||||
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
|
||||
setBitmap(rendererContext, resource);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final Bitmap bitmap = getBitmap();
|
||||
if (bitmap != null) {
|
||||
rendererContext.save();
|
||||
|
||||
rendererContext.canvasMatrix.concat(imageProjectionMatrix);
|
||||
|
||||
// Units are image level pixels at this point.
|
||||
|
||||
int alpha = paint.getAlpha();
|
||||
paint.setAlpha(rendererContext.getAlpha(alpha));
|
||||
|
||||
rendererContext.canvas.drawBitmap(bitmap, 0, 0, paint);
|
||||
|
||||
paint.setAlpha(alpha);
|
||||
|
||||
rendererContext.restore();
|
||||
} else {
|
||||
// If failed to load, we draw a black out, in case image was sticker positioned to cover private info.
|
||||
rendererContext.canvas.drawRect(Bounds.FULL_BOUNDS, paint);
|
||||
}
|
||||
}
|
||||
|
||||
private GlideRequest<Bitmap> getBitmapGlideRequest(@NonNull Context context) {
|
||||
return GlideApp.with(context)
|
||||
.asBitmap()
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.override(maxWidth, maxHeight)
|
||||
.centerInside()
|
||||
.load(decryptable ? new DecryptableStreamUriLoader.DecryptableUri(imageUri) : imageUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hitTest(float x, float y) {
|
||||
return pixelAlphaNotZero(x, y);
|
||||
}
|
||||
|
||||
private boolean pixelAlphaNotZero(float x, float y) {
|
||||
Bitmap bitmap = getBitmap();
|
||||
|
||||
if (bitmap == null) return false;
|
||||
|
||||
imageProjectionMatrix.invert(temp);
|
||||
|
||||
float[] onBmp = new float[2];
|
||||
temp.mapPoints(onBmp, new float[]{ x, y });
|
||||
|
||||
int xInt = (int) onBmp[0];
|
||||
int yInt = (int) onBmp[1];
|
||||
|
||||
if (xInt >= 0 && xInt < bitmap.getWidth() && yInt >= 0 && yInt < bitmap.getHeight()) {
|
||||
return (bitmap.getPixel(xInt, yInt) & 0xff000000) != 0;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Bitmap getBitmap() {
|
||||
if (bitmap != null && bitmap.isRecycled()) {
|
||||
bitmap = null;
|
||||
}
|
||||
return bitmap;
|
||||
}
|
||||
|
||||
private void setBitmap(@NonNull RendererContext rendererContext, @Nullable Bitmap bitmap) {
|
||||
this.bitmap = bitmap;
|
||||
if (bitmap != null) {
|
||||
RectF from = new RectF(0, 0, bitmap.getWidth(), bitmap.getHeight());
|
||||
imageProjectionMatrix.setRectToRect(from, Bounds.FULL_BOUNDS, Matrix.ScaleToFit.CENTER);
|
||||
rendererContext.rendererReady.onReady(UriGlideRenderer.this, cropMatrix(bitmap), new Point(bitmap.getWidth(), bitmap.getHeight()));
|
||||
}
|
||||
}
|
||||
|
||||
private static Matrix cropMatrix(Bitmap bitmap) {
|
||||
Matrix matrix = new Matrix();
|
||||
if (bitmap.getWidth() > bitmap.getHeight()) {
|
||||
matrix.preScale(1, ((float) bitmap.getHeight()) / bitmap.getWidth());
|
||||
} else {
|
||||
matrix.preScale(((float) bitmap.getWidth()) / bitmap.getHeight(), 1);
|
||||
}
|
||||
return matrix;
|
||||
}
|
||||
|
||||
public static final Creator<UriGlideRenderer> CREATOR = new Creator<UriGlideRenderer>() {
|
||||
@Override
|
||||
public UriGlideRenderer createFromParcel(Parcel in) {
|
||||
return new UriGlideRenderer(Uri.parse(in.readString()),
|
||||
in.readInt() == 1,
|
||||
in.readInt(),
|
||||
in.readInt()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public UriGlideRenderer[] newArray(int size) {
|
||||
return new UriGlideRenderer[size];
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(imageUri.toString());
|
||||
dest.writeInt(decryptable ? 1 : 0);
|
||||
dest.writeInt(maxWidth);
|
||||
dest.writeInt(maxHeight);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.multitouch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
/**
|
||||
* @author Almer Thie (code.almeros.com)
|
||||
* Copyright (c) 2013, Almer Thie (code.almeros.com)
|
||||
* <p>
|
||||
* All rights reserved.
|
||||
* <p>
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* <p>
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
* in the documentation and/or other materials provided with the distribution.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
* OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
public abstract class BaseGestureDetector {
|
||||
/**
|
||||
* This value is the threshold ratio between the previous combined pressure
|
||||
* and the current combined pressure. When pressure decreases rapidly
|
||||
* between events the position values can often be imprecise, as it usually
|
||||
* indicates that the user is in the process of lifting a pointer off of the
|
||||
* device. This value was tuned experimentally.
|
||||
*/
|
||||
protected static final float PRESSURE_THRESHOLD = 0.67f;
|
||||
protected final Context mContext;
|
||||
protected boolean mGestureInProgress;
|
||||
protected MotionEvent mPrevEvent;
|
||||
protected MotionEvent mCurrEvent;
|
||||
protected float mCurrPressure;
|
||||
protected float mPrevPressure;
|
||||
protected long mTimeDelta;
|
||||
|
||||
|
||||
public BaseGestureDetector(Context context) {
|
||||
mContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* All gesture detectors need to be called through this method to be able to
|
||||
* detect gestures. This method delegates work to handler methods
|
||||
* (handleStartProgressEvent, handleInProgressEvent) implemented in
|
||||
* extending classes.
|
||||
*
|
||||
* @param event
|
||||
* @return
|
||||
*/
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
final int actionCode = event.getAction() & MotionEvent.ACTION_MASK;
|
||||
if (!mGestureInProgress) {
|
||||
handleStartProgressEvent(actionCode, event);
|
||||
} else {
|
||||
handleInProgressEvent(actionCode, event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the current event occurred when NO gesture is in progress
|
||||
* yet. The handling in this implementation may set the gesture in progress
|
||||
* (via mGestureInProgress) or out of progress
|
||||
*
|
||||
* @param actionCode
|
||||
* @param event
|
||||
*/
|
||||
protected abstract void handleStartProgressEvent(int actionCode, MotionEvent event);
|
||||
|
||||
/**
|
||||
* Called when the current event occurred when a gesture IS in progress. The
|
||||
* handling in this implementation may set the gesture out of progress (via
|
||||
* mGestureInProgress).
|
||||
*
|
||||
* @param actionCode
|
||||
* @param event
|
||||
*/
|
||||
protected abstract void handleInProgressEvent(int actionCode, MotionEvent event);
|
||||
|
||||
|
||||
protected void updateStateByEvent(MotionEvent curr) {
|
||||
final MotionEvent prev = mPrevEvent;
|
||||
|
||||
// Reset mCurrEvent
|
||||
if (mCurrEvent != null) {
|
||||
mCurrEvent.recycle();
|
||||
mCurrEvent = null;
|
||||
}
|
||||
mCurrEvent = MotionEvent.obtain(curr);
|
||||
|
||||
|
||||
// Delta time
|
||||
mTimeDelta = curr.getEventTime() - prev.getEventTime();
|
||||
|
||||
// Pressure
|
||||
mCurrPressure = curr.getPressure(curr.getActionIndex());
|
||||
mPrevPressure = prev.getPressure(prev.getActionIndex());
|
||||
}
|
||||
|
||||
protected void resetState() {
|
||||
if (mPrevEvent != null) {
|
||||
mPrevEvent.recycle();
|
||||
mPrevEvent = null;
|
||||
}
|
||||
if (mCurrEvent != null) {
|
||||
mCurrEvent.recycle();
|
||||
mCurrEvent = null;
|
||||
}
|
||||
mGestureInProgress = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns {@code true} if a gesture is currently in progress.
|
||||
*
|
||||
* @return {@code true} if a gesture is currently in progress, {@code false} otherwise.
|
||||
*/
|
||||
public boolean isInProgress() {
|
||||
return mGestureInProgress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the time difference in milliseconds between the previous accepted
|
||||
* GestureDetector event and the current GestureDetector event.
|
||||
*
|
||||
* @return Time difference since the last move event in milliseconds.
|
||||
*/
|
||||
public long getTimeDelta() {
|
||||
return mTimeDelta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the event time of the current GestureDetector event being
|
||||
* processed.
|
||||
*
|
||||
* @return Current GestureDetector event time in milliseconds.
|
||||
*/
|
||||
public long getEventTime() {
|
||||
return mCurrEvent.getEventTime();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.multitouch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PointF;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
/**
|
||||
* @author Almer Thie (code.almeros.com)
|
||||
* Copyright (c) 2013, Almer Thie (code.almeros.com)
|
||||
* <p>
|
||||
* All rights reserved.
|
||||
* <p>
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* <p>
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
* in the documentation and/or other materials provided with the distribution.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
* OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
public class MoveGestureDetector extends BaseGestureDetector {
|
||||
|
||||
private static final PointF FOCUS_DELTA_ZERO = new PointF();
|
||||
private final OnMoveGestureListener mListener;
|
||||
private PointF mCurrFocusInternal;
|
||||
private PointF mPrevFocusInternal;
|
||||
private PointF mFocusExternal = new PointF();
|
||||
private PointF mFocusDeltaExternal = new PointF();
|
||||
public MoveGestureDetector(Context context, OnMoveGestureListener listener) {
|
||||
super(context);
|
||||
mListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleStartProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
resetState(); // In case we missed an UP/CANCEL event
|
||||
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
mTimeDelta = 0;
|
||||
|
||||
updateStateByEvent(event);
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
mGestureInProgress = mListener.onMoveBegin(this);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleInProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
mListener.onMoveEnd(this);
|
||||
resetState();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
updateStateByEvent(event);
|
||||
|
||||
// Only accept the event if our relative pressure is within
|
||||
// a certain limit. This can help filter shaky data as a
|
||||
// finger is lifted.
|
||||
if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD) {
|
||||
final boolean updatePrevious = mListener.onMove(this);
|
||||
if (updatePrevious) {
|
||||
mPrevEvent.recycle();
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected void updateStateByEvent(MotionEvent curr) {
|
||||
super.updateStateByEvent(curr);
|
||||
|
||||
final MotionEvent prev = mPrevEvent;
|
||||
|
||||
// Focus intenal
|
||||
mCurrFocusInternal = determineFocalPoint(curr);
|
||||
mPrevFocusInternal = determineFocalPoint(prev);
|
||||
|
||||
// Focus external
|
||||
// - Prevent skipping of focus delta when a finger is added or removed
|
||||
boolean mSkipNextMoveEvent = prev.getPointerCount() != curr.getPointerCount();
|
||||
mFocusDeltaExternal = mSkipNextMoveEvent ? FOCUS_DELTA_ZERO : new PointF(mCurrFocusInternal.x - mPrevFocusInternal.x, mCurrFocusInternal.y - mPrevFocusInternal.y);
|
||||
|
||||
// - Don't directly use mFocusInternal (or skipping will occur). Add
|
||||
// unskipped delta values to mFocusExternal instead.
|
||||
mFocusExternal.x += mFocusDeltaExternal.x;
|
||||
mFocusExternal.y += mFocusDeltaExternal.y;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine (multi)finger focal point (a.k.a. center point between all
|
||||
* fingers)
|
||||
*
|
||||
* @return PointF focal point
|
||||
*/
|
||||
private PointF determineFocalPoint(MotionEvent e) {
|
||||
// Number of fingers on screen
|
||||
final int pCount = e.getPointerCount();
|
||||
float x = 0f;
|
||||
float y = 0f;
|
||||
|
||||
for (int i = 0; i < pCount; i++) {
|
||||
x += e.getX(i);
|
||||
y += e.getY(i);
|
||||
}
|
||||
|
||||
return new PointF(x / pCount, y / pCount);
|
||||
}
|
||||
|
||||
public float getFocusX() {
|
||||
return mFocusExternal.x;
|
||||
}
|
||||
|
||||
public float getFocusY() {
|
||||
return mFocusExternal.y;
|
||||
}
|
||||
|
||||
public PointF getFocusDelta() {
|
||||
return mFocusDeltaExternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which must be implemented which is used by MoveGestureDetector
|
||||
* to perform callbacks to any implementing class which is registered to a
|
||||
* MoveGestureDetector via the constructor.
|
||||
*
|
||||
* @see MoveGestureDetector.SimpleOnMoveGestureListener
|
||||
*/
|
||||
public interface OnMoveGestureListener {
|
||||
public boolean onMove(MoveGestureDetector detector);
|
||||
|
||||
public boolean onMoveBegin(MoveGestureDetector detector);
|
||||
|
||||
public void onMoveEnd(MoveGestureDetector detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class which may be extended and where the methods may be
|
||||
* implemented. This way it is not necessary to implement all methods
|
||||
* of OnMoveGestureListener.
|
||||
*/
|
||||
public static class SimpleOnMoveGestureListener implements OnMoveGestureListener {
|
||||
public boolean onMove(MoveGestureDetector detector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean onMoveBegin(MoveGestureDetector detector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onMoveEnd(MoveGestureDetector detector) {
|
||||
// Do nothing, overridden implementation may be used
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.multitouch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
/**
|
||||
* @author Almer Thie (code.almeros.com)
|
||||
* Copyright (c) 2013, Almer Thie (code.almeros.com)
|
||||
* <p>
|
||||
* All rights reserved.
|
||||
* <p>
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* <p>
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
* in the documentation and/or other materials provided with the distribution.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
* OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
public class RotateGestureDetector extends TwoFingerGestureDetector {
|
||||
|
||||
private static final String TAG = RotateGestureDetector.class.getSimpleName();
|
||||
private final OnRotateGestureListener mListener;
|
||||
private boolean mSloppyGesture;
|
||||
|
||||
|
||||
public RotateGestureDetector(Context context, OnRotateGestureListener listener) {
|
||||
super(context);
|
||||
mListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleStartProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_POINTER_DOWN:
|
||||
// At least the second finger is on screen now
|
||||
|
||||
resetState(); // In case we missed an UP/CANCEL event
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
mTimeDelta = 0;
|
||||
|
||||
updateStateByEvent(event);
|
||||
|
||||
// See if we have a sloppy gesture
|
||||
mSloppyGesture = isSloppyGesture(event);
|
||||
if (!mSloppyGesture) {
|
||||
// No, start gesture now
|
||||
mGestureInProgress = mListener.onRotateBegin(this);
|
||||
}
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (!mSloppyGesture) {
|
||||
break;
|
||||
}
|
||||
|
||||
// See if we still have a sloppy gesture
|
||||
mSloppyGesture = isSloppyGesture(event);
|
||||
if (!mSloppyGesture) {
|
||||
// No, start normal gesture now
|
||||
mGestureInProgress = mListener.onRotateBegin(this);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_POINTER_UP:
|
||||
if (!mSloppyGesture) {
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleInProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_POINTER_UP:
|
||||
// Gesture ended but
|
||||
updateStateByEvent(event);
|
||||
|
||||
if (!mSloppyGesture) {
|
||||
mListener.onRotateEnd(this);
|
||||
}
|
||||
|
||||
resetState();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
if (!mSloppyGesture) {
|
||||
mListener.onRotateEnd(this);
|
||||
}
|
||||
|
||||
resetState();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
updateStateByEvent(event);
|
||||
|
||||
// Only accept the event if our relative pressure is within
|
||||
// a certain limit. This can help filter shaky data as a
|
||||
// finger is lifted.
|
||||
if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD) {
|
||||
final boolean updatePrevious = mListener.onRotate(this);
|
||||
if (updatePrevious) {
|
||||
mPrevEvent.recycle();
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void resetState() {
|
||||
super.resetState();
|
||||
mSloppyGesture = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the rotation difference from the previous rotate event to the current
|
||||
* event.
|
||||
*
|
||||
* @return The current rotation //difference in degrees.
|
||||
*/
|
||||
public float getRotationDegreesDelta() {
|
||||
double diffRadians = Math.atan2(mPrevFingerDiffY, mPrevFingerDiffX) - Math.atan2(mCurrFingerDiffY, mCurrFingerDiffX);
|
||||
return (float) (diffRadians * 180 / Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which must be implemented which is used by RotateGestureDetector
|
||||
* to perform callbacks to any implementing class which is registered to a
|
||||
* RotateGestureDetector via the constructor.
|
||||
*
|
||||
* @see RotateGestureDetector.SimpleOnRotateGestureListener
|
||||
*/
|
||||
public interface OnRotateGestureListener {
|
||||
public boolean onRotate(RotateGestureDetector detector);
|
||||
|
||||
public boolean onRotateBegin(RotateGestureDetector detector);
|
||||
|
||||
public void onRotateEnd(RotateGestureDetector detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class which may be extended and where the methods may be
|
||||
* implemented. This way it is not necessary to implement all methods
|
||||
* of OnRotateGestureListener.
|
||||
*/
|
||||
public static class SimpleOnRotateGestureListener implements OnRotateGestureListener {
|
||||
public boolean onRotate(RotateGestureDetector detector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean onRotateBegin(RotateGestureDetector detector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onRotateEnd(RotateGestureDetector detector) {
|
||||
// Do nothing, overridden implementation may be used
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.multitouch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
/**
|
||||
* @author Robert Nordan (robert.nordan@norkart.no)
|
||||
* <p>
|
||||
* Copyright (c) 2013, Norkart AS
|
||||
* <p>
|
||||
* All rights reserved.
|
||||
* <p>
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* <p>
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
* in the documentation and/or other materials provided with the distribution.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
* OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
public class ShoveGestureDetector extends TwoFingerGestureDetector {
|
||||
|
||||
private final OnShoveGestureListener mListener;
|
||||
private float mPrevAverageY;
|
||||
private float mCurrAverageY;
|
||||
private boolean mSloppyGesture;
|
||||
|
||||
public ShoveGestureDetector(Context context, OnShoveGestureListener listener) {
|
||||
super(context);
|
||||
mListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleStartProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_POINTER_DOWN:
|
||||
// At least the second finger is on screen now
|
||||
|
||||
resetState(); // In case we missed an UP/CANCEL event
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
mTimeDelta = 0;
|
||||
|
||||
updateStateByEvent(event);
|
||||
|
||||
// See if we have a sloppy gesture
|
||||
mSloppyGesture = isSloppyGesture(event);
|
||||
if (!mSloppyGesture) {
|
||||
// No, start gesture now
|
||||
mGestureInProgress = mListener.onShoveBegin(this);
|
||||
}
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
if (!mSloppyGesture) {
|
||||
break;
|
||||
}
|
||||
|
||||
// See if we still have a sloppy gesture
|
||||
mSloppyGesture = isSloppyGesture(event);
|
||||
if (!mSloppyGesture) {
|
||||
// No, start normal gesture now
|
||||
mGestureInProgress = mListener.onShoveBegin(this);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_POINTER_UP:
|
||||
if (!mSloppyGesture) {
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleInProgressEvent(int actionCode, MotionEvent event) {
|
||||
switch (actionCode) {
|
||||
case MotionEvent.ACTION_POINTER_UP:
|
||||
// Gesture ended but
|
||||
updateStateByEvent(event);
|
||||
|
||||
if (!mSloppyGesture) {
|
||||
mListener.onShoveEnd(this);
|
||||
}
|
||||
|
||||
resetState();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
if (!mSloppyGesture) {
|
||||
mListener.onShoveEnd(this);
|
||||
}
|
||||
|
||||
resetState();
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
updateStateByEvent(event);
|
||||
|
||||
// Only accept the event if our relative pressure is within
|
||||
// a certain limit. This can help filter shaky data as a
|
||||
// finger is lifted. Also check that shove is meaningful.
|
||||
if (mCurrPressure / mPrevPressure > PRESSURE_THRESHOLD
|
||||
&& Math.abs(getShovePixelsDelta()) > 0.5f) {
|
||||
final boolean updatePrevious = mListener.onShove(this);
|
||||
if (updatePrevious) {
|
||||
mPrevEvent.recycle();
|
||||
mPrevEvent = MotionEvent.obtain(event);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateStateByEvent(MotionEvent curr) {
|
||||
super.updateStateByEvent(curr);
|
||||
|
||||
final MotionEvent prev = mPrevEvent;
|
||||
float py0 = prev.getY(0);
|
||||
float py1 = prev.getY(1);
|
||||
mPrevAverageY = (py0 + py1) / 2.0f;
|
||||
|
||||
float cy0 = curr.getY(0);
|
||||
float cy1 = curr.getY(1);
|
||||
mCurrAverageY = (cy0 + cy1) / 2.0f;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isSloppyGesture(MotionEvent event) {
|
||||
boolean sloppy = super.isSloppyGesture(event);
|
||||
if (sloppy)
|
||||
return true;
|
||||
|
||||
// If it's not traditionally sloppy, we check if the angle between fingers
|
||||
// is acceptable.
|
||||
double angle = Math.abs(Math.atan2(mCurrFingerDiffY, mCurrFingerDiffX));
|
||||
//about 20 degrees, left or right
|
||||
return !((0.0f < angle && angle < 0.35f)
|
||||
|| 2.79f < angle && angle < Math.PI);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the distance in pixels from the previous shove event to the current
|
||||
* event.
|
||||
*
|
||||
* @return The current distance in pixels.
|
||||
*/
|
||||
public float getShovePixelsDelta() {
|
||||
return mCurrAverageY - mPrevAverageY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void resetState() {
|
||||
super.resetState();
|
||||
mSloppyGesture = false;
|
||||
mPrevAverageY = 0.0f;
|
||||
mCurrAverageY = 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener which must be implemented which is used by ShoveGestureDetector
|
||||
* to perform callbacks to any implementing class which is registered to a
|
||||
* ShoveGestureDetector via the constructor.
|
||||
*
|
||||
* @see ShoveGestureDetector.SimpleOnShoveGestureListener
|
||||
*/
|
||||
public interface OnShoveGestureListener {
|
||||
public boolean onShove(ShoveGestureDetector detector);
|
||||
|
||||
public boolean onShoveBegin(ShoveGestureDetector detector);
|
||||
|
||||
public void onShoveEnd(ShoveGestureDetector detector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class which may be extended and where the methods may be
|
||||
* implemented. This way it is not necessary to implement all methods
|
||||
* of OnShoveGestureListener.
|
||||
*/
|
||||
public static class SimpleOnShoveGestureListener implements OnShoveGestureListener {
|
||||
public boolean onShove(ShoveGestureDetector detector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean onShoveBegin(ShoveGestureDetector detector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void onShoveEnd(ShoveGestureDetector detector) {
|
||||
// Do nothing, overridden implementation may be used
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package org.thoughtcrime.securesms.scribbles.multitouch;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ViewConfiguration;
|
||||
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
/**
|
||||
* @author Almer Thie (code.almeros.com)
|
||||
* Copyright (c) 2013, Almer Thie (code.almeros.com)
|
||||
* <p>
|
||||
* All rights reserved.
|
||||
* <p>
|
||||
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
* <p>
|
||||
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
|
||||
* in the documentation and/or other materials provided with the distribution.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
|
||||
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
|
||||
* OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY
|
||||
* OF SUCH DAMAGE.
|
||||
*/
|
||||
public abstract class TwoFingerGestureDetector extends BaseGestureDetector {
|
||||
|
||||
private static final String TAG = TwoFingerGestureDetector.class.getSimpleName();
|
||||
|
||||
private final float mEdgeSlop;
|
||||
protected float mPrevFingerDiffX;
|
||||
protected float mPrevFingerDiffY;
|
||||
protected float mCurrFingerDiffX;
|
||||
protected float mCurrFingerDiffY;
|
||||
private float mRightSlopEdge;
|
||||
private float mBottomSlopEdge;
|
||||
private float mCurrLen;
|
||||
private float mPrevLen;
|
||||
|
||||
public TwoFingerGestureDetector(Context context) {
|
||||
super(context);
|
||||
|
||||
ViewConfiguration config = ViewConfiguration.get(context);
|
||||
mEdgeSlop = config.getScaledEdgeSlop();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected abstract void handleStartProgressEvent(int actionCode, MotionEvent event);
|
||||
|
||||
@Override
|
||||
protected abstract void handleInProgressEvent(int actionCode, MotionEvent event);
|
||||
|
||||
protected void updateStateByEvent(MotionEvent curr) {
|
||||
super.updateStateByEvent(curr);
|
||||
|
||||
final MotionEvent prev = mPrevEvent;
|
||||
|
||||
mCurrLen = -1;
|
||||
mPrevLen = -1;
|
||||
|
||||
// Previous
|
||||
final float px0 = prev.getX(0);
|
||||
final float py0 = prev.getY(0);
|
||||
final float px1 = prev.getX(1);
|
||||
final float py1 = prev.getY(1);
|
||||
final float pvx = px1 - px0;
|
||||
final float pvy = py1 - py0;
|
||||
mPrevFingerDiffX = pvx;
|
||||
mPrevFingerDiffY = pvy;
|
||||
|
||||
// Current
|
||||
final float cx0 = curr.getX(0);
|
||||
final float cy0 = curr.getY(0);
|
||||
final float cx1 = curr.getX(1);
|
||||
final float cy1 = curr.getY(1);
|
||||
final float cvx = cx1 - cx0;
|
||||
final float cvy = cy1 - cy0;
|
||||
mCurrFingerDiffX = cvx;
|
||||
mCurrFingerDiffY = cvy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current distance between the two pointers forming the
|
||||
* gesture in progress.
|
||||
*
|
||||
* @return Distance between pointers in pixels.
|
||||
*/
|
||||
public float getCurrentSpan() {
|
||||
if (mCurrLen == -1) {
|
||||
final float cvx = mCurrFingerDiffX;
|
||||
final float cvy = mCurrFingerDiffY;
|
||||
mCurrLen = (float) Math.sqrt(cvx * cvx + cvy * cvy);
|
||||
}
|
||||
return mCurrLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the previous distance between the two pointers forming the
|
||||
* gesture in progress.
|
||||
*
|
||||
* @return Previous distance between pointers in pixels.
|
||||
*/
|
||||
public float getPreviousSpan() {
|
||||
if (mPrevLen == -1) {
|
||||
final float pvx = mPrevFingerDiffX;
|
||||
final float pvy = mPrevFingerDiffY;
|
||||
mPrevLen = (float) Math.sqrt(pvx * pvx + pvy * pvy);
|
||||
}
|
||||
return mPrevLen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have a sloppy gesture. Sloppy gestures can happen if the edge
|
||||
* of the user's hand is touching the screen, for example.
|
||||
*
|
||||
* @param event
|
||||
* @return
|
||||
*/
|
||||
protected boolean isSloppyGesture(MotionEvent event) {
|
||||
// As orientation can change, query the metrics in touch down
|
||||
DisplayMetrics metrics = mContext.getResources().getDisplayMetrics();
|
||||
mRightSlopEdge = metrics.widthPixels - mEdgeSlop;
|
||||
mBottomSlopEdge = metrics.heightPixels - mEdgeSlop;
|
||||
|
||||
final float edgeSlop = mEdgeSlop;
|
||||
final float rightSlop = mRightSlopEdge;
|
||||
final float bottomSlop = mBottomSlopEdge;
|
||||
|
||||
final float x0 = event.getRawX();
|
||||
final float y0 = event.getRawY();
|
||||
final float x1 = getRawX(event, 1);
|
||||
final float y1 = getRawY(event, 1);
|
||||
|
||||
|
||||
Log.d(TAG,
|
||||
String.format("x0: %f, y0: %f, x1: %f, y1: %f, EdgeSlop: %f, RightSlop: %f, BottomSlop: %f",
|
||||
x0, y0, x1, y1, edgeSlop, rightSlop, bottomSlop));
|
||||
|
||||
|
||||
boolean p0sloppy = x0 < edgeSlop || y0 < edgeSlop
|
||||
|| x0 > rightSlop || y0 > bottomSlop;
|
||||
boolean p1sloppy = x1 < edgeSlop || y1 < edgeSlop
|
||||
|| x1 > rightSlop || y1 > bottomSlop;
|
||||
|
||||
if (p0sloppy && p1sloppy) {
|
||||
return true;
|
||||
} else if (p0sloppy) {
|
||||
return true;
|
||||
} else if (p1sloppy) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* MotionEvent has no getRawX(int) method; simulate it pending future API approval.
|
||||
*
|
||||
* @param event
|
||||
* @param pointerIndex
|
||||
* @return
|
||||
*/
|
||||
protected static float getRawX(MotionEvent event, int pointerIndex) {
|
||||
float offset = event.getX() - event.getRawX();
|
||||
if (pointerIndex < event.getPointerCount()) {
|
||||
return event.getX(pointerIndex) + offset;
|
||||
}
|
||||
return 0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* MotionEvent has no getRawY(int) method; simulate it pending future API approval.
|
||||
*
|
||||
* @param event
|
||||
* @param pointerIndex
|
||||
* @return
|
||||
*/
|
||||
protected static float getRawY(MotionEvent event, int pointerIndex) {
|
||||
float offset = Math.abs(event.getY() - event.getRawY());
|
||||
if (pointerIndex < event.getPointerCount()) {
|
||||
return event.getY(pointerIndex) + offset;
|
||||
}
|
||||
return 0f;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.viewmodel;
|
||||
|
||||
|
||||
public class Font {
|
||||
|
||||
/**
|
||||
* color value (ex: 0xFF00FF)
|
||||
*/
|
||||
private int color;
|
||||
/**
|
||||
* name of the font
|
||||
*/
|
||||
private String typeface;
|
||||
/**
|
||||
* size of the font, relative to parent
|
||||
*/
|
||||
private float size;
|
||||
|
||||
public Font() {
|
||||
}
|
||||
|
||||
public void increaseSize(float diff) {
|
||||
if (size + diff <= Limits.MAX_FONT_SIZE) {
|
||||
size = size + diff;
|
||||
}
|
||||
}
|
||||
|
||||
public void decreaseSize(float diff) {
|
||||
if (size - diff >= Limits.MIN_FONT_SIZE) {
|
||||
size = size - diff;
|
||||
}
|
||||
}
|
||||
|
||||
public int getColor() {
|
||||
return color;
|
||||
}
|
||||
|
||||
public void setColor(int color) {
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
public String getTypeface() {
|
||||
return typeface;
|
||||
}
|
||||
|
||||
public void setTypeface(String typeface) {
|
||||
this.typeface = typeface;
|
||||
}
|
||||
|
||||
public float getSize() {
|
||||
return size;
|
||||
}
|
||||
|
||||
public void setSize(float size) {
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
private interface Limits {
|
||||
float MIN_FONT_SIZE = 0.01F;
|
||||
float MAX_FONT_SIZE = 0.46F;
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.scribbles.viewmodel;
|
||||
|
||||
import android.support.annotation.FloatRange;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
public class Layer {
|
||||
|
||||
/**
|
||||
* rotation relative to the layer center, in degrees
|
||||
*/
|
||||
@FloatRange(from = 0.0F, to = 360.0F)
|
||||
private float rotationInDegrees;
|
||||
|
||||
private float scale;
|
||||
/**
|
||||
* top left X coordinate, relative to parent canvas
|
||||
*/
|
||||
private float x;
|
||||
/**
|
||||
* top left Y coordinate, relative to parent canvas
|
||||
*/
|
||||
private float y;
|
||||
/**
|
||||
* is layer flipped horizontally (by X-coordinate)
|
||||
*/
|
||||
private boolean isFlipped;
|
||||
|
||||
public Layer() {
|
||||
reset();
|
||||
}
|
||||
|
||||
protected void reset() {
|
||||
this.rotationInDegrees = 0.0F;
|
||||
this.scale = 1.0F;
|
||||
this.isFlipped = false;
|
||||
this.x = 0.0F;
|
||||
this.y = 0.0F;
|
||||
}
|
||||
|
||||
public void postScale(float scaleDiff) {
|
||||
Log.i("Layer", "ScaleDiff: " + scaleDiff);
|
||||
float newVal = scale + scaleDiff;
|
||||
if (newVal >= getMinScale() && newVal <= getMaxScale()) {
|
||||
scale = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
protected float getMaxScale() {
|
||||
return Limits.MAX_SCALE;
|
||||
}
|
||||
|
||||
protected float getMinScale() {
|
||||
return Limits.MIN_SCALE;
|
||||
}
|
||||
|
||||
public void postRotate(float rotationInDegreesDiff) {
|
||||
this.rotationInDegrees += rotationInDegreesDiff;
|
||||
this.rotationInDegrees %= 360.0F;
|
||||
}
|
||||
|
||||
public void postTranslate(float dx, float dy) {
|
||||
this.x += dx;
|
||||
this.y += dy;
|
||||
}
|
||||
|
||||
public void flip() {
|
||||
this.isFlipped = !isFlipped;
|
||||
}
|
||||
|
||||
public float initialScale() {
|
||||
return Limits.INITIAL_ENTITY_SCALE;
|
||||
}
|
||||
|
||||
public float getRotationInDegrees() {
|
||||
return rotationInDegrees;
|
||||
}
|
||||
|
||||
public void setRotationInDegrees(@FloatRange(from = 0.0, to = 360.0) float rotationInDegrees) {
|
||||
this.rotationInDegrees = rotationInDegrees;
|
||||
}
|
||||
|
||||
public float getScale() {
|
||||
return scale;
|
||||
}
|
||||
|
||||
public void setScale(float scale) {
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
public float getX() {
|
||||
return x;
|
||||
}
|
||||
|
||||
public void setX(float x) {
|
||||
this.x = x;
|
||||
}
|
||||
|
||||
public float getY() {
|
||||
return y;
|
||||
}
|
||||
|
||||
public void setY(float y) {
|
||||
this.y = y;
|
||||
}
|
||||
|
||||
public boolean isFlipped() {
|
||||
return isFlipped;
|
||||
}
|
||||
|
||||
public void setFlipped(boolean flipped) {
|
||||
isFlipped = flipped;
|
||||
}
|
||||
|
||||
interface Limits {
|
||||
float MIN_SCALE = 0.06F;
|
||||
float MAX_SCALE = 4.0F;
|
||||
float INITIAL_ENTITY_SCALE = 0.4F;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.scribbles.viewmodel;
|
||||
|
||||
public class TextLayer extends Layer {
|
||||
|
||||
private String text;
|
||||
private Font font;
|
||||
|
||||
public TextLayer() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reset() {
|
||||
super.reset();
|
||||
this.text = "";
|
||||
this.font = new Font();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected float getMaxScale() {
|
||||
return Limits.MAX_SCALE;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected float getMinScale() {
|
||||
return Limits.MIN_SCALE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float initialScale() {
|
||||
return Limits.INITIAL_SCALE;
|
||||
}
|
||||
|
||||
public String getText() {
|
||||
return text;
|
||||
}
|
||||
|
||||
public void setText(String text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
public Font getFont() {
|
||||
return font;
|
||||
}
|
||||
|
||||
public void setFont(Font font) {
|
||||
this.font = font;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postScale(float scaleDiff) {
|
||||
if (scaleDiff > 0) font.increaseSize(scaleDiff);
|
||||
else if (scaleDiff < 0) font.decreaseSize(Math.abs(scaleDiff));
|
||||
}
|
||||
|
||||
public interface Limits {
|
||||
/**
|
||||
* limit text size to view bounds
|
||||
* so that users don't put small font size and scale it 100+ times
|
||||
*/
|
||||
float MAX_SCALE = 1.0F;
|
||||
float MIN_SCALE = 0.2F;
|
||||
|
||||
float MIN_BITMAP_HEIGHT = 0.13F;
|
||||
|
||||
float FONT_SIZE_STEP = 0.008F;
|
||||
|
||||
float INITIAL_FONT_SIZE = 0.1F;
|
||||
int INITIAL_FONT_COLOR = 0xff000000;
|
||||
|
||||
float INITIAL_SCALE = 0.8F; // set the same to avoid text scaling
|
||||
}
|
||||
}
|
||||
@@ -1,881 +0,0 @@
|
||||
/**
|
||||
* CanvasView.java
|
||||
*
|
||||
* Copyright (c) 2014 Tomohiro IKEDA (Korilakkuma)
|
||||
* Released under the MIT license
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.scribbles.widget;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Bitmap.CompressFormat;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffXfermode;
|
||||
import android.graphics.RectF;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* This class defines fields and methods for drawing.
|
||||
*/
|
||||
public class CanvasView extends View {
|
||||
|
||||
private static final String TAG = CanvasView.class.getSimpleName();
|
||||
|
||||
public static final int DEFAULT_STROKE_WIDTH = 15;
|
||||
|
||||
// Enumeration for Mode
|
||||
public enum Mode {
|
||||
DRAW,
|
||||
TEXT,
|
||||
ERASER;
|
||||
}
|
||||
|
||||
// Enumeration for Drawer
|
||||
public enum Drawer {
|
||||
PEN,
|
||||
LINE,
|
||||
RECTANGLE,
|
||||
CIRCLE,
|
||||
ELLIPSE,
|
||||
QUADRATIC_BEZIER,
|
||||
QUBIC_BEZIER;
|
||||
}
|
||||
|
||||
private int initialWidth = 0;
|
||||
private int initialHeight = 0;
|
||||
private int canvasWidth = 1;
|
||||
private int canvasHeight = 1;
|
||||
private Bitmap bitmap = null;
|
||||
|
||||
private List<Path> pathLists = new ArrayList<Path>();
|
||||
private List<Paint> paintLists = new ArrayList<Paint>();
|
||||
|
||||
// for Eraser
|
||||
// private int baseColor = Color.WHITE;
|
||||
private int baseColor = Color.TRANSPARENT;
|
||||
|
||||
// for Undo, Redo
|
||||
private int historyPointer = 0;
|
||||
|
||||
// Flags
|
||||
private Mode mode = Mode.DRAW;
|
||||
private Drawer drawer = Drawer.PEN;
|
||||
private boolean isDown = false;
|
||||
|
||||
// for Paint
|
||||
private Paint.Style paintStyle = Paint.Style.STROKE;
|
||||
private int paintStrokeColor = Color.BLACK;
|
||||
private int paintFillColor = Color.BLACK;
|
||||
private float paintStrokeWidth = DEFAULT_STROKE_WIDTH;
|
||||
private int opacity = 255;
|
||||
private float blur = 0F;
|
||||
private Paint.Cap lineCap = Paint.Cap.ROUND;
|
||||
|
||||
// for Drawer
|
||||
private float startX = 0F;
|
||||
private float startY = 0F;
|
||||
private float controlX = 0F;
|
||||
private float controlY = 0F;
|
||||
|
||||
private boolean active = false;
|
||||
|
||||
/**
|
||||
* Copy Constructor
|
||||
*
|
||||
* @param context
|
||||
* @param attrs
|
||||
* @param defStyle
|
||||
*/
|
||||
public CanvasView(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
this.setup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy Constructor
|
||||
*
|
||||
* @param context
|
||||
* @param attrs
|
||||
*/
|
||||
public CanvasView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
this.setup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy Constructor
|
||||
*
|
||||
* @param context
|
||||
*/
|
||||
public CanvasView(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
|
||||
private void setup() {
|
||||
this.pathLists.add(new Path());
|
||||
this.paintLists.add(this.createPaint());
|
||||
this.historyPointer++;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method creates the instance of Paint.
|
||||
* In addition, this method sets styles for Paint.
|
||||
*
|
||||
* @return paint This is returned as the instance of Paint
|
||||
*/
|
||||
private Paint createPaint() {
|
||||
Paint paint = new Paint();
|
||||
|
||||
paint.setAntiAlias(true);
|
||||
paint.setStyle(this.paintStyle);
|
||||
paint.setStrokeWidth(this.paintStrokeWidth);
|
||||
paint.setStrokeCap(this.lineCap);
|
||||
paint.setStrokeJoin(Paint.Join.ROUND); // fixed
|
||||
|
||||
if (this.mode == Mode.ERASER) {
|
||||
// Eraser
|
||||
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
|
||||
paint.setARGB(0, 0, 0, 0);
|
||||
|
||||
// paint.setColor(this.baseColor);
|
||||
// paint.setShadowLayer(this.blur, 0F, 0F, this.baseColor);
|
||||
} else {
|
||||
// Otherwise
|
||||
paint.setColor(this.paintStrokeColor);
|
||||
paint.setShadowLayer(this.blur, 0F, 0F, this.paintStrokeColor);
|
||||
paint.setAlpha(this.opacity);
|
||||
}
|
||||
|
||||
return paint;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method initialize Path.
|
||||
* Namely, this method creates the instance of Path,
|
||||
* and moves current position.
|
||||
*
|
||||
* @param event This is argument of onTouchEvent method
|
||||
* @return path This is returned as the instance of Path
|
||||
*/
|
||||
private Path createPath(MotionEvent event) {
|
||||
Path path = new Path();
|
||||
|
||||
// Save for ACTION_MOVE
|
||||
this.startX = scaleX(event.getX());
|
||||
this.startY = scaleY(event.getY());
|
||||
|
||||
path.moveTo(this.startX, this.startY);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method updates the lists for the instance of Path and Paint.
|
||||
* "Undo" and "Redo" are enabled by this method.
|
||||
*
|
||||
* @param path the instance of Path
|
||||
*/
|
||||
private void updateHistory(Path path) {
|
||||
if (this.historyPointer == this.pathLists.size()) {
|
||||
this.pathLists.add(path);
|
||||
this.paintLists.add(this.createPaint());
|
||||
this.historyPointer++;
|
||||
} else {
|
||||
// On the way of Undo or Redo
|
||||
this.pathLists.set(this.historyPointer, path);
|
||||
this.paintLists.set(this.historyPointer, this.createPaint());
|
||||
this.historyPointer++;
|
||||
|
||||
for (int i = this.historyPointer, size = this.paintLists.size(); i < size; i++) {
|
||||
this.pathLists.remove(this.historyPointer);
|
||||
this.paintLists.remove(this.historyPointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets the instance of Path that pointer indicates.
|
||||
*
|
||||
* @return the instance of Path
|
||||
*/
|
||||
private Path getCurrentPath() {
|
||||
return this.pathLists.get(this.historyPointer - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method defines processes on MotionEvent.ACTION_DOWN
|
||||
*
|
||||
* @param event This is argument of onTouchEvent method
|
||||
*/
|
||||
private void onActionDown(MotionEvent event) {
|
||||
switch (this.mode) {
|
||||
case DRAW :
|
||||
case ERASER :
|
||||
if ((this.drawer != Drawer.QUADRATIC_BEZIER) && (this.drawer != Drawer.QUBIC_BEZIER)) {
|
||||
// Oherwise
|
||||
this.updateHistory(this.createPath(event));
|
||||
this.isDown = true;
|
||||
} else {
|
||||
// Bezier
|
||||
if ((this.startX == 0F) && (this.startY == 0F)) {
|
||||
// The 1st tap
|
||||
this.updateHistory(this.createPath(event));
|
||||
} else {
|
||||
// The 2nd tap
|
||||
this.controlX = event.getX();
|
||||
this.controlY = event.getY();
|
||||
|
||||
this.isDown = true;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case TEXT :
|
||||
this.startX = event.getX();
|
||||
this.startY = event.getY();
|
||||
|
||||
break;
|
||||
default :
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method defines processes on MotionEvent.ACTION_MOVE
|
||||
*
|
||||
* @param event This is argument of onTouchEvent method
|
||||
*/
|
||||
private void onActionMove(MotionEvent event) {
|
||||
float x = event.getX();
|
||||
float y = event.getY();
|
||||
|
||||
switch (this.mode) {
|
||||
case DRAW :
|
||||
case ERASER :
|
||||
|
||||
if ((this.drawer != Drawer.QUADRATIC_BEZIER) && (this.drawer != Drawer.QUBIC_BEZIER)) {
|
||||
if (!isDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path path = this.getCurrentPath();
|
||||
|
||||
switch (this.drawer) {
|
||||
case PEN :
|
||||
for (int i = 0; i < event.getHistorySize(); i++) {
|
||||
path.lineTo(scaleX(event.getHistoricalX(i)), scaleY(event.getHistoricalY(i)));
|
||||
}
|
||||
break;
|
||||
case LINE :
|
||||
path.reset();
|
||||
path.moveTo(this.startX, this.startY);
|
||||
path.lineTo(x, y);
|
||||
break;
|
||||
case RECTANGLE :
|
||||
path.reset();
|
||||
path.addRect(this.startX, this.startY, x, y, Path.Direction.CCW);
|
||||
break;
|
||||
case CIRCLE :
|
||||
double distanceX = Math.abs((double)(this.startX - x));
|
||||
double distanceY = Math.abs((double)(this.startX - y));
|
||||
double radius = Math.sqrt(Math.pow(distanceX, 2.0) + Math.pow(distanceY, 2.0));
|
||||
|
||||
path.reset();
|
||||
path.addCircle(this.startX, this.startY, (float)radius, Path.Direction.CCW);
|
||||
break;
|
||||
case ELLIPSE :
|
||||
RectF rect = new RectF(this.startX, this.startY, x, y);
|
||||
|
||||
path.reset();
|
||||
path.addOval(rect, Path.Direction.CCW);
|
||||
break;
|
||||
default :
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (!isDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path path = this.getCurrentPath();
|
||||
|
||||
path.reset();
|
||||
path.moveTo(scaleX(this.startX), scaleY(this.startY));
|
||||
path.quadTo(this.controlX, this.controlY, x, y);
|
||||
}
|
||||
|
||||
break;
|
||||
case TEXT :
|
||||
this.startX = x;
|
||||
this.startY = y;
|
||||
|
||||
break;
|
||||
default :
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method defines processes on MotionEvent.ACTION_DOWN
|
||||
*
|
||||
* @param event This is argument of onTouchEvent method
|
||||
*/
|
||||
private void onActionUp(MotionEvent event) {
|
||||
if (isDown) {
|
||||
this.startX = 0F;
|
||||
this.startY = 0F;
|
||||
this.isDown = false;
|
||||
}
|
||||
}
|
||||
|
||||
public SavedState saveState() {
|
||||
return new SavedState(pathLists, paintLists, historyPointer, initialWidth, initialHeight, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
public void restoreState(@NonNull SavedState state) {
|
||||
this.pathLists.clear();
|
||||
this.pathLists.addAll(state.getPaths());
|
||||
|
||||
this.paintLists.clear();
|
||||
this.paintLists.addAll(state.getPaints());
|
||||
|
||||
this.historyPointer = state.getHistoryPointer();
|
||||
|
||||
this.initialWidth = state.getInitialWidth();
|
||||
this.initialHeight = state.getInitialHeight();
|
||||
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method updates the instance of Canvas (View)
|
||||
*
|
||||
* @param canvas the new instance of Canvas
|
||||
*/
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
canvas.drawColor(this.baseColor);
|
||||
render(canvas);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
this.canvasWidth = w;
|
||||
this.canvasHeight = h;
|
||||
|
||||
if (initialWidth == 0) {
|
||||
initialWidth = canvasWidth;
|
||||
}
|
||||
|
||||
if (initialHeight == 0) {
|
||||
initialHeight = canvasHeight;
|
||||
}
|
||||
}
|
||||
|
||||
public void render(Canvas canvas) {
|
||||
render(canvas, initialWidth, initialHeight, canvasWidth, canvasHeight, pathLists, paintLists, historyPointer);
|
||||
}
|
||||
|
||||
public static void render(Canvas canvas, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight, List<Path> pathLists, List<Paint> paintLists, int historyPointer) {
|
||||
float scaleX = 1f;
|
||||
float scaleY = 1f;
|
||||
|
||||
if (initialWidth > 0) {
|
||||
scaleX *= (float) canvasWidth / initialWidth;
|
||||
}
|
||||
|
||||
if (initialHeight > 0) {
|
||||
scaleY *= (float) canvasHeight / initialHeight;
|
||||
}
|
||||
|
||||
scaleX *= (float) canvas.getWidth() / canvasWidth;
|
||||
scaleY *= (float) canvas.getHeight() / canvasHeight;
|
||||
|
||||
Matrix matrix = new Matrix();
|
||||
matrix.setScale(scaleX, scaleY);
|
||||
|
||||
for (int i = 0; i < historyPointer; i++) {
|
||||
Path path = pathLists.get(i);
|
||||
Paint paint = paintLists.get(i);
|
||||
|
||||
Path scaledPath = new Path();
|
||||
path.transform(matrix, scaledPath);
|
||||
|
||||
Paint scaledPaint = new Paint(paint);
|
||||
scaledPaint.setStrokeWidth(scaledPaint.getStrokeWidth() * scaleX);
|
||||
|
||||
canvas.drawPath(scaledPath, scaledPaint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method set event listener for drawing.
|
||||
*
|
||||
* @param event the instance of MotionEvent
|
||||
* @return
|
||||
*/
|
||||
@Override
|
||||
public boolean onTouchEvent(MotionEvent event) {
|
||||
if (!active) return false;
|
||||
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
this.onActionDown(event);
|
||||
break;
|
||||
case MotionEvent.ACTION_MOVE :
|
||||
this.onActionMove(event);
|
||||
break;
|
||||
case MotionEvent.ACTION_UP :
|
||||
this.onActionUp(event);
|
||||
break;
|
||||
default :
|
||||
break;
|
||||
}
|
||||
|
||||
// Re draw
|
||||
this.invalidate();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for mode.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public Mode getMode() {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for mode.
|
||||
*
|
||||
* @param mode
|
||||
*/
|
||||
public void setMode(Mode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for drawer.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public Drawer getDrawer() {
|
||||
return this.drawer;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for drawer.
|
||||
*
|
||||
* @param drawer
|
||||
*/
|
||||
public void setDrawer(Drawer drawer) {
|
||||
this.drawer = drawer;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method draws canvas again for Undo.
|
||||
*
|
||||
* @return If Undo is enabled, this is returned as true. Otherwise, this is returned as false.
|
||||
*/
|
||||
public boolean undo() {
|
||||
if (this.historyPointer > 1) {
|
||||
this.historyPointer--;
|
||||
this.invalidate();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method draws canvas again for Redo.
|
||||
*
|
||||
* @return If Redo is enabled, this is returned as true. Otherwise, this is returned as false.
|
||||
*/
|
||||
public boolean redo() {
|
||||
if (this.historyPointer < this.pathLists.size()) {
|
||||
this.historyPointer++;
|
||||
this.invalidate();
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method initializes canvas.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public void clear() {
|
||||
Path path = new Path();
|
||||
path.moveTo(0F, 0F);
|
||||
path.addRect(0F, 0F, 1000F, 1000F, Path.Direction.CCW);
|
||||
path.close();
|
||||
|
||||
Paint paint = new Paint();
|
||||
paint.setColor(Color.WHITE);
|
||||
paint.setStyle(Paint.Style.FILL);
|
||||
|
||||
if (this.historyPointer == this.pathLists.size()) {
|
||||
this.pathLists.add(path);
|
||||
this.paintLists.add(paint);
|
||||
this.historyPointer++;
|
||||
} else {
|
||||
// On the way of Undo or Redo
|
||||
this.pathLists.set(this.historyPointer, path);
|
||||
this.paintLists.set(this.historyPointer, paint);
|
||||
this.historyPointer++;
|
||||
|
||||
for (int i = this.historyPointer, size = this.paintLists.size(); i < size; i++) {
|
||||
this.pathLists.remove(this.historyPointer);
|
||||
this.paintLists.remove(this.historyPointer);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for canvas background color
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public int getBaseColor() {
|
||||
return this.baseColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for canvas background color
|
||||
*
|
||||
* @param color
|
||||
*/
|
||||
public void setBaseColor(int color) {
|
||||
this.baseColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for stroke or fill.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public Paint.Style getPaintStyle() {
|
||||
return this.paintStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for stroke or fill.
|
||||
*
|
||||
* @param style
|
||||
*/
|
||||
public void setPaintStyle(Paint.Style style) {
|
||||
this.paintStyle = style;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for stroke color.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public int getPaintStrokeColor() {
|
||||
return this.paintStrokeColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for stroke color.
|
||||
*
|
||||
* @param color
|
||||
*/
|
||||
public void setPaintStrokeColor(int color) {
|
||||
this.paintStrokeColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for fill color.
|
||||
* But, current Android API cannot set fill color (?).
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public int getPaintFillColor() {
|
||||
return this.paintFillColor;
|
||||
};
|
||||
|
||||
/**
|
||||
* This method is setter for fill color.
|
||||
* But, current Android API cannot set fill color (?).
|
||||
*
|
||||
* @param color
|
||||
*/
|
||||
public void setPaintFillColor(int color) {
|
||||
this.paintFillColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for stroke width.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public float getPaintStrokeWidth() {
|
||||
return this.paintStrokeWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for stroke width.
|
||||
*
|
||||
* @param width
|
||||
*/
|
||||
public void setPaintStrokeWidth(float width) {
|
||||
if (width >= 0) {
|
||||
this.paintStrokeWidth = width;
|
||||
} else {
|
||||
this.paintStrokeWidth = 3F;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for alpha.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public int getOpacity() {
|
||||
return this.opacity;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for alpha.
|
||||
* The 1st argument must be between 0 and 255.
|
||||
*
|
||||
* @param opacity
|
||||
*/
|
||||
public void setOpacity(int opacity) {
|
||||
if ((opacity >= 0) && (opacity <= 255)) {
|
||||
this.opacity = opacity;
|
||||
} else {
|
||||
this.opacity= 255;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for amount of blur.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public float getBlur() {
|
||||
return this.blur;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for amount of blur.
|
||||
* The 1st argument is greater than or equal to 0.0.
|
||||
*
|
||||
* @param blur
|
||||
*/
|
||||
public void setBlur(float blur) {
|
||||
if (blur >= 0) {
|
||||
this.blur = blur;
|
||||
} else {
|
||||
this.blur = 0F;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is getter for line cap.
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
public Paint.Cap getLineCap() {
|
||||
return this.lineCap;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is setter for line cap.
|
||||
*
|
||||
* @param cap
|
||||
*/
|
||||
public void setLineCap(Paint.Cap cap) {
|
||||
this.lineCap = cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets current canvas as bitmap.
|
||||
*
|
||||
* @return This is returned as bitmap.
|
||||
*/
|
||||
public Bitmap getBitmap() {
|
||||
this.setDrawingCacheEnabled(false);
|
||||
this.setDrawingCacheEnabled(true);
|
||||
|
||||
return Bitmap.createBitmap(this.getDrawingCache());
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets current canvas as scaled bitmap.
|
||||
*
|
||||
* @return This is returned as scaled bitmap.
|
||||
*/
|
||||
public Bitmap getScaleBitmap(int w, int h) {
|
||||
this.setDrawingCacheEnabled(false);
|
||||
this.setDrawingCacheEnabled(true);
|
||||
|
||||
return Bitmap.createScaledBitmap(this.getDrawingCache(), w, h, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method draws the designated bitmap to canvas.
|
||||
*
|
||||
* @param bitmap
|
||||
*/
|
||||
public void drawBitmap(Bitmap bitmap) {
|
||||
this.bitmap = bitmap;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method draws the designated byte array of bitmap to canvas.
|
||||
*
|
||||
* @param byteArray This is returned as byte array of bitmap.
|
||||
*/
|
||||
public void drawBitmap(byte[] byteArray) {
|
||||
this.drawBitmap(BitmapFactory.decodeByteArray(byteArray, 0, byteArray.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* This static method gets the designated bitmap as byte array.
|
||||
*
|
||||
* @param bitmap
|
||||
* @param format
|
||||
* @param quality
|
||||
* @return This is returned as byte array of bitmap.
|
||||
*/
|
||||
public static byte[] getBitmapAsByteArray(Bitmap bitmap, CompressFormat format, int quality) {
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
bitmap.compress(format, quality, byteArrayOutputStream);
|
||||
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets the bitmap as byte array.
|
||||
*
|
||||
* @param format
|
||||
* @param quality
|
||||
* @return This is returned as byte array of bitmap.
|
||||
*/
|
||||
public byte[] getBitmapAsByteArray(CompressFormat format, int quality) {
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
this.getBitmap().compress(format, quality, byteArrayOutputStream);
|
||||
|
||||
return byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method gets the bitmap as byte array.
|
||||
* Bitmap format is PNG, and quality is 100.
|
||||
*
|
||||
* @return This is returned as byte array of bitmap.
|
||||
*/
|
||||
public byte[] getBitmapAsByteArray() {
|
||||
return this.getBitmapAsByteArray(CompressFormat.PNG, 100);
|
||||
}
|
||||
|
||||
public @NonNull Set<Integer> getUniqueColors() {
|
||||
Set<Integer> colors = new LinkedHashSet<>();
|
||||
|
||||
for (int i = 1; i < paintLists.size() && i < historyPointer; i++) {
|
||||
int color = paintLists.get(i).getColor();
|
||||
colors.add(Color.rgb(Color.red(color), Color.green(color), Color.blue(color)));
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
private float scaleX(float x) {
|
||||
return ((float) initialWidth / canvasWidth) * x;
|
||||
}
|
||||
|
||||
private float scaleY(float y) {
|
||||
return ((float) initialWidth / canvasWidth) * y;
|
||||
}
|
||||
|
||||
static class SavedState {
|
||||
private final List<Path> paths;
|
||||
private final List<Paint> paints;
|
||||
private final int historyPointer;
|
||||
private final int initialWidth;
|
||||
private final int initialHeight;
|
||||
private final int canvasWidth;
|
||||
private final int canvasHeight;
|
||||
|
||||
SavedState(List<Path> paths, List<Paint> paints, int historyPointer, int initialWidth, int initialHeight, int canvasWidth, int canvasHeight) {
|
||||
this.paths = new ArrayList<>(paths);
|
||||
this.paints = new ArrayList<>(paints);
|
||||
this.historyPointer = historyPointer;
|
||||
this.initialWidth = initialWidth;
|
||||
this.initialHeight = initialHeight;
|
||||
this.canvasWidth = canvasWidth;
|
||||
this.canvasHeight = canvasHeight;
|
||||
}
|
||||
|
||||
List<Path> getPaths() {
|
||||
return paths;
|
||||
}
|
||||
|
||||
List<Paint> getPaints() {
|
||||
return paints;
|
||||
}
|
||||
|
||||
int getHistoryPointer() {
|
||||
return historyPointer;
|
||||
}
|
||||
|
||||
int getInitialWidth() {
|
||||
return initialWidth;
|
||||
}
|
||||
|
||||
int getInitialHeight() {
|
||||
return initialHeight;
|
||||
}
|
||||
|
||||
int getCanvasWidth() {
|
||||
return canvasWidth;
|
||||
}
|
||||
|
||||
int getCanvasHeight() {
|
||||
return canvasHeight;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return paths.size() <= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.os.Build;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.support.v4.view.GestureDetectorCompat;
|
||||
import android.support.v4.view.ViewCompat;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.Selection;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
import android.view.GestureDetector;
|
||||
import android.view.Gravity;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ScaleGestureDetector;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.scribbles.multitouch.MoveGestureDetector;
|
||||
import org.thoughtcrime.securesms.scribbles.multitouch.RotateGestureDetector;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class MotionView extends FrameLayout implements TextWatcher {
|
||||
|
||||
private static final String TAG = MotionView.class.getSimpleName();
|
||||
|
||||
public interface Constants {
|
||||
float SELECTED_LAYER_ALPHA = 0.15F;
|
||||
}
|
||||
|
||||
public interface MotionViewCallback {
|
||||
void onEntitySelected(@Nullable MotionEntity entity);
|
||||
void onEntityDoubleTap(@NonNull MotionEntity entity);
|
||||
}
|
||||
|
||||
// layers
|
||||
private final List<MotionEntity> entities = new ArrayList<>();
|
||||
@Nullable
|
||||
private MotionEntity selectedEntity;
|
||||
|
||||
private Paint selectedLayerPaint;
|
||||
|
||||
// callback
|
||||
@Nullable
|
||||
private MotionViewCallback motionViewCallback;
|
||||
|
||||
private EditText editText;
|
||||
|
||||
// gesture detection
|
||||
private ScaleGestureDetector scaleGestureDetector;
|
||||
private RotateGestureDetector rotateGestureDetector;
|
||||
private MoveGestureDetector moveGestureDetector;
|
||||
private GestureDetectorCompat gestureDetectorCompat;
|
||||
|
||||
// constructors
|
||||
public MotionView(Context context) {
|
||||
super(context);
|
||||
init(context, null);
|
||||
}
|
||||
|
||||
public MotionView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
public MotionView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public MotionView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
private void init(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
// I fucking love Android
|
||||
setWillNotDraw(false);
|
||||
|
||||
selectedLayerPaint = new Paint();
|
||||
selectedLayerPaint.setAlpha((int) (255 * Constants.SELECTED_LAYER_ALPHA));
|
||||
selectedLayerPaint.setAntiAlias(true);
|
||||
|
||||
this.editText = new EditText(context, attrs);
|
||||
ViewCompat.setAlpha(this.editText, 0);
|
||||
this.editText.setLayoutParams(new LayoutParams(1, 1, Gravity.TOP | Gravity.LEFT));
|
||||
this.editText.setClickable(false);
|
||||
this.editText.setBackgroundColor(Color.TRANSPARENT);
|
||||
this.editText.setTextSize(TypedValue.COMPLEX_UNIT_SP, 1);
|
||||
this.editText.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD);
|
||||
this.editText.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI);
|
||||
this.addView(editText);
|
||||
this.editText.clearFocus();
|
||||
this.editText.addTextChangedListener(this);
|
||||
this.editText.setId(R.id.motion_view_edittext);
|
||||
|
||||
// init listeners
|
||||
this.scaleGestureDetector = new ScaleGestureDetector(context, new ScaleListener());
|
||||
this.rotateGestureDetector = new RotateGestureDetector(context, new RotateListener());
|
||||
this.moveGestureDetector = new MoveGestureDetector(context, new MoveListener());
|
||||
this.gestureDetectorCompat = new GestureDetectorCompat(context, new TapsListener());
|
||||
|
||||
setOnTouchListener(onTouchListener);
|
||||
|
||||
updateUI();
|
||||
}
|
||||
|
||||
public SavedState saveState() {
|
||||
return new SavedState(entities);
|
||||
}
|
||||
|
||||
public void restoreState(@NonNull SavedState savedState) {
|
||||
this.entities.clear();
|
||||
this.entities.addAll(savedState.getEntities());
|
||||
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
public void startEditing(TextEntity entity) {
|
||||
editText.setFocusableInTouchMode(true);
|
||||
editText.setFocusable(true);
|
||||
editText.requestFocus();
|
||||
editText.setText(entity.getLayer().getText());
|
||||
Selection.setSelection(editText.getText(), editText.length());
|
||||
|
||||
InputMethodManager ims = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ims.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT);
|
||||
}
|
||||
|
||||
public MotionEntity getSelectedEntity() {
|
||||
return selectedEntity;
|
||||
}
|
||||
|
||||
public List<MotionEntity> getEntities() {
|
||||
return entities;
|
||||
}
|
||||
|
||||
public void setMotionViewCallback(@Nullable MotionViewCallback callback) {
|
||||
this.motionViewCallback = callback;
|
||||
}
|
||||
|
||||
public void addEntity(@Nullable MotionEntity entity) {
|
||||
if (entity != null) {
|
||||
entities.add(entity);
|
||||
selectEntity(entity, false);
|
||||
}
|
||||
}
|
||||
|
||||
public void addEntityAndPosition(@Nullable MotionEntity entity) {
|
||||
if (entity != null) {
|
||||
initEntityBorder(entity);
|
||||
initialTranslateAndScale(entity);
|
||||
entities.add(entity);
|
||||
selectEntity(entity, true);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Set<Integer> getUniqueColors() {
|
||||
Set<Integer> colors = new LinkedHashSet<>();
|
||||
|
||||
for (MotionEntity entity : entities) {
|
||||
if (entity instanceof TextEntity) {
|
||||
colors.add(((TextEntity) entity).getLayer().getFont().getColor());
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
private void initEntityBorder(@NonNull MotionEntity entity ) {
|
||||
// init stroke
|
||||
int strokeSize = getResources().getDimensionPixelSize(R.dimen.scribble_stroke_size);
|
||||
Paint borderPaint = new Paint();
|
||||
borderPaint.setStrokeWidth(strokeSize);
|
||||
borderPaint.setAntiAlias(true);
|
||||
borderPaint.setColor(getContext().getResources().getColor(R.color.sticker_selected_color));
|
||||
|
||||
entity.setBorderPaint(borderPaint);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void dispatchDraw(Canvas canvas) {
|
||||
super.dispatchDraw(canvas);
|
||||
|
||||
// dispatch draw is called after child views is drawn.
|
||||
// the idea that is we draw background stickers, than child views (if any), and than selected item
|
||||
// to draw on top of child views - do it in dispatchDraw(Canvas)
|
||||
// to draw below that - do it in onDraw(Canvas)
|
||||
if (selectedEntity != null) {
|
||||
selectedEntity.draw(canvas, selectedLayerPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
render(canvas, entities);
|
||||
}
|
||||
|
||||
public void render(Canvas canvas) {
|
||||
unselectEntity();
|
||||
draw(canvas);
|
||||
}
|
||||
|
||||
public static void render(Canvas canvas, List<MotionEntity> entities) {
|
||||
for (int i = 0; i < entities.size(); i++) {
|
||||
entities.get(i).draw(canvas, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* as a side effect - the method deselects Entity (if any selected)
|
||||
* @return bitmap with all the Entities at their current positions
|
||||
*/
|
||||
public Bitmap getThumbnailImage() {
|
||||
selectEntity(null, false);
|
||||
|
||||
Bitmap bmp = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
|
||||
// IMPORTANT: always create white background, cos if the image is saved in JPEG format,
|
||||
// which doesn't have transparent pixels, the background will be black
|
||||
bmp.eraseColor(Color.WHITE);
|
||||
Canvas canvas = new Canvas(bmp);
|
||||
render(canvas, entities);
|
||||
|
||||
return bmp;
|
||||
}
|
||||
|
||||
private void updateUI() {
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private void handleTranslate(PointF delta) {
|
||||
if (selectedEntity != null) {
|
||||
float newCenterX = selectedEntity.absoluteCenterX() + delta.x;
|
||||
float newCenterY = selectedEntity.absoluteCenterY() + delta.y;
|
||||
// limit entity center to screen bounds
|
||||
boolean needUpdateUI = false;
|
||||
if (newCenterX >= 0 && newCenterX <= getWidth()) {
|
||||
selectedEntity.getLayer().postTranslate(delta.x / getWidth(), 0.0F);
|
||||
needUpdateUI = true;
|
||||
}
|
||||
if (newCenterY >= 0 && newCenterY <= getHeight()) {
|
||||
selectedEntity.getLayer().postTranslate(0.0F, delta.y / getHeight());
|
||||
needUpdateUI = true;
|
||||
}
|
||||
if (needUpdateUI) {
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void initialTranslateAndScale(@NonNull MotionEntity entity) {
|
||||
entity.moveToCanvasCenter();
|
||||
entity.getLayer().setScale(entity.getLayer().initialScale());
|
||||
}
|
||||
|
||||
private void selectEntity(@Nullable MotionEntity entity, boolean updateCallback) {
|
||||
if (selectedEntity != null && entity != selectedEntity) {
|
||||
selectedEntity.setIsSelected(false);
|
||||
|
||||
if (selectedEntity instanceof TextEntity) {
|
||||
if (TextUtils.isEmpty(((TextEntity) selectedEntity).getLayer().getText())) {
|
||||
deletedSelectedEntity();
|
||||
} else {
|
||||
editText.clearComposingText();
|
||||
editText.clearFocus();
|
||||
}
|
||||
InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
imm.hideSoftInputFromWindow(editText.getWindowToken(), 0);
|
||||
}
|
||||
|
||||
}
|
||||
if (entity != null) {
|
||||
entity.setIsSelected(true);
|
||||
}
|
||||
selectedEntity = entity;
|
||||
invalidate();
|
||||
if (updateCallback && motionViewCallback != null) {
|
||||
motionViewCallback.onEntitySelected(entity);
|
||||
}
|
||||
}
|
||||
|
||||
public void unselectEntity() {
|
||||
if (selectedEntity != null) {
|
||||
selectEntity(null, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private MotionEntity findEntityAtPoint(float x, float y) {
|
||||
MotionEntity selected = null;
|
||||
PointF p = new PointF(x, y);
|
||||
for (int i = entities.size() - 1; i >= 0; i--) {
|
||||
if (entities.get(i).pointInLayerRect(p)) {
|
||||
selected = entities.get(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private void updateSelectionOnTap(MotionEvent e) {
|
||||
MotionEntity entity = findEntityAtPoint(e.getX(), e.getY());
|
||||
selectEntity(entity, true);
|
||||
}
|
||||
|
||||
private void updateOnLongPress(MotionEvent e) {
|
||||
// if layer is currently selected and point inside layer - move it to front
|
||||
if (selectedEntity != null) {
|
||||
PointF p = new PointF(e.getX(), e.getY());
|
||||
if (selectedEntity.pointInLayerRect(p)) {
|
||||
bringLayerToFront(selectedEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void bringLayerToFront(@NonNull MotionEntity entity) {
|
||||
// removing and adding brings layer to front
|
||||
if (entities.remove(entity)) {
|
||||
entities.add(entity);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
private void moveEntityToBack(@Nullable MotionEntity entity) {
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
if (entities.remove(entity)) {
|
||||
entities.add(0, entity);
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public void flipSelectedEntity() {
|
||||
if (selectedEntity == null) {
|
||||
return;
|
||||
}
|
||||
selectedEntity.getLayer().flip();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
public void moveSelectedBack() {
|
||||
moveEntityToBack(selectedEntity);
|
||||
}
|
||||
|
||||
public void deletedSelectedEntity() {
|
||||
if (selectedEntity == null) {
|
||||
return;
|
||||
}
|
||||
if (entities.remove(selectedEntity)) {
|
||||
selectedEntity.release();
|
||||
selectedEntity = null;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// memory
|
||||
public void release() {
|
||||
for (MotionEntity entity : entities) {
|
||||
entity.release();
|
||||
}
|
||||
}
|
||||
|
||||
// gesture detectors
|
||||
|
||||
private final View.OnTouchListener onTouchListener = new View.OnTouchListener() {
|
||||
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
if (scaleGestureDetector != null) {
|
||||
scaleGestureDetector.onTouchEvent(event);
|
||||
rotateGestureDetector.onTouchEvent(event);
|
||||
moveGestureDetector.onTouchEvent(event);
|
||||
gestureDetectorCompat.onTouchEvent(event);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
private class TapsListener extends GestureDetector.SimpleOnGestureListener {
|
||||
@Override
|
||||
public boolean onDoubleTap(MotionEvent e) {
|
||||
if (motionViewCallback != null && selectedEntity != null) {
|
||||
motionViewCallback.onEntityDoubleTap(selectedEntity);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLongPress(MotionEvent e) {
|
||||
updateOnLongPress(e);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onSingleTapUp(MotionEvent e) {
|
||||
updateSelectionOnTap(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDown(MotionEvent e) {
|
||||
updateSelectionOnTap(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
|
||||
@Override
|
||||
public boolean onScale(ScaleGestureDetector detector) {
|
||||
if (selectedEntity != null) {
|
||||
float scaleFactorDiff = detector.getScaleFactor();
|
||||
Log.d(TAG, "ScaleFactorDiff: " + scaleFactorDiff);
|
||||
selectedEntity.getLayer().postScale(scaleFactorDiff - 1.0F);
|
||||
selectedEntity.updateEntity();
|
||||
updateUI();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private class RotateListener extends RotateGestureDetector.SimpleOnRotateGestureListener {
|
||||
@Override
|
||||
public boolean onRotate(RotateGestureDetector detector) {
|
||||
if (selectedEntity != null) {
|
||||
selectedEntity.getLayer().postRotate(-detector.getRotationDegreesDelta());
|
||||
updateUI();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private class MoveListener extends MoveGestureDetector.SimpleOnMoveGestureListener {
|
||||
@Override
|
||||
public boolean onMove(MoveGestureDetector detector) {
|
||||
handleTranslate(detector.getFocusDelta());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {
|
||||
String text = s.toString();
|
||||
MotionEntity entity = getSelectedEntity();
|
||||
|
||||
if (entity != null && entity instanceof TextEntity) {
|
||||
TextEntity textEntity = (TextEntity)entity;
|
||||
|
||||
if (!textEntity.getLayer().getText().equals(text)) {
|
||||
textEntity.getLayer().setText(text);
|
||||
textEntity.updateEntity();
|
||||
MotionView.this.invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static class SavedState {
|
||||
|
||||
private final List<MotionEntity> entities;
|
||||
|
||||
SavedState(List<MotionEntity> entities) {
|
||||
this.entities = new ArrayList<>(entities);
|
||||
Stream.of(entities).forEach(e -> e.setIsSelected(false));
|
||||
}
|
||||
|
||||
List<MotionEntity> getEntities() {
|
||||
return entities;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return entities.isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2016 Open Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.MotionEntity;
|
||||
import org.thoughtcrime.securesms.scribbles.widget.entity.TextEntity;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
|
||||
public class ScribbleView extends FrameLayout {
|
||||
|
||||
private static final String TAG = ScribbleView.class.getSimpleName();
|
||||
|
||||
public static final int DEFAULT_BRUSH_WIDTH = CanvasView.DEFAULT_STROKE_WIDTH;
|
||||
|
||||
private ImageView imageView;
|
||||
private MotionView motionView;
|
||||
private CanvasView canvasView;
|
||||
|
||||
private @Nullable Uri imageUri;
|
||||
|
||||
public ScribbleView(Context context) {
|
||||
super(context);
|
||||
initialize(context);
|
||||
}
|
||||
|
||||
public ScribbleView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize(context);
|
||||
}
|
||||
|
||||
public ScribbleView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
initialize(context);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
|
||||
public ScribbleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
initialize(context);
|
||||
}
|
||||
|
||||
public void setImage(@NonNull GlideRequests glideRequests, @NonNull Uri uri) {
|
||||
this.imageUri = uri;
|
||||
|
||||
glideRequests.load(new DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(DrawableTransitionOptions.withCrossFade())
|
||||
.fitCenter()
|
||||
.into(imageView);
|
||||
}
|
||||
|
||||
public @NonNull ListenableFuture<Bitmap> getRenderedImage(@NonNull GlideRequests glideRequests) {
|
||||
return renderImage(getContext(), imageUri, saveState(), glideRequests);
|
||||
}
|
||||
|
||||
public static @NonNull ListenableFuture<Bitmap> renderImage(@NonNull Context context,
|
||||
@Nullable Uri imageUri,
|
||||
@NonNull SavedState savedState,
|
||||
@NonNull GlideRequests glideRequests)
|
||||
{
|
||||
final SettableFuture<Bitmap> future = new SettableFuture<>();
|
||||
final boolean isLowMemory = Util.isLowMemory(context);
|
||||
|
||||
if (imageUri == null) {
|
||||
future.setException(new IllegalStateException("No image URI."));
|
||||
return future;
|
||||
}
|
||||
|
||||
int width = Target.SIZE_ORIGINAL;
|
||||
int height = Target.SIZE_ORIGINAL;
|
||||
|
||||
if (isLowMemory) {
|
||||
width = 768;
|
||||
height = 768;
|
||||
}
|
||||
|
||||
glideRequests.asBitmap()
|
||||
.load(new DecryptableUri(imageUri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.skipMemoryCache(true)
|
||||
.override(width, height)
|
||||
.into(new SimpleTarget<Bitmap>() {
|
||||
@Override
|
||||
public void onResourceReady(@NonNull Bitmap bitmap, @Nullable Transition<? super Bitmap> transition) {
|
||||
Canvas canvas = new Canvas(bitmap);
|
||||
MotionView.render(canvas, savedState.getMotionState().getEntities());
|
||||
CanvasView.render(canvas,
|
||||
savedState.getCanvasState().getInitialWidth(),
|
||||
savedState.getCanvasState().getInitialHeight(),
|
||||
savedState.getCanvasState().getCanvasWidth(),
|
||||
savedState.getCanvasState().getCanvasHeight(),
|
||||
savedState.getCanvasState().getPaths(),
|
||||
savedState.getCanvasState().getPaints(),
|
||||
savedState.getCanvasState().getHistoryPointer());
|
||||
future.set(bitmap);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFailed(@Nullable Drawable errorDrawable) {
|
||||
future.setException(new Throwable("Failed to load image."));
|
||||
}
|
||||
});
|
||||
|
||||
return future;
|
||||
}
|
||||
|
||||
public SavedState saveState() {
|
||||
return new SavedState(canvasView.saveState(), motionView.saveState());
|
||||
}
|
||||
|
||||
public void restoreState(@NonNull SavedState state) {
|
||||
canvasView.restoreState(state.getCanvasState());
|
||||
motionView.restoreState(state.getMotionState());
|
||||
}
|
||||
|
||||
private void initialize(@NonNull Context context) {
|
||||
inflate(context, R.layout.scribble_view, this);
|
||||
|
||||
this.imageView = findViewById(R.id.image_view);
|
||||
this.motionView = findViewById(R.id.motion_view);
|
||||
this.canvasView = findViewById(R.id.canvas_view);
|
||||
}
|
||||
|
||||
public void setMotionViewCallback(MotionView.MotionViewCallback callback) {
|
||||
this.motionView.setMotionViewCallback(callback);
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
public void setDrawingChangedListener(@Nullable DrawingChangedListener listener) {
|
||||
this.canvasView.setOnTouchListener((v, event) -> {
|
||||
if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) {
|
||||
if (listener != null) {
|
||||
listener.onDrawingChanged();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public void setDrawingMode(boolean enabled) {
|
||||
this.canvasView.setActive(enabled);
|
||||
if (enabled) this.motionView.unselectEntity();
|
||||
}
|
||||
|
||||
public void setDrawingBrushColor(int color) {
|
||||
this.canvasView.setPaintFillColor(color);
|
||||
this.canvasView.setPaintStrokeColor(color);
|
||||
this.canvasView.setOpacity(Color.alpha(color));
|
||||
}
|
||||
|
||||
public void setDrawingBrushWidth(int width) {
|
||||
this.canvasView.setPaintStrokeWidth(width);
|
||||
}
|
||||
|
||||
public void addEntityAndPosition(MotionEntity entity) {
|
||||
this.motionView.addEntityAndPosition(entity);
|
||||
}
|
||||
|
||||
public MotionEntity getSelectedEntity() {
|
||||
return this.motionView.getSelectedEntity();
|
||||
}
|
||||
|
||||
public void deleteSelected() {
|
||||
this.motionView.deletedSelectedEntity();
|
||||
}
|
||||
|
||||
public void clearSelection() {
|
||||
this.motionView.unselectEntity();
|
||||
}
|
||||
|
||||
public void undoDrawing() {
|
||||
this.canvasView.undo();
|
||||
}
|
||||
|
||||
public void startEditing(TextEntity entity) {
|
||||
this.motionView.startEditing(entity);
|
||||
}
|
||||
|
||||
public @NonNull Set<Integer> getUniqueColors() {
|
||||
Set<Integer> colors = new LinkedHashSet<>();
|
||||
|
||||
colors.addAll(motionView.getUniqueColors());
|
||||
colors.addAll(canvasView.getUniqueColors());
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMeasure(int width, int height) {
|
||||
super.onMeasure(width, height);
|
||||
|
||||
setMeasuredDimension(imageView.getMeasuredWidth(), imageView.getMeasuredHeight());
|
||||
|
||||
canvasView.measure(MeasureSpec.makeMeasureSpec(imageView.getMeasuredWidth(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(imageView.getMeasuredHeight(), MeasureSpec.EXACTLY));
|
||||
|
||||
motionView.measure(MeasureSpec.makeMeasureSpec(imageView.getMeasuredWidth(), MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(imageView.getMeasuredHeight(), MeasureSpec.EXACTLY));
|
||||
}
|
||||
|
||||
public interface DrawingChangedListener {
|
||||
void onDrawingChanged();
|
||||
}
|
||||
|
||||
public static class SavedState {
|
||||
private final CanvasView.SavedState canvasState;
|
||||
private final MotionView.SavedState motionState;
|
||||
|
||||
SavedState(@NonNull CanvasView.SavedState canvasState, @NonNull MotionView.SavedState motionState) {
|
||||
this.canvasState = canvasState;
|
||||
this.motionState = motionState;
|
||||
}
|
||||
|
||||
CanvasView.SavedState getCanvasState() {
|
||||
return canvasState;
|
||||
}
|
||||
|
||||
MotionView.SavedState getMotionState() {
|
||||
return motionState;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return canvasState.isEmpty() && motionState.isEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,8 @@ public class VerticalSlideColorPicker extends View {
|
||||
viewWidth = w;
|
||||
viewHeight = h;
|
||||
|
||||
if (viewWidth <= 0 || viewHeight <= 0) return;
|
||||
|
||||
int barWidth = (int) (viewWidth * INDICATOR_TO_BAR_WIDTH_RATIO);
|
||||
|
||||
centerX = viewWidth / 2;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget.entity;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.support.annotation.IntRange;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.Layer;
|
||||
|
||||
|
||||
public class ImageEntity extends MotionEntity {
|
||||
|
||||
@NonNull
|
||||
private final Bitmap bitmap;
|
||||
|
||||
public ImageEntity(@NonNull Layer layer,
|
||||
@NonNull Bitmap bitmap,
|
||||
@IntRange(from = 1) int canvasWidth,
|
||||
@IntRange(from = 1) int canvasHeight) {
|
||||
super(layer, canvasWidth, canvasHeight);
|
||||
|
||||
this.bitmap = bitmap;
|
||||
float width = bitmap.getWidth();
|
||||
float height = bitmap.getHeight();
|
||||
|
||||
float widthAspect = 1.0F * canvasWidth / width;
|
||||
float heightAspect = 1.0F * canvasHeight / height;
|
||||
// fit the smallest size
|
||||
holyScale = Math.min(widthAspect, heightAspect);
|
||||
|
||||
// initial position of the entity
|
||||
srcPoints[0] = 0; srcPoints[1] = 0;
|
||||
srcPoints[2] = width; srcPoints[3] = 0;
|
||||
srcPoints[4] = width; srcPoints[5] = height;
|
||||
srcPoints[6] = 0; srcPoints[7] = height;
|
||||
srcPoints[8] = 0; srcPoints[8] = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {
|
||||
canvas.drawBitmap(bitmap, matrix, drawingPaint);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getWidth() {
|
||||
return bitmap.getWidth();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return bitmap.getHeight();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
if (!bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget.entity;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Matrix;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.IntRange;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.util.MathUtils;
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.Layer;
|
||||
|
||||
|
||||
@SuppressWarnings({"WeakerAccess"})
|
||||
public abstract class MotionEntity {
|
||||
|
||||
/**
|
||||
* data
|
||||
*/
|
||||
@NonNull
|
||||
protected final Layer layer;
|
||||
|
||||
/**
|
||||
* transformation matrix for the entity
|
||||
*/
|
||||
protected final Matrix matrix = new Matrix();
|
||||
/**
|
||||
* true - entity is selected and need to draw it's border
|
||||
* false - not selected, no need to draw it's border
|
||||
*/
|
||||
private boolean isSelected;
|
||||
|
||||
/**
|
||||
* maximum scale of the initial image, so that
|
||||
* the entity still fits within the parent canvas
|
||||
*/
|
||||
protected float holyScale;
|
||||
|
||||
/**
|
||||
* width of canvas the entity is drawn in
|
||||
*/
|
||||
@IntRange(from = 0)
|
||||
protected int canvasWidth;
|
||||
/**
|
||||
* height of canvas the entity is drawn in
|
||||
*/
|
||||
@IntRange(from = 0)
|
||||
protected int canvasHeight;
|
||||
|
||||
/**
|
||||
* Destination points of the entity
|
||||
* 5 points. Size of array - 10; Starting upper left corner, clockwise
|
||||
* last point is the same as first to close the circle
|
||||
* NOTE: saved as a field variable in order to avoid creating array in draw()-like methods
|
||||
*/
|
||||
private final float[] destPoints = new float[10]; // x0, y0, x1, y1, x2, y2, x3, y3, x0, y0
|
||||
/**
|
||||
* Initial points of the entity
|
||||
* @see #destPoints
|
||||
*/
|
||||
protected final float[] srcPoints = new float[10]; // x0, y0, x1, y1, x2, y2, x3, y3, x0, y0
|
||||
|
||||
@NonNull
|
||||
private Paint borderPaint = new Paint();
|
||||
|
||||
public MotionEntity(@NonNull Layer layer,
|
||||
@IntRange(from = 1) int canvasWidth,
|
||||
@IntRange(from = 1) int canvasHeight) {
|
||||
this.layer = layer;
|
||||
this.canvasWidth = canvasWidth;
|
||||
this.canvasHeight = canvasHeight;
|
||||
}
|
||||
|
||||
private boolean isSelected() {
|
||||
return isSelected;
|
||||
}
|
||||
|
||||
public void setIsSelected(boolean isSelected) {
|
||||
this.isSelected = isSelected;
|
||||
}
|
||||
|
||||
/**
|
||||
* S - scale matrix, R - rotate matrix, T - translate matrix,
|
||||
* L - result transformation matrix
|
||||
* <p>
|
||||
* The correct order of applying transformations is : L = S * R * T
|
||||
* <p>
|
||||
* See more info: <a href="http://gamedev.stackexchange.com/questions/29260/transform-matrix-multiplication-order">Game Dev: Transform Matrix multiplication order</a>
|
||||
* <p>
|
||||
* Preconcat works like M` = M * S, so we apply preScale -> preRotate -> preTranslate
|
||||
* the result will be the same: L = S * R * T
|
||||
* <p>
|
||||
* NOTE: postconcat (postScale, etc.) works the other way : M` = S * M, in order to use it
|
||||
* we'd need to reverse the order of applying
|
||||
* transformations : post holy scale -> postTranslate -> postRotate -> postScale
|
||||
*/
|
||||
protected void updateMatrix() {
|
||||
// init matrix to E - identity matrix
|
||||
matrix.reset();
|
||||
|
||||
float widthAspect = 1.0F * canvasWidth / getWidth();
|
||||
float heightAspect = 1.0F * canvasHeight / getHeight();
|
||||
// fit the smallest size
|
||||
holyScale = Math.min(widthAspect, heightAspect);
|
||||
|
||||
float topLeftX = layer.getX() * canvasWidth;
|
||||
float topLeftY = layer.getY() * canvasHeight;
|
||||
|
||||
float centerX = topLeftX + getWidth() * holyScale * 0.5F;
|
||||
float centerY = topLeftY + getHeight() * holyScale * 0.5F;
|
||||
|
||||
// calculate params
|
||||
float rotationInDegree = layer.getRotationInDegrees();
|
||||
float scaleX = layer.getScale();
|
||||
float scaleY = layer.getScale();
|
||||
if (layer.isFlipped()) {
|
||||
// flip (by X-coordinate) if needed
|
||||
rotationInDegree *= -1.0F;
|
||||
scaleX *= -1.0F;
|
||||
}
|
||||
|
||||
// applying transformations : L = S * R * T
|
||||
|
||||
// scale
|
||||
matrix.preScale(scaleX, scaleY, centerX, centerY);
|
||||
|
||||
// rotate
|
||||
matrix.preRotate(rotationInDegree, centerX, centerY);
|
||||
|
||||
// translate
|
||||
matrix.preTranslate(topLeftX, topLeftY);
|
||||
|
||||
// applying holy scale - S`, the result will be : L = S * R * T * S`
|
||||
matrix.preScale(holyScale, holyScale);
|
||||
}
|
||||
|
||||
public float absoluteCenterX() {
|
||||
float topLeftX = layer.getX() * canvasWidth;
|
||||
return topLeftX + getWidth() * holyScale * 0.5F;
|
||||
}
|
||||
|
||||
public float absoluteCenterY() {
|
||||
float topLeftY = layer.getY() * canvasHeight;
|
||||
|
||||
return topLeftY + getHeight() * holyScale * 0.5F;
|
||||
}
|
||||
|
||||
public PointF absoluteCenter() {
|
||||
float topLeftX = layer.getX() * canvasWidth;
|
||||
float topLeftY = layer.getY() * canvasHeight;
|
||||
|
||||
float centerX = topLeftX + getWidth() * holyScale * 0.5F;
|
||||
float centerY = topLeftY + getHeight() * holyScale * 0.5F;
|
||||
|
||||
return new PointF(centerX, centerY);
|
||||
}
|
||||
|
||||
public void moveToCanvasCenter() {
|
||||
moveCenterTo(new PointF(canvasWidth * 0.5F, canvasHeight * 0.5F));
|
||||
}
|
||||
|
||||
public void moveCenterTo(PointF moveToCenter) {
|
||||
PointF currentCenter = absoluteCenter();
|
||||
layer.postTranslate(1.0F * (moveToCenter.x - currentCenter.x) / canvasWidth,
|
||||
1.0F * (moveToCenter.y - currentCenter.y) / canvasHeight);
|
||||
}
|
||||
|
||||
private final PointF pA = new PointF();
|
||||
private final PointF pB = new PointF();
|
||||
private final PointF pC = new PointF();
|
||||
private final PointF pD = new PointF();
|
||||
|
||||
/**
|
||||
* For more info:
|
||||
* <a href="http://math.stackexchange.com/questions/190111/how-to-check-if-a-point-is-inside-a-rectangle">StackOverflow: How to check point is in rectangle</a>
|
||||
* <p>NOTE: it's easier to apply the same transformation matrix (calculated before) to the original source points, rather than
|
||||
* calculate the result points ourselves
|
||||
* @param point point
|
||||
* @return true if point (x, y) is inside the triangle
|
||||
*/
|
||||
public boolean pointInLayerRect(PointF point) {
|
||||
|
||||
updateMatrix();
|
||||
// map rect vertices
|
||||
matrix.mapPoints(destPoints, srcPoints);
|
||||
|
||||
pA.x = destPoints[0];
|
||||
pA.y = destPoints[1];
|
||||
pB.x = destPoints[2];
|
||||
pB.y = destPoints[3];
|
||||
pC.x = destPoints[4];
|
||||
pC.y = destPoints[5];
|
||||
pD.x = destPoints[6];
|
||||
pD.y = destPoints[7];
|
||||
|
||||
return MathUtils.pointInTriangle(point, pA, pB, pC) || MathUtils.pointInTriangle(point, pA, pD, pC);
|
||||
}
|
||||
|
||||
/**
|
||||
* http://judepereira.com/blog/calculate-the-real-scale-factor-and-the-angle-of-rotation-from-an-android-matrix/
|
||||
*
|
||||
* @param canvas Canvas to draw
|
||||
* @param drawingPaint Paint to use during drawing
|
||||
*/
|
||||
public final void draw(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {
|
||||
|
||||
this.canvasWidth = canvas.getWidth();
|
||||
this.canvasHeight = canvas.getHeight();
|
||||
|
||||
updateMatrix();
|
||||
|
||||
canvas.save();
|
||||
|
||||
drawContent(canvas, drawingPaint);
|
||||
|
||||
if (isSelected()) {
|
||||
// get alpha from drawingPaint
|
||||
int storedAlpha = borderPaint.getAlpha();
|
||||
if (drawingPaint != null) {
|
||||
borderPaint.setAlpha(drawingPaint.getAlpha());
|
||||
}
|
||||
drawSelectedBg(canvas);
|
||||
// restore border alpha
|
||||
borderPaint.setAlpha(storedAlpha);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
private void drawSelectedBg(Canvas canvas) {
|
||||
matrix.mapPoints(destPoints, srcPoints);
|
||||
//noinspection Range
|
||||
canvas.drawLines(destPoints, 0, 8, borderPaint);
|
||||
//noinspection Range
|
||||
canvas.drawLines(destPoints, 2, 8, borderPaint);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Layer getLayer() {
|
||||
return layer;
|
||||
}
|
||||
|
||||
public void setBorderPaint(@NonNull Paint borderPaint) {
|
||||
this.borderPaint = borderPaint;
|
||||
}
|
||||
|
||||
protected abstract void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint);
|
||||
|
||||
public abstract int getWidth();
|
||||
|
||||
public abstract int getHeight();
|
||||
|
||||
public void release() {
|
||||
// free resources here
|
||||
}
|
||||
|
||||
public void updateEntity() {}
|
||||
|
||||
@Override
|
||||
protected void finalize() throws Throwable {
|
||||
try {
|
||||
release();
|
||||
} finally {
|
||||
//noinspection ThrowFromFinallyBlock
|
||||
super.finalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 UPTech
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.scribbles.widget.entity;
|
||||
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.PointF;
|
||||
import android.support.annotation.IntRange;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
import android.text.Layout;
|
||||
import android.text.StaticLayout;
|
||||
import android.text.TextPaint;
|
||||
|
||||
import org.thoughtcrime.securesms.scribbles.viewmodel.TextLayer;
|
||||
|
||||
|
||||
public class TextEntity extends MotionEntity {
|
||||
|
||||
private final TextPaint textPaint;
|
||||
|
||||
@Nullable
|
||||
private Bitmap bitmap;
|
||||
|
||||
public TextEntity(@NonNull TextLayer textLayer,
|
||||
@IntRange(from = 1) int canvasWidth,
|
||||
@IntRange(from = 1) int canvasHeight)
|
||||
{
|
||||
super(textLayer, canvasWidth, canvasHeight);
|
||||
this.textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
|
||||
|
||||
updateEntity(false);
|
||||
}
|
||||
|
||||
private void updateEntity(boolean moveToPreviousCenter) {
|
||||
// save previous center
|
||||
PointF oldCenter = absoluteCenter();
|
||||
|
||||
Bitmap newBmp = createBitmap(getLayer(), bitmap);
|
||||
|
||||
// recycle previous bitmap (if not reused) as soon as possible
|
||||
if (bitmap != null && bitmap != newBmp && !bitmap.isRecycled()) {
|
||||
bitmap.recycle();
|
||||
}
|
||||
|
||||
this.bitmap = newBmp;
|
||||
|
||||
float width = bitmap.getWidth();
|
||||
float height = bitmap.getHeight();
|
||||
|
||||
@SuppressWarnings("UnnecessaryLocalVariable")
|
||||
float widthAspect = 1.0F * canvasWidth / width;
|
||||
|
||||
// for text we always match text width with parent width
|
||||
this.holyScale = widthAspect;
|
||||
|
||||
// initial position of the entity
|
||||
srcPoints[0] = 0;
|
||||
srcPoints[1] = 0;
|
||||
srcPoints[2] = width;
|
||||
srcPoints[3] = 0;
|
||||
srcPoints[4] = width;
|
||||
srcPoints[5] = height;
|
||||
srcPoints[6] = 0;
|
||||
srcPoints[7] = height;
|
||||
srcPoints[8] = 0;
|
||||
srcPoints[8] = 0;
|
||||
|
||||
if (moveToPreviousCenter) {
|
||||
// move to previous center
|
||||
moveCenterTo(oldCenter);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If reuseBmp is not null, and size of the new bitmap matches the size of the reuseBmp,
|
||||
* new bitmap won't be created, reuseBmp it will be reused instead
|
||||
*
|
||||
* @param textLayer text to draw
|
||||
* @param reuseBmp the bitmap that will be reused
|
||||
* @return bitmap with the text
|
||||
*/
|
||||
@NonNull
|
||||
private Bitmap createBitmap(@NonNull TextLayer textLayer, @Nullable Bitmap reuseBmp) {
|
||||
|
||||
int boundsWidth = canvasWidth;
|
||||
|
||||
// init params - size, color, typeface
|
||||
textPaint.setStyle(Paint.Style.FILL);
|
||||
textPaint.setTextSize(textLayer.getFont().getSize() * canvasWidth);
|
||||
textPaint.setColor(textLayer.getFont().getColor());
|
||||
// textPaint.setTypeface(fontProvider.getTypeface(textLayer.getFont().getTypeface()));
|
||||
|
||||
// drawing text guide : http://ivankocijan.xyz/android-drawing-multiline-text-on-canvas/
|
||||
// Static layout which will be drawn on canvas
|
||||
StaticLayout sl = new StaticLayout(
|
||||
textLayer.getText(), // - text which will be drawn
|
||||
textPaint,
|
||||
boundsWidth, // - width of the layout
|
||||
Layout.Alignment.ALIGN_CENTER, // - layout alignment
|
||||
1, // 1 - text spacing multiply
|
||||
1, // 1 - text spacing add
|
||||
true); // true - include padding
|
||||
|
||||
// calculate height for the entity, min - Limits.MIN_BITMAP_HEIGHT
|
||||
int boundsHeight = sl.getHeight();
|
||||
|
||||
// create bitmap not smaller than TextLayer.Limits.MIN_BITMAP_HEIGHT
|
||||
int bmpHeight = (int) (canvasHeight * Math.max(TextLayer.Limits.MIN_BITMAP_HEIGHT,
|
||||
1.0F * boundsHeight / canvasHeight));
|
||||
|
||||
// create bitmap where text will be drawn
|
||||
Bitmap bmp;
|
||||
if (reuseBmp != null && reuseBmp.getWidth() == boundsWidth
|
||||
&& reuseBmp.getHeight() == bmpHeight) {
|
||||
// if previous bitmap exists, and it's width/height is the same - reuse it
|
||||
bmp = reuseBmp;
|
||||
bmp.eraseColor(Color.TRANSPARENT); // erase color when reusing
|
||||
} else {
|
||||
bmp = Bitmap.createBitmap(boundsWidth, bmpHeight, Bitmap.Config.ARGB_8888);
|
||||
}
|
||||
|
||||
Canvas canvas = new Canvas(bmp);
|
||||
canvas.save();
|
||||
|
||||
// move text to center if bitmap is bigger that text
|
||||
if (boundsHeight < bmpHeight) {
|
||||
//calculate Y coordinate - In this case we want to draw the text in the
|
||||
//center of the canvas so we move Y coordinate to center.
|
||||
float textYCoordinate = (bmpHeight - boundsHeight) / 2;
|
||||
canvas.translate(0, textYCoordinate);
|
||||
}
|
||||
|
||||
//draws static layout on canvas
|
||||
sl.draw(canvas);
|
||||
canvas.restore();
|
||||
|
||||
return bmp;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public TextLayer getLayer() {
|
||||
return (TextLayer) layer;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void drawContent(@NonNull Canvas canvas, @Nullable Paint drawingPaint) {
|
||||
if (bitmap != null) {
|
||||
canvas.drawBitmap(bitmap, matrix, drawingPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getWidth() {
|
||||
return bitmap != null ? bitmap.getWidth() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getHeight() {
|
||||
return bitmap != null ? bitmap.getHeight() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateEntity() {
|
||||
updateEntity(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user