1 /*
2  * Copyright (C) 2021 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;
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.R;
49 
50 import java.util.List;
51 
52 /**
53  * CropView has top and bottom draggable crop handles, with a scrim to darken the areas being
54  * cropped out.
55  */
56 public class CropView extends View {
57     private static final String TAG = "CropView";
58 
59     public enum CropBoundary {
60         NONE, TOP, BOTTOM, LEFT, RIGHT
61     }
62 
63     private final float mCropTouchMargin;
64     private final Paint mShadePaint;
65     private final Paint mHandlePaint;
66     private final Paint mContainerBackgroundPaint;
67 
68     // Crop rect with each element represented as [0,1] along its proper axis.
69     private RectF mCrop = new RectF(0, 0, 1, 1);
70 
71     private int mExtraTopPadding;
72     private int mExtraBottomPadding;
73     private int mImageWidth;
74 
75     private CropBoundary mCurrentDraggingBoundary = CropBoundary.NONE;
76     private int mActivePointerId;
77     // The starting value of mCurrentDraggingBoundary's crop, used to compute touch deltas.
78     private float mMovementStartValue;
79     private float mStartingY;  // y coordinate of ACTION_DOWN
80     private float mStartingX;
81     // The allowable values for the current boundary being dragged
82     private Range<Float> mMotionRange;
83 
84     // Value [0,1] indicating progress in animateEntrance()
85     private float mEntranceInterpolation = 1f;
86 
87     private CropInteractionListener mCropInteractionListener;
88     private final ExploreByTouchHelper mExploreByTouchHelper;
89 
CropView(Context context, @Nullable AttributeSet attrs)90     public CropView(Context context, @Nullable AttributeSet attrs) {
91         this(context, attrs, 0);
92     }
93 
CropView(Context context, @Nullable AttributeSet attrs, int defStyleAttr)94     public CropView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
95         super(context, attrs, defStyleAttr);
96         TypedArray t = context.getTheme().obtainStyledAttributes(
97                 attrs, R.styleable.CropView, 0, 0);
98         mShadePaint = new Paint();
99         int alpha = t.getInteger(R.styleable.CropView_scrimAlpha, 255);
100         int scrimColor = t.getColor(R.styleable.CropView_scrimColor, Color.TRANSPARENT);
101         mShadePaint.setColor(ColorUtils.setAlphaComponent(scrimColor, alpha));
102         mContainerBackgroundPaint = new Paint();
103         mContainerBackgroundPaint.setColor(t.getColor(R.styleable.CropView_containerBackgroundColor,
104                 Color.TRANSPARENT));
105         mHandlePaint = new Paint();
106         mHandlePaint.setColor(t.getColor(R.styleable.CropView_handleColor, Color.BLACK));
107         mHandlePaint.setStrokeCap(Paint.Cap.ROUND);
108         mHandlePaint.setStrokeWidth(
109                 t.getDimensionPixelSize(R.styleable.CropView_handleThickness, 20));
110         t.recycle();
111         // 48 dp touchable region around each handle.
112         mCropTouchMargin = 24 * getResources().getDisplayMetrics().density;
113 
114         mExploreByTouchHelper = new AccessibilityHelper();
115         ViewCompat.setAccessibilityDelegate(this, mExploreByTouchHelper);
116     }
117 
118     @Override
onSaveInstanceState()119     protected Parcelable onSaveInstanceState() {
120         Log.d(TAG, "onSaveInstanceState");
121         Parcelable superState = super.onSaveInstanceState();
122 
123         SavedState ss = new SavedState(superState);
124         ss.mCrop = mCrop;
125         Log.d(TAG, "saving mCrop=" + mCrop);
126 
127         return ss;
128     }
129 
130     @Override
onRestoreInstanceState(Parcelable state)131     protected void onRestoreInstanceState(Parcelable state) {
132         Log.d(TAG, "onRestoreInstanceState");
133         SavedState ss = (SavedState) state;
134         super.onRestoreInstanceState(ss.getSuperState());
135         Log.d(TAG, "restoring mCrop=" + ss.mCrop + " (was " + mCrop + ")");
136         mCrop = ss.mCrop;
137     }
138 
139     @Override
onDraw(Canvas canvas)140     public void onDraw(Canvas canvas) {
141         super.onDraw(canvas);
142         // Top and bottom borders reflect the boundary between the (scrimmed) image and the
143         // opaque container background. This is only meaningful during an entrance transition.
144         float topBorder = MathUtils.lerp(mCrop.top, 0, mEntranceInterpolation);
145         float bottomBorder = MathUtils.lerp(mCrop.bottom, 1, mEntranceInterpolation);
146         drawShade(canvas, 0, topBorder, 1, mCrop.top);
147         drawShade(canvas, 0, mCrop.bottom, 1, bottomBorder);
148         drawShade(canvas, 0, mCrop.top, mCrop.left, mCrop.bottom);
149         drawShade(canvas, mCrop.right, mCrop.top, 1, mCrop.bottom);
150 
151         // Entrance transition expects the crop bounds to be full width, so we only draw container
152         // background on the top and bottom.
153         drawContainerBackground(canvas, 0, 0, 1, topBorder);
154         drawContainerBackground(canvas, 0, bottomBorder, 1, 1);
155 
156         mHandlePaint.setAlpha((int) (mEntranceInterpolation * 255));
157 
158         drawHorizontalHandle(canvas, mCrop.top, /* draw the handle tab up */ true);
159         drawHorizontalHandle(canvas, mCrop.bottom, /* draw the handle tab down */ false);
160         drawVerticalHandle(canvas, mCrop.left, /* left */ true);
161         drawVerticalHandle(canvas, mCrop.right, /* right */ false);
162     }
163 
164     @Override
onTouchEvent(MotionEvent event)165     public boolean onTouchEvent(MotionEvent event) {
166         int topPx = fractionToVerticalPixels(mCrop.top);
167         int bottomPx = fractionToVerticalPixels(mCrop.bottom);
168         switch (event.getActionMasked()) {
169             case MotionEvent.ACTION_DOWN:
170                 mCurrentDraggingBoundary = nearestBoundary(event, topPx, bottomPx,
171                         fractionToHorizontalPixels(mCrop.left),
172                         fractionToHorizontalPixels(mCrop.right));
173                 if (mCurrentDraggingBoundary != CropBoundary.NONE) {
174                     mActivePointerId = event.getPointerId(0);
175                     mStartingY = event.getY();
176                     mStartingX = event.getX();
177                     mMovementStartValue = getBoundaryPosition(mCurrentDraggingBoundary);
178                     updateListener(MotionEvent.ACTION_DOWN, event.getX());
179                     mMotionRange = getAllowedValues(mCurrentDraggingBoundary);
180                 }
181                 return true;
182             case MotionEvent.ACTION_MOVE:
183                 if (mCurrentDraggingBoundary != CropBoundary.NONE) {
184                     int pointerIndex = event.findPointerIndex(mActivePointerId);
185                     if (pointerIndex >= 0) {
186                         // Original pointer still active, do the move.
187                         float deltaPx = isVertical(mCurrentDraggingBoundary)
188                                 ? event.getY(pointerIndex) - mStartingY
189                                 : event.getX(pointerIndex) - mStartingX;
190                         float delta = pixelDistanceToFraction((int) deltaPx,
191                                 mCurrentDraggingBoundary);
192                         setBoundaryPosition(mCurrentDraggingBoundary,
193                                 mMotionRange.clamp(mMovementStartValue + delta));
194                         updateListener(MotionEvent.ACTION_MOVE, event.getX(pointerIndex));
195                         invalidate();
196                     }
197                     return true;
198                 }
199                 break;
200             case MotionEvent.ACTION_POINTER_DOWN:
201                 if (mActivePointerId == event.getPointerId(event.getActionIndex())
202                         && mCurrentDraggingBoundary != CropBoundary.NONE) {
203                     updateListener(MotionEvent.ACTION_DOWN, event.getX(event.getActionIndex()));
204                     return true;
205                 }
206                 break;
207             case MotionEvent.ACTION_POINTER_UP:
208                 if (mActivePointerId == event.getPointerId(event.getActionIndex())
209                         && mCurrentDraggingBoundary != CropBoundary.NONE) {
210                     updateListener(MotionEvent.ACTION_UP, event.getX(event.getActionIndex()));
211                     return true;
212                 }
213                 break;
214             case MotionEvent.ACTION_CANCEL:
215             case MotionEvent.ACTION_UP:
216                 if (mCurrentDraggingBoundary != CropBoundary.NONE
217                         && mActivePointerId == event.getPointerId(mActivePointerId)) {
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         Log.i(TAG, "getAllowedValues: " + boundary + ", "
382                 + "result=[lower=" + lower + ", upper=" + upper + "]");
383         return new Range<>(lower, upper);
384     }
385 
386     /**
387      * @param action either ACTION_DOWN, ACTION_UP or ACTION_MOVE.
388      * @param x coordinate of the relevant pointer.
389      */
updateListener(int action, float x)390     private void updateListener(int action, float x) {
391         if (mCropInteractionListener != null && isVertical(mCurrentDraggingBoundary)) {
392             float boundaryPosition = getBoundaryPosition(mCurrentDraggingBoundary);
393             switch (action) {
394                 case MotionEvent.ACTION_DOWN:
395                     mCropInteractionListener.onCropDragStarted(mCurrentDraggingBoundary,
396                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
397                             (mCrop.left + mCrop.right) / 2, x);
398                     break;
399                 case MotionEvent.ACTION_MOVE:
400                     mCropInteractionListener.onCropDragMoved(mCurrentDraggingBoundary,
401                             boundaryPosition, fractionToVerticalPixels(boundaryPosition),
402                             (mCrop.left + mCrop.right) / 2, x);
403                     break;
404                 case MotionEvent.ACTION_UP:
405                     mCropInteractionListener.onCropDragComplete();
406                     break;
407 
408             }
409         }
410     }
411 
412     /**
413      * Draw a shade to the given canvas with the given [0,1] fractional image bounds.
414      */
drawShade(Canvas canvas, float left, float top, float right, float bottom)415     private void drawShade(Canvas canvas, float left, float top, float right, float bottom) {
416         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
417                 fractionToHorizontalPixels(right),
418                 fractionToVerticalPixels(bottom), mShadePaint);
419     }
420 
drawContainerBackground(Canvas canvas, float left, float top, float right, float bottom)421     private void drawContainerBackground(Canvas canvas, float left, float top, float right,
422             float bottom) {
423         canvas.drawRect(fractionToHorizontalPixels(left), fractionToVerticalPixels(top),
424                 fractionToHorizontalPixels(right),
425                 fractionToVerticalPixels(bottom), mContainerBackgroundPaint);
426     }
427 
drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp)428     private void drawHorizontalHandle(Canvas canvas, float frac, boolean handleTabUp) {
429         int y = fractionToVerticalPixels(frac);
430         canvas.drawLine(fractionToHorizontalPixels(mCrop.left), y,
431                 fractionToHorizontalPixels(mCrop.right), y, mHandlePaint);
432         float radius = 8 * getResources().getDisplayMetrics().density;
433         int x = (fractionToHorizontalPixels(mCrop.left) + fractionToHorizontalPixels(mCrop.right))
434                 / 2;
435         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabUp ? 180 : 0, 180,
436                 true, mHandlePaint);
437     }
438 
drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft)439     private void drawVerticalHandle(Canvas canvas, float frac, boolean handleTabLeft) {
440         int x = fractionToHorizontalPixels(frac);
441         canvas.drawLine(x, fractionToVerticalPixels(mCrop.top), x,
442                 fractionToVerticalPixels(mCrop.bottom), mHandlePaint);
443         float radius = 8 * getResources().getDisplayMetrics().density;
444         int y = (fractionToVerticalPixels(getBoundaryPosition(CropBoundary.TOP))
445                 + fractionToVerticalPixels(
446                 getBoundaryPosition(CropBoundary.BOTTOM))) / 2;
447         canvas.drawArc(x - radius, y - radius, x + radius, y + radius, handleTabLeft ? 90 : 270,
448                 180,
449                 true, mHandlePaint);
450     }
451 
452     /**
453      * Convert the given fraction position to pixel position within the View.
454      */
fractionToVerticalPixels(float frac)455     private int fractionToVerticalPixels(float frac) {
456         return (int) (mExtraTopPadding + frac * getImageHeight());
457     }
458 
fractionToHorizontalPixels(float frac)459     private int fractionToHorizontalPixels(float frac) {
460         return (int) ((getWidth() - mImageWidth) / 2 + frac * mImageWidth);
461     }
462 
getImageHeight()463     private int getImageHeight() {
464         return getHeight() - mExtraTopPadding - mExtraBottomPadding;
465     }
466 
467     /**
468      * Convert the given pixel distance to fraction of the image.
469      */
pixelDistanceToFraction(float px, CropBoundary boundary)470     private float pixelDistanceToFraction(float px, CropBoundary boundary) {
471         if (isVertical(boundary)) {
472             return px / getImageHeight();
473         } else {
474             return px / mImageWidth;
475         }
476     }
477 
nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx, int rightPx)478     private CropBoundary nearestBoundary(MotionEvent event, int topPx, int bottomPx, int leftPx,
479             int rightPx) {
480         if (Math.abs(event.getY() - topPx) < mCropTouchMargin) {
481             return CropBoundary.TOP;
482         }
483         if (Math.abs(event.getY() - bottomPx) < mCropTouchMargin) {
484             return CropBoundary.BOTTOM;
485         }
486         if (event.getY() > topPx || event.getY() < bottomPx) {
487             if (Math.abs(event.getX() - leftPx) < mCropTouchMargin) {
488                 return CropBoundary.LEFT;
489             }
490             if (Math.abs(event.getX() - rightPx) < mCropTouchMargin) {
491                 return CropBoundary.RIGHT;
492             }
493         }
494         return CropBoundary.NONE;
495     }
496 
497     private class AccessibilityHelper extends ExploreByTouchHelper {
498 
499         private static final int TOP_HANDLE_ID = 1;
500         private static final int BOTTOM_HANDLE_ID = 2;
501         private static final int LEFT_HANDLE_ID = 3;
502         private static final int RIGHT_HANDLE_ID = 4;
503 
AccessibilityHelper()504         AccessibilityHelper() {
505             super(CropView.this);
506         }
507 
508         @Override
getVirtualViewAt(float x, float y)509         protected int getVirtualViewAt(float x, float y) {
510             if (Math.abs(y - fractionToVerticalPixels(mCrop.top)) < mCropTouchMargin) {
511                 return TOP_HANDLE_ID;
512             }
513             if (Math.abs(y - fractionToVerticalPixels(mCrop.bottom)) < mCropTouchMargin) {
514                 return BOTTOM_HANDLE_ID;
515             }
516             if (y > fractionToVerticalPixels(mCrop.top)
517                     && y < fractionToVerticalPixels(mCrop.bottom)) {
518                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.left)) < mCropTouchMargin) {
519                     return LEFT_HANDLE_ID;
520                 }
521                 if (Math.abs(x - fractionToHorizontalPixels(mCrop.right)) < mCropTouchMargin) {
522                     return RIGHT_HANDLE_ID;
523                 }
524             }
525 
526             return ExploreByTouchHelper.HOST_ID;
527         }
528 
529         @Override
getVisibleVirtualViews(List<Integer> virtualViewIds)530         protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
531             // Add views in traversal order
532             virtualViewIds.add(TOP_HANDLE_ID);
533             virtualViewIds.add(LEFT_HANDLE_ID);
534             virtualViewIds.add(RIGHT_HANDLE_ID);
535             virtualViewIds.add(BOTTOM_HANDLE_ID);
536         }
537 
538         @Override
onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event)539         protected void onPopulateEventForVirtualView(int virtualViewId, AccessibilityEvent event) {
540             CropBoundary boundary = viewIdToBoundary(virtualViewId);
541             event.setContentDescription(getBoundaryContentDescription(boundary));
542         }
543 
544         @Override
onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node)545         protected void onPopulateNodeForVirtualView(int virtualViewId,
546                 AccessibilityNodeInfoCompat node) {
547             CropBoundary boundary = viewIdToBoundary(virtualViewId);
548             node.setContentDescription(getBoundaryContentDescription(boundary));
549             setNodePosition(getNodeRect(boundary), node);
550 
551             // Intentionally set the class name to SeekBar so that TalkBack uses volume control to
552             // scroll.
553             node.setClassName(SeekBar.class.getName());
554             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
555             node.addAction(AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
556         }
557 
558         @Override
onPerformActionForVirtualView( int virtualViewId, int action, Bundle arguments)559         protected boolean onPerformActionForVirtualView(
560                 int virtualViewId, int action, Bundle arguments) {
561             if (action != AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
562                     && action != AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD) {
563                 return false;
564             }
565             CropBoundary boundary = viewIdToBoundary(virtualViewId);
566             float delta = pixelDistanceToFraction(mCropTouchMargin, boundary);
567             if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD) {
568                 delta = -delta;
569             }
570             setBoundaryPosition(boundary, delta + getBoundaryPosition(boundary));
571             invalidateVirtualView(virtualViewId);
572             sendEventForVirtualView(virtualViewId, AccessibilityEvent.TYPE_VIEW_SELECTED);
573             return true;
574         }
575 
getBoundaryContentDescription(CropBoundary boundary)576         private CharSequence getBoundaryContentDescription(CropBoundary boundary) {
577             int template;
578             switch (boundary) {
579                 case TOP:
580                     template = R.string.screenshot_top_boundary_pct;
581                     break;
582                 case BOTTOM:
583                     template = R.string.screenshot_bottom_boundary_pct;
584                     break;
585                 case LEFT:
586                     template = R.string.screenshot_left_boundary_pct;
587                     break;
588                 case RIGHT:
589                     template = R.string.screenshot_right_boundary_pct;
590                     break;
591                 default:
592                     return "";
593             }
594 
595             return getResources().getString(template,
596                     Math.round(getBoundaryPosition(boundary) * 100));
597         }
598 
viewIdToBoundary(int viewId)599         private CropBoundary viewIdToBoundary(int viewId) {
600             switch (viewId) {
601                 case TOP_HANDLE_ID:
602                     return CropBoundary.TOP;
603                 case BOTTOM_HANDLE_ID:
604                     return CropBoundary.BOTTOM;
605                 case LEFT_HANDLE_ID:
606                     return CropBoundary.LEFT;
607                 case RIGHT_HANDLE_ID:
608                     return CropBoundary.RIGHT;
609             }
610             return CropBoundary.NONE;
611         }
612 
getNodeRect(CropBoundary boundary)613         private Rect getNodeRect(CropBoundary boundary) {
614             Rect rect;
615             if (isVertical(boundary)) {
616                 int pixels = fractionToVerticalPixels(getBoundaryPosition(boundary));
617                 rect = new Rect(0, (int) (pixels - mCropTouchMargin),
618                         getWidth(), (int) (pixels + mCropTouchMargin));
619                 // Top boundary can sometimes go beyond the view, shift it down to compensate so
620                 // the area is big enough.
621                 if (rect.top < 0) {
622                     rect.offset(0, -rect.top);
623                 }
624             } else {
625                 int pixels = fractionToHorizontalPixels(getBoundaryPosition(boundary));
626                 rect = new Rect((int) (pixels - mCropTouchMargin),
627                         (int) (fractionToVerticalPixels(mCrop.top) + mCropTouchMargin),
628                         (int) (pixels + mCropTouchMargin),
629                         (int) (fractionToVerticalPixels(mCrop.bottom) - mCropTouchMargin));
630             }
631             return rect;
632         }
633 
setNodePosition(Rect rect, AccessibilityNodeInfoCompat node)634         private void setNodePosition(Rect rect, AccessibilityNodeInfoCompat node) {
635             node.setBoundsInParent(rect);
636             int[] pos = new int[2];
637             getLocationOnScreen(pos);
638             rect.offset(pos[0], pos[1]);
639             node.setBoundsInScreen(rect);
640         }
641     }
642 
643     /**
644      * Listen for crop motion events and state.
645      */
646     public interface CropInteractionListener {
onCropDragStarted(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)647         void onCropDragStarted(CropBoundary boundary, float boundaryPosition,
648                 int boundaryPositionPx, float horizontalCenter, float x);
onCropDragMoved(CropBoundary boundary, float boundaryPosition, int boundaryPositionPx, float horizontalCenter, float x)649         void onCropDragMoved(CropBoundary boundary, float boundaryPosition,
650                 int boundaryPositionPx, float horizontalCenter, float x);
onCropDragComplete()651         void onCropDragComplete();
652     }
653 
654     static class SavedState extends BaseSavedState {
655         RectF mCrop;
656 
657         /**
658          * Constructor called from {@link CropView#onSaveInstanceState()}
659          */
SavedState(Parcelable superState)660         SavedState(Parcelable superState) {
661             super(superState);
662         }
663 
664         /**
665          * Constructor called from {@link #CREATOR}
666          */
SavedState(Parcel in)667         private SavedState(Parcel in) {
668             super(in);
669             mCrop = in.readParcelable(ClassLoader.getSystemClassLoader());
670         }
671 
672         @Override
writeToParcel(Parcel out, int flags)673         public void writeToParcel(Parcel out, int flags) {
674             super.writeToParcel(out, flags);
675             out.writeParcelable(mCrop, 0);
676         }
677 
678         public static final Parcelable.Creator<SavedState> CREATOR
679                 = new Parcelable.Creator<SavedState>() {
680             public SavedState createFromParcel(Parcel in) {
681                 return new SavedState(in);
682             }
683 
684             public SavedState[] newArray(int size) {
685                 return new SavedState[size];
686             }
687         };
688     }
689 }
690