1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.screenshot.scroll;
18 
19 import android.animation.ValueAnimator;
20 import android.content.Context;
21 import android.content.res.TypedArray;
22 import android.graphics.Canvas;
23 import android.graphics.Color;
24 import android.graphics.Paint;
25 import android.graphics.Rect;
26 import android.graphics.RectF;
27 import android.os.Bundle;
28 import android.os.Parcel;
29 import android.os.Parcelable;
30 import android.util.AttributeSet;
31 import android.util.Log;
32 import android.util.MathUtils;
33 import android.util.Range;
34 import android.view.KeyEvent;
35 import android.view.MotionEvent;
36 import android.view.View;
37 import android.view.accessibility.AccessibilityEvent;
38 import android.view.accessibility.AccessibilityNodeInfo;
39 import android.widget.SeekBar;
40 
41 import androidx.annotation.Nullable;
42 import androidx.core.view.ViewCompat;
43 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
44 import androidx.customview.widget.ExploreByTouchHelper;
45 import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
46 
47 import com.android.internal.graphics.ColorUtils;
48 import com.android.systemui.Flags;
49 import com.android.systemui.res.R;
50 
51 import java.util.List;
52 
53 /**
54  * CropView has top and bottom draggable crop handles, with a scrim to darken the areas being
55  * cropped out.
56  */
57 public class CropView extends View {
58     private static final String TAG = "CropView";
59 
60     public enum CropBoundary {
61         NONE, TOP, BOTTOM, LEFT, RIGHT
62     }
63 
64     private final float mCropTouchMargin;
65     private final Paint mShadePaint;
66     private final Paint mHandlePaint;
67     private final Paint mContainerBackgroundPaint;
68 
69     // Crop rect with each element represented as [0,1] along its proper axis.
70     private RectF mCrop = new RectF(0, 0, 1, 1);
71 
72     private int mExtraTopPadding;
73     private int mExtraBottomPadding;
74     private int mImageWidth;
75 
76     private CropBoundary mCurrentDraggingBoundary = CropBoundary.NONE;
77     private int mActivePointerId;
78     // The starting value of mCurrentDraggingBoundary's crop, used to compute touch deltas.
79     private float mMovementStartValue;
80     private float mStartingY;  // y coordinate of ACTION_DOWN
81     private float mStartingX;
82     // The allowable values for the current boundary being dragged
83     private Range<Float> mMotionRange;
84 
85     // Value [0,1] indicating progress in animateEntrance()
86     private float mEntranceInterpolation = 1f;
87 
88     private CropInteractionListener mCropInteractionListener;
89     private final ExploreByTouchHelper mExploreByTouchHelper;
90 
CropView(Context context, @Nullable AttributeSet attrs)91     public CropView(Context context, @Nullable AttributeSet attrs) {
92         this(context, attrs, 0);
93     }
94 
CropView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)95     public CropView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
96         super(context, attrs, defStyleAttr);
97         TypedArray t = context.getTheme().obtainStyledAttributes(
98                 attrs, R.styleable.CropView, 0, 0);
99         mShadePaint = new Paint();
100         int alpha = t.getInteger(R.styleable.CropView_scrimAlpha, 255);
101         int scrimColor = t.getColor(R.styleable.CropView_scrimColor, Color.TRANSPARENT);
102         mShadePaint.setColor(ColorUtils.setAlphaComponent(scrimColor, alpha));
103         mContainerBackgroundPaint = new Paint();
104         mContainerBackgroundPaint.setColor(t.getColor(R.styleable.CropView_containerBackgroundColor,
105                 Color.TRANSPARENT));
106         mHandlePaint = new Paint();
107         mHandlePaint.setColor(t.getColor(R.styleable.CropView_handleColor, Color.BLACK));
108         mHandlePaint.setStrokeCap(Paint.Cap.ROUND);
109         mHandlePaint.setStrokeWidth(
110                 t.getDimensionPixelSize(R.styleable.CropView_handleThickness, 20));
111         t.recycle();
112         // 48 dp touchable region around each handle.
113         mCropTouchMargin = 24 * getResources().getDisplayMetrics().density;
114 
115         mExploreByTouchHelper = new AccessibilityHelper();
116         ViewCompat.setAccessibilityDelegate(this, mExploreByTouchHelper);
117     }
118 
119     @Override
onSaveInstanceState()120     protected Parcelable onSaveInstanceState() {
121         Log.d(TAG, "onSaveInstanceState");
122         Parcelable superState = super.onSaveInstanceState();
123 
124         SavedState ss = new SavedState(superState);
125         ss.mCrop = mCrop;
126         Log.d(TAG, "saving mCrop=" + mCrop);
127 
128         return ss;
129     }
130 
131     @Override
onRestoreInstanceState(Parcelable state)132     protected void onRestoreInstanceState(Parcelable state) {
133         Log.d(TAG, "onRestoreInstanceState");
134         SavedState ss = (SavedState) state;
135         super.onRestoreInstanceState(ss.getSuperState());
136         Log.d(TAG, "restoring mCrop=" + ss.mCrop + " (was " + mCrop + ")");
137         mCrop = ss.mCrop;
138     }
139 
140     @Override
onDraw(Canvas canvas)141     public void onDraw(Canvas canvas) {
142         super.onDraw(canvas);
143         // Top and bottom borders reflect the boundary between the (scrimmed) image and the
144         // opaque container background. This is only meaningful during an entrance transition.
145         float topBorder = MathUtils.lerp(mCrop.top, 0, mEntranceInterpolation);
146         float bottomBorder = MathUtils.lerp(mCrop.bottom, 1, mEntranceInterpolation);
147         drawShade(canvas, 0, topBorder, 1, mCrop.top);
148         drawShade(canvas, 0, mCrop.bottom, 1, bottomBorder);
149         drawShade(canvas, 0, mCrop.top, mCrop.left, mCrop.bottom);
150         drawShade(canvas, mCrop.right, mCrop.top, 1, mCrop.bottom);
151 
152         // Entrance transition expects the crop bounds to be full width, so we only draw container
153         // background on the top and bottom.
154         drawContainerBackground(canvas, 0, 0, 1, topBorder);
155         drawContainerBackground(canvas, 0, bottomBorder, 1, 1);
156 
157         mHandlePaint.setAlpha((int) (mEntranceInterpolation * 255));
158 
159         drawHorizontalHandle(canvas, mCrop.top, /* draw the handle tab up */ true);
160         drawHorizontalHandle(canvas, mCrop.bottom, /* draw the handle tab down */ false);
161         drawVerticalHandle(canvas, mCrop.left, /* left */ true);
162         drawVerticalHandle(canvas, mCrop.right, /* right */ false);
163     }
164 
165     @Override
onTouchEvent(MotionEvent event)166     public boolean onTouchEvent(MotionEvent event) {
167         int topPx = fractionToVerticalPixels(mCrop.top);
168         int bottomPx = fractionToVerticalPixels(mCrop.bottom);
169         switch (event.getActionMasked()) {
170             case MotionEvent.ACTION_DOWN:
171                 mCurrentDraggingBoundary = nearestBoundary(event, topPx, bottomPx,
172                         fractionToHorizontalPixels(mCrop.left),
173                         fractionToHorizontalPixels(mCrop.right));
174                 if (mCurrentDraggingBoundary != CropBoundary.NONE) {
175                     mActivePointerId = event.getPointerId(0);
176                     mStartingY = event.getY();
177                     mStartingX = event.getX();
178                     mMovementStartValue = getBoundaryPosition(mCurrentDraggingBoundary);
179                     updateListener(MotionEvent.ACTION_DOWN, event.getX());
180                     mMotionRange = getAllowedValues(mCurrentDraggingBoundary);
181                 }
182                 return true;
183             case MotionEvent.ACTION_MOVE:
184                 if (mCurrentDraggingBoundary != CropBoundary.NONE) {
185                     int pointerIndex = event.findPointerIndex(mActivePointerId);
186                     if (pointerIndex >= 0) {
187                         // Original pointer still active, do the move.
188                         float deltaPx = isVertical(mCurrentDraggingBoundary)
189                                 ? event.getY(pointerIndex) - mStartingY
190                                 : event.getX(pointerIndex) - mStartingX;
191                         float delta = pixelDistanceToFraction((int) deltaPx,
192                                 mCurrentDraggingBoundary);
193                         setBoundaryPosition(mCurrentDraggingBoundary,
194                                 mMotionRange.clamp(mMovementStartValue + delta));
195                         updateListener(MotionEvent.ACTION_MOVE, event.getX(pointerIndex));
196                         invalidate();
197                     }
198                     return true;
199                 }
200                 break;
201             case MotionEvent.ACTION_POINTER_DOWN:
202                 if (mActivePointerId == event.getPointerId(event.getActionIndex())
203                         && mCurrentDraggingBoundary != CropBoundary.NONE) {
204                     updateListener(MotionEvent.ACTION_DOWN, event.getX(event.getActionIndex()));
205                     return true;
206                 }
207                 break;
208             case MotionEvent.ACTION_POINTER_UP:
209                 if (mActivePointerId == event.getPointerId(event.getActionIndex())
210                         && mCurrentDraggingBoundary != CropBoundary.NONE) {
211                     updateListener(MotionEvent.ACTION_UP, event.getX(event.getActionIndex()));
212                     return true;
213                 }
214                 break;
215             case MotionEvent.ACTION_CANCEL:
216             case MotionEvent.ACTION_UP:
217                 if (mCurrentDraggingBoundary != CropBoundary.NONE
218                         && mActivePointerId == event.getPointerId(mActivePointerId)) {
219                     updateListener(MotionEvent.ACTION_UP, event.getX(0));
220                     return true;
221                 }
222                 break;
223         }
224         return super.onTouchEvent(event);
225     }
226 
227     @Override
dispatchHoverEvent(MotionEvent event)228     public boolean dispatchHoverEvent(MotionEvent event) {
229         return mExploreByTouchHelper.dispatchHoverEvent(event)
230                 || super.dispatchHoverEvent(event);
231     }
232 
233     @Override
dispatchKeyEvent(KeyEvent event)234     public boolean dispatchKeyEvent(KeyEvent event) {
235         return mExploreByTouchHelper.dispatchKeyEvent(event)
236                 || super.dispatchKeyEvent(event);
237     }
238 
239     @Override
onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect)240     public void onFocusChanged(boolean gainFocus, int direction,
241             Rect previouslyFocusedRect) {
242         super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
243         mExploreByTouchHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
244     }
245 
246     /**
247      * Set the given boundary to the given value without animation.
248      */
setBoundaryPosition(CropBoundary boundary, float position)249     public void setBoundaryPosition(CropBoundary boundary, float position) {
250         Log.i(TAG, "setBoundaryPosition: " + boundary + ", position=" + position);
251         position = (float) getAllowedValues(boundary).clamp(position);
252         switch (boundary) {
253             case TOP:
254                 mCrop.top = position;
255                 break;
256             case BOTTOM:
257                 mCrop.bottom = position;
258                 break;
259             case LEFT:
260                 mCrop.left = position;
261                 break;
262             case RIGHT:
263                 mCrop.right = position;
264                 break;
265             case NONE:
266                 Log.w(TAG, "No boundary selected");
267                 break;
268         }
269         Log.i(TAG, "Updated mCrop: " + mCrop);
270 
271         invalidate();
272     }
273 
getBoundaryPosition(CropBoundary boundary)274     private float getBoundaryPosition(CropBoundary boundary) {
275         switch (boundary) {
276             case TOP:
277                 return mCrop.top;
278             case BOTTOM:
279                 return mCrop.bottom;
280             case LEFT:
281                 return mCrop.left;
282             case RIGHT:
283                 return mCrop.right;
284         }
285         return 0;
286     }
287 
isVertical(CropBoundary boundary)288     private static boolean isVertical(CropBoundary boundary) {
289         return boundary == CropBoundary.TOP || boundary == CropBoundary.BOTTOM;
290     }
291 
292     /**
293      * Animate the given boundary to the given value.
294      */
animateBoundaryTo(CropBoundary boundary, float value)295     public void animateBoundaryTo(CropBoundary boundary, float value) {
296         if (boundary == CropBoundary.NONE) {
297             Log.w(TAG, "No boundary selected for animation");
298             return;
299         }
300         float start = getBoundaryPosition(boundary);
301         ValueAnimator animator = new ValueAnimator();
302         animator.addUpdateListener(animation -> {
303             setBoundaryPosition(boundary,
304                     MathUtils.lerp(start, value, animation.getAnimatedFraction()));
305             invalidate();
306         });
307         animator.setFloatValues(0f, 1f);
308         animator.setDuration(750);
309         animator.setInterpolator(new FastOutSlowInInterpolator());
310         animator.start();
311     }
312 
313     /**
314      * Fade in crop bounds, animate reveal of cropped-out area from current crop bounds.
315      */
animateEntrance()316     public void animateEntrance() {
317         mEntranceInterpolation = 0;
318         ValueAnimator animator = new ValueAnimator();
319         animator.addUpdateListener(animation -> {
320             mEntranceInterpolation = animation.getAnimatedFraction();
321             invalidate();
322         });
323         animator.setFloatValues(0f, 1f);
324         animator.setDuration(750);
325         animator.setInterpolator(new FastOutSlowInInterpolator());
326         animator.start();
327     }
328 
329     /**
330      * Set additional top and bottom padding for the image being cropped (used when the
331      * corresponding ImageView doesn't take the full height).
332      */
setExtraPadding(int top, int bottom)333     public void setExtraPadding(int top, int bottom) {
334         mExtraTopPadding = top;
335         mExtraBottomPadding = bottom;
336         invalidate();
337     }
338 
339     /**
340      * Set the pixel width of the image on the screen (on-screen dimension, not actual bitmap
341      * dimension)
342      */
setImageWidth(int width)343     public void setImageWidth(int width) {
344         mImageWidth = width;
345         invalidate();
346     }
347 
348     /**
349      * @return RectF with values [0,1] representing the position of the boundaries along image axes.
350      */
getCropBoundaries(int imageWidth, int imageHeight)351     public Rect getCropBoundaries(int imageWidth, int imageHeight) {
352         return new Rect((int) (mCrop.left * imageWidth), (int) (mCrop.top * imageHeight),
353                 (int) (mCrop.right * imageWidth), (int) (mCrop.bottom * imageHeight));
354     }
355 
setCropInteractionListener(CropInteractionListener listener)356     public void setCropInteractionListener(CropInteractionListener listener) {
357         mCropInteractionListener = listener;
358     }
359 
getAllowedValues(CropBoundary boundary)360     private Range<Float> getAllowedValues(CropBoundary boundary) {
361         float upper = 0f;
362         float lower = 1f;
363         switch (boundary) {
364             case TOP:
365                 lower = 0f;
366                 upper = mCrop.bottom - pixelDistanceToFraction(mCropTouchMargin,
367                         CropBoundary.BOTTOM);
368                 break;
369             case BOTTOM:
370                 lower = mCrop.top + pixelDistanceToFraction(mCropTouchMargin, CropBoundary.TOP);
371                 upper = 1;
372                 break;
373             case LEFT:
374                 lower = 0f;
375                 upper = mCrop.right - pixelDistanceToFraction(mCropTouchMargin, CropBoundary.RIGHT);
376                 break;
377             case RIGHT:
378                 lower = mCrop.left + pixelDistanceToFraction(mCropTouchMargin, CropBoundary.LEFT);
379                 upper = 1;
380                 break;
381         }
382         if (lower >= upper) {
383             Log.wtf(TAG, "getAllowedValues computed an invalid range "
384                     + "[" + lower + ", " + upper + "]");
385             if (Flags.screenshotScrollCropViewCrashFix()) {
386                 lower = Math.min(lower, upper);
387                 upper = lower;
388             }
389         }
390         return new Range<>(lower, upper);
391     }
392 
393     /**
394      * @param action either ACTION_DOWN, ACTION_UP or ACTION_MOVE.
395      * @param x      x-coordinate of the relevant pointer.
396      */
updateListener(int action, float x)397     private void updateListener(int action, float x) {
398         if (mCropInteractionListener != null && isVertical(mCurrentDraggingBoundary)) {
399             float boundaryPosition = getBoundaryPosition(mCurrentDraggingBoundary);
400             switch (action) {
401                 case MotionEvent.ACTION_DOWN:
402                     mCropInteractionListener.onCropDragStarted(mCurrentDraggingBoundary,
403                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
404                             (mCrop.left + mCrop.right) / 2, x);
405                     break;
406                 case MotionEvent.ACTION_MOVE:
407                     mCropInteractionListener.onCropDragMoved(mCurrentDraggingBoundary,
408                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
409                             (mCrop.left + mCrop.right) / 2, x);
410                     break;
411                 case MotionEvent.ACTION_UP:
412                     mCropInteractionListener.onCropDragComplete();
413                     break;
414 
415             }
416         }
417     }
418 
419     /**
420      * Draw a shade to the given canvas with the given [0,1] fractional image bounds.
421      */
drawShade(Canvas canvas, float left, float top, float right, float bottom)422     private void drawShade(Canvas canvas, float left, float top, float right, float bottom) {
423         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
424                 fractionToHorizontalPixels(right),
425                 fractionToVerticalPixels(bottom), mShadePaint);
426     }
427 
drawContainerBackground(Canvas canvas, float left, float top, float right, float bottom)428     private void drawContainerBackground(Canvas canvas, float left, float top, float right,
429             float bottom) {
430         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
431                 fractionToHorizontalPixels(right),
432                 fractionToVerticalPixels(bottom), mContainerBackgroundPaint);
433     }
434 
drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp)435     private void drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp) {
436         int y = fractionToVerticalPixels(frac);
437         canvas.drawLine(fractionToHorizontalPixels(mCrop.left), y,
438                 fractionToHorizontalPixels(mCrop.right), y, mHandlePaint);
439         float radius = 8 * getResources().getDisplayMetrics().density;
440         int x = (fractionToHorizontalPixels(mCrop.left) + fractionToHorizontalPixels(mCrop.right))
441                 / 2;
442         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabUp ? 180 : 0, 180,
443                 true, mHandlePaint);
444     }
445 
drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft)446     private void drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft) {
447         int x = fractionToHorizontalPixels(frac);
448         canvas.drawLine(x, fractionToVerticalPixels(mCrop.top), x,
449                 fractionToVerticalPixels(mCrop.bottom), mHandlePaint);
450         float radius = 8 * getResources().getDisplayMetrics().density;
451         int y = (fractionToVerticalPixels(getBoundaryPosition(CropBoundary.TOP))
452                 + fractionToVerticalPixels(
453                 getBoundaryPosition(CropBoundary.BOTTOM))) / 2;
454         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabLeft ? 90 : 270,
455                 180,
456                 true, mHandlePaint);
457     }
458 
459     /**
460      * Convert the given fraction position to pixel position within the View.
461      */
fractionToVerticalPixels(float frac)462     private int fractionToVerticalPixels(float frac) {
463         return (int) (mExtraTopPadding + frac * getImageHeight());
464     }
465 
fractionToHorizontalPixels(float frac)466     private int fractionToHorizontalPixels(float frac) {
467         return (int) ((getWidth() - mImageWidth) / 2 + frac * mImageWidth);
468     }
469 
getImageHeight()470     private int getImageHeight() {
471         return getHeight() - mExtraTopPadding - mExtraBottomPadding;
472     }
473 
474     /**
475      * Convert the given pixel distance to fraction of the image.
476      */
pixelDistanceToFraction(float px, CropBoundary boundary)477     private float pixelDistanceToFraction(float px, CropBoundary boundary) {
478         if (isVertical(boundary)) {
479             return px / getImageHeight();
480         } else {
481             return px / mImageWidth;
482         }
483     }
484 
nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx, int rightPx)485     private CropBoundary nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx,
486             int rightPx) {
487         if (Math.abs(event.getY() - topPx) < mCropTouchMargin) {
488             return CropBoundary.TOP;
489         }
490         if (Math.abs(event.getY() - bottomPx) < mCropTouchMargin) {
491             return CropBoundary.BOTTOM;
492         }
493         if (event.getY() > topPx || event.getY() < bottomPx) {
494             if (Math.abs(event.getX() - leftPx) < mCropTouchMargin) {
495                 return CropBoundary.LEFT;
496             }
497             if (Math.abs(event.getX() - rightPx) < mCropTouchMargin) {
498                 return CropBoundary.RIGHT;
499             }
500         }
501         return CropBoundary.NONE;
502     }
503 
504     private class AccessibilityHelper extends ExploreByTouchHelper {
505 
506         private static final int TOP_HANDLE_ID = 1;
507         private static final int BOTTOM_HANDLE_ID = 2;
508         private static final int LEFT_HANDLE_ID = 3;
509         private static final int RIGHT_HANDLE_ID = 4;
510 
AccessibilityHelper()511         AccessibilityHelper() {
512             super(CropView.this);
513         }
514 
515         @Override
getVirtualViewAt(float x, float y)516         protected int getVirtualViewAt(float x, float y) {
517             if (Math.abs(y - fractionToVerticalPixels(mCrop.top)) < mCropTouchMargin) {
518                 return TOP_HANDLE_ID;
519             }
520             if (Math.abs(y - fractionToVerticalPixels(mCrop.bottom)) < mCropTouchMargin) {
521                 return BOTTOM_HANDLE_ID;
522             }
523             if (y > fractionToVerticalPixels(mCrop.top)
524                     && y < fractionToVerticalPixels(mCrop.bottom)) {
525                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.left)) < mCropTouchMargin) {
526                     return LEFT_HANDLE_ID;
527                 }
528                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.right)) < mCropTouchMargin) {
529                     return RIGHT_HANDLE_ID;
530                 }
531             }
532 
533             return ExploreByTouchHelper.HOST_ID;
534         }
535 
536         @Override
getVisibleVirtualViews(List<Integer> virtualViewIds)537         protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
538             // Add views in traversal order
539             virtualViewIds.add(TOP_HANDLE_ID);
540             virtualViewIds.add(LEFT_HANDLE_ID);
541             virtualViewIds.add(RIGHT_HANDLE_ID);
542             virtualViewIds.add(BOTTOM_HANDLE_ID);
543         }
544 
545         @Override
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)546         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
547             CropBoundary boundary = viewIdToBoundary(virtualViewId);
548             event.setContentDescription(getBoundaryContentDescription(boundary));
549         }
550 
551         @Override
onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node)552         protected void onPopulateNodeForVirtualView(int virtualViewId,
553                 AccessibilityNodeInfoCompat node) {
554             CropBoundary boundary = viewIdToBoundary(virtualViewId);
555             node.setContentDescription(getBoundaryContentDescription(boundary));
556             setNodePosition(getNodeRect(boundary), node);
557 
558             // Intentionally set the class name to SeekBar so that TalkBack uses volume control to
559             // scroll.
560             node.setClassName(SeekBar.class.getName());
561             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
562             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
563         }
564 
565         @Override
onPerformActionForVirtualView( int virtualViewId, int action, Bundle arguments)566         protected boolean onPerformActionForVirtualView(
567                 int virtualViewId, int action, Bundle arguments) {
568             if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
569                     && action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
570                 return false;
571             }
572             CropBoundary boundary = viewIdToBoundary(virtualViewId);
573             float delta = pixelDistanceToFraction(mCropTouchMargin, boundary);
574             if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
575                 delta = -delta;
576             }
577             setBoundaryPosition(boundary, delta + getBoundaryPosition(boundary));
578             invalidateVirtualView(virtualViewId);
579             sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
580             return true;
581         }
582 
getBoundaryContentDescription(CropBoundary boundary)583         private CharSequence getBoundaryContentDescription(CropBoundary boundary) {
584             int template;
585             switch (boundary) {
586                 case TOP:
587                     template = R.string.screenshot_top_boundary_pct;
588                     break;
589                 case BOTTOM:
590                     template = R.string.screenshot_bottom_boundary_pct;
591                     break;
592                 case LEFT:
593                     template = R.string.screenshot_left_boundary_pct;
594                     break;
595                 case RIGHT:
596                     template = R.string.screenshot_right_boundary_pct;
597                     break;
598                 default:
599                     return "";
600             }
601 
602             return getResources().getString(template,
603                     Math.round(getBoundaryPosition(boundary) * 100));
604         }
605 
viewIdToBoundary(int viewId)606         private CropBoundary viewIdToBoundary(int viewId) {
607             switch (viewId) {
608                 case TOP_HANDLE_ID:
609                     return CropBoundary.TOP;
610                 case BOTTOM_HANDLE_ID:
611                     return CropBoundary.BOTTOM;
612                 case LEFT_HANDLE_ID:
613                     return CropBoundary.LEFT;
614                 case RIGHT_HANDLE_ID:
615                     return CropBoundary.RIGHT;
616             }
617             return CropBoundary.NONE;
618         }
619 
getNodeRect(CropBoundary boundary)620         private Rect getNodeRect(CropBoundary boundary) {
621             Rect rect;
622             if (isVertical(boundary)) {
623                 int pixels = fractionToVerticalPixels(getBoundaryPosition(boundary));
624                 rect = new Rect(0, (int) (pixels - mCropTouchMargin),
625                         getWidth(), (int) (pixels + mCropTouchMargin));
626                 // Top boundary can sometimes go beyond the view, shift it down to compensate so
627                 // the area is big enough.
628                 if (rect.top < 0) {
629                     rect.offset(0, -rect.top);
630                 }
631             } else {
632                 int pixels = fractionToHorizontalPixels(getBoundaryPosition(boundary));
633                 rect = new Rect((int) (pixels - mCropTouchMargin),
634                         (int) (fractionToVerticalPixels(mCrop.top) + mCropTouchMargin),
635                         (int) (pixels + mCropTouchMargin),
636                         (int) (fractionToVerticalPixels(mCrop.bottom) - mCropTouchMargin));
637             }
638             return rect;
639         }
640 
setNodePosition(Rect rect, AccessibilityNodeInfoCompat node)641         private void setNodePosition(Rect rect, AccessibilityNodeInfoCompat node) {
642             node.setBoundsInParent(rect);
643             int[] pos = new int[2];
644             getLocationOnScreen(pos);
645             rect.offset(pos[0], pos[1]);
646             node.setBoundsInScreen(rect);
647         }
648     }
649 
650     /**
651      * Listen for crop motion events and state.
652      */
653     interface CropInteractionListener {
onCropDragStarted(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)654         void onCropDragStarted(CropBoundary boundary, float boundaryPosition,
655                 int boundaryPositionPx, float horizontalCenter, float x);
656 
onCropDragMoved(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)657         void onCropDragMoved(CropBoundary boundary, float boundaryPosition,
658                 int boundaryPositionPx, float horizontalCenter, float x);
659 
onCropDragComplete()660         void onCropDragComplete();
661     }
662 
663     static class SavedState extends BaseSavedState {
664         RectF mCrop;
665 
666         /**
667          * Constructor called from {@link CropView#onSaveInstanceState()}
668          */
SavedState(Parcelable superState)669         SavedState(Parcelable superState) {
670             super(superState);
671         }
672 
673         /**
674          * Constructor called from {@link #CREATOR}
675          */
SavedState(Parcel in)676         private SavedState(Parcel in) {
677             super(in);
678             mCrop = in.readParcelable(ClassLoader.getSystemClassLoader());
679         }
680 
681         @Override
writeToParcel(Parcel out, int flags)682         public void writeToParcel(Parcel out, int flags) {
683             super.writeToParcel(out, flags);
684             out.writeParcelable(mCrop, 0);
685         }
686 
687         public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<>() {
688             public SavedState createFromParcel(Parcel in) {
689                 return new SavedState(in);
690             }
691 
692             public SavedState[] newArray(int size) {
693                 return new SavedState[size];
694             }
695         };
696     }
697 }
698