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