1 /* 2 * Copyright (C) 2017 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 android.widget; 18 19 import static java.lang.annotation.RetentionPolicy.SOURCE; 20 21 import android.animation.Animator; 22 import android.animation.AnimatorSet; 23 import android.animation.ObjectAnimator; 24 import android.animation.ValueAnimator; 25 import android.annotation.ColorInt; 26 import android.annotation.FloatRange; 27 import android.annotation.IntDef; 28 import android.content.Context; 29 import android.graphics.Canvas; 30 import android.graphics.Paint; 31 import android.graphics.Path; 32 import android.graphics.PointF; 33 import android.graphics.RectF; 34 import android.graphics.drawable.Drawable; 35 import android.graphics.drawable.ShapeDrawable; 36 import android.graphics.drawable.shapes.Shape; 37 import android.text.Layout; 38 import android.view.animation.AnimationUtils; 39 import android.view.animation.Interpolator; 40 41 import java.lang.annotation.Retention; 42 import java.util.ArrayList; 43 import java.util.Collections; 44 import java.util.Comparator; 45 import java.util.List; 46 import java.util.Objects; 47 48 /** 49 * A utility class for creating and animating the Smart Select animation. 50 */ 51 final class SmartSelectSprite { 52 53 private static final int EXPAND_DURATION = 300; 54 private static final int CORNER_DURATION = 50; 55 56 private final Interpolator mExpandInterpolator; 57 private final Interpolator mCornerInterpolator; 58 59 private Animator mActiveAnimator = null; 60 private final Runnable mInvalidator; 61 @ColorInt 62 private final int mFillColor; 63 64 static final Comparator<RectF> RECTANGLE_COMPARATOR = Comparator 65 .<RectF>comparingDouble(e -> e.bottom) 66 .thenComparingDouble(e -> e.left); 67 68 private Drawable mExistingDrawable = null; 69 private RectangleList mExistingRectangleList = null; 70 71 static final class RectangleWithTextSelectionLayout { 72 private final RectF mRectangle; 73 @Layout.TextSelectionLayout 74 private final int mTextSelectionLayout; 75 RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout)76 RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) { 77 mRectangle = Objects.requireNonNull(rectangle); 78 mTextSelectionLayout = textSelectionLayout; 79 } 80 getRectangle()81 public RectF getRectangle() { 82 return mRectangle; 83 } 84 85 @Layout.TextSelectionLayout getTextSelectionLayout()86 public int getTextSelectionLayout() { 87 return mTextSelectionLayout; 88 } 89 } 90 91 /** 92 * A rounded rectangle with a configurable corner radius and the ability to expand outside of 93 * its bounding rectangle and clip against it. 94 */ 95 private static final class RoundedRectangleShape extends Shape { 96 97 private static final String PROPERTY_ROUND_RATIO = "roundRatio"; 98 99 /** 100 * The direction in which the rectangle will perform its expansion. A rectangle can expand 101 * from its left edge, its right edge or from the center (or, more precisely, the user's 102 * touch point). For example, in left-to-right text, a selection spanning two lines with the 103 * user's action being on the first line will have the top rectangle and expansion direction 104 * of CENTER, while the bottom one will have an expansion direction of RIGHT. 105 */ 106 @Retention(SOURCE) 107 @IntDef({ExpansionDirection.LEFT, ExpansionDirection.CENTER, ExpansionDirection.RIGHT}) 108 private @interface ExpansionDirection { 109 int LEFT = -1; 110 int CENTER = 0; 111 int RIGHT = 1; 112 } 113 invert(@xpansionDirection int expansionDirection)114 private static @ExpansionDirection int invert(@ExpansionDirection int expansionDirection) { 115 return expansionDirection * -1; 116 } 117 118 private final RectF mBoundingRectangle; 119 private float mRoundRatio = 1.0f; 120 private final @ExpansionDirection int mExpansionDirection; 121 122 private final RectF mDrawRect = new RectF(); 123 private final Path mClipPath = new Path(); 124 125 /** How offset the left edge of the rectangle is from the left side of the bounding box. */ 126 private float mLeftBoundary = 0; 127 /** How offset the right edge of the rectangle is from the left side of the bounding box. */ 128 private float mRightBoundary = 0; 129 130 /** Whether the horizontal bounds are inverted (for RTL scenarios). */ 131 private final boolean mInverted; 132 133 private final float mBoundingWidth; 134 RoundedRectangleShape( final RectF boundingRectangle, final @ExpansionDirection int expansionDirection, final boolean inverted)135 private RoundedRectangleShape( 136 final RectF boundingRectangle, 137 final @ExpansionDirection int expansionDirection, 138 final boolean inverted) { 139 mBoundingRectangle = new RectF(boundingRectangle); 140 mBoundingWidth = boundingRectangle.width(); 141 mInverted = inverted && expansionDirection != ExpansionDirection.CENTER; 142 143 if (inverted) { 144 mExpansionDirection = invert(expansionDirection); 145 } else { 146 mExpansionDirection = expansionDirection; 147 } 148 149 if (boundingRectangle.height() > boundingRectangle.width()) { 150 setRoundRatio(0.0f); 151 } else { 152 setRoundRatio(1.0f); 153 } 154 } 155 156 /* 157 * In order to achieve the "rounded rectangle hits the wall" effect, we draw an expanding 158 * rounded rectangle that is clipped by the bounding box of the selected text. 159 */ 160 @Override draw(Canvas canvas, Paint paint)161 public void draw(Canvas canvas, Paint paint) { 162 if (mLeftBoundary == mRightBoundary) { 163 return; 164 } 165 166 final float cornerRadius = getCornerRadius(); 167 final float adjustedCornerRadius = getAdjustedCornerRadius(); 168 169 mDrawRect.set(mBoundingRectangle); 170 mDrawRect.left = mBoundingRectangle.left + mLeftBoundary - cornerRadius / 2; 171 mDrawRect.right = mBoundingRectangle.left + mRightBoundary + cornerRadius / 2; 172 173 canvas.save(); 174 mClipPath.reset(); 175 mClipPath.addRoundRect( 176 mDrawRect, 177 adjustedCornerRadius, 178 adjustedCornerRadius, 179 Path.Direction.CW); 180 canvas.clipPath(mClipPath); 181 canvas.drawRect(mBoundingRectangle, paint); 182 canvas.restore(); 183 } 184 setRoundRatio(@loatRangefrom = 0.0, to = 1.0) final float roundRatio)185 void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) { 186 mRoundRatio = roundRatio; 187 } 188 getRoundRatio()189 float getRoundRatio() { 190 return mRoundRatio; 191 } 192 setStartBoundary(final float startBoundary)193 private void setStartBoundary(final float startBoundary) { 194 if (mInverted) { 195 mRightBoundary = mBoundingWidth - startBoundary; 196 } else { 197 mLeftBoundary = startBoundary; 198 } 199 } 200 setEndBoundary(final float endBoundary)201 private void setEndBoundary(final float endBoundary) { 202 if (mInverted) { 203 mLeftBoundary = mBoundingWidth - endBoundary; 204 } else { 205 mRightBoundary = endBoundary; 206 } 207 } 208 getCornerRadius()209 private float getCornerRadius() { 210 return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height()); 211 } 212 getAdjustedCornerRadius()213 private float getAdjustedCornerRadius() { 214 return (getCornerRadius() * mRoundRatio); 215 } 216 getBoundingWidth()217 private float getBoundingWidth() { 218 return (int) (mBoundingRectangle.width() + getCornerRadius()); 219 } 220 221 } 222 223 /** 224 * A collection of {@link RoundedRectangleShape}s that abstracts them to a single shape whose 225 * collective left and right boundary can be manipulated. 226 */ 227 private static final class RectangleList extends Shape { 228 229 @Retention(SOURCE) 230 @IntDef({DisplayType.RECTANGLES, DisplayType.POLYGON}) 231 private @interface DisplayType { 232 int RECTANGLES = 0; 233 int POLYGON = 1; 234 } 235 236 private static final String PROPERTY_RIGHT_BOUNDARY = "rightBoundary"; 237 private static final String PROPERTY_LEFT_BOUNDARY = "leftBoundary"; 238 239 private final List<RoundedRectangleShape> mRectangles; 240 private final List<RoundedRectangleShape> mReversedRectangles; 241 242 private final Path mOutlinePolygonPath; 243 private @DisplayType int mDisplayType = DisplayType.RECTANGLES; 244 RectangleList(final List<RoundedRectangleShape> rectangles)245 private RectangleList(final List<RoundedRectangleShape> rectangles) { 246 mRectangles = new ArrayList<>(rectangles); 247 mReversedRectangles = new ArrayList<>(rectangles); 248 Collections.reverse(mReversedRectangles); 249 mOutlinePolygonPath = generateOutlinePolygonPath(rectangles); 250 } 251 setLeftBoundary(final float leftBoundary)252 private void setLeftBoundary(final float leftBoundary) { 253 float boundarySoFar = getTotalWidth(); 254 for (RoundedRectangleShape rectangle : mReversedRectangles) { 255 final float rectangleLeftBoundary = boundarySoFar - rectangle.getBoundingWidth(); 256 if (leftBoundary < rectangleLeftBoundary) { 257 rectangle.setStartBoundary(0); 258 } else if (leftBoundary > boundarySoFar) { 259 rectangle.setStartBoundary(rectangle.getBoundingWidth()); 260 } else { 261 rectangle.setStartBoundary( 262 rectangle.getBoundingWidth() - boundarySoFar + leftBoundary); 263 } 264 265 boundarySoFar = rectangleLeftBoundary; 266 } 267 } 268 setRightBoundary(final float rightBoundary)269 private void setRightBoundary(final float rightBoundary) { 270 float boundarySoFar = 0; 271 for (RoundedRectangleShape rectangle : mRectangles) { 272 final float rectangleRightBoundary = rectangle.getBoundingWidth() + boundarySoFar; 273 if (rectangleRightBoundary < rightBoundary) { 274 rectangle.setEndBoundary(rectangle.getBoundingWidth()); 275 } else if (boundarySoFar > rightBoundary) { 276 rectangle.setEndBoundary(0); 277 } else { 278 rectangle.setEndBoundary(rightBoundary - boundarySoFar); 279 } 280 281 boundarySoFar = rectangleRightBoundary; 282 } 283 } 284 setDisplayType(@isplayType int displayType)285 void setDisplayType(@DisplayType int displayType) { 286 mDisplayType = displayType; 287 } 288 getTotalWidth()289 private int getTotalWidth() { 290 int sum = 0; 291 for (RoundedRectangleShape rectangle : mRectangles) { 292 sum += rectangle.getBoundingWidth(); 293 } 294 return sum; 295 } 296 297 @Override draw(Canvas canvas, Paint paint)298 public void draw(Canvas canvas, Paint paint) { 299 if (mDisplayType == DisplayType.POLYGON) { 300 drawPolygon(canvas, paint); 301 } else { 302 drawRectangles(canvas, paint); 303 } 304 } 305 drawRectangles(final Canvas canvas, final Paint paint)306 private void drawRectangles(final Canvas canvas, final Paint paint) { 307 for (RoundedRectangleShape rectangle : mRectangles) { 308 rectangle.draw(canvas, paint); 309 } 310 } 311 drawPolygon(final Canvas canvas, final Paint paint)312 private void drawPolygon(final Canvas canvas, final Paint paint) { 313 canvas.drawPath(mOutlinePolygonPath, paint); 314 } 315 generateOutlinePolygonPath( final List<RoundedRectangleShape> rectangles)316 private static Path generateOutlinePolygonPath( 317 final List<RoundedRectangleShape> rectangles) { 318 final Path path = new Path(); 319 for (final RoundedRectangleShape shape : rectangles) { 320 final Path rectanglePath = new Path(); 321 rectanglePath.addRect(shape.mBoundingRectangle, Path.Direction.CW); 322 path.op(rectanglePath, Path.Op.UNION); 323 } 324 return path; 325 } 326 327 } 328 329 /** 330 * @param context the {@link Context} in which the animation will run 331 * @param highlightColor the highlight color of the underlying {@link TextView} 332 * @param invalidator a {@link Runnable} which will be called every time the animation updates, 333 * indicating that the view drawing the animation should invalidate itself 334 */ SmartSelectSprite(final Context context, @ColorInt int highlightColor, final Runnable invalidator)335 SmartSelectSprite(final Context context, @ColorInt int highlightColor, 336 final Runnable invalidator) { 337 mExpandInterpolator = AnimationUtils.loadInterpolator( 338 context, 339 android.R.interpolator.fast_out_slow_in); 340 mCornerInterpolator = AnimationUtils.loadInterpolator( 341 context, 342 android.R.interpolator.fast_out_linear_in); 343 mFillColor = highlightColor; 344 mInvalidator = Objects.requireNonNull(invalidator); 345 } 346 347 /** 348 * Performs the Smart Select animation on the view bound to this SmartSelectSprite. 349 * 350 * @param start The point from which the animation will start. Must be inside 351 * destinationRectangles. 352 * @param destinationRectangles The rectangles which the animation will fill out by its 353 * "selection" and finally join them into a single polygon. In 354 * order to get the correct visual behavior, these rectangles 355 * should be sorted according to {@link #RECTANGLE_COMPARATOR}. 356 * @param onAnimationEnd the callback which will be invoked once the whole animation 357 * completes 358 * @throws IllegalArgumentException if the given start point is not in any of the 359 * destinationRectangles 360 * @see #cancelAnimation() 361 */ 362 // TODO nullability checks on parameters startAnimation( final PointF start, final List<RectangleWithTextSelectionLayout> destinationRectangles, final Runnable onAnimationEnd)363 public void startAnimation( 364 final PointF start, 365 final List<RectangleWithTextSelectionLayout> destinationRectangles, 366 final Runnable onAnimationEnd) { 367 cancelAnimation(); 368 369 final ValueAnimator.AnimatorUpdateListener updateListener = 370 valueAnimator -> mInvalidator.run(); 371 372 final int rectangleCount = destinationRectangles.size(); 373 374 final List<RoundedRectangleShape> shapes = new ArrayList<>(rectangleCount); 375 final List<Animator> cornerAnimators = new ArrayList<>(rectangleCount); 376 377 RectangleWithTextSelectionLayout centerRectangle = null; 378 379 int startingOffset = 0; 380 for (RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout : 381 destinationRectangles) { 382 final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle(); 383 if (contains(rectangle, start)) { 384 centerRectangle = rectangleWithTextSelectionLayout; 385 break; 386 } 387 startingOffset += rectangle.width(); 388 } 389 390 if (centerRectangle == null) { 391 throw new IllegalArgumentException("Center point is not inside any of the rectangles!"); 392 } 393 394 startingOffset += start.x - centerRectangle.getRectangle().left; 395 396 final @RoundedRectangleShape.ExpansionDirection int[] expansionDirections = 397 generateDirections(centerRectangle, destinationRectangles); 398 399 for (int index = 0; index < rectangleCount; ++index) { 400 final RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout = 401 destinationRectangles.get(index); 402 final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle(); 403 final RoundedRectangleShape shape = new RoundedRectangleShape( 404 rectangle, 405 expansionDirections[index], 406 rectangleWithTextSelectionLayout.getTextSelectionLayout() 407 == Layout.TEXT_SELECTION_LAYOUT_RIGHT_TO_LEFT); 408 cornerAnimators.add(createCornerAnimator(shape, updateListener)); 409 shapes.add(shape); 410 } 411 412 final RectangleList rectangleList = new RectangleList(shapes); 413 final ShapeDrawable shapeDrawable = new ShapeDrawable(rectangleList); 414 415 final Paint paint = shapeDrawable.getPaint(); 416 paint.setColor(mFillColor); 417 paint.setStyle(Paint.Style.FILL); 418 419 mExistingRectangleList = rectangleList; 420 mExistingDrawable = shapeDrawable; 421 422 mActiveAnimator = createAnimator(rectangleList, startingOffset, startingOffset, 423 cornerAnimators, updateListener, onAnimationEnd); 424 mActiveAnimator.start(); 425 } 426 427 /** Returns whether the sprite is currently animating. */ isAnimationActive()428 public boolean isAnimationActive() { 429 return mActiveAnimator != null && mActiveAnimator.isRunning(); 430 } 431 createAnimator( final RectangleList rectangleList, final float startingOffsetLeft, final float startingOffsetRight, final List<Animator> cornerAnimators, final ValueAnimator.AnimatorUpdateListener updateListener, final Runnable onAnimationEnd)432 private Animator createAnimator( 433 final RectangleList rectangleList, 434 final float startingOffsetLeft, 435 final float startingOffsetRight, 436 final List<Animator> cornerAnimators, 437 final ValueAnimator.AnimatorUpdateListener updateListener, 438 final Runnable onAnimationEnd) { 439 final ObjectAnimator rightBoundaryAnimator = ObjectAnimator.ofFloat( 440 rectangleList, 441 RectangleList.PROPERTY_RIGHT_BOUNDARY, 442 startingOffsetRight, 443 rectangleList.getTotalWidth()); 444 445 final ObjectAnimator leftBoundaryAnimator = ObjectAnimator.ofFloat( 446 rectangleList, 447 RectangleList.PROPERTY_LEFT_BOUNDARY, 448 startingOffsetLeft, 449 0); 450 451 rightBoundaryAnimator.setDuration(EXPAND_DURATION); 452 leftBoundaryAnimator.setDuration(EXPAND_DURATION); 453 454 rightBoundaryAnimator.addUpdateListener(updateListener); 455 leftBoundaryAnimator.addUpdateListener(updateListener); 456 457 rightBoundaryAnimator.setInterpolator(mExpandInterpolator); 458 leftBoundaryAnimator.setInterpolator(mExpandInterpolator); 459 460 final AnimatorSet cornerAnimator = new AnimatorSet(); 461 cornerAnimator.playTogether(cornerAnimators); 462 463 final AnimatorSet boundaryAnimator = new AnimatorSet(); 464 boundaryAnimator.playTogether(leftBoundaryAnimator, rightBoundaryAnimator); 465 466 final AnimatorSet animatorSet = new AnimatorSet(); 467 animatorSet.playSequentially(boundaryAnimator, cornerAnimator); 468 469 setUpAnimatorListener(animatorSet, onAnimationEnd); 470 471 return animatorSet; 472 } 473 setUpAnimatorListener(final Animator animator, final Runnable onAnimationEnd)474 private void setUpAnimatorListener(final Animator animator, final Runnable onAnimationEnd) { 475 animator.addListener(new Animator.AnimatorListener() { 476 @Override 477 public void onAnimationStart(Animator animator) { 478 } 479 480 @Override 481 public void onAnimationEnd(Animator animator) { 482 mExistingRectangleList.setDisplayType(RectangleList.DisplayType.POLYGON); 483 mInvalidator.run(); 484 485 onAnimationEnd.run(); 486 } 487 488 @Override 489 public void onAnimationCancel(Animator animator) { 490 } 491 492 @Override 493 public void onAnimationRepeat(Animator animator) { 494 } 495 }); 496 } 497 createCornerAnimator( final RoundedRectangleShape shape, final ValueAnimator.AnimatorUpdateListener listener)498 private ObjectAnimator createCornerAnimator( 499 final RoundedRectangleShape shape, 500 final ValueAnimator.AnimatorUpdateListener listener) { 501 final ObjectAnimator animator = ObjectAnimator.ofFloat( 502 shape, 503 RoundedRectangleShape.PROPERTY_ROUND_RATIO, 504 shape.getRoundRatio(), 0.0F); 505 animator.setDuration(CORNER_DURATION); 506 animator.addUpdateListener(listener); 507 animator.setInterpolator(mCornerInterpolator); 508 return animator; 509 } 510 generateDirections( final RectangleWithTextSelectionLayout centerRectangle, final List<RectangleWithTextSelectionLayout> rectangles)511 private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections( 512 final RectangleWithTextSelectionLayout centerRectangle, 513 final List<RectangleWithTextSelectionLayout> rectangles) { 514 final @RoundedRectangleShape.ExpansionDirection int[] result = new int[rectangles.size()]; 515 516 final int centerRectangleIndex = rectangles.indexOf(centerRectangle); 517 518 for (int i = 0; i < centerRectangleIndex - 1; ++i) { 519 result[i] = RoundedRectangleShape.ExpansionDirection.LEFT; 520 } 521 522 if (rectangles.size() == 1) { 523 result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER; 524 } else if (centerRectangleIndex == 0) { 525 result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.LEFT; 526 } else if (centerRectangleIndex == rectangles.size() - 1) { 527 result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.RIGHT; 528 } else { 529 result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER; 530 } 531 532 for (int i = centerRectangleIndex + 1; i < result.length; ++i) { 533 result[i] = RoundedRectangleShape.ExpansionDirection.RIGHT; 534 } 535 536 return result; 537 } 538 539 /** 540 * A variant of {@link RectF#contains(float, float)} that also allows the point to reside on 541 * the right boundary of the rectangle. 542 * 543 * @param rectangle the rectangle inside which the point should be to be considered "contained" 544 * @param point the point which will be tested 545 * @return whether the point is inside the rectangle (or on it's right boundary) 546 */ contains(final RectF rectangle, final PointF point)547 private static boolean contains(final RectF rectangle, final PointF point) { 548 final float x = point.x; 549 final float y = point.y; 550 return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top 551 && y <= rectangle.bottom; 552 } 553 removeExistingDrawables()554 private void removeExistingDrawables() { 555 mExistingDrawable = null; 556 mExistingRectangleList = null; 557 mInvalidator.run(); 558 } 559 560 /** 561 * Cancels any active Smart Select animation that might be in progress. 562 */ cancelAnimation()563 public void cancelAnimation() { 564 if (mActiveAnimator != null) { 565 mActiveAnimator.cancel(); 566 mActiveAnimator = null; 567 removeExistingDrawables(); 568 } 569 } 570 draw(Canvas canvas)571 public void draw(Canvas canvas) { 572 if (mExistingDrawable != null) { 573 mExistingDrawable.draw(canvas); 574 } 575 } 576 577 } 578