Add animation for Semantic Lift

Test: manual - trigger smart select in a TextView
Test: bit FrameworksCoreTests:android.widget.TextViewActivityTest
Test: bit CtsWidgetTestCases:android.widget.cts.TextViewTest
Change-Id: I2b147a9cc4cbb79118bb78d948bac76a63cf4253
diff --git a/core/java/android/widget/SelectionActionModeHelper.java b/core/java/android/widget/SelectionActionModeHelper.java
index 3f4ce44..240271e 100644
--- a/core/java/android/widget/SelectionActionModeHelper.java
+++ b/core/java/android/widget/SelectionActionModeHelper.java
@@ -20,11 +20,14 @@
 import android.annotation.Nullable;
 import android.annotation.UiThread;
 import android.annotation.WorkerThread;
+import android.graphics.RectF;
 import android.os.AsyncTask;
 import android.os.LocaleList;
+import android.text.Layout;
 import android.text.Selection;
 import android.text.Spannable;
 import android.text.TextUtils;
+import android.util.Pair;
 import android.view.ActionMode;
 import android.view.textclassifier.TextClassification;
 import android.view.textclassifier.TextClassifier;
@@ -33,6 +36,8 @@
 
 import com.android.internal.util.Preconditions;
 
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Objects;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
@@ -50,6 +55,8 @@
     // TODO: Consider making this a ViewConfiguration.
     private static final int TIMEOUT_DURATION = 200;
 
+    private static final boolean SMART_SELECT_ANIMATION_ENABLED = false;
+
     private final Editor mEditor;
     private final TextClassificationHelper mTextClassificationHelper;
 
@@ -57,6 +64,7 @@
     private AsyncTask mTextClassificationAsyncTask;
 
     private final SelectionTracker mSelectionTracker;
+    private final SmartSelectSprite mSmartSelectSprite;
 
     SelectionActionModeHelper(@NonNull Editor editor) {
         mEditor = Preconditions.checkNotNull(editor);
@@ -64,6 +72,12 @@
         mTextClassificationHelper = new TextClassificationHelper(
                 textView.getTextClassifier(), textView.getText(), 0, 1, textView.getTextLocales());
         mSelectionTracker = new SelectionTracker(textView.getTextClassifier());
+
+        if (SMART_SELECT_ANIMATION_ENABLED) {
+            mSmartSelectSprite = new SmartSelectSprite(textView);
+        } else {
+            mSmartSelectSprite = null;
+        }
     }
 
     public void startActionModeAsync(boolean adjustSelection) {
@@ -79,7 +93,9 @@
                     adjustSelection
                             ? mTextClassificationHelper::suggestSelection
                             : mTextClassificationHelper::classifyText,
-                    this::startActionMode)
+                    mSmartSelectSprite != null
+                            ? this::startActionModeWithSmartSelectAnimation
+                            : this::startActionMode)
                     .execute();
         }
     }
@@ -116,6 +132,7 @@
     }
 
     public void onDestroyActionMode() {
+        cancelSmartSelectAnimation();
         mSelectionTracker.onSelectionDestroyed();
         cancelAsyncTask();
     }
@@ -165,7 +182,66 @@
         mTextClassificationAsyncTask = null;
     }
 
