blob: 94109d741fd8d875a6724d62587efb18d47e29e5 [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.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;
// GBLUE700
@ColorInt
private static final int DEFAULT_STROKE_COLOR = 0xFF3367D6;
private final Interpolator mExpandInterpolator;
private final Interpolator mCornerInterpolator;
private final float mStrokeWidth;
private final View mView;
private Animator mActiveAnimator = null;
@ColorInt
private final int mStrokeColor;
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<PointF> points) {
mLineCoordinates = new float[points.size() * POINTS_PER_LINE];
int index = 0;
PointF currentPoint = points.get(0);
for (final PointF nextPoint : points) {
mLineCoordinates[index] = currentPoint.x;
mLineCoordinates[index + 1] = currentPoint.y;
mLineCoordinates[index + 2] = nextPoint.x;
mLineCoordinates[index + 3] = nextPoint.y;
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);
mStrokeColor = getStrokeColor(context);
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<PointF>> mergedPaths = calculateMergedPolygonPoints(rectangles);
for (List<PointF> 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<PointF>> 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<PointF>> result = new HashSet<>();
for (List<RectF> partition : partitions) {
final List<PointF> points = new LinkedList<>();
final Stack<RectF> rects = new Stack<>();
for (RectF rect : partition) {
points.add(new PointF(rect.right, rect.top));
points.add(new PointF(rect.right, rect.bottom));
rects.add(rect);
}
while (!rects.isEmpty()) {
final RectF rect = rects.pop();
points.add(new PointF(rect.left, rect.bottom));
points.add(new PointF(rect.left, rect.top));
}
result.add(points);
}
return result;
}
/**
* 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.
* @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 -> mView.invalidate();
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);
addToOverlay(shapeDrawable);
mActiveAnimator = createAnimator(mStrokeColor, 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());
}
@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 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();
}
}
}