/* * 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 RECTANGLE_COMPARATOR = Comparator .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 mRectangles; private final List mReversedRectangles; private final Path mOutlinePolygonPath; private @DisplayType int mDisplayType = DisplayType.RECTANGLES; private RectangleList(final List 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 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 destinationRectangles, final Runnable onAnimationEnd) throws IllegalArgumentException { cancelAnimation(); final ValueAnimator.AnimatorUpdateListener updateListener = valueAnimator -> mInvalidator.run(); final List shapes = new LinkedList<>(); final List 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 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 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 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); } } }