+    private void startActionModeWithSmartSelectAnimation(@Nullable SelectionResult result) {
+        final TextView textView = mEditor.getTextView();
+        final Layout layout = textView.getLayout();
+
+        final Runnable onAnimationEndCallback = () -> startActionMode(result);
+        // TODO do not trigger the animation if the change included only non-printable characters
+        final boolean didSelectionChange =
+                textView.getSelectionStart() != result.mStart
+                        || textView.getSelectionEnd() != result.mEnd;
+
+        if (!didSelectionChange) {
+            onAnimationEndCallback.run();
+            return;
+        }
+
+        final List<RectF> selectionRectangles =
+                convertSelectionToRectangles(layout, result.mStart, result.mEnd);
+
+        /*
+         * TODO Figure out a more robust approach for this
+         * We have to translate all the generated rectangles by the top-left padding of the
+         * TextView because the padding influences the rendering of the ViewOverlay, but is not
+         * taken into account when generating the selection path rectangles.
+         */
+        for (RectF rectangle : selectionRectangles) {
+            rectangle.left += textView.getPaddingLeft();
+            rectangle.right += textView.getPaddingLeft();
+            rectangle.top += textView.getPaddingTop();
+            rectangle.bottom += textView.getPaddingTop();
+        }
+
+        final RectF firstRectangle = selectionRectangles.get(0);
+
+        // TODO use the original touch point instead of the hardcoded point generated here
+        final Pair<Float, Float> halfPoint = new Pair<>(
+                firstRectangle.centerX(),
+                firstRectangle.centerY());
+
+        mSmartSelectSprite.startAnimation(
+                // TODO replace with colorControlActivated taken from the view attributes
+                // Color GBLUE700
+                0xFF3367D6,
+                halfPoint,
+                selectionRectangles,
+                onAnimationEndCallback);
+    }
+
+    private List<RectF> convertSelectionToRectangles(final Layout layout, final int start,
+            final int end) {
+        final List<RectF> result = new ArrayList<>();
+        // TODO filter out invalid rectangles
+        // getSelection might give us overlapping and zero-dimension rectangles which will interfere
+        // with the Smart Select animation
+        layout.getSelection(start, end, (left, top, right, bottom) ->
+                result.add(new RectF(left, top, right, bottom)));
+        return result;
+    }
+
     private void invalidateActionMode(@Nullable SelectionResult result) {
+        cancelSmartSelectAnimation();
         mTextClassification = result != null ? result.mClassification : null;
         final ActionMode actionMode = mEditor.getTextActionMode();
         if (actionMode != null) {
@@ -185,6 +261,12 @@
                 resetSelectionTag, textView.getTextLocales());
     }
 
