/* * 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 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 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 CREATOR = new Parcelable.Creator() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } }