blob: 27b93bc74969d57c0d1c92571a885a2e6da38669 [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.content.res.TypedArray;
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.util.TypedValue;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import com.android.internal.util.Preconditions;
import java.lang.annotation.Retention;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
/**
* 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 = 150;
private static final float STROKE_WIDTH_DP = 1.5F;
// GBLUE700
@ColorInt
private static final int DEFAULT_STROKE_COLOR = 0xFF3367D6;
private final Interpolator mExpandInterpolator;
private final Interpolator mCornerInterpolator;
private final float mStrokeWidth;
private Animator mActiveAnimator = null;
private final Runnable mInvalidator;
@ColorInt
private final int mStrokeColor;
static final Comparator<RectF> RECTANGLE_COMPARATOR = Comparator
.<RectF>comparingDouble(e -> e.bottom)
.thenComparingDouble(e -> e.left);
private Drawable mExistingDrawable = null;
private RectangleList mExistingRectangleList = null;
/**
* 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";
@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 mRoundRatio = 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;
if (boundingRectangle.height() > boundingRectangle.width()) {
setRoundRatio(0.0f);
} else {
setRoundRatio(1.0f);
}
}
/*
* 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.inset(-mStrokeWidth, -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 setRoundRatio(@FloatRange(from = 0.0, to = 1.0) final float roundRatio) {
mRoundRatio = roundRatio;
}
public float getRoundRatio() {
return mRoundRatio;
}
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() * mRoundRatio);
}
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 {
@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 LinkedList<>(rectangles);
mReversedRectangles = new LinkedList<>(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.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;
}
}
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 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, 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);
mStrokeWidth = dpToPixel(context, STROKE_WIDTH_DP);
mStrokeColor = getStrokeColor(context);
mInvalidator = Preconditions.checkNotNull(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()
*/
public void startAnimation(
final PointF start,
final List<RectF> destinationRectangles,
final Runnable onAnimationEnd) throws IllegalArgumentException {
cancelAnimation();
final ValueAnimator.AnimatorUpdateListener updateListener =
valueAnimator -> mInvalidator.run();
final List<RoundedRectangleShape> shapes = new LinkedList<>();
final List<Animator> cornerAnimators = new LinkedList<>();
final RectF centerRectangle = destinationRectangles
.stream()
.filter((r) -> contains(r, start))
.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.x - 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(mStrokeColor);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(mStrokeWidth);
mExistingRectangleList = rectangleList;
mExistingDrawable = shapeDrawable;
mActiveAnimator = createAnimator(rectangleList, startingOffsetLeft, startingOffsetRight,
cornerAnimators, updateListener,
onAnimationEnd);
mActiveAnimator.start();
}
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 RectF centerRectangle, final List<RectF> 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;
}
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());
}
@ColorInt
private static int getStrokeColor(final Context context) {
final TypedValue typedValue = new TypedValue();
final TypedArray array = context.obtainStyledAttributes(typedValue.data, new int[]{
android.R.attr.colorControlActivated});
final int result = array.getColor(0, DEFAULT_STROKE_COLOR);
array.recycle();
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);
}
}
}