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