Home | History | Annotate | Download | only in widget
      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 
     77         RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) {
     78             mRectangle = Preconditions.checkNotNull(rectangle);
     79             mTextSelectionLayout = textSelectionLayout;
     80         }
     81 
     82         public RectF getRectangle() {
     83             return mRectangle;
     84         }
     85 
     86         @Layout.TextSelectionLayout
     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 
    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 
    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
    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 
    186         void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) {
    187             mRoundRatio = roundRatio;
    188         }
    189 
    190         float getRoundRatio() {
    191             return mRoundRatio;
    192         }
    193 
    194         private void setStartBoundary(final float startBoundary) {
    195             if (mInverted) {
    196                 mRightBoundary = mBoundingWidth - startBoundary;
    197             } else {
    198                 mLeftBoundary = startBoundary;
    199             }
    200         }
    201 
    202         private void setEndBoundary(final float endBoundary) {
    203             if (mInverted) {
    204                 mLeftBoundary = mBoundingWidth - endBoundary;
    205             } else {
    206                 mRightBoundary = endBoundary;
    207             }
    208         }
    209 
    210         private float getCornerRadius() {
    211             return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height());
    212         }
    213 
    214         private float getAdjustedCornerRadius() {
    215             return (getCornerRadius() * mRoundRatio);
    216         }
    217 
    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 
    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 
    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 
    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 
    286         void setDisplayType(@DisplayType int displayType) {
    287             mDisplayType = displayType;
    288         }
    289 
    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
    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 
    307         private void drawRectangles(final Canvas canvas, final Paint paint) {
    308             for (RoundedRectangleShape rectangle : mRectangles) {
    309                 rectangle.draw(canvas, paint);
    310             }
    311         }
    312 
    313         private void drawPolygon(final Canvas canvas, final Paint paint) {
    314             canvas.drawPath(mOutlinePolygonPath, paint);
    315         }
    316 
    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      */
    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
    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. */
    429     public boolean isAnimationActive() {
    430         return mActiveAnimator != null && mActiveAnimator.isRunning();
    431     }
    432 
    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 
    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 
    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 
    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      */
    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 
    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      */
    564     public void cancelAnimation() {
    565         if (mActiveAnimator != null) {
    566             mActiveAnimator.cancel();
    567             mActiveAnimator = null;
    568             removeExistingDrawables();
    569         }
    570     }
    571 
    572     public void draw(Canvas canvas) {
    573         if (mExistingDrawable != null) {
    574             mExistingDrawable.draw(canvas);
    575         }
    576     }
    577 
    578 }
    579