/* * 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.PointF; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.Shape; import android.text.Layout; import android.view.animation.AnimationUtils; import android.view.animation.Interpolator; import java.lang.annotation.Retention; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; /** * A utility class for creating and animating the Smart Select animation. */ final class SmartSelectSprite { private static final int EXPAND_DURATION = 200; private final Interpolator mExpandInterpolator; private Animator mActiveAnimator = null; private final Runnable mInvalidator; @ColorInt private final int mFillColor; static final Comparator RECTANGLE_COMPARATOR = Comparator .comparingDouble(e -> e.bottom) .thenComparingDouble(e -> e.left); private Drawable mExistingDrawable = null; private RectangleList mExistingRectangleList = null; static final class RectangleWithTextSelectionLayout { private final RectF mRectangle; @Layout.TextSelectionLayout private final int mTextSelectionLayout; RectangleWithTextSelectionLayout(RectF rectangle, int textSelectionLayout) { mRectangle = Objects.requireNonNull(rectangle); mTextSelectionLayout = textSelectionLayout; } public RectF getRectangle() { return mRectangle; } @Layout.TextSelectionLayout public int getTextSelectionLayout() { return mTextSelectionLayout; } } /** * 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_RATIO = "roundRatio"; /** * The direction in which the rectangle will perform its expansion. A rectangle can expand * from its left edge, its right edge or from the center (or, more precisely, the user's * touch point). For example, in left-to-right text, a selection spanning two lines with the * user's action being on the first line will have the top rectangle and expansion direction * of CENTER, while the bottom one will have an expansion direction of RIGHT. */ @Retention(SOURCE) @IntDef({ExpansionDirection.LEFT, ExpansionDirection.CENTER, ExpansionDirection.RIGHT}) private @interface ExpansionDirection { int LEFT = -1; int CENTER = 0; int RIGHT = 1; } private static @ExpansionDirection int invert(@ExpansionDirection int expansionDirection) { return expansionDirection * -1; } private final RectF mBoundingRectangle; private float mRoundRatio = 1.0f; private final @ExpansionDirection int mExpansionDirection; private final RectF mDrawRect = new RectF(); private final Path mClipPath = new Path(); /** How offset the left edge of the rectangle is from the left side of the bounding box. */ private float mLeftBoundary = 0; /** How offset the right edge of the rectangle is from the left side of the bounding box. */ private float mRightBoundary = 0; /** Whether the horizontal bounds are inverted (for RTL scenarios). */ private final boolean mInverted; private final float mBoundingWidth; private RoundedRectangleShape( final RectF boundingRectangle, final @ExpansionDirection int expansionDirection, final boolean inverted) { mBoundingRectangle = new RectF(boundingRectangle); mBoundingWidth = boundingRectangle.width(); mInverted = inverted && expansionDirection != ExpansionDirection.CENTER; if (inverted) { mExpansionDirection = invert(expansionDirection); } else { mExpansionDirection = expansionDirection; } if (boundingRectangle.height() > boundingRectangle.width()) { setRoundRatio(0.0f); } else { setRoundRatio(1.0f); } } /* * In order to achieve the "rounded rectangle hits the wall" effect, we draw an expanding * rounded rectangle that is clipped by the bounding box of the selected text. */ @Override public void draw(Canvas canvas, Paint paint) { if (mLeftBoundary == mRightBoundary) { return; } final float cornerRadius = getCornerRadius(); final float adjustedCornerRadius = getAdjustedCornerRadius(); mDrawRect.set(mBoundingRectangle); mDrawRect.left = mBoundingRectangle.left + mLeftBoundary - cornerRadius / 2; mDrawRect.right = mBoundingRectangle.left + mRightBoundary + cornerRadius / 2; canvas.save(); mClipPath.reset(); mClipPath.addRoundRect( mDrawRect, adjustedCornerRadius, adjustedCornerRadius, Path.Direction.CW); canvas.clipPath(mClipPath); canvas.drawRect(mBoundingRectangle, paint); canvas.restore(); } void setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) { mRoundRatio = roundRatio; } float getRoundRatio() { return mRoundRatio; } private void setStartBoundary(final float startBoundary) { if (mInverted) { mRightBoundary = mBoundingWidth - startBoundary; } else { mLeftBoundary = startBoundary; } } private void setEndBoundary(final float endBoundary) { if (mInverted) { mLeftBoundary = mBoundingWidth - endBoundary; } else { mRightBoundary = endBoundary; } } private float getCornerRadius() { return Math.min(mBoundingRectangle.width(), mBoundingRectangle.height()); } private float getAdjustedCornerRadius() { return (getCornerRadius() * mRoundRatio); } private float getBoundingWidth() { return (int) (mBoundingRectangle.width() + getCornerRadius()); } } /** * 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 { @Retention(SOURCE) @IntDef({DisplayType.RECTANGLES, DisplayType.POLYGON}) private @interface DisplayType { int RECTANGLES = 0; int POLYGON = 1; } private static final String PROPERTY_RIGHT_BOUNDARY = "rightBoundary"; private static final String PROPERTY_LEFT_BOUNDARY = "leftBoundary"; private final List mRectangles; private final List mReversedRectangles; private final Path mOutlinePolygonPath; private @DisplayType int mDisplayType = DisplayType.RECTANGLES; private RectangleList(final List rectangles) { mRectangles = new ArrayList<>(rectangles); mReversedRectangles = new ArrayList<>(rectangles); Collections.reverse(mReversedRectangles); mOutlinePolygonPath = generateOutlinePolygonPath(rectangles); } private void setLeftBoundary(final float leftBoundary) { float boundarySoFar = getTotalWidth(); for (RoundedRectangleShape rectangle : mReversedRectangles) { final float rectangleLeftBoundary = boundarySoFar - rectangle.getBoundingWidth(); if (leftBoundary < rectangleLeftBoundary) { rectangle.setStartBoundary(0); } else if (leftBoundary > boundarySoFar) { rectangle.setStartBoundary(rectangle.getBoundingWidth()); } else { rectangle.setStartBoundary( 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.setEndBoundary(rectangle.getBoundingWidth()); } else if (boundarySoFar > rightBoundary) { rectangle.setEndBoundary(0); } else { rectangle.setEndBoundary(rightBoundary - boundarySoFar); } boundarySoFar = rectangleRightBoundary; } } void setDisplayType(@DisplayType int displayType) { mDisplayType = displayType; } private int getTotalWidth() { int sum = 0; for (RoundedRectangleShape rectangle : mRectangles) { sum += rectangle.getBoundingWidth(); } return sum; } @Override public void draw(Canvas canvas, Paint paint) { if (mDisplayType == DisplayType.POLYGON) { drawPolygon(canvas, paint); } else { drawRectangles(canvas, paint); } } private void drawRectangles(final Canvas canvas, final Paint paint) { for (RoundedRectangleShape rectangle : mRectangles) { rectangle.draw(canvas, paint); } } private void drawPolygon(final Canvas canvas, final Paint paint) { canvas.drawPath(mOutlinePolygonPath, paint); } private static Path generateOutlinePolygonPath( final List rectangles) { final Path path = new Path(); for (final RoundedRectangleShape shape : rectangles) { final Path rectanglePath = new Path(); rectanglePath.addRect(shape.mBoundingRectangle, Path.Direction.CW); path.op(rectanglePath, Path.Op.UNION); } return path; } } /** * @param context the {@link Context} in which the animation will run * @param highlightColor the highlight color of the underlying {@link TextView} * @param invalidator a {@link Runnable} which will be called every time the animation updates, * indicating that the view drawing the animation should invalidate itself */ SmartSelectSprite(final Context context, @ColorInt int highlightColor, final Runnable invalidator) { mExpandInterpolator = AnimationUtils.loadInterpolator( context, android.R.interpolator.fast_out_slow_in); mFillColor = highlightColor; mInvalidator = Objects.requireNonNull(invalidator); } /** * Performs the Smart Select animation on the view bound to this SmartSelectSprite. * * @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. In * order to get the correct visual behavior, these rectangles * should be sorted according to {@link #RECTANGLE_COMPARATOR}. * @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() */ // TODO nullability checks on parameters public void startAnimation( final PointF start, final List destinationRectangles, final Runnable onAnimationEnd) { cancelAnimation(); final ValueAnimator.AnimatorUpdateListener updateListener = valueAnimator -> mInvalidator.run(); final int rectangleCount = destinationRectangles.size(); final List shapes = new ArrayList<>(rectangleCount); RectangleWithTextSelectionLayout centerRectangle = null; int startingOffset = 0; for (RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout : destinationRectangles) { final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle(); if (contains(rectangle, start)) { centerRectangle = rectangleWithTextSelectionLayout; break; } startingOffset += rectangle.width(); } if (centerRectangle == null) { throw new IllegalArgumentException("Center point is not inside any of the rectangles!"); } startingOffset += start.x - centerRectangle.getRectangle().left; final @RoundedRectangleShape.ExpansionDirection int[] expansionDirections = generateDirections(centerRectangle, destinationRectangles); for (int index = 0; index < rectangleCount; ++index) { final RectangleWithTextSelectionLayout rectangleWithTextSelectionLayout = destinationRectangles.get(index); final RectF rectangle = rectangleWithTextSelectionLayout.getRectangle(); final RoundedRectangleShape shape = new RoundedRectangleShape( rectangle, expansionDirections[index], rectangleWithTextSelectionLayout.getTextSelectionLayout() == Layout.TEXT_SELECTION_LAYOUT_RIGHT_TO_LEFT); shapes.add(shape); } final RectangleList rectangleList = new RectangleList(shapes); final ShapeDrawable shapeDrawable = new ShapeDrawable(rectangleList); final Paint paint = shapeDrawable.getPaint(); paint.setColor(mFillColor); paint.setStyle(Paint.Style.FILL); mExistingRectangleList = rectangleList; mExistingDrawable = shapeDrawable; mActiveAnimator = createAnimator(rectangleList, startingOffset, startingOffset, updateListener, onAnimationEnd); mActiveAnimator.start(); } /** Returns whether the sprite is currently animating. */ public boolean isAnimationActive() { return mActiveAnimator != null && mActiveAnimator.isRunning(); } private Animator createAnimator( final RectangleList rectangleList, final float startingOffsetLeft, final float startingOffsetRight, 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 boundaryAnimator = new AnimatorSet(); boundaryAnimator.playTogether(leftBoundaryAnimator, rightBoundaryAnimator); setUpAnimatorListener(boundaryAnimator, onAnimationEnd); return boundaryAnimator; } private void setUpAnimatorListener(final Animator animator, final Runnable onAnimationEnd) { animator.addListener(new Animator.AnimatorListener() { @Override public void onAnimationStart(Animator animator) { } @Override public void onAnimationEnd(Animator animator) { mExistingRectangleList.setDisplayType(RectangleList.DisplayType.POLYGON); mInvalidator.run(); onAnimationEnd.run(); } @Override public void onAnimationCancel(Animator animator) { } @Override public void onAnimationRepeat(Animator animator) { } }); } private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections( final RectangleWithTextSelectionLayout centerRectangle, final List rectangles) { 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; } if (rectangles.size() == 1) { result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER; } else if (centerRectangleIndex == 0) { result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.LEFT; } else if (centerRectangleIndex == rectangles.size() - 1) { result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.RIGHT; } else { result[centerRectangleIndex] = RoundedRectangleShape.ExpansionDirection.CENTER; } for (int i = centerRectangleIndex + 1; i < result.length; ++i) { result[i] = RoundedRectangleShape.ExpansionDirection.RIGHT; } return result; } /** * A variant of {@link RectF#contains(float, float)} that also allows the point to reside on * the right boundary of the rectangle. * * @param rectangle the rectangle inside which the point should be to be considered "contained" * @param point the point which will be tested * @return whether the point is inside the rectangle (or on it's right boundary) */ private static boolean contains(final RectF rectangle, final PointF point) { final float x = point.x; final float y = point.y; return x >= rectangle.left && x <= rectangle.right && y >= rectangle.top && y <= rectangle.bottom; } private void removeExistingDrawables() { mExistingDrawable = null; mExistingRectangleList = null; mInvalidator.run(); } /** * Cancels any active Smart Select animation that might be in progress. */ public void cancelAnimation() { if (mActiveAnimator != null) { mActiveAnimator.cancel(); mActiveAnimator = null; removeExistingDrawables(); } } public void draw(Canvas canvas) { if (mExistingDrawable != null) { mExistingDrawable.draw(canvas); } } }