blob: dc472e1bbd822182631179cf295c002b18121429 [file] [log] [blame]
Alan Viverette3da604b2020-06-10 18:34:39 +00001/*
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
17package android.widget;
18
19import static java.lang.annotation.RetentionPolicy.SOURCE;
20
21import android.animation.Animator;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.animation.ValueAnimator;
25import android.annotation.ColorInt;
26import android.annotation.FloatRange;
27import android.annotation.IntDef;
28import android.content.Context;
29import android.graphics.Canvas;
30import android.graphics.Paint;
31import android.graphics.Path;
32import android.graphics.PointF;
33import android.graphics.RectF;
34import android.graphics.drawable.Drawable;
35import android.graphics.drawable.ShapeDrawable;
36import android.graphics.drawable.shapes.Shape;
37import android.text.Layout;
38import android.view.animation.AnimationUtils;
39import android.view.animation.Interpolator;
40
41import java.lang.annotation.Retention;
42import java.util.ArrayList;
43import java.util.Collections;
44import java.util.Comparator;
45import java.util.List;
46import java.util.Objects;
47
48/**
49 * A utility class for creating and animating the Smart Select animation.
50 */
51final 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
76 RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) {
77 mRectangle = Objects.requireNonNull(rectangle);
78 mTextSelectionLayout = textSelectionLayout;
79 }
80
81 public RectF getRectangle() {
82 return mRectangle;
83 }
84
85 @Layout.TextSelectionLayout
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
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
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
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
185 void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) {
186 mRoundRatio = roundRatio;
187 }
188
189 float getRoundRatio() {
190 return mRoundRatio;
191 }
192
193 private void setStartBoundary(final float startBoundary) {
194 if (mInverted) {
195 mRightBoundary = mBoundingWidth - startBoundary;
196 } else {
197 mLeftBoundary = startBoundary;
198 }
199 }
200
201 private void setEndBoundary(final float endBoundary) {
202 if (mInverted) {
203 mLeftBoundary = mBoundingWidth - endBoundary;
204 } else {
205 mRightBoundary = endBoundary;
206 }
207 }
208
209 private float getCornerRadius() {
210 return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height());
211 }
212
213 private float getAdjustedCornerRadius() {
214 return (getCornerRadius() * mRoundRatio);
215 }
216
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
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
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
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
285 void setDisplayType(@DisplayType int displayType) {
286 mDisplayType = displayType;
287 }
288
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
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
306 private void drawRectangles(final Canvas canvas, final Paint paint) {
307 for (RoundedRectangleShape rectangle : mRectangles) {
308 rectangle.draw(canvas, paint);
309 }
310 }
311
312 private void drawPolygon(final Canvas canvas, final Paint paint) {
313 canvas.drawPath(mOutlinePolygonPath, paint);
314 }
315
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 */
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
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. */
428 public boolean isAnimationActive() {
429 return mActiveAnimator != null && mActiveAnimator.isRunning();
430 }
431
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
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
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
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 */
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
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 */
563 public void cancelAnimation() {
564 if (mActiveAnimator != null) {
565 mActiveAnimator.cancel();
566 mActiveAnimator = null;
567 removeExistingDrawables();
568 }
569 }
570
571 public void draw(Canvas canvas) {
572 if (mExistingDrawable != null) {
573 mExistingDrawable.draw(canvas);
574 }
575 }
576
577}