+    private void cancelSmartSelectAnimation() {
+        if (mSmartSelectSprite != null) {
+            mSmartSelectSprite.cancelAnimation();
+        }
+    }
+
     /**
      * Tracks and logs smart selection changes.
      * It is important to trigger this object's methods at the appropriate event so that it tracks
diff --git a/core/java/android/widget/SmartSelectSprite.java b/core/java/android/widget/SmartSelectSprite.java
new file mode 100644
index 0000000..5eed985
--- /dev/null
+++ b/core/java/android/widget/SmartSelectSprite.java
@@ -0,0 +1,642 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.widget;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.annotation.ColorInt;
+import android.annotation.FloatRange;
+import android.annotation.IntDef;
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.Shape;
+import android.util.Pair;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewOverlay;
+import android.view.animation.AnimationUtils;
+import android.view.animation.Interpolator;
+
+import java.lang.annotation.Retention;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Set;
+import java.util.Stack;
+
+/**
+ * A utility class for creating and animating the Smart Select animation.
+ */
+// TODO Do not rely on ViewOverlays for drawing the Smart Select sprite
+final class SmartSelectSprite {
+
+    private static final int EXPAND_DURATION = 300;
+    private static final int CORNER_DURATION = 150;
+    private static final float STROKE_WIDTH_DP = 1.5F;
+    private static final int POINTS_PER_LINE = 4;
+    private final Interpolator mExpandInterpolator;
+    private final Interpolator mCornerInterpolator;
+    private final float mStrokeWidth;
+
+    private final View mView;
+    private Animator mActiveAnimator = null;
+    private Set<Drawable> mExistingAnimationDrawables = new HashSet<>();
+
+    /**
+     * Represents a set of points connected by lines.
+     */
+    private static final class PolygonShape extends Shape {
+
+        private final float[] mLineCoordinates;
+
+        private PolygonShape(final List<Pair<Float, Float>> points) {
+            mLineCoordinates = new float[points.size() * POINTS_PER_LINE];
+
+            int index = 0;
+            Pair<Float, Float> currentPoint = points.get(0);
+            for (final Pair<Float, Float> nextPoint : points) {
+                mLineCoordinates[index] = currentPoint.first;
+                mLineCoordinates[index + 1] = currentPoint.second;
+                mLineCoordinates[index + 2] = nextPoint.first;
+                mLineCoordinates[index + 3] = nextPoint.second;
+
+                index += POINTS_PER_LINE;
+                currentPoint = nextPoint;
+            }
+        }
+
+        @Override
+        public void draw(Canvas canvas, Paint paint) {
+            canvas.drawLines(mLineCoordinates, paint);
+        }
+    }
+
+    /**
+     * A rounded rectangle with a configurable corner radius and the ability to expand outside of
+     * its bounding rectangle and clip against it.
+     */
+    private static final class RoundedRectangleShape extends Shape {
+
+        private static final String PROPERTY_ROUND_PERCENTAGE = "roundPercentage";
+
+        @Retention(SOURCE)
+        @IntDef({ExpansionDirection.LEFT, ExpansionDirection.CENTER, ExpansionDirection.RIGHT})
+        private @interface ExpansionDirection {
+        int LEFT = 0;
+        int CENTER = 1;
+        int RIGHT = 2;
+        }
+
+        @Retention(SOURCE)
+        @IntDef({RectangleBorderType.FIT, RectangleBorderType.OVERSHOOT})
+        private @interface RectangleBorderType {
+        /** A rectangle which, fully expanded, fits inside of its bounding rectangle. */
+        int FIT = 0;
+        /**
+         * A rectangle which, when fully expanded, clips outside of its bounding rectangle so that
+         * its edges no longer appear rounded.
+         */
+        int OVERSHOOT = 1;
+        }
+
+        private final float mStrokeWidth;
+        private final RectF mBoundingRectangle;
+        private float mRoundPercentage = 1.0f;
+        private final @ExpansionDirection int mExpansionDirection;
+        private final @RectangleBorderType int mRectangleBorderType;
+
+        private final RectF mDrawRect = new RectF();
+        private final RectF mClipRect = new RectF();
+        private final Path mClipPath = new Path();
+
+        /** How far offset the left edge of the rectangle is from the bounding box. */
+        private float mLeftBoundary = 0;
+        /** How far offset the right edge of the rectangle is from the bounding box. */
+        private float mRightBoundary = 0;
+
+        private RoundedRectangleShape(
+                final RectF boundingRectangle,
+                final @ExpansionDirection int expansionDirection,
+                final @RectangleBorderType int rectangleBorderType,
+                final float strokeWidth) {
+            mBoundingRectangle = new RectF(boundingRectangle);
+            mExpansionDirection = expansionDirection;
+            mRectangleBorderType = rectangleBorderType;
+            mStrokeWidth = strokeWidth;
+        }
+
+        /*
+         * In order to achieve the "rounded rectangle hits the wall" effect, the drawing needs to be
+         * done in two passes. In this context, the wall is the bounding rectangle and in the first
+         * pass we need to draw the rounded rectangle (expanded and with a corner radius as per
+         * object properties) clipped by the bounding box. If the rounded rectangle expands outside
+         * of the bounding box, one more pass needs to be done, as there will now be a hole in the
+         * rounded rectangle where it "flattened" against the bounding box. In order to fill just
+         * this hole, we need to draw the bounding box, but clip it with the rounded rectangle and
+         * this will connect the missing pieces.
+         */
+        @Override
+        public void draw(Canvas canvas, Paint paint) {
+            final float cornerRadius = getCornerRadius();
+            final float adjustedCornerRadius = getAdjustedCornerRadius();
+
+            mDrawRect.set(mBoundingRectangle);
+            mDrawRect.left = mBoundingRectangle.left + mLeftBoundary;
+            mDrawRect.right = mBoundingRectangle.left + mRightBoundary;
+
+            if (mRectangleBorderType == RectangleBorderType.OVERSHOOT) {
+                mDrawRect.left -= cornerRadius / 2;
+                mDrawRect.right -= cornerRadius / 2;
+            } else {
+                switch (mExpansionDirection) {
+                    case ExpansionDirection.CENTER:
+                        break;
+                    case ExpansionDirection.LEFT:
+                        mDrawRect.right += cornerRadius;
+                        break;
+                    case ExpansionDirection.RIGHT:
+                        mDrawRect.left -= cornerRadius;
+                        break;
+                }
+            }
+
+            canvas.save();
+            mClipRect.set(mBoundingRectangle);
+            mClipRect.top -= mStrokeWidth;
+            mClipRect.bottom += mStrokeWidth;
+            mClipRect.left -= mStrokeWidth;
+            mClipRect.right += mStrokeWidth;
+            canvas.clipRect(mClipRect);
+            canvas.drawRoundRect(mDrawRect, adjustedCornerRadius, adjustedCornerRadius, paint);
+            canvas.restore();
+
+            canvas.save();
+            mClipPath.reset();
+            mClipPath.addRoundRect(
+                    mDrawRect,
+                    adjustedCornerRadius,
+                    adjustedCornerRadius,
+                    Path.Direction.CW);
+            canvas.clipPath(mClipPath);
+            canvas.drawRect(mBoundingRectangle, paint);
+            canvas.restore();
+        }
+
+        public void setRoundPercentage(
+                @FloatRange(from = 0.0, to = 1.0) final float newPercentage) {
+            mRoundPercentage = newPercentage;
+        }
+
+        private void setLeftBoundary(final float leftBoundary) {
+            mLeftBoundary = leftBoundary;
+        }
+
+        private void setRightBoundary(final float rightBoundary) {
+            mRightBoundary = rightBoundary;
+        }
+
+        private float getCornerRadius() {
+            return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height());
+        }
+
+        private float getAdjustedCornerRadius() {
+            return (getCornerRadius() * mRoundPercentage);
+        }
+
+        private float getBoundingWidth() {
+            if (mRectangleBorderType == RectangleBorderType.OVERSHOOT) {
+                return (int) (mBoundingRectangle.width() + getCornerRadius());
+            } else {
+                return mBoundingRectangle.width();
+            }
+        }
+
+    }
+
+    /**
+     * A collection of {@link RoundedRectangleShape}s that abstracts them to a single shape whose
+     * collective left and right boundary can be manipulated.
+     */
+    private static final class RectangleList extends Shape {
+
+        private static final String PROPERTY_RIGHT_BOUNDARY = "rightBoundary";
+        private static final String PROPERTY_LEFT_BOUNDARY = "leftBoundary";
+
+        private final List<RoundedRectangleShape> mRectangles;
+        private final List<RoundedRectangleShape> mReversedRectangles;
+
+        private RectangleList(List<RoundedRectangleShape> rectangles) {
+            mRectangles = new LinkedList<>(rectangles);
+            mRectangles.sort((o1, o2) -> {
+                if (o1.mBoundingRectangle.top == o2.mBoundingRectangle.top) {
+                    return Float.compare(o1.mBoundingRectangle.left, o2.mBoundingRectangle.left);
+                } else {
+                    return Float.compare(o1.mBoundingRectangle.top, o2.mBoundingRectangle.top);
+                }
+            });
+            mReversedRectangles = new LinkedList<>(rectangles);
+            Collections.reverse(mReversedRectangles);
+        }
+
+        private void setLeftBoundary(final float leftBoundary) {
+            float boundarySoFar = getTotalWidth();
+            for (RoundedRectangleShape rectangle : mReversedRectangles) {
+                final float rectangleLeftBoundary = boundarySoFar - rectangle.getBoundingWidth();
+                if (leftBoundary < rectangleLeftBoundary) {
+                    rectangle.setLeftBoundary(0);
+                } else if (leftBoundary > boundarySoFar) {
+                    rectangle.setLeftBoundary(rectangle.getBoundingWidth());
+                } else {
+                    rectangle.setLeftBoundary(
+                            rectangle.getBoundingWidth() - boundarySoFar + leftBoundary);
+                }
+
+                boundarySoFar = rectangleLeftBoundary;
+            }
+        }
+
+        private void setRightBoundary(final float rightBoundary) {
+            float boundarySoFar = 0;
+            for (RoundedRectangleShape rectangle : mRectangles) {
+                final float rectangleRightBoundary = rectangle.getBoundingWidth() + boundarySoFar;
+                if (rectangleRightBoundary < rightBoundary) {
+                    rectangle.setRightBoundary(rectangle.getBoundingWidth());
+                } else if (boundarySoFar > rightBoundary) {
+                    rectangle.setRightBoundary(0);
+                } else {
+                    rectangle.setRightBoundary(rightBoundary - boundarySoFar);
+                }
+
+                boundarySoFar = rectangleRightBoundary;
+            }
+        }
+
+        private int getTotalWidth() {
+            int sum = 0;
+            for (RoundedRectangleShape rectangle : mRectangles) {
+                sum += rectangle.getBoundingWidth();
+            }
+            return sum;
+        }
+
+        @Override
+        public void draw(Canvas canvas, Paint paint) {
+            for (RoundedRectangleShape rectangle : mRectangles) {
+                rectangle.draw(canvas, paint);
+            }
+        }
+
+    }
+
+    SmartSelectSprite(final View view) {
+        final Context context = view.getContext();
+        mExpandInterpolator = AnimationUtils.loadInterpolator(
+                context,
+                android.R.interpolator.fast_out_slow_in);
+        mCornerInterpolator = AnimationUtils.loadInterpolator(
+                context,
+                android.R.interpolator.fast_out_linear_in);
+        mStrokeWidth = dpToPixel(context, STROKE_WIDTH_DP);
+        mView = view;
+    }
+
+    private static boolean intersectsOrTouches(RectF a, RectF b) {
+        return a.left <= b.right && b.left <= a.right && a.top <= b.bottom && b.top <= a.bottom;
+    }
+
+    private List<Drawable> mergeRectanglesToPolygonShape(
+            final List<RectF> rectangles,
+            final int color) {
+        final List<Drawable> drawables = new LinkedList<>();
+        final Set<List<Pair<Float, Float>>> mergedPaths = calculateMergedPolygonPoints(rectangles);
+
+        for (List<Pair<Float, Float>> path : mergedPaths) {
+            // Add the starting point to the end of the polygon so that it ends up closed.
+            path.add(path.get(0));
+
+            final PolygonShape shape = new PolygonShape(path);
+            final ShapeDrawable drawable = new ShapeDrawable(shape);
+
+            drawable.getPaint().setColor(color);
+            drawable.getPaint().setStyle(Paint.Style.STROKE);
+            drawable.getPaint().setStrokeWidth(mStrokeWidth);
+
+            drawables.add(drawable);
+        }
+
+        return drawables;
+    }
+
+    private static Set<List<Pair<Float, Float>>> calculateMergedPolygonPoints(
+            List<RectF> rectangles) {
+        final Set<List<RectF>> partitions = new HashSet<>();
+        final LinkedList<RectF> listOfRects = new LinkedList<>(rectangles);
+
+        while (!listOfRects.isEmpty()) {
+            final RectF candidate = listOfRects.removeFirst();
+            final List<RectF> partition = new LinkedList<>();
+            partition.add(candidate);
+
+            final LinkedList<RectF> otherCandidates = new LinkedList<>();
+            otherCandidates.addAll(listOfRects);
+
+            while (!otherCandidates.isEmpty()) {
+                final RectF otherCandidate = otherCandidates.removeFirst();
+                for (RectF partitionElement : partition) {
+                    if (intersectsOrTouches(partitionElement, otherCandidate)) {
+                        partition.add(otherCandidate);
+                        listOfRects.remove(otherCandidate);
+                        break;
+                    }
+                }
+            }
+
+            partition.sort(Comparator.comparing(o -> o.top));
+            partitions.add(partition);
+        }
+
+        final Set<List<Pair<Float, Float>>> result = new HashSet<>();
+        for (List<RectF> partition : partitions) {
+            final List<Pair<Float, Float>> points = new LinkedList<>();
+
+            final Stack<RectF> rects = new Stack<>();
+            for (RectF rect : partition) {
+                points.add(new Pair<>(rect.right, rect.top));
+                points.add(new Pair<>(rect.right, rect.bottom));
+                rects.add(rect);
+            }
+            while (!rects.isEmpty()) {
+                final RectF rect = rects.pop();
+                points.add(new Pair<>(rect.left, rect.bottom));
+                points.add(new Pair<>(rect.left, rect.top));
+            }
+
+            result.add(points);
+        }
+
+        return result;
+
+    }
+
+    /**
+     * Performs the Smart Select animation on the view bound to this SmartSelectSprite.
+     *
+     * @param color                 The color of the stroke used.
+     * @param start                 The point from which the animation will start. Must be inside
+     *                              destinationRectangles.
+     * @param destinationRectangles The rectangles which the animation will fill out by its
+     *                              "selection" and finally join them into a single polygon.
+     * @param onAnimationEnd        The callback which will be invoked once the whole animation
+     *                              completes.
+     * @throws IllegalArgumentException if the given start point is not in any of the
+     *                                  destinationRectangles.
+     * @see #cancelAnimation()
+     */
+    public void startAnimation(
+            final @ColorInt int color,
+            final Pair<Float, Float> start,
+            final List<RectF> destinationRectangles,
+            final Runnable onAnimationEnd) throws IllegalArgumentException {
+        cancelAnimation();
+
+        final ValueAnimator.AnimatorUpdateListener updateListener =
+                valueAnimator -> mView.invalidate();
+
+        final List<RoundedRectangleShape> shapes = new LinkedList<>();
+        final List<Animator> cornerAnimators = new LinkedList<>();
+
+        final RectF centerRectangle = destinationRectangles
+                .stream()
+                .filter((r) -> r.contains(start.first, start.second))
+                .findFirst()
+                .orElseThrow(() -> new IllegalArgumentException(
+                        "Center point is not inside any of the rectangles!"));
+
+        int startingOffset = 0;
+        for (RectF rectangle : destinationRectangles) {
+            if (rectangle.equals(centerRectangle)) {
+                break;
+            }
+            startingOffset += rectangle.width();
+        }
+
+        startingOffset += start.first - centerRectangle.left;
+
+        final float centerRectangleHalfHeight = centerRectangle.height() / 2;
+        final float startingOffsetLeft = startingOffset - centerRectangleHalfHeight;
+        final float startingOffsetRight = startingOffset + centerRectangleHalfHeight;
+
+        final @RoundedRectangleShape.ExpansionDirection int[] expansionDirections =
+                generateDirections(centerRectangle, destinationRectangles);
+
+        final @RoundedRectangleShape.RectangleBorderType int[] rectangleBorderTypes =
+                generateBorderTypes(destinationRectangles);
+
+        int index = 0;
+
+        for (RectF rectangle : destinationRectangles) {
+            final RoundedRectangleShape shape = new RoundedRectangleShape(
+                    rectangle,
+                    expansionDirections[index],
+                    rectangleBorderTypes[index],
+                    mStrokeWidth);
+            cornerAnimators.add(createCornerAnimator(shape, updateListener));
+            shapes.add(shape);
+            index++;
+        }
+
+        final RectangleList rectangleList = new RectangleList(shapes);
+        final ShapeDrawable shapeDrawable = new ShapeDrawable(rectangleList);
+
+        final Paint paint = shapeDrawable.getPaint();
+        paint.setColor(color);
+        paint.setStyle(Paint.Style.STROKE);
+        paint.setStrokeWidth(mStrokeWidth);
+
+        addToOverlay(shapeDrawable);
+
+        mActiveAnimator = createAnimator(color, destinationRectangles, rectangleList,
+                startingOffsetLeft, startingOffsetRight, cornerAnimators, updateListener,
+                onAnimationEnd);
+        mActiveAnimator.start();
+    }
+
+    private Animator createAnimator(
+            final @ColorInt int color,
+            final List<RectF> destinationRectangles,
+            final RectangleList rectangleList,
+            final float startingOffsetLeft,
+            final float startingOffsetRight,
+            final List<Animator> cornerAnimators,
+            final ValueAnimator.AnimatorUpdateListener updateListener,
+            final Runnable onAnimationEnd) {
+        final ObjectAnimator rightBoundaryAnimator = ObjectAnimator.ofFloat(
+                rectangleList,
+                RectangleList.PROPERTY_RIGHT_BOUNDARY,
+                startingOffsetRight,
+                rectangleList.getTotalWidth());
+
+        final ObjectAnimator leftBoundaryAnimator = ObjectAnimator.ofFloat(
+                rectangleList,
+                RectangleList.PROPERTY_LEFT_BOUNDARY,
+                startingOffsetLeft,
+                0);
+
+        rightBoundaryAnimator.setDuration(EXPAND_DURATION);
+        leftBoundaryAnimator.setDuration(EXPAND_DURATION);
+
+        rightBoundaryAnimator.addUpdateListener(updateListener);
+        leftBoundaryAnimator.addUpdateListener(updateListener);
+
+        rightBoundaryAnimator.setInterpolator(mExpandInterpolator);
+        leftBoundaryAnimator.setInterpolator(mExpandInterpolator);
+
+        final AnimatorSet cornerAnimator = new AnimatorSet();
+        cornerAnimator.playTogether(cornerAnimators);
+
+        final AnimatorSet boundaryAnimator = new AnimatorSet();
+        boundaryAnimator.playTogether(leftBoundaryAnimator, rightBoundaryAnimator);
+
+        final AnimatorSet animatorSet = new AnimatorSet();
+        animatorSet.playSequentially(boundaryAnimator, cornerAnimator);
+
+        setUpAnimatorListener(animatorSet, destinationRectangles, color, onAnimationEnd);
+
+        return animatorSet;
+    }
+
+    private void setUpAnimatorListener(final Animator animator,
+            final List<RectF> destinationRectangles,
+            final @ColorInt int color,
+            final Runnable onAnimationEnd) {
+        animator.addListener(new Animator.AnimatorListener() {
+            @Override
+            public void onAnimationStart(Animator animator) {
+            }
+
+            @Override
+            public void onAnimationEnd(Animator animator) {
+                removeExistingDrawables();
+
+                final List<Drawable> polygonShapes = mergeRectanglesToPolygonShape(
+                        destinationRectangles,
+                        color);
+
+                for (Drawable drawable : polygonShapes) {
+                    addToOverlay(drawable);
+                }
+
+                onAnimationEnd.run();
+            }
+
+            @Override
+            public void onAnimationCancel(Animator animator) {
+            }
+
+            @Override
+            public void onAnimationRepeat(Animator animator) {
+            }
+        });
+    }
+
+    private ObjectAnimator createCornerAnimator(
+            final RoundedRectangleShape shape,
+            final ValueAnimator.AnimatorUpdateListener listener) {
+        final ObjectAnimator animator = ObjectAnimator.ofFloat(
+                shape,
+                RoundedRectangleShape.PROPERTY_ROUND_PERCENTAGE,
+                1.0F, 0.0F);
+        animator.setDuration(CORNER_DURATION);
+        animator.addUpdateListener(listener);
+        animator.setInterpolator(mCornerInterpolator);
+        return animator;
+    }
+
+    private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections(
+            final RectF centerRectangle,
+            final List<RectF> rectangles) throws IllegalArgumentException {
+        final @RoundedRectangleShape.ExpansionDirection int[] result = new int[rectangles.size()];
+
+        final int centerRectangleIndex = rectangles.indexOf(centerRectangle);
+
+        for (int i = 0; i < centerRectangleIndex - 1; ++i) {
+            result[i] = RoundedRectangleShape.ExpansionDirection.LEFT;
+        }
+        result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER;
+        for (int i = centerRectangleIndex + 1; i < result.length; ++i) {
+            result[i] = RoundedRectangleShape.ExpansionDirection.RIGHT;
+        }
+
+        return result;
+    }
+
+    private static @RoundedRectangleShape.RectangleBorderType int[] generateBorderTypes(
+            final List<RectF> rectangles) {
+        final @RoundedRectangleShape.RectangleBorderType int[] result = new int[rectangles.size()];
+
+        for (int i = 1; i < result.length - 1; ++i) {
+            result[i] = RoundedRectangleShape.RectangleBorderType.OVERSHOOT;
+        }
+
+        result[0] = RoundedRectangleShape.RectangleBorderType.FIT;
+        result[result.length - 1] = RoundedRectangleShape.RectangleBorderType.FIT;
+        return result;
+    }
+
+    private static float dpToPixel(final Context context, final float dp) {
+        return TypedValue.applyDimension(
+                TypedValue.COMPLEX_UNIT_DIP,
+                dp,
+                context.getResources().getDisplayMetrics());
+    }
+
+    private void addToOverlay(final Drawable drawable) {
+        mView.getOverlay().add(drawable);
+        mExistingAnimationDrawables.add(drawable);
+    }
+
+    private void removeExistingDrawables() {
+        final ViewOverlay overlay = mView.getOverlay();
+        for (Drawable drawable : mExistingAnimationDrawables) {
+            overlay.remove(drawable);
+        }
+        mExistingAnimationDrawables.clear();
+    }
+
+    /**
+     * Cancels any active Smart Select animation that might be in progress.
+     */
+    public void cancelAnimation() {
+        if (mActiveAnimator != null) {
+            mActiveAnimator.cancel();
+            mActiveAnimator = null;
+            removeExistingDrawables();
+        }
+    }
+
+}