blob: a9cecaaf1f76e5e486db532693cc42433d4ec73d [file] [log] [blame]
/*
* Copyright (C) 2021 The Android Open Source Project
*
* 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
*
* http://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.systemui.screenshot;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.util.Log;
import android.util.MathUtils;
import android.util.Range;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.SeekBar;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.customview.widget.ExploreByTouchHelper;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import com.android.internal.graphics.ColorUtils;
import com.android.systemui.R;
import java.util.List;
/**
* CropView has top and bottom draggable crop handles, with a scrim to darken the areas being
* cropped out.
*/
public class CropView extends View {
private static final String TAG = "CropView";
public enum CropBoundary {
NONE, TOP, BOTTOM, LEFT, RIGHT
}
private final float mCropTouchMargin;
private final Paint mShadePaint;
private final Paint mHandlePaint;
private final Paint mContainerBackgroundPaint;
// Crop rect with each element represented as [0,1] along its proper axis.
private RectF mCrop = new RectF(0, 0, 1, 1);
private int mExtraTopPadding;
private int mExtraBottomPadding;
private int mImageWidth;
private CropBoundary mCurrentDraggingBoundary = CropBoundary.NONE;
private int mActivePointerId;
// The starting value of mCurrentDraggingBoundary's crop, used to compute touch deltas.
private float mMovementStartValue;
private float mStartingY; // y coordinate of ACTION_DOWN
private float mStartingX;
// The allowable values for the current boundary being dragged
private Range<Float> mMotionRange;
// Value [0,1] indicating progress in animateEntrance()
private float mEntranceInterpolation = 1f;
private CropInteractionListener mCropInteractionListener;
private final ExploreByTouchHelper mExploreByTouchHelper;
public CropView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CropView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray t = context.getTheme().obtainStyledAttributes(
attrs, R.styleable.CropView, 0, 0);
mShadePaint = new Paint();
int alpha = t.getInteger(R.styleable.CropView_scrimAlpha, 255);
int scrimColor = t.getColor(R.styleable.CropView_scrimColor, Color.TRANSPARENT);
mShadePaint.setColor(ColorUtils.setAlphaComponent(scrimColor, alpha));
mContainerBackgroundPaint = new Paint();
mContainerBackgroundPaint.setColor(t.getColor(R.styleable.CropView_containerBackgroundColor,
Color.TRANSPARENT));
mHandlePaint = new Paint();
mHandlePaint.setColor(t.getColor(R.styleable.CropView_handleColor, Color.BLACK));
mHandlePaint.setStrokeCap(Paint.Cap.ROUND);
mHandlePaint.setStrokeWidth(
t.getDimensionPixelSize(R.styleable.CropView_handleThickness, 20));
t.recycle();
// 48 dp touchable region around each handle.
mCropTouchMargin = 24 * getResources().getDisplayMetrics().density;
mExploreByTouchHelper = new AccessibilityHelper();
ViewCompat.setAccessibilityDelegate(this, mExploreByTouchHelper);
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable superState = super.onSaveInstanceState();
SavedState ss = new SavedState(superState);
ss.mCrop = mCrop;
return ss;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
SavedState ss = (SavedState) state;
super.onRestoreInstanceState(ss.getSuperState());
mCrop = ss.mCrop;
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
// Top and bottom borders reflect the boundary between the (scrimmed) image and the
// opaque container background. This is only meaningful during an entrance transition.
float topBorder = MathUtils.lerp(mCrop.top, 0, mEntranceInterpolation);
float bottomBorder = MathUtils.lerp(mCrop.bottom, 1, mEntranceInterpolation);
drawShade(canvas, 0, topBorder, 1, mCrop.top);
drawShade(canvas, 0, mCrop.bottom, 1, bottomBorder);
drawShade(canvas, 0, mCrop.top, mCrop.left, mCrop.bottom);
drawShade(canvas, mCrop.right, mCrop.top, 1, mCrop.bottom);
// Entrance transition expects the crop bounds to be full width, so we only draw container
// background on the top and bottom.
drawContainerBackground(canvas, 0, 0, 1, topBorder);
drawContainerBackground(canvas, 0, bottomBorder, 1, 1);
mHandlePaint.setAlpha((int) (mEntranceInterpolation * 255));
drawHorizontalHandle(canvas, mCrop.top, /* draw the handle tab up */ true);
drawHorizontalHandle(canvas, mCrop.bottom, /* draw the handle tab down */ false);
drawVerticalHandle(canvas, mCrop.left, /* left */ true);
drawVerticalHandle(canvas, mCrop.right, /* right */ false);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int topPx = fractionToVerticalPixels(mCrop.top);
int bottomPx = fractionToVerticalPixels(mCrop.bottom);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mCurrentDraggingBoundary = nearestBoundary(event, topPx, bottomPx,
fractionToHorizontalPixels(mCrop.left),
fractionToHorizontalPixels(mCrop.right));
if (mCurrentDraggingBoundary != CropBoundary.NONE) {
mActivePointerId = event.getPointerId(0);
mStartingY = event.getY();
mStartingX = event.getX();
mMovementStartValue = getBoundaryPosition(mCurrentDraggingBoundary);
updateListener(MotionEvent.ACTION_DOWN, event.getX());
mMotionRange = getAllowedValues(mCurrentDraggingBoundary);
}
return true;
case MotionEvent.ACTION_MOVE:
if (mCurrentDraggingBoundary != CropBoundary.NONE) {
int pointerIndex = event.findPointerIndex(mActivePointerId);
if (pointerIndex >= 0) {
// Original pointer still active, do the move.
float deltaPx = isVertical(mCurrentDraggingBoundary)
? event.getY(pointerIndex) - mStartingY
: event.getX(pointerIndex) - mStartingX;
float delta = pixelDistanceToFraction((int) deltaPx,
mCurrentDraggingBoundary);
setBoundaryPosition(mCurrentDraggingBoundary,
mMotionRange.clamp(mMovementStartValue + delta));
updateListener(MotionEvent.ACTION_MOVE, event.getX(pointerIndex));
invalidate();
}
return true;
}
break;
case MotionEvent.ACTION_POINTER_DOWN:
if (mActivePointerId == event.getPointerId(event.getActionIndex())
&& mCurrentDraggingBoundary != CropBoundary.NONE) {
updateListener(MotionEvent.ACTION_DOWN, event.getX(event.getActionIndex()));
return true;
}
break;
case MotionEvent.ACTION_POINTER_UP:
if (mActivePointerId == event.getPointerId(event.getActionIndex())
&& mCurrentDraggingBoundary != CropBoundary.NONE) {
updateListener(MotionEvent.ACTION_UP, event.getX(event.getActionIndex()));
return true;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (mCurrentDraggingBoundary != CropBoundary.NONE
&& mActivePointerId == event.getPointerId(mActivePointerId)) {
updateListener(MotionEvent.ACTION_UP, event.getX(0));
return true;
}
break;
}
return super.onTouchEvent(event);
}
@Override
public boolean dispatchHoverEvent(MotionEvent event) {
return mExploreByTouchHelper.dispatchHoverEvent(event)
|| super.dispatchHoverEvent(event);
}
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
return mExploreByTouchHelper.dispatchKeyEvent(event)
|| super.dispatchKeyEvent(event);
}
@Override
public void onFocusChanged(boolean gainFocus, int direction,
Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
mExploreByTouchHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
}
/**
* Set the given boundary to the given value without animation.
*/
public void setBoundaryPosition(CropBoundary boundary, float position) {
position = (float) getAllowedValues(boundary).clamp(position);
switch (boundary) {
case TOP:
mCrop.top = position;
break;
case BOTTOM:
mCrop.bottom = position;
break;
case LEFT:
mCrop.left = position;
break;
case RIGHT:
mCrop.right = position;
break;
case NONE:
Log.w(TAG, "No boundary selected");
break;
}
invalidate();
}
private float getBoundaryPosition(CropBoundary boundary) {
switch (boundary) {
case TOP:
return mCrop.top;
case BOTTOM:
return mCrop.bottom;
case LEFT:
return mCrop.left;
case RIGHT:
return mCrop.right;
}
return 0;
}
private static boolean isVertical(CropBoundary boundary) {
return boundary == CropBoundary.TOP || boundary == CropBoundary.BOTTOM;
}
/**
* Animate the given boundary to the given value.
*/
public void animateBoundaryTo(CropBoundary boundary, float value) {
if (boundary == CropBoundary.NONE) {
Log.w(TAG, "No boundary selected for animation");
return;
}
float start = getBoundaryPosition(boundary);
ValueAnimator animator = new ValueAnimator();
animator.addUpdateListener(animation -> {
setBoundaryPosition(boundary,
MathUtils.lerp(start, value, animation.getAnimatedFraction()));
invalidate();
});
animator.setFloatValues(0f, 1f);
animator.setDuration(750);
animator.setInterpolator(new FastOutSlowInInterpolator());
animator.start();
}
/**
* Fade in crop bounds, animate reveal of cropped-out area from current crop bounds.
*/
public void animateEntrance() {
mEntranceInterpolation = 0;
ValueAnimator animator = new ValueAnimator();
animator.addUpdateListener(animation -> {
mEntranceInterpolation = animation.getAnimatedFraction();
invalidate();
});
animator.setFloatValues(0f, 1f);
animator.setDuration(750);
animator.setInterpolator(new FastOutSlowInInterpolator());
animator.start();
}
/**
* Set additional top and bottom padding for the image being cropped (used when the
* corresponding ImageView doesn't take the full height).
*/
public void setExtraPadding(int top, int bottom) {
mExtraTopPadding = top;
mExtraBottomPadding = bottom;
invalidate();
}
/**
* Set the pixel width of the image on the screen (on-screen dimension, not actual bitmap
* dimension)
*/
public void setImageWidth(int width) {
mImageWidth = width;
invalidate();
}
/**
* @return RectF with values [0,1] representing the position of the boundaries along image axes.
*/
public Rect getCropBoundaries(int imageWidth, int imageHeight) {
return new Rect((int) (mCrop.left * imageWidth), (int) (mCrop.top * imageHeight),
(int) (mCrop.right * imageWidth), (int) (mCrop.bottom * imageHeight));
}
public void setCropInteractionListener(CropInteractionListener listener) {
mCropInteractionListener = listener;
}
private Range getAllowedValues(CropBoundary boundary) {
switch (boundary) {
case TOP:
return new Range<>(0f,
mCrop.bottom - pixelDistanceToFraction(mCropTouchMargin,
CropBoundary.BOTTOM));
case BOTTOM:
return new Range<>(
mCrop.top + pixelDistanceToFraction(mCropTouchMargin,
CropBoundary.TOP), 1f);
case LEFT:
return new Range<>(0f,
mCrop.right - pixelDistanceToFraction(mCropTouchMargin,
CropBoundary.RIGHT));
case RIGHT:
return new Range<>(
mCrop.left + pixelDistanceToFraction(mCropTouchMargin,
CropBoundary.LEFT), 1f);
}
return null;
}
/**
* @param action either ACTION_DOWN, ACTION_UP or ACTION_MOVE.
* @param x coordinate of the relevant pointer.
*/
private void updateListener(int action, float x) {
if (mCropInteractionListener != null && isVertical(mCurrentDraggingBoundary)) {
float boundaryPosition = getBoundaryPosition(mCurrentDraggingBoundary);
switch (action) {
case MotionEvent.ACTION_DOWN:
mCropInteractionListener.onCropDragStarted(mCurrentDraggingBoundary,
boundaryPosition, fractionToVerticalPixels(boundaryPosition),
(mCrop.left + mCrop.right) / 2, x);
break;
case MotionEvent.ACTION_MOVE:
mCropInteractionListener.onCropDragMoved(mCurrentDraggingBoundary,
boundaryPosition, fractionToVerticalPixels(boundaryPosition),
(mCrop.left + mCrop.right) / 2, x);
break;
case MotionEvent.ACTION_UP:
mCropInteractionListener.onCropDragComplete();
break;
}
}
}
/**
* Draw a shade to the given canvas with the given [0,1] fractional image bounds.
*/
private void drawShade(Canvas canvas, float left, float top, float right, float bottom) {
canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
fractionToHorizontalPixels(right),
fractionToVerticalPixels(bottom), mShadePaint);
}
private void drawContainerBackground(Canvas canvas, float left, float top, float right,
float bottom) {
canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
fractionToHorizontalPixels(right),
fractionToVerticalPixels(bottom), mContainerBackgroundPaint);
}
private void drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp) {
int y = fractionToVerticalPixels(frac);
canvas.drawLine(fractionToHorizontalPixels(mCrop.left), y,
fractionToHorizontalPixels(mCrop.right), y, mHandlePaint);
float radius = 8 * getResources().getDisplayMetrics().density;
int x = (fractionToHorizontalPixels(mCrop.left) + fractionToHorizontalPixels(mCrop.right))
/ 2;
canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabUp ? 180 : 0, 180,
true, mHandlePaint);
}
private void drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft) {
int x = fractionToHorizontalPixels(frac);
canvas.drawLine(x, fractionToVerticalPixels(mCrop.top), x,
fractionToVerticalPixels(mCrop.bottom), mHandlePaint);
float radius = 8 * getResources().getDisplayMetrics().density;
int y = (fractionToVerticalPixels(getBoundaryPosition(CropBoundary.TOP))
+ fractionToVerticalPixels(
getBoundaryPosition(CropBoundary.BOTTOM))) / 2;
canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabLeft ? 90 : 270,
180,
true, mHandlePaint);
}
/**
* Convert the given fraction position to pixel position within the View.
*/
private int fractionToVerticalPixels(float frac) {
return (int) (mExtraTopPadding + frac * getImageHeight());
}
private int fractionToHorizontalPixels(float frac) {
return (int) ((getWidth() - mImageWidth) / 2 + frac * mImageWidth);
}
private int getImageHeight() {
return getHeight() - mExtraTopPadding - mExtraBottomPadding;
}
/**
* Convert the given pixel distance to fraction of the image.
*/
private float pixelDistanceToFraction(float px, CropBoundary boundary) {
if (isVertical(boundary)) {
return px / getImageHeight();
} else {
return px / mImageWidth;
}
}
private CropBoundary nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx,
int rightPx) {
if (Math.abs(event.getY() - topPx) < mCropTouchMargin) {
return CropBoundary.TOP;
}
if (Math.abs(event.getY() - bottomPx) < mCropTouchMargin) {
return CropBoundary.BOTTOM;
}
if (event.getY() > topPx || event.getY() < bottomPx) {
if (Math.abs(event.getX() - leftPx) < mCropTouchMargin) {
return CropBoundary.LEFT;
}
if (Math.abs(event.getX() - rightPx) < mCropTouchMargin) {
return CropBoundary.RIGHT;
}
}
return CropBoundary.NONE;
}
private class AccessibilityHelper extends ExploreByTouchHelper {
private static final int TOP_HANDLE_ID = 1;
private static final int BOTTOM_HANDLE_ID = 2;
private static final int LEFT_HANDLE_ID = 3;
private static final int RIGHT_HANDLE_ID = 4;
AccessibilityHelper() {
super(CropView.this);
}
@Override
protected int getVirtualViewAt(float x, float y) {
if (Math.abs(y - fractionToVerticalPixels(mCrop.top)) < mCropTouchMargin) {
return TOP_HANDLE_ID;
}
if (Math.abs(y - fractionToVerticalPixels(mCrop.bottom)) < mCropTouchMargin) {
return BOTTOM_HANDLE_ID;
}
if (y > fractionToVerticalPixels(mCrop.top)
&& y < fractionToVerticalPixels(mCrop.bottom)) {
if (Math.abs(x - fractionToHorizontalPixels(mCrop.left)) < mCropTouchMargin) {
return LEFT_HANDLE_ID;
}
if (Math.abs(x - fractionToHorizontalPixels(mCrop.right)) < mCropTouchMargin) {
return RIGHT_HANDLE_ID;
}
}
return ExploreByTouchHelper.HOST_ID;
}
@Override
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
// Add views in traversal order
virtualViewIds.add(TOP_HANDLE_ID);
virtualViewIds.add(LEFT_HANDLE_ID);
virtualViewIds.add(RIGHT_HANDLE_ID);
virtualViewIds.add(BOTTOM_HANDLE_ID);
}
@Override
protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
CropBoundary boundary = viewIdToBoundary(virtualViewId);
event.setContentDescription(getBoundaryContentDescription(boundary));
}
@Override
protected void onPopulateNodeForVirtualView(int virtualViewId,
AccessibilityNodeInfoCompat node) {
CropBoundary boundary = viewIdToBoundary(virtualViewId);
node.setContentDescription(getBoundaryContentDescription(boundary));
setNodePosition(getNodeRect(boundary), node);
// Intentionally set the class name to SeekBar so that TalkBack uses volume control to
// scroll.
node.setClassName(SeekBar.class.getName());
node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
}
@Override
protected boolean onPerformActionForVirtualView(
int virtualViewId, int action, Bundle arguments) {
if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
&& action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
return false;
}
CropBoundary boundary = viewIdToBoundary(virtualViewId);
float delta = pixelDistanceToFraction(mCropTouchMargin, boundary);
if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
delta = -delta;
}
setBoundaryPosition(boundary, delta + getBoundaryPosition(boundary));
invalidateVirtualView(virtualViewId);
sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
return true;
}
private CharSequence getBoundaryContentDescription(CropBoundary boundary) {
int template;
switch (boundary) {
case TOP:
template = R.string.screenshot_top_boundary_pct;
break;
case BOTTOM:
template = R.string.screenshot_bottom_boundary_pct;
break;
case LEFT:
template = R.string.screenshot_left_boundary_pct;
break;
case RIGHT:
template = R.string.screenshot_right_boundary_pct;
break;
default:
return "";
}
return getResources().getString(template,
Math.round(getBoundaryPosition(boundary) * 100));
}
private CropBoundary viewIdToBoundary(int viewId) {
switch (viewId) {
case TOP_HANDLE_ID:
return CropBoundary.TOP;
case BOTTOM_HANDLE_ID:
return CropBoundary.BOTTOM;
case LEFT_HANDLE_ID:
return CropBoundary.LEFT;
case RIGHT_HANDLE_ID:
return CropBoundary.RIGHT;
}
return CropBoundary.NONE;
}
private Rect getNodeRect(CropBoundary boundary) {
Rect rect;
if (isVertical(boundary)) {
int pixels = fractionToVerticalPixels(getBoundaryPosition(boundary));
rect = new Rect(0, (int) (pixels - mCropTouchMargin),
getWidth(), (int) (pixels + mCropTouchMargin));
// Top boundary can sometimes go beyond the view, shift it down to compensate so
// the area is big enough.
if (rect.top < 0) {
rect.offset(0, -rect.top);
}
} else {
int pixels = fractionToHorizontalPixels(getBoundaryPosition(boundary));
rect = new Rect((int) (pixels - mCropTouchMargin),
(int) (fractionToVerticalPixels(mCrop.top) + mCropTouchMargin),
(int) (pixels + mCropTouchMargin),
(int) (fractionToVerticalPixels(mCrop.bottom) - mCropTouchMargin));
}
return rect;
}
private void setNodePosition(Rect rect, AccessibilityNodeInfoCompat node) {
node.setBoundsInParent(rect);
int[] pos = new int[2];
getLocationOnScreen(pos);
rect.offset(pos[0], pos[1]);
node.setBoundsInScreen(rect);
}
}
/**
* Listen for crop motion events and state.
*/
public interface CropInteractionListener {
void onCropDragStarted(CropBoundary boundary, float boundaryPosition,
int boundaryPositionPx, float horizontalCenter, float x);
void onCropDragMoved(CropBoundary boundary, float boundaryPosition,
int boundaryPositionPx, float horizontalCenter, float x);
void onCropDragComplete();
}
static class SavedState extends BaseSavedState {
RectF mCrop;
/**
* Constructor called from {@link CropView#onSaveInstanceState()}
*/
SavedState(Parcelable superState) {
super(superState);
}
/**
* Constructor called from {@link #CREATOR}
*/
private SavedState(Parcel in) {
super(in);
mCrop = in.readParcelable(ClassLoader.getSystemClassLoader());
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeParcelable(mCrop, 0);
}
public static final Parcelable.Creator<SavedState> CREATOR
= new Parcelable.Creator<SavedState>() {
public SavedState createFromParcel(Parcel in) {
return new SavedState(in);
}
public SavedState[] newArray(int size) {
return new SavedState[size];
}
};
}
}