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