• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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