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