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