diff --git a/lib/java/com/google/android/material/shape/CornerTreatment.java b/lib/java/com/google/android/material/shape/CornerTreatment.java index 5fd31e5f2aa..a79e2008e45 100644 --- a/lib/java/com/google/android/material/shape/CornerTreatment.java +++ b/lib/java/com/google/android/material/shape/CornerTreatment.java @@ -50,7 +50,7 @@ public class CornerTreatment { public void getCornerPath(float angle, float interpolation, @NonNull ShapePath shapePath) {} /** - * Generates a {@link ShapePath} for this corner treatment. + * Generates a {@link ShapePath} using a single radius value for this corner treatment. * *

CornerTreatments are assumed to have an origin of (0, 0) (i.e. they represent the top-left * corner), and are automatically rotated and scaled as necessary when applied to other corners. @@ -70,6 +70,32 @@ public void getCornerPath( getCornerPath(angle, interpolation, shapePath); } + /** + * Generates a {@link ShapePath} using start and end radius values for this corner treatment. + * + *

CornerTreatments are assumed to have an origin of (0, 0) (i.e. they represent the top-left + * corner), and are automatically rotated and scaled as necessary when applied to other corners. + * + * @param shapePath the {@link ShapePath} that this treatment should write to. + * @param angle the angle of the corner, typically 90 degrees. + * @param interpolation the interpolation of the corner treatment. Ranges between 0 (none) and 1 + * (fully) interpolated. Custom corner treatments can implement interpolation to support shape + * transition between two arbitrary states. Typically, a value of 0 indicates that the custom + * corner treatment is not rendered (i.e. that it is a 90 degree angle), and a value of 1 + * indicates that the treatment is fully rendered. Animation between these two values can + * "heal" or "reveal" a corner treatment. + * @param startRadius the starting radius or size of this corner before interpolation. + * @param endRadius the ending radius or size of this corner after interpolation. + */ + public void getCornerPath( + @NonNull ShapePath shapePath, + float angle, + float interpolation, + float startRadius, + float endRadius) { + getCornerPath(shapePath, angle, interpolation, endRadius); + } + /** * Generates a {@link ShapePath} for this corner treatment. * @@ -97,4 +123,40 @@ public void getCornerPath( @NonNull CornerSize size) { getCornerPath(shapePath, angle, interpolation, size.getCornerSize(bounds)); } + + /** + * Generates a {@link ShapePath} using start and end {@link CornerSize} values for this corner + * treatment. + * + *

CornerTreatments are assumed to have an origin of (0, 0) (i.e. they represent the top-left + * corner), and are automatically rotated and scaled as necessary when applied to other corners. + * + * @param shapePath the {@link ShapePath} that this treatment should write to. + * @param angle the angle of the corner, typically 90 degrees. + * @param interpolation the interpolation of the corner treatment. Ranges between 0 (none) and 1 + * (fully) interpolated. Custom corner treatments can implement interpolation to support shape + * transition between two arbitrary states. Typically, a value of 0 indicates that the custom + * corner treatment is not rendered (i.e. that it is a 90 degree angle), and a value of 1 + * indicates that the treatment is fully rendered. Animation between these two values can + * "heal" or "reveal" a corner treatment. + * @param bounds the bounds of the full shape that will be drawn. This could be used change the + * behavior of the CornerTreatment depending on how much space is available for the full + * shape. + * @param startSize the starting {@link CornerSize} used for this corner before interpolation + * @param endSize the ending {@link CornerSize} used for this corner after interpolation + */ + public void getCornerPath( + @NonNull ShapePath shapePath, + float angle, + float interpolation, + @NonNull RectF bounds, + @NonNull CornerSize startSize, + @NonNull CornerSize endSize) { + getCornerPath( + shapePath, + angle, + interpolation, + startSize.getCornerSize(bounds), + endSize.getCornerSize(bounds)); + } } diff --git a/lib/java/com/google/android/material/shape/CutCornerTreatment.java b/lib/java/com/google/android/material/shape/CutCornerTreatment.java index 067f97e217f..beb7e9e059c 100644 --- a/lib/java/com/google/android/material/shape/CutCornerTreatment.java +++ b/lib/java/com/google/android/material/shape/CutCornerTreatment.java @@ -16,6 +16,8 @@ package com.google.android.material.shape; +import static com.google.android.material.math.MathUtils.lerp; + import androidx.annotation.NonNull; /** A corner treatment which cuts or clips the original corner of a shape with a straight line. */ @@ -44,11 +46,22 @@ public CutCornerTreatment(float size) { @Override public void getCornerPath( @NonNull ShapePath shapePath, float angle, float interpolation, float radius) { - shapePath.reset(0, radius * interpolation, ShapePath.ANGLE_LEFT, 180 - angle); + getCornerPath(shapePath, angle, interpolation, 0, radius); + } + + @Override + public void getCornerPath( + @NonNull ShapePath shapePath, + float angle, + float interpolation, + float startRadius, + float endRadius) { + float radius = lerp(startRadius, endRadius, interpolation); + shapePath.reset(0, radius, ShapePath.ANGLE_LEFT, 180 - angle); shapePath.lineTo( - (float) (Math.sin(Math.toRadians(angle)) * radius * interpolation), + (float) (Math.sin(Math.toRadians(angle)) * radius), // Something about using cos() is causing rounding which prevents the path from being convex // on api levels 21 and 22. Using sin() with 90 - angle is helping for now. - (float) (Math.sin(Math.toRadians(90 - angle)) * radius * interpolation)); + (float) (Math.sin(Math.toRadians(90 - angle)) * radius)); } } diff --git a/lib/java/com/google/android/material/shape/MaterialShapeDrawable.java b/lib/java/com/google/android/material/shape/MaterialShapeDrawable.java index f84f6fae0f0..861ac71f396 100644 --- a/lib/java/com/google/android/material/shape/MaterialShapeDrawable.java +++ b/lib/java/com/google/android/material/shape/MaterialShapeDrawable.java @@ -19,6 +19,7 @@ import com.google.android.material.R; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; +import static com.google.android.material.math.MathUtils.lerp; import android.annotation.TargetApi; import android.content.Context; @@ -81,6 +82,9 @@ public class MaterialShapeDrawable extends Drawable implements TintAwareDrawable private static final float SHADOW_OFFSET_MULTIPLIER = .25f; + static final ShapeAppearanceModel DEFAULT_INTERPOLATION_START_SHAPE_APPEARANCE_MODEL = + ShapeAppearanceModel.builder().setAllCorners(CornerFamily.ROUNDED, 0).build(); + /** * Try to draw native elevation shadows if possible, otherwise use fake shadows. This is best for * paths which will always be convex. If the path might change to be concave, you should consider @@ -293,6 +297,35 @@ public ShapeAppearanceModel getShapeAppearanceModel() { return drawableState.shapeAppearanceModel; } + /** + * Set the shape appearance when interpolation is 0. + * + * @param startShape the ShapeAppearanceModel for the shape when interpolation is 0. The edge + * treatments within it are ignored. + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public void setInterpolationStartShapeAppearanceModel(@NonNull ShapeAppearanceModel startShape) { + if (drawableState.interpolationStartShapeAppearanceModel != startShape) { + drawableState.interpolationStartShapeAppearanceModel = startShape; + pathDirty = true; + invalidateSelf(); + } + } + + /** + * Get the {@link ShapeAppearanceModel} containing the path that should be rendered at the + * beginning of interpolation (when interpolation=0). + * + * @return the starting model. + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + @NonNull + public ShapeAppearanceModel getInterpolationStartShapeAppearanceModel() { + return drawableState.interpolationStartShapeAppearanceModel; + } + /** * Set the {@link ShapePathModel} containing the path that will be rendered in this drawable. * @@ -1071,10 +1104,11 @@ private void drawShape( @NonNull ShapeAppearanceModel shapeAppearanceModel, @NonNull RectF bounds) { if (shapeAppearanceModel.isRoundRect(bounds)) { - float cornerSize = - shapeAppearanceModel.getTopRightCornerSize().getCornerSize(bounds) - * drawableState.interpolation; - canvas.drawRoundRect(bounds, cornerSize, cornerSize, paint); + float endRadius = shapeAppearanceModel.getTopLeftCornerSize().getCornerSize(bounds); + shapeAppearanceModel = drawableState.interpolationStartShapeAppearanceModel; + float startRadius = shapeAppearanceModel.getTopLeftCornerSize().getCornerSize(bounds); + float radius = lerp(startRadius, endRadius, drawableState.interpolation); + canvas.drawRoundRect(bounds, radius, radius, paint); } else { canvas.drawPath(path, paint); } @@ -1183,6 +1217,7 @@ public void getPathForSize(int width, int height, @NonNull Path path) { protected final void calculatePathForSize(@NonNull RectF bounds, @NonNull Path path) { pathProvider.calculatePath( drawableState.shapeAppearanceModel, + drawableState.interpolationStartShapeAppearanceModel, drawableState.interpolation, bounds, pathShadowListener, @@ -1211,8 +1246,10 @@ public CornerSize apply(@NonNull CornerSize cornerSize) { pathProvider.calculatePath( strokeShapeAppearance, + drawableState.interpolationStartShapeAppearanceModel, drawableState.interpolation, getBoundsInsetByStroke(), + null, pathInsetByStroke); } @@ -1225,11 +1262,16 @@ public void getOutline(@NonNull Outline outline) { } if (isRoundRect()) { - float radius = getTopLeftCornerResolvedSize() * drawableState.interpolation; + float startRadius = + drawableState + .interpolationStartShapeAppearanceModel + .getTopLeftCornerSize() + .getCornerSize(getBoundsAsRectF()); + float endRadius = getTopLeftCornerResolvedSize(); + float radius = lerp(startRadius, endRadius, drawableState.interpolation); outline.setRoundRect(getBounds(), radius); return; } - calculatePath(getBoundsAsRectF(), path); DrawableUtils.setOutlineToPath(outline, path); } @@ -1409,7 +1451,8 @@ public float getBottomRightCornerResolvedSize() { */ @RestrictTo(LIBRARY_GROUP) public boolean isRoundRect() { - return drawableState.shapeAppearanceModel.isRoundRect(getBoundsAsRectF()); + return drawableState.shapeAppearanceModel.isRoundRect(getBoundsAsRectF()) + && drawableState.interpolationStartShapeAppearanceModel.isRoundRect(getBoundsAsRectF()); } /** @@ -1421,6 +1464,8 @@ public boolean isRoundRect() { protected static class MaterialShapeDrawableState extends ConstantState { @NonNull ShapeAppearanceModel shapeAppearanceModel; + // The shape appearance when interpolation is 0. Edge treatments are ignored. + @NonNull ShapeAppearanceModel interpolationStartShapeAppearanceModel; @Nullable ElevationOverlayProvider elevationOverlayProvider; @Nullable ColorFilter colorFilter; @@ -1453,10 +1498,13 @@ public MaterialShapeDrawableState( @Nullable ElevationOverlayProvider elevationOverlayProvider) { this.shapeAppearanceModel = shapeAppearanceModel; this.elevationOverlayProvider = elevationOverlayProvider; + this.interpolationStartShapeAppearanceModel = + DEFAULT_INTERPOLATION_START_SHAPE_APPEARANCE_MODEL; } public MaterialShapeDrawableState(@NonNull MaterialShapeDrawableState orig) { shapeAppearanceModel = orig.shapeAppearanceModel; + interpolationStartShapeAppearanceModel = orig.interpolationStartShapeAppearanceModel; elevationOverlayProvider = orig.elevationOverlayProvider; strokeWidth = orig.strokeWidth; colorFilter = orig.colorFilter; diff --git a/lib/java/com/google/android/material/shape/RoundedCornerTreatment.java b/lib/java/com/google/android/material/shape/RoundedCornerTreatment.java index ebcfa9b4c18..e7542cca9c9 100644 --- a/lib/java/com/google/android/material/shape/RoundedCornerTreatment.java +++ b/lib/java/com/google/android/material/shape/RoundedCornerTreatment.java @@ -16,6 +16,8 @@ package com.google.android.material.shape; +import static com.google.android.material.math.MathUtils.lerp; + import androidx.annotation.NonNull; /** A corner treatment which rounds a corner of a shape. */ @@ -40,7 +42,18 @@ public RoundedCornerTreatment(float radius) { @Override public void getCornerPath( @NonNull ShapePath shapePath, float angle, float interpolation, float radius) { - shapePath.reset(0, radius * interpolation, ShapePath.ANGLE_LEFT, 180 - angle); - shapePath.addArc(0, 0, 2 * radius * interpolation, 2 * radius * interpolation, 180, angle); + getCornerPath(shapePath, angle, interpolation, 0, radius); + } + + @Override + public void getCornerPath( + @NonNull ShapePath shapePath, + float angle, + float interpolation, + float startRadius, + float endRadius) { + float radius = lerp(startRadius, endRadius, interpolation); + shapePath.reset(0, radius, ShapePath.ANGLE_LEFT, 180 - angle); + shapePath.addArc(0, 0, 2 * radius, 2 * radius, 180, angle); } } diff --git a/lib/java/com/google/android/material/shape/ShapeAppearancePathProvider.java b/lib/java/com/google/android/material/shape/ShapeAppearancePathProvider.java index 3b97906965b..4cab6319208 100644 --- a/lib/java/com/google/android/material/shape/ShapeAppearancePathProvider.java +++ b/lib/java/com/google/android/material/shape/ShapeAppearancePathProvider.java @@ -41,6 +41,8 @@ private static class Lazy { /** * Listener called every time a {@link ShapePath} is created for a corner or an edge treatment. + * + * @hide */ @RestrictTo(LIBRARY_GROUP) public interface PathListener { @@ -76,6 +78,7 @@ public ShapeAppearancePathProvider() { } } + /** @hide */ @UiThread @RestrictTo(LIBRARY_GROUP) @NonNull @@ -107,10 +110,42 @@ public void calculatePath( * @param bounds the desired bounds for the path. * @param pathListener the path * @param path the returned path out-var. + * + * @hide + */ + @RestrictTo(LIBRARY_GROUP) + public void calculatePath( + ShapeAppearanceModel shapeAppearanceModel, + float interpolation, + RectF bounds, + PathListener pathListener, + @NonNull Path path) { + calculatePath( + shapeAppearanceModel, + MaterialShapeDrawable.DEFAULT_INTERPOLATION_START_SHAPE_APPEARANCE_MODEL, + interpolation, + bounds, + pathListener, + path); + } + + /** + * Writes the given {@link ShapeAppearanceModel} to {@code path} + * + * @param shapeAppearanceModel The shape to be applied in the path. + * @param interpolationStartShapeAppearanceModel The shape to be applied in the path when + * interpolation is 0. + * @param interpolation the desired interpolation. + * @param bounds the desired bounds for the path. + * @param pathListener the path + * @param path the returned path out-var. + * + * @hide */ @RestrictTo(LIBRARY_GROUP) public void calculatePath( ShapeAppearanceModel shapeAppearanceModel, + @NonNull ShapeAppearanceModel interpolationStartShapeAppearanceModel, float interpolation, RectF bounds, PathListener pathListener, @@ -121,7 +156,12 @@ public void calculatePath( boundsPath.addRect(bounds, Direction.CW); ShapeAppearancePathSpec spec = new ShapeAppearancePathSpec( - shapeAppearanceModel, interpolation, bounds, pathListener, path); + shapeAppearanceModel, + interpolationStartShapeAppearanceModel, + interpolation, + bounds, + pathListener, + path); // Calculate the transformations (rotations and translations) necessary for each edge and // corner treatment. @@ -146,8 +186,10 @@ public void calculatePath( private void setCornerPathAndTransform(@NonNull ShapeAppearancePathSpec spec, int index) { CornerSize size = getCornerSizeForIndex(index, spec.shapeAppearanceModel); + CornerSize startSize = + getCornerSizeForIndex(index, spec.interpolationStartShapeAppearanceModel); getCornerTreatmentForIndex(index, spec.shapeAppearanceModel) - .getCornerPath(cornerPaths[index], 90, spec.interpolation, spec.bounds, size); + .getCornerPath(cornerPaths[index], 90, spec.interpolation, spec.bounds, startSize, size); float edgeAngle = angleOfEdge(index); cornerTransforms[index].reset(); @@ -333,6 +375,7 @@ void setEdgeIntersectionCheckEnable(boolean enable) { static final class ShapeAppearancePathSpec { @NonNull public final ShapeAppearanceModel shapeAppearanceModel; + @NonNull public final ShapeAppearanceModel interpolationStartShapeAppearanceModel; @NonNull public final Path path; @NonNull public final RectF bounds; @@ -342,12 +385,14 @@ static final class ShapeAppearancePathSpec { ShapeAppearancePathSpec( @NonNull ShapeAppearanceModel shapeAppearanceModel, + @NonNull ShapeAppearanceModel interpolationStartShapeAppearanceModel, float interpolation, RectF bounds, @Nullable PathListener pathListener, Path path) { this.pathListener = pathListener; this.shapeAppearanceModel = shapeAppearanceModel; + this.interpolationStartShapeAppearanceModel = interpolationStartShapeAppearanceModel; this.interpolation = interpolation; this.bounds = bounds; this.path = path; diff --git a/lib/javatests/com/google/android/material/shape/MaterialShapeDrawableTest.java b/lib/javatests/com/google/android/material/shape/MaterialShapeDrawableTest.java index 5f14eff542b..0d470b77e24 100644 --- a/lib/javatests/com/google/android/material/shape/MaterialShapeDrawableTest.java +++ b/lib/javatests/com/google/android/material/shape/MaterialShapeDrawableTest.java @@ -17,6 +17,7 @@ import com.google.android.material.test.R; +import static com.google.android.material.shape.CornerFamily.ROUNDED; import static com.google.common.truth.Truth.assertThat; import android.content.Context; @@ -32,6 +33,7 @@ public class MaterialShapeDrawableTest { private static final float ELEVATION = 4; + private static final float DEFAULT_SIZE = 50; private static final float TRANSLATION_Z = 2; private static final float Z = ELEVATION + TRANSLATION_Z; private static final int ALPHA = 127; @@ -140,4 +142,14 @@ public void whenSetAlpha_returnsAlpha() { assertThat(materialShapeDrawable.getAlpha()).isEqualTo(ALPHA); } + + @Test + public void roundRect_withInterpolationAndStartShape() throws Exception { + ShapeAppearanceModel startingShape = + ShapeAppearanceModel.builder().setAllCorners(ROUNDED, DEFAULT_SIZE).build(); + + materialShapeDrawable.setInterpolationStartShapeAppearanceModel(startingShape); + assertThat(materialShapeDrawable.getInterpolationStartShapeAppearanceModel()) + .isEqualTo(startingShape); + } }