blob: dc472e1bbd822182631179cf295c002b18121429 [file] [log] [blame]
/*
* 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 = 300;
private static final int CORNER_DURATION = 50;
private final Interpolator mExpandInterpolator;
private final Interpolator mCornerInterpolator;
private Animator mActiveAnimator = null;
private final Runnable mInvalidator;
@ColorInt
private final int mFillColor;
static final Comparator<RectF> RECTANGLE_COMPARATOR = Comparator
.<RectF>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<RoundedRectangleShape> mRectangles;
private final List<RoundedRectangleShape> mReversedRectangles;
private final Path mOutlinePolygonPath;
private @DisplayType int mDisplayType = DisplayType.RECTANGLES;
private RectangleList(final List<RoundedRectangleShape> 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<RoundedRectangleShape> 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);
mCornerInterpolator = AnimationUtils.loadInterpolator(
context,
android.R.interpolator.fast_out_linear_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<RectangleWithTextSelectionLayout> destinationRectangles,
final Runnable onAnimationEnd) {
cancelAnimation();
final ValueAnimator.AnimatorUpdateListener updateListener =
valueAnimator -> mInvalidator.run();
final int rectangleCount = destinationRectangles.size();
final List<RoundedRectangleShape> shapes = new ArrayList<>(rectangleCount);
final List<Animator> cornerAnimators = 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);
cornerAnimators.add(createCornerAnimator(shape, updateListener));
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,
cornerAnimators, 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 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, onAnimationEnd);
return animatorSet;
}
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 ObjectAnimator createCornerAnimator(
final RoundedRectangleShape shape,
final ValueAnimator.AnimatorUpdateListener listener) {
final ObjectAnimator animator = ObjectAnimator.ofFloat(
shape,
RoundedRectangleShape.PROPERTY_ROUND_RATIO,
shape.getRoundRatio(), 0.0F);
animator.setDuration(CORNER_DURATION);
animator.addUpdateListener(listener);
animator.setInterpolator(mCornerInterpolator);
return animator;
}
private static @RoundedRectangleShape.ExpansionDirection int[] generateDirections(
final RectangleWithTextSelectionLayout centerRectangle,
final List<RectangleWithTextSelectionLayout> 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);
}
}
}