• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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                     updateListener(MotionEvent.ACTION_UP, event.getX(0));
219                     return true;
220                 }
221                 break;
222         }
223         return super.onTouchEvent(event);
224     }
225 
226     @Override
dispatchHoverEvent(MotionEvent event)227     public boolean dispatchHoverEvent(MotionEvent event) {
228         return mExploreByTouchHelper.dispatchHoverEvent(event)
229                 || super.dispatchHoverEvent(event);
230     }
231 
232     @Override
dispatchKeyEvent(KeyEvent event)233     public boolean dispatchKeyEvent(KeyEvent event) {
234         return mExploreByTouchHelper.dispatchKeyEvent(event)
235                 || super.dispatchKeyEvent(event);
236     }
237 
238     @Override
onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect)239     public void onFocusChanged(boolean gainFocus, int direction,
240             Rect previouslyFocusedRect) {
241         super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
242         mExploreByTouchHelper.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
243     }
244 
245     /**
246      * Set the given boundary to the given value without animation.
247      */
setBoundaryPosition(CropBoundary boundary, float position)248     public void setBoundaryPosition(CropBoundary boundary, float position) {
249         Log.i(TAG, "setBoundaryPosition: " + boundary + ", position=" + position);
250         position = (float) getAllowedValues(boundary).clamp(position);
251         switch (boundary) {
252             case TOP:
253                 mCrop.top = position;
254                 break;
255             case BOTTOM:
256                 mCrop.bottom = position;
257                 break;
258             case LEFT:
259                 mCrop.left = position;
260                 break;
261             case RIGHT:
262                 mCrop.right = position;
263                 break;
264             case NONE:
265                 Log.w(TAG, "No boundary selected");
266                 break;
267         }
268         Log.i(TAG, "Updated mCrop: " + mCrop);
269 
270         invalidate();
271     }
272 
getBoundaryPosition(CropBoundary boundary)273     private float getBoundaryPosition(CropBoundary boundary) {
274         switch (boundary) {
275             case TOP:
276                 return mCrop.top;
277             case BOTTOM:
278                 return mCrop.bottom;
279             case LEFT:
280                 return mCrop.left;
281             case RIGHT:
282                 return mCrop.right;
283         }
284         return 0;
285     }
286 
isVertical(CropBoundary boundary)287     private static boolean isVertical(CropBoundary boundary) {
288         return boundary == CropBoundary.TOP || boundary == CropBoundary.BOTTOM;
289     }
290 
291     /**
292      * Animate the given boundary to the given value.
293      */
animateBoundaryTo(CropBoundary boundary, float value)294     public void animateBoundaryTo(CropBoundary boundary, float value) {
295         if (boundary == CropBoundary.NONE) {
296             Log.w(TAG, "No boundary selected for animation");
297             return;
298         }
299         float start = getBoundaryPosition(boundary);
300         ValueAnimator animator = new ValueAnimator();
301         animator.addUpdateListener(animation -> {
302             setBoundaryPosition(boundary,
303                     MathUtils.lerp(start, value, animation.getAnimatedFraction()));
304             invalidate();
305         });
306         animator.setFloatValues(0f, 1f);
307         animator.setDuration(750);
308         animator.setInterpolator(new FastOutSlowInInterpolator());
309         animator.start();
310     }
311 
312     /**
313      * Fade in crop bounds, animate reveal of cropped-out area from current crop bounds.
314      */
animateEntrance()315     public void animateEntrance() {
316         mEntranceInterpolation = 0;
317         ValueAnimator animator = new ValueAnimator();
318         animator.addUpdateListener(animation -> {
319             mEntranceInterpolation = animation.getAnimatedFraction();
320             invalidate();
321         });
322         animator.setFloatValues(0f, 1f);
323         animator.setDuration(750);
324         animator.setInterpolator(new FastOutSlowInInterpolator());
325         animator.start();
326     }
327 
328     /**
329      * Set additional top and bottom padding for the image being cropped (used when the
330      * corresponding ImageView doesn't take the full height).
331      */
setExtraPadding(int top, int bottom)332     public void setExtraPadding(int top, int bottom) {
333         mExtraTopPadding = top;
334         mExtraBottomPadding = bottom;
335         invalidate();
336     }
337 
338     /**
339      * Set the pixel width of the image on the screen (on-screen dimension, not actual bitmap
340      * dimension)
341      */
setImageWidth(int width)342     public void setImageWidth(int width) {
343         mImageWidth = width;
344         invalidate();
345     }
346 
347     /**
348      * @return RectF with values [0,1] representing the position of the boundaries along image axes.
349      */
getCropBoundaries(int imageWidth, int imageHeight)350     public Rect getCropBoundaries(int imageWidth, int imageHeight) {
351         return new Rect((int) (mCrop.left * imageWidth), (int) (mCrop.top * imageHeight),
352                 (int) (mCrop.right * imageWidth), (int) (mCrop.bottom * imageHeight));
353     }
354 
setCropInteractionListener(CropInteractionListener listener)355     public void setCropInteractionListener(CropInteractionListener listener) {
356         mCropInteractionListener = listener;
357     }
358 
getAllowedValues(CropBoundary boundary)359     private Range<Float> getAllowedValues(CropBoundary boundary) {
360         float upper = 0f;
361         float lower = 1f;
362         switch (boundary) {
363             case TOP:
364                 lower = 0f;
365                 upper = mCrop.bottom - pixelDistanceToFraction(mCropTouchMargin,
366                         CropBoundary.BOTTOM);
367                 break;
368             case BOTTOM:
369                 lower = mCrop.top + pixelDistanceToFraction(mCropTouchMargin, CropBoundary.TOP);
370                 upper = 1;
371                 break;
372             case LEFT:
373                 lower = 0f;
374                 upper = mCrop.right - pixelDistanceToFraction(mCropTouchMargin, CropBoundary.RIGHT);
375                 break;
376             case RIGHT:
377                 lower = mCrop.left + pixelDistanceToFraction(mCropTouchMargin, CropBoundary.LEFT);
378                 upper = 1;
379                 break;
380         }
381         if (lower >= upper) {
382             Log.wtf(TAG, "getAllowedValues computed an invalid range "
383                     + "[" + lower + ", " + upper + "]");
384             if (Flags.screenshotScrollCropViewCrashFix()) {
385                 lower = Math.min(lower, upper);
386                 upper = lower;
387             }
388         }
389         return new Range<>(lower, upper);
390     }
391 
392     /**
393      * @param action either ACTION_DOWN, ACTION_UP or ACTION_MOVE.
394      * @param x      x-coordinate of the relevant pointer.
395      */
updateListener(int action, float x)396     private void updateListener(int action, float x) {
397         if (mCropInteractionListener != null && isVertical(mCurrentDraggingBoundary)) {
398             float boundaryPosition = getBoundaryPosition(mCurrentDraggingBoundary);
399             switch (action) {
400                 case MotionEvent.ACTION_DOWN:
401                     mCropInteractionListener.onCropDragStarted(mCurrentDraggingBoundary,
402                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
403                             (mCrop.left + mCrop.right) / 2, x);
404                     break;
405                 case MotionEvent.ACTION_MOVE:
406                     mCropInteractionListener.onCropDragMoved(mCurrentDraggingBoundary,
407                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
408                             (mCrop.left + mCrop.right) / 2, x);
409                     break;
410                 case MotionEvent.ACTION_UP:
411                     mCropInteractionListener.onCropDragComplete();
412                     break;
413 
414             }
415         }
416     }
417 
418     /**
419      * Draw a shade to the given canvas with the given [0,1] fractional image bounds.
420      */
drawShade(Canvas canvas, float left, float top, float right, float bottom)421     private void drawShade(Canvas canvas, float left, float top, float right, float bottom) {
422         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
423                 fractionToHorizontalPixels(right),
424                 fractionToVerticalPixels(bottom), mShadePaint);
425     }
426 
drawContainerBackground(Canvas canvas, float left, float top, float right, float bottom)427     private void drawContainerBackground(Canvas canvas, float left, float top, float right,
428             float bottom) {
429         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
430                 fractionToHorizontalPixels(right),
431                 fractionToVerticalPixels(bottom), mContainerBackgroundPaint);
432     }
433 
drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp)434     private void drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp) {
435         int y = fractionToVerticalPixels(frac);
436         canvas.drawLine(fractionToHorizontalPixels(mCrop.left), y,
437                 fractionToHorizontalPixels(mCrop.right), y, mHandlePaint);
438         float radius = 8 * getResources().getDisplayMetrics().density;
439         int x = (fractionToHorizontalPixels(mCrop.left) + fractionToHorizontalPixels(mCrop.right))
440                 / 2;
441         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabUp ? 180 : 0, 180,
442                 true, mHandlePaint);
443     }
444 
drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft)445     private void drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft) {
446         int x = fractionToHorizontalPixels(frac);
447         canvas.drawLine(x, fractionToVerticalPixels(mCrop.top), x,
448                 fractionToVerticalPixels(mCrop.bottom), mHandlePaint);
449         float radius = 8 * getResources().getDisplayMetrics().density;
450         int y = (fractionToVerticalPixels(getBoundaryPosition(CropBoundary.TOP))
451                 + fractionToVerticalPixels(
452                 getBoundaryPosition(CropBoundary.BOTTOM))) / 2;
453         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabLeft ? 90 : 270,
454                 180,
455                 true, mHandlePaint);
456     }
457 
458     /**
459      * Convert the given fraction position to pixel position within the View.
460      */
fractionToVerticalPixels(float frac)461     private int fractionToVerticalPixels(float frac) {
462         return (int) (mExtraTopPadding + frac * getImageHeight());
463     }
464 
fractionToHorizontalPixels(float frac)465     private int fractionToHorizontalPixels(float frac) {
466         return (int) ((getWidth() - mImageWidth) / 2 + frac * mImageWidth);
467     }
468 
getImageHeight()469     private int getImageHeight() {
470         return getHeight() - mExtraTopPadding - mExtraBottomPadding;
471     }
472 
473     /**
474      * Convert the given pixel distance to fraction of the image.
475      */
pixelDistanceToFraction(float px, CropBoundary boundary)476     private float pixelDistanceToFraction(float px, CropBoundary boundary) {
477         if (isVertical(boundary)) {
478             return px / getImageHeight();
479         } else {
480             return px / mImageWidth;
481         }
482     }
483 
nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx, int rightPx)484     private CropBoundary nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx,
485             int rightPx) {
486         if (Math.abs(event.getY() - topPx) < mCropTouchMargin) {
487             return CropBoundary.TOP;
488         }
489         if (Math.abs(event.getY() - bottomPx) < mCropTouchMargin) {
490             return CropBoundary.BOTTOM;
491         }
492         if (event.getY() > topPx || event.getY() < bottomPx) {
493             if (Math.abs(event.getX() - leftPx) < mCropTouchMargin) {
494                 return CropBoundary.LEFT;
495             }
496             if (Math.abs(event.getX() - rightPx) < mCropTouchMargin) {
497                 return CropBoundary.RIGHT;
498             }
499         }
500         return CropBoundary.NONE;
501     }
502 
503     private class AccessibilityHelper extends ExploreByTouchHelper {
504 
505         private static final int TOP_HANDLE_ID = 1;
506         private static final int BOTTOM_HANDLE_ID = 2;
507         private static final int LEFT_HANDLE_ID = 3;
508         private static final int RIGHT_HANDLE_ID = 4;
509 
AccessibilityHelper()510         AccessibilityHelper() {
511             super(CropView.this);
512         }
513 
514         @Override
getVirtualViewAt(float x, float y)515         protected int getVirtualViewAt(float x, float y) {
516             if (Math.abs(y - fractionToVerticalPixels(mCrop.top)) < mCropTouchMargin) {
517                 return TOP_HANDLE_ID;
518             }
519             if (Math.abs(y - fractionToVerticalPixels(mCrop.bottom)) < mCropTouchMargin) {
520                 return BOTTOM_HANDLE_ID;
521             }
522             if (y > fractionToVerticalPixels(mCrop.top)
523                     && y < fractionToVerticalPixels(mCrop.bottom)) {
524                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.left)) < mCropTouchMargin) {
525                     return LEFT_HANDLE_ID;
526                 }
527                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.right)) < mCropTouchMargin) {
528                     return RIGHT_HANDLE_ID;
529                 }
530             }
531 
532             return ExploreByTouchHelper.HOST_ID;
533         }
534 
535         @Override
getVisibleVirtualViews(List<Integer> virtualViewIds)536         protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
537             // Add views in traversal order
538             virtualViewIds.add(TOP_HANDLE_ID);
539             virtualViewIds.add(LEFT_HANDLE_ID);
540             virtualViewIds.add(RIGHT_HANDLE_ID);
541             virtualViewIds.add(BOTTOM_HANDLE_ID);
542         }
543 
544         @Override
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)545         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
546             CropBoundary boundary = viewIdToBoundary(virtualViewId);
547             event.setContentDescription(getBoundaryContentDescription(boundary));
548         }
549 
550         @Override
onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node)551         protected void onPopulateNodeForVirtualView(int virtualViewId,
552                 AccessibilityNodeInfoCompat node) {
553             CropBoundary boundary = viewIdToBoundary(virtualViewId);
554             node.setContentDescription(getBoundaryContentDescription(boundary));
555             setNodePosition(getNodeRect(boundary), node);
556 
557             // Intentionally set the class name to SeekBar so that TalkBack uses volume control to
558             // scroll.
559             node.setClassName(SeekBar.class.getName());
560             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
561             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
562         }
563 
564         @Override
onPerformActionForVirtualView( int virtualViewId, int action, Bundle arguments)565         protected boolean onPerformActionForVirtualView(
566                 int virtualViewId, int action, Bundle arguments) {
567             if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
568                     && action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
569                 return false;
570             }
571             CropBoundary boundary = viewIdToBoundary(virtualViewId);
572             float delta = pixelDistanceToFraction(mCropTouchMargin, boundary);
573             if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
574                 delta = -delta;
575             }
576             setBoundaryPosition(boundary, delta + getBoundaryPosition(boundary));
577             invalidateVirtualView(virtualViewId);
578             sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
579             return true;
580         }
581 
getBoundaryContentDescription(CropBoundary boundary)582         private CharSequence getBoundaryContentDescription(CropBoundary boundary) {
583             int template;
584             switch (boundary) {
585                 case TOP:
586                     template = R.string.screenshot_top_boundary_pct;
587                     break;
588                 case BOTTOM:
589                     template = R.string.screenshot_bottom_boundary_pct;
590                     break;
591                 case LEFT:
592                     template = R.string.screenshot_left_boundary_pct;
593                     break;
594                 case RIGHT:
595                     template = R.string.screenshot_right_boundary_pct;
596                     break;
597                 default:
598                     return "";
599             }
600 
601             return getResources().getString(template,
602                     Math.round(getBoundaryPosition(boundary) * 100));
603         }
604 
viewIdToBoundary(int viewId)605         private CropBoundary viewIdToBoundary(int viewId) {
606             switch (viewId) {
607                 case TOP_HANDLE_ID:
608                     return CropBoundary.TOP;
609                 case BOTTOM_HANDLE_ID:
610                     return CropBoundary.BOTTOM;
611                 case LEFT_HANDLE_ID:
612                     return CropBoundary.LEFT;
613                 case RIGHT_HANDLE_ID:
614                     return CropBoundary.RIGHT;
615             }
616             return CropBoundary.NONE;
617         }
618 
getNodeRect(CropBoundary boundary)619         private Rect getNodeRect(CropBoundary boundary) {
620             Rect rect;
621             if (isVertical(boundary)) {
622                 int pixels = fractionToVerticalPixels(getBoundaryPosition(boundary));
623                 rect = new Rect(0, (int) (pixels - mCropTouchMargin),
624                         getWidth(), (int) (pixels + mCropTouchMargin));
625                 // Top boundary can sometimes go beyond the view, shift it down to compensate so
626                 // the area is big enough.
627                 if (rect.top < 0) {
628                     rect.offset(0, -rect.top);
629                 }
630             } else {
631                 int pixels = fractionToHorizontalPixels(getBoundaryPosition(boundary));
632                 rect = new Rect((int) (pixels - mCropTouchMargin),
633                         (int) (fractionToVerticalPixels(mCrop.top) + mCropTouchMargin),
634                         (int) (pixels + mCropTouchMargin),
635                         (int) (fractionToVerticalPixels(mCrop.bottom) - mCropTouchMargin));
636             }
637             return rect;
638         }
639 
setNodePosition(Rect rect, AccessibilityNodeInfoCompat node)640         private void setNodePosition(Rect rect, AccessibilityNodeInfoCompat node) {
641             node.setBoundsInParent(rect);
642             int[] pos = new int[2];
643             getLocationOnScreen(pos);
644             rect.offset(pos[0], pos[1]);
645             node.setBoundsInScreen(rect);
646         }
647     }
648 
649     /**
650      * Listen for crop motion events and state.
651      */
652     interface CropInteractionListener {
onCropDragStarted(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)653         void onCropDragStarted(CropBoundary boundary, float boundaryPosition,
654                 int boundaryPositionPx, float horizontalCenter, float x);
655 
onCropDragMoved(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)656         void onCropDragMoved(CropBoundary boundary, float boundaryPosition,
657                 int boundaryPositionPx, float horizontalCenter, float x);
658 
onCropDragComplete()659         void onCropDragComplete();
660     }
661 
662     static class SavedState extends BaseSavedState {
663         RectF mCrop;
664 
665         /**
666          * Constructor called from {@link CropView#onSaveInstanceState()}
667          */
SavedState(Parcelable superState)668         SavedState(Parcelable superState) {
669             super(superState);
670         }
671 
672         /**
673          * Constructor called from {@link #CREATOR}
674          */
SavedState(Parcel in)675         private SavedState(Parcel in) {
676             super(in);
677             mCrop = in.readParcelable(ClassLoader.getSystemClassLoader());
678         }
679 
680         @Override
writeToParcel(Parcel out, int flags)681         public void writeToParcel(Parcel out, int flags) {
682             super.writeToParcel(out, flags);
683             out.writeParcelable(mCrop, 0);
684         }
685 
686         public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<>() {
687             public SavedState createFromParcel(Parcel in) {
688                 return new SavedState(in);
689             }
690 
691             public SavedState[] newArray(int size) {
692                 return new SavedState[size];
693             }
694         };
695     }
696 }
697