2024-07-22 18:26:34 +00:00

394 lines
13 KiB
Java

/*
* Copyright 2018 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 com.google.android.material.shape;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
import static com.google.android.material.shape.ShapeAppearanceModel.NUM_CORNERS;
import android.graphics.Matrix;
import android.graphics.Path;
import android.graphics.Path.Direction;
import android.graphics.Path.Op;
import android.graphics.PointF;
import android.graphics.RectF;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.UiThread;
/** A class to convert a {@link ShapeAppearanceModel} to a {@link android.graphics.Path}. */
public class ShapeAppearancePathProvider {
protected static final int TOP_RIGHT_CORNER_INDEX = 0;
protected static final int BOTTOM_RIGHT_CORNER_INDEX = 1;
protected static final int BOTTOM_LEFT_CORNER_INDEX = 2;
protected static final int TOP_LEFT_CORNER_INDEX = 3;
private static class Lazy {
static final ShapeAppearancePathProvider INSTANCE = new ShapeAppearancePathProvider();
}
/**
* Listener called every time a {@link ShapePath} is created for a corner or an edge treatment.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
public interface PathListener {
void onCornerPathCreated(ShapePath cornerPath, Matrix transform, int count);
void onEdgePathCreated(ShapePath edgePath, Matrix transform, int count);
}
// Inter-method state. This class works under the assumption that there is only one exposed
// method, the method is responsible for correctly reset state.
private final ShapePath[] cornerPaths = new ShapePath[NUM_CORNERS];
private final Matrix[] cornerTransforms = new Matrix[NUM_CORNERS];
private final Matrix[] edgeTransforms = new Matrix[NUM_CORNERS];
// Pre-allocated objects that are re-used several times during path computation and rendering.
private final PointF pointF = new PointF();
private final Path overlappedEdgePath = new Path();
private final Path boundsPath = new Path();
private final ShapePath shapePath = new ShapePath();
private final float[] scratch = new float[2];
private final float[] scratch2 = new float[2];
private final Path edgePath = new Path();
private final Path cornerPath = new Path();
private boolean edgeIntersectionCheckEnabled = true;
public ShapeAppearancePathProvider() {
for (int i = 0; i < NUM_CORNERS; i++) {
cornerPaths[i] = new ShapePath();
cornerTransforms[i] = new Matrix();
edgeTransforms[i] = new Matrix();
}
}
/** @hide */
@UiThread
@RestrictTo(LIBRARY_GROUP)
@NonNull
public static ShapeAppearancePathProvider getInstance() {
return Lazy.INSTANCE;
}
/**
* Writes the given {@link ShapeAppearanceModel} to {@code path}
*
* @param shapeAppearanceModel The shape to be applied in the path.
* @param interpolation the desired interpolation.
* @param bounds the desired bounds for the path.
* @param path the returned path out-var.
*/
public void calculatePath(
ShapeAppearanceModel shapeAppearanceModel,
float interpolation,
RectF bounds,
@NonNull Path path) {
calculatePath(shapeAppearanceModel, interpolation, bounds, null, path);
}
/**
* Writes the given {@link ShapeAppearanceModel} to {@code path}
*
* @param shapeAppearanceModel The shape to be applied in the path.
* @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,
float interpolation,
RectF bounds,
PathListener pathListener,
@NonNull Path path) {
calculatePath(
shapeAppearanceModel,
/* cornerSizeOverrides= */ null,
interpolation,
bounds,
pathListener,
path);
}
/**
* Writes the given {@link ShapeAppearanceModel} to {@code path}
*
* @param shapeAppearanceModel The shape to be applied in the path.
* @param cornerSizeOverrides the corner sizes to overload the ones from shapeAppearanceModel.
* @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(
@NonNull ShapeAppearanceModel shapeAppearanceModel,
@Nullable float[] cornerSizeOverrides,
float interpolation,
RectF bounds,
PathListener pathListener,
@NonNull Path path) {
path.rewind();
overlappedEdgePath.rewind();
boundsPath.rewind();
boundsPath.addRect(bounds, Direction.CW);
ShapeAppearancePathSpec spec =
new ShapeAppearancePathSpec(
shapeAppearanceModel, interpolation, bounds, pathListener, path);
// Calculate the transformations (rotations and translations) necessary for each edge and
// corner treatment.
for (int index = 0; index < NUM_CORNERS; index++) {
setCornerPathAndTransform(spec, index, cornerSizeOverrides);
setEdgePathAndTransform(index);
}
for (int index = 0; index < NUM_CORNERS; index++) {
appendCornerPath(spec, index);
appendEdgePath(spec, index);
}
path.close();
overlappedEdgePath.close();
// Union with the edge paths that had an intersection to handle overlaps.
if (!overlappedEdgePath.isEmpty()) {
path.op(overlappedEdgePath, Op.UNION);
}
}
private void setCornerPathAndTransform(
@NonNull ShapeAppearancePathSpec spec, int index, @Nullable float[] cornerSizes) {
CornerSize cornerSize =
cornerSizes == null
? getCornerSizeForIndex(index, spec.shapeAppearanceModel)
: new ClampedCornerSize(cornerSizes[index]);
getCornerTreatmentForIndex(index, spec.shapeAppearanceModel)
.getCornerPath(cornerPaths[index], 90, spec.interpolation, spec.bounds, cornerSize);
float edgeAngle = angleOfEdge(index);
cornerTransforms[index].reset();
getCoordinatesOfCorner(index, spec.bounds, pointF);
cornerTransforms[index].setTranslate(pointF.x, pointF.y);
cornerTransforms[index].preRotate(edgeAngle);
}
private void setEdgePathAndTransform(int index) {
scratch[0] = cornerPaths[index].getEndX();
scratch[1] = cornerPaths[index].getEndY();
cornerTransforms[index].mapPoints(scratch);
float edgeAngle = angleOfEdge(index);
edgeTransforms[index].reset();
edgeTransforms[index].setTranslate(scratch[0], scratch[1]);
edgeTransforms[index].preRotate(edgeAngle);
}
private void appendCornerPath(@NonNull ShapeAppearancePathSpec spec, int index) {
scratch[0] = cornerPaths[index].getStartX();
scratch[1] = cornerPaths[index].getStartY();
cornerTransforms[index].mapPoints(scratch);
if (index == 0) {
spec.path.moveTo(scratch[0], scratch[1]);
} else {
spec.path.lineTo(scratch[0], scratch[1]);
}
cornerPaths[index].applyToPath(cornerTransforms[index], spec.path);
if (spec.pathListener != null) {
spec.pathListener.onCornerPathCreated(cornerPaths[index], cornerTransforms[index], index);
}
}
private void appendEdgePath(@NonNull ShapeAppearancePathSpec spec, int index) {
int nextIndex = (index + 1) % 4;
scratch[0] = cornerPaths[index].getEndX();
scratch[1] = cornerPaths[index].getEndY();
cornerTransforms[index].mapPoints(scratch);
scratch2[0] = cornerPaths[nextIndex].getStartX();
scratch2[1] = cornerPaths[nextIndex].getStartY();
cornerTransforms[nextIndex].mapPoints(scratch2);
float edgeLength = (float) Math.hypot(scratch[0] - scratch2[0], scratch[1] - scratch2[1]);
// TODO(b/121352029): Remove this -.001f that is currently needed to handle rounding errors
edgeLength = Math.max(edgeLength - .001f, 0);
float center = getEdgeCenterForIndex(spec.bounds, index);
shapePath.reset(0, 0);
EdgeTreatment edgeTreatment = getEdgeTreatmentForIndex(index, spec.shapeAppearanceModel);
edgeTreatment.getEdgePath(edgeLength, center, spec.interpolation, shapePath);
edgePath.reset();
shapePath.applyToPath(edgeTransforms[index], edgePath);
if (edgeIntersectionCheckEnabled
&& (edgeTreatment.forceIntersection()
|| pathOverlapsCorner(edgePath, index)
|| pathOverlapsCorner(edgePath, nextIndex))) {
// Calculate the difference between the edge and the bounds to calculate the part of the edge
// outside of the bounds of the shape.
edgePath.op(edgePath, boundsPath, Op.DIFFERENCE);
// Add a line to the path between the previous corner and this edge.
// TODO(b/144784590): handle the shadow as well.
scratch[0] = shapePath.getStartX();
scratch[1] = shapePath.getStartY();
edgeTransforms[index].mapPoints(scratch);
overlappedEdgePath.moveTo(scratch[0], scratch[1]);
// Add this to the overlappedEdgePath which will be unioned later.
shapePath.applyToPath(edgeTransforms[index], overlappedEdgePath);
} else {
shapePath.applyToPath(edgeTransforms[index], spec.path);
}
if (spec.pathListener != null) {
spec.pathListener.onEdgePathCreated(shapePath, edgeTransforms[index], index);
}
}
private boolean pathOverlapsCorner(Path edgePath, int index) {
cornerPath.reset();
cornerPaths[index].applyToPath(cornerTransforms[index], cornerPath);
RectF bounds = new RectF();
edgePath.computeBounds(bounds, /* exact= */ true);
cornerPath.computeBounds(bounds, /* exact= */ true);
edgePath.op(cornerPath, Op.INTERSECT);
edgePath.computeBounds(bounds, /* exact= */ true);
return !bounds.isEmpty() || (bounds.width() > 1 && bounds.height() > 1);
}
private float getEdgeCenterForIndex(@NonNull RectF bounds, int index) {
scratch[0] = cornerPaths[index].endX;
scratch[1] = cornerPaths[index].endY;
cornerTransforms[index].mapPoints(scratch);
switch (index) {
case 1:
case 3:
return Math.abs(bounds.centerX() - scratch[0]);
case 2:
case 0:
default:
return Math.abs(bounds.centerY() - scratch[1]);
}
}
private CornerTreatment getCornerTreatmentForIndex(
int index, @NonNull ShapeAppearanceModel shapeAppearanceModel) {
switch (index) {
case 1:
return shapeAppearanceModel.getBottomRightCorner();
case 2:
return shapeAppearanceModel.getBottomLeftCorner();
case 3:
return shapeAppearanceModel.getTopLeftCorner();
case 0:
default:
return shapeAppearanceModel.getTopRightCorner();
}
}
@NonNull
CornerSize getCornerSizeForIndex(int index, @NonNull ShapeAppearanceModel shapeAppearanceModel) {
switch (index) {
case 1:
return shapeAppearanceModel.getBottomRightCornerSize();
case 2:
return shapeAppearanceModel.getBottomLeftCornerSize();
case 3:
return shapeAppearanceModel.getTopLeftCornerSize();
case 0:
default:
return shapeAppearanceModel.getTopRightCornerSize();
}
}
private EdgeTreatment getEdgeTreatmentForIndex(
int index, @NonNull ShapeAppearanceModel shapeAppearanceModel) {
switch (index) {
case 1:
return shapeAppearanceModel.getBottomEdge();
case 2:
return shapeAppearanceModel.getLeftEdge();
case 3:
return shapeAppearanceModel.getTopEdge();
case 0:
default:
return shapeAppearanceModel.getRightEdge();
}
}
private void getCoordinatesOfCorner(int index, @NonNull RectF bounds, @NonNull PointF pointF) {
switch (index) {
case 1: // bottom-right
pointF.set(bounds.right, bounds.bottom);
break;
case 2: // bottom-left
pointF.set(bounds.left, bounds.bottom);
break;
case 3: // top-left
pointF.set(bounds.left, bounds.top);
break;
case 0: // top-right
default:
pointF.set(bounds.right, bounds.top);
break;
}
}
private float angleOfEdge(int index) {
return 90 * ((index + 1) % 4);
}
void setEdgeIntersectionCheckEnable(boolean enable) {
edgeIntersectionCheckEnabled = enable;
}
/** Necessary information to map a {@link ShapeAppearanceModel} into a Path. */
static final class ShapeAppearancePathSpec {
@NonNull public final ShapeAppearanceModel shapeAppearanceModel;
@NonNull public final Path path;
@NonNull public final RectF bounds;
@Nullable public final PathListener pathListener;
public final float interpolation;
ShapeAppearancePathSpec(
@NonNull ShapeAppearanceModel shapeAppearanceModel,
float interpolation,
RectF bounds,
@Nullable PathListener pathListener,
Path path) {
this.pathListener = pathListener;
this.shapeAppearanceModel = shapeAppearanceModel;
this.interpolation = interpolation;
this.bounds = bounds;
this.path = path;
}
}
}