/* * Copyright 2017 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.example.spline.view; import android.content.Context; import android.databinding.InverseBindingListener; import android.databinding.Observable; import android.databinding.ObservableList; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.os.Handler; import android.support.v4.view.VelocityTrackerCompat; import android.util.AttributeSet; import android.util.DisplayMetrics; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.PointerIcon; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.widget.OverScroller; import com.android.example.spline.R; import com.android.example.spline.model.Layer; import com.android.example.spline.model.LayerGroup; import com.android.example.spline.model.OvalLayer; import com.android.example.spline.model.RectLayer; import com.android.example.spline.model.SelectionGroup; import com.android.example.spline.model.ShapeLayer; import com.android.example.spline.model.TriangleLayer; import com.android.example.spline.util.LayerUtils; import java.util.List; /** * The heart of the spline editor, DocumentView renders shape layers on a canvas and allows for * selection and touch manipulation (dragging and resizing) of the layers. */ public class DocumentView extends View { // The radius within which a touch is considered to have touched a control point private static final int TOUCH_RADIUS_DP = 24; private static final float EDIT_CTRL_STROKE_DP = 2; private static final int EDIT_VERTEX_WIDTH_DP = 24; private static final int MODE_DEFAULT = 0; private static final int MODE_LAYER_SELECTION = 1; private static final int MODE_LAYER_PRE_DRAG = 2; private static final int MODE_LAYER_DRAG = 3; private static final int MODE_LAYER_TRANSFORM_DRAG = 4; private static final int MODE_VIEWPORT_DRAG = 5; private VelocityTracker mVelocityTracker = null; private OverScroller mScroller; private float mDensity; private float mTouchSlop; private int mLongPressTimeout; private float mTouchRadius; private float mEditCtrlStrokeWidth; private int mEditColor; private int mMode; private PointF mCurrentPoint; private float mViewportX; private float mViewportY; private int mViewportWidth; private int mViewportHeight; private float mTouchDownX; private float mTouchDownY; private float mPrevX; private float mPrevY; private float mPrevRawX; private float mPrevRawY; private boolean mTouchDownInCurrentLayerBounds; private LayerGroup mRoot; private Layer mCurrentLayer; private Layer mLayerDown; private ObservableList.OnListChangedCallback<ObservableList<Layer>> mOnListChangedCallback; private Observable.OnPropertyChangedCallback mOnPropertyChangedCallback; private InverseBindingListener mCurrentLayerAttrChangedListener; private InverseBindingListener mViewportXAttrChangedListener; private InverseBindingListener mViewportYAttrChangedListener; private InverseBindingListener mViewportWidthAttrChangedListener; private InverseBindingListener mViewportHeightAttrChangedListener; public DocumentView(Context context) { super(context); init(context); } public DocumentView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public void init(Context context) { DisplayMetrics metrics = context.getResources().getDisplayMetrics(); mDensity = metrics.density; ViewConfiguration vc = ViewConfiguration.get(context); mTouchSlop = vc.getScaledTouchSlop(); mLongPressTimeout = vc.getLongPressTimeout(); mScroller = new OverScroller(context); mTouchRadius = TOUCH_RADIUS_DP * mDensity; mEditCtrlStrokeWidth = EDIT_CTRL_STROKE_DP * mDensity; mEditColor = getResources().getColor(R.color.colorAccent, context.getTheme()); mOnListChangedCallback = new ObservableList .OnListChangedCallback<ObservableList<Layer>>() { @Override public void onChanged(ObservableList<Layer> layers) { invalidate(); } @Override public void onItemRangeChanged(ObservableList<Layer> layers, int i, int i1) { invalidate(); } @Override public void onItemRangeInserted(ObservableList<Layer> layers, int start, int count) { invalidate(); addPropertyChangedCallbacks(layers, start, start + count); } @Override public void onItemRangeMoved(ObservableList<Layer> layers, int i, int i1, int i2) { invalidate(); } @Override public void onItemRangeRemoved(ObservableList<Layer> layers, int i, int i1) { invalidate(); } }; mOnPropertyChangedCallback = new Observable.OnPropertyChangedCallback() { @Override public void onPropertyChanged(Observable observable, int i) { invalidate(); } }; } private void addPropertyChangedCallbacks(List<Layer> layers) { addPropertyChangedCallbacks(layers, 0, layers.size()); } private void addPropertyChangedCallbacks(List<Layer> layers, int start, int end) { for (int i = start; i < end; i++) { Layer l = layers.get(i); l.addOnPropertyChangedCallback(mOnPropertyChangedCallback); if (l instanceof LayerGroup) { LayerGroup lg = (LayerGroup) l; ObservableList<Layer> childLayers = lg.getLayers(); // Add list listener for future changes to the layer group's list of children childLayers.addOnListChangedCallback(mOnListChangedCallback); // Recursive call to add property listeners to each child layer addPropertyChangedCallbacks(childLayers); } } } public void setRoot(LayerGroup root) { mRoot = root; if (root != null) { mRoot.getLayers().addOnListChangedCallback(mOnListChangedCallback); addPropertyChangedCallbacks(mRoot.getLayers()); } } public Layer getCurrentLayer() { return mCurrentLayer; } public void setCurrentLayer(Layer layer) { if (mCurrentLayer != layer) { mCurrentLayer = layer; if (mCurrentLayerAttrChangedListener != null) { mCurrentLayerAttrChangedListener.onChange(); } invalidate(); } } public void setCurrentLayerAttrChanged(InverseBindingListener listener) { mCurrentLayerAttrChangedListener = listener; } public float getViewportX() { return mViewportX; } public void setViewportX(float viewportX) { if (viewportX != mViewportX) { mViewportX = viewportX; if (mViewportXAttrChangedListener != null) { mViewportXAttrChangedListener.onChange(); } invalidate(); } } public void setViewportXAttrChanged(InverseBindingListener listener) { mViewportXAttrChangedListener = listener; } public float getViewportY() { return mViewportY; } public void setViewportY(float viewportY) { if (viewportY != mViewportY) { mViewportY = viewportY; if (mViewportYAttrChangedListener != null) { mViewportYAttrChangedListener.onChange(); } invalidate(); } } public void setViewportYAttrChanged(InverseBindingListener listener) { mViewportYAttrChangedListener = listener; } public float getViewportWidth() { return mViewportWidth; } public void setViewportWidth(int viewportWidth) { if (viewportWidth != mViewportWidth) { mViewportWidth = viewportWidth; if (mViewportWidthAttrChangedListener != null) { mViewportWidthAttrChangedListener.onChange(); } } } public void setViewportWidthAttrChanged(InverseBindingListener listener) { mViewportWidthAttrChangedListener = listener; } public float getViewportHeight() { return mViewportHeight; } public void setViewportHeight(int viewportHeight) { if (viewportHeight != mViewportHeight) { mViewportHeight = viewportHeight; if (mViewportHeightAttrChangedListener != null) { mViewportHeightAttrChangedListener.onChange(); } } } public void setViewportHeightAttrChanged(InverseBindingListener listener) { mViewportHeightAttrChangedListener = listener; } @Override public void onSizeChanged(int width, int height, int oldWidth, int oldHeight) { setViewportWidth(width); setViewportHeight(height); super.onSizeChanged(width, height, oldWidth, oldHeight); } /** * View contents are entirely custom drawn */ @Override protected void onDraw(Canvas canvas) { float vx = getViewportX(); float vy = getViewportY(); if (mRoot != null) { drawLayers(canvas, vx, vy, mRoot.getLayers()); } // Drag current layer bounding box and control points afterwards to draw on top if (mCurrentLayer != null) { Layer l = mCurrentLayer; Paint strokePaint = new Paint(); strokePaint.setColor(mEditColor); strokePaint.setStyle(Paint.Style.STROKE); strokePaint.setStrokeWidth(mEditCtrlStrokeWidth); // Draw bounding box canvas.drawRect( l.getLeft() + vx, l.getTop() + vy, l.getRight() + vx, l.getBottom() + vy, strokePaint ); Paint pointPaint = new Paint(); pointPaint.setColor(mEditColor); pointPaint.setStrokeWidth(mEditCtrlStrokeWidth); pointPaint.setAntiAlias(true); // Draw control points drawRoundRect(canvas, l.getLeft() + vx, l.getTop() + vy, pointPaint); drawRoundRect(canvas, l.getMidX() + vx, l.getTop() + vy, pointPaint); drawRoundRect(canvas, l.getRight() + vx, l.getTop() + vy, pointPaint); drawRoundRect(canvas, l.getLeft() + vx, l.getMidY() + vy, pointPaint); drawRoundRect(canvas, l.getRight() + vx, l.getMidY() + vy, pointPaint); drawRoundRect(canvas, l.getLeft() + vx, l.getBottom() + vy, pointPaint); drawRoundRect(canvas, l.getMidX() + vx, l.getBottom() + vy, pointPaint); drawRoundRect(canvas, l.getRight() + vx, l.getBottom() + vy, pointPaint); } } private void drawLayers(Canvas canvas, float vx, float vy, List<Layer> layers) { if (layers != null) { Paint p = new Paint(); for (Layer layer : layers) { if (layer.isVisible()) { if (layer instanceof LayerGroup) { LayerGroup group = (LayerGroup) layer; drawLayers(canvas, vx, vy, group.getLayers()); } else if (layer instanceof ShapeLayer) { ShapeLayer shapeLayer = (ShapeLayer) layer; p.setColor(shapeLayer.getColorInt()); p.setAlpha(Math.round(layer.getCompOpacity() / 100f * 255)); // Use different canvas draw method depending on shape if (shapeLayer instanceof RectLayer) { p.setAntiAlias(false); canvas.drawRect( layer.getLeft() + vx, layer.getTop() + vy, layer.getRight() + vx, layer.getBottom() + vy, p ); } else if (shapeLayer instanceof TriangleLayer) { p.setAntiAlias(true); Path path = new Path(); path.moveTo(layer.getLeft() + vx, layer.getBottom() + vy); path.lineTo(layer.getRight() + vx, layer.getBottom() + vy); path.lineTo(layer.getMidX() + vx, layer.getTop() + vy); canvas.drawPath(path, p); } else if (shapeLayer instanceof OvalLayer) { p.setAntiAlias(true); canvas.drawOval( layer.getLeft() + vx, layer.getTop() + vy, layer.getRight() + vx, layer.getBottom() + vy, p ); } } } } } } public void drawRoundRect(Canvas canvas, float x, float y, Paint pointPaint) { float w = EDIT_VERTEX_WIDTH_DP / 2; canvas.drawRoundRect(x - w, y - w, x + w, y + w, w / 4, w / 4, pointPaint); } /** * @return true if the current touch should trigger a context menu - i.e., was this a touch on * the currently selected layer */ public boolean shouldShowContextMenu() { return mMode < MODE_LAYER_DRAG; } public boolean shouldShowCurrentLayerContextItems() { return mCurrentLayer != null && mTouchDownInCurrentLayerBounds; } /** * Adds support for different mouse pointer icons depending on document state and mouse position */ @Override public PointerIcon onResolvePointerIcon(MotionEvent event, int pointerIndex) { int icon = PointerIcon.TYPE_DEFAULT; Layer l = mCurrentLayer; float x = event.getX() - getViewportX(); float y = event.getY() - getViewportY(); if (mMode == MODE_LAYER_DRAG || mMode == MODE_LAYER_PRE_DRAG) { icon = PointerIcon.TYPE_GRABBING; } else { if (l != null) { if (inPointTouchRadius(x, y, l.getTopLeft()) || inPointTouchRadius(x, y, l.getBottomRight())) { icon = PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW; } else if (inPointTouchRadius(x, y, l.getTopRight()) || inPointTouchRadius(x, y, l.getBottomLeft())) { icon = PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW; } else if (inPointTouchRadius(x, y, l.getMidTop()) || inPointTouchRadius(x, y, l.getMidBottom())) { icon = PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW; } else if (inPointTouchRadius(x, y, l.getMidLeft()) || inPointTouchRadius(x, y, l.getMidRight())) { icon = PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW; } else if (l.inBounds(x, y)) { switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: // Only change to hand if this is a primary button click if (event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { icon = PointerIcon.TYPE_GRABBING; } else { icon = PointerIcon.TYPE_DEFAULT; } break; case MotionEvent.ACTION_HOVER_MOVE: icon = PointerIcon.TYPE_GRAB; break; case MotionEvent.ACTION_UP: default: if (event.getActionButton() == MotionEvent.BUTTON_PRIMARY) { icon = PointerIcon.TYPE_GRAB; } else { icon = PointerIcon.TYPE_DEFAULT; } } } } } return PointerIcon.getSystemIcon(getContext(), icon); } private boolean inPointTouchRadius(float x, float y, PointF p) { float dx = x - p.x; float dy = y - p.y; return Math.sqrt(dx * dx + dy * dy) < mTouchRadius; } @Override public boolean onTouchEvent(MotionEvent event) { float x = event.getX() - getViewportX(); float y = event.getY() - getViewportY(); float dx; float dy; int index = event.getActionIndex(); int pointerId = event.getPointerId(index); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: if (!mScroller.isFinished()) { mScroller.abortAnimation(); } if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } else { mVelocityTracker.clear(); } mVelocityTracker.addMovement(event); mMode = MODE_LAYER_SELECTION; mLayerDown = null; mCurrentPoint = null; mTouchDownInCurrentLayerBounds = false; // Disable interaction after long press new Handler().postDelayed(new Runnable() { public void run() { if (mMode < MODE_LAYER_DRAG) { mMode = MODE_DEFAULT; } } }, mLongPressTimeout); mTouchDownX = x; mTouchDownY = y; mPrevRawX = event.getX(); mPrevRawY = event.getY(); if (mCurrentLayer != null) { if (mCurrentLayer instanceof ShapeLayer) { ShapeLayer s = (ShapeLayer) mCurrentLayer; mTouchDownInCurrentLayerBounds = s.inShapeBounds(x, y); } else { mTouchDownInCurrentLayerBounds = mCurrentLayer.inBounds(x, y); } // Skip vertex check if we're inside the hit area of all vertices if (mCurrentLayer.inInsetBounds(x, y, mTouchRadius)) { mMode = MODE_LAYER_PRE_DRAG; } else { PointF closestPoint = null; double closestDist = -1; // Find the closest control point of the layer's bounding box List<PointF> transformVertices = mCurrentLayer.getTransformVertices(); for (PointF p : transformVertices) { dx = x - p.x; dy = y - p.y; float dist = (float) Math.sqrt(dx * dx + dy * dy); if (closestPoint == null || dist < closestDist) { closestPoint = p; closestDist = dist; } } // If that closest point falls within the touch radius, change the mode to // transform drag if (closestPoint != null && closestDist < mTouchRadius) { boolean vertexChanged = false; if (closestPoint != mCurrentPoint) { vertexChanged = true; } mCurrentPoint = closestPoint; mMode = MODE_LAYER_TRANSFORM_DRAG; mCurrentLayer.startResize(); if (vertexChanged) { invalidate(); } } else if (mCurrentLayer.inBounds(x, y)) { // Otherwise, if we're within the layer's bounds move to pre drag mMode = MODE_LAYER_PRE_DRAG; } } } // If none of the preceding checks changed the mode, look for the top layer under // the touch down coordinates (if one exists) if (mMode == MODE_LAYER_SELECTION && mRoot != null) { mLayerDown = getTopLayerHit(x, y); } break; case MotionEvent.ACTION_MOVE: mVelocityTracker.addMovement(event); if (mMode == MODE_LAYER_SELECTION || mMode == MODE_LAYER_PRE_DRAG) { dx = x - mTouchDownX; dy = y - mTouchDownY; float delta = (float) Math.sqrt(dx * dx + dy * dy); // Move to a drag mode if we've exceeded the system touch slop if (delta > mTouchSlop) { // If our touch doesn't fall within the currently selected layer, // consider this a drag of the viewport if (mMode == MODE_LAYER_SELECTION) { mMode = MODE_VIEWPORT_DRAG; } else { mMode = MODE_LAYER_DRAG; mCurrentLayer.startDrag(); } } } if (mMode == MODE_VIEWPORT_DRAG) { setViewportX(getViewportX() + event.getX() - mPrevRawX); setViewportY(getViewportY() + event.getY() - mPrevRawY); invalidate(); } if (mMode == MODE_LAYER_DRAG) { // For now, simply round deltas to the nearest pixel. Effectively makes the // atomic drag unit the pixel. dx = Math.round(x - mTouchDownX); dy = Math.round(y - mTouchDownY); int metaState = event.getMetaState(); boolean isShiftPressed = (metaState & KeyEvent.META_SHIFT_ON) != 0; if (isShiftPressed) { if (Math.abs(dx) > Math.abs(dy)) { mCurrentLayer.setX(mCurrentLayer.getStartX() + dx); mCurrentLayer.setY(mCurrentLayer.getStartY()); } else { mCurrentLayer.setX(mCurrentLayer.getStartX()); mCurrentLayer.setY(mCurrentLayer.getStartY() + dy); } } else { mCurrentLayer.setX(mCurrentLayer.getStartX() + dx); mCurrentLayer.setY(mCurrentLayer.getStartY() + dy); } invalidate(); } if (mMode == MODE_LAYER_TRANSFORM_DRAG) { // For now, simply round deltas to the nearest pixel. Effectively makes the // atomic drag unit the pixel. dx = Math.round(x - mPrevX); dy = Math.round(y - mPrevY); mCurrentLayer.resize(mCurrentPoint, dx, dy); invalidate(); } break; case MotionEvent.ACTION_UP: if (mMode == MODE_LAYER_TRANSFORM_DRAG) { mCurrentLayer.endResize(); } if (mMode == MODE_VIEWPORT_DRAG) { mVelocityTracker.computeCurrentVelocity(1000); int vx = (int) (VelocityTrackerCompat.getXVelocity(mVelocityTracker, pointerId)); int vy = (int) (VelocityTrackerCompat.getYVelocity(mVelocityTracker, pointerId)); mScroller.fling((int) getViewportX(), (int) getViewportY(), vx, vy, -10000, 10000, -10000, 10000); this.invalidate(); } // We didn't end up dragging the current layer, so check if we selected another // layer on top of the current layer that may be within its bounds. if (mMode == MODE_LAYER_PRE_DRAG) { mLayerDown = getTopLayerHit(mTouchDownX, mTouchDownY); if (mLayerDown != mCurrentLayer) { int downIdx = 0; //mLayers.indexOf(mLayerDown); int currentIdx = 0; //mLayers.indexOf(mCurrentLayer); boolean outsideCurrentShape = true; if (mCurrentLayer != null && mCurrentLayer instanceof ShapeLayer) { outsideCurrentShape = !((ShapeLayer) mCurrentLayer).inShapeBounds( mTouchDownX, mTouchDownY); } if (downIdx > currentIdx || outsideCurrentShape) { mMode = MODE_LAYER_SELECTION; } } } // Do the actual layer selection if ending a touch in selection mode (i.e., did not // exceed the touch slop if (mMode == MODE_LAYER_SELECTION) { dx = x - mTouchDownX; dy = y - mTouchDownY; float delta = (float) Math.sqrt(dx * dx + dy * dy); if (delta < mTouchSlop) { int metaState = event.getMetaState(); boolean isShiftPressed = (metaState & KeyEvent.META_SHIFT_ON) != 0; Layer selection = LayerUtils.selectionFrom( mCurrentLayer, mLayerDown, isShiftPressed); setCurrentLayer(selection); } } case MotionEvent.ACTION_CANCEL: mMode = MODE_DEFAULT; break; } mPrevX = x; mPrevY = y; mPrevRawX = event.getX(); mPrevRawY = event.getY(); // Call super at the end so that it can trigger onCreateContextMenu, but only after the // subclass has a chance to record state info about the touch down to help the host activity // determine if a context menu should be shown for this touch super.onTouchEvent(event); return true; } @Override public void computeScroll() { if (mScroller != null && mScroller.computeScrollOffset()) { int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); setViewportX(x); setViewportY(y); this.postInvalidate(); } } public Layer getTopLayerHit(float x, float y) { LayerGroup root; // If the current layer is a selection, use the most recently selected layer's parent as // the root of a search for the top layer hit, if available. if (mCurrentLayer instanceof SelectionGroup) { SelectionGroup group = (SelectionGroup) mCurrentLayer; List<Layer> layers = group.getLayers(); if (layers.size() > 0) { Layer mostRecent = layers.get(layers.size() - 1); if (mostRecent != null && mostRecent.getParent() instanceof LayerGroup) { root = (LayerGroup) mostRecent.getParent(); } else { root = mRoot; } } else { root = mRoot; } } else if (mCurrentLayer instanceof LayerGroup) { root = (LayerGroup) mCurrentLayer; } else if (mCurrentLayer != null && mCurrentLayer.getParent() instanceof LayerGroup) { root = (LayerGroup) mCurrentLayer.getParent(); } else { root = null; } return getTopLayerHit(x, y, root); } public Layer getTopLayerHit(float x, float y, LayerGroup root) { Layer topLayerHit = null; List<Layer> layers; if (root == null) { root = mRoot; } layers = root.getLayers(); for (int i = layers.size() - 1; i >= 0; i--) { Layer l = layers.get(i); // Only select visible layers if (l != mCurrentLayer && l.isVisible() && l.inBounds(x, y)) { // ShapeLayer is only a hit if the point is within the shape bounds if (l instanceof ShapeLayer && !((ShapeLayer) l).inShapeBounds(x, y)) { continue; } topLayerHit = l; break; } } if (topLayerHit == null && root != null && root.getParent() instanceof LayerGroup) { return getTopLayerHit(x, y, ((LayerGroup) root.getParent())); } return topLayerHit; } }