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