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
17package android.widget;
18
19import static java.lang.annotation.RetentionPolicy.SOURCE;
20
21import android.animation.Animator;
22import android.animation.AnimatorSet;
23import android.animation.ObjectAnimator;
24import android.animation.ValueAnimator;
25import android.annotation.ColorInt;
26import android.annotation.FloatRange;
27import android.annotation.IntDef;
28import android.content.Context;
29import android.graphics.Canvas;
30import android.graphics.Paint;
31import android.graphics.Path;
32import android.graphics.PointF;
33import android.graphics.RectF;
34import android.graphics.drawable.Drawable;
35import android.graphics.drawable.ShapeDrawable;
36import android.graphics.drawable.shapes.Shape;
37import android.text.Layout;
38import android.view.animation.AnimationUtils;
39import android.view.animation.Interpolator;
40
41import com.android.internal.util.Preconditions;
42
43import java.lang.annotation.Retention;
44import java.util.ArrayList;
45import java.util.Collections;
46import java.util.Comparator;
47import java.util.List;
48
49/**
50 * A utility class for creating and animating the Smart Select animation.
51 */
52final 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