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