Navigation Menu

Skip to content

Commit

Permalink
[Carousel] Updated MaskableFrameLayout to clip more performantly.
Browse files Browse the repository at this point in the history
Clipping is now handled differently depending on the shape being used and API level.
* 30+ always uses a ViewOutlineProvider
* 21+ uses a ViewOutlineProvider when the shape is a round rect
* All other API levels and cases fall back to canvas clipping

PiperOrigin-RevId: 516297199
  • Loading branch information
hunterstich authored and paulfthomas committed Mar 13, 2023
1 parent 0c62df4 commit 733c9e0
Show file tree
Hide file tree
Showing 9 changed files with 475 additions and 50 deletions.
8 changes: 8 additions & 0 deletions lib/java/com/google/android/material/canvas/CanvasCompat.java
Expand Up @@ -61,4 +61,12 @@ public static int saveLayerAlpha(
return canvas.saveLayerAlpha(left, top, right, bottom, alpha, Canvas.ALL_SAVE_FLAG);
}
}

/**
* Helper interface to allow delegates to alter the canvas before and after a canvas operation.
*/
public interface CanvasOperation {
void run(@NonNull Canvas canvas);
}

}
293 changes: 253 additions & 40 deletions lib/java/com/google/android/material/carousel/MaskableFrameLayout.java
Expand Up @@ -21,6 +21,7 @@
import android.graphics.Canvas;
import android.graphics.Outline;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
Expand All @@ -33,20 +34,24 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.core.math.MathUtils;
import com.google.android.material.animation.AnimationUtils;
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
import com.google.android.material.shape.AbsoluteCornerSize;
import com.google.android.material.shape.ClampedCornerSize;
import com.google.android.material.shape.ShapeAppearanceModel;
import com.google.android.material.shape.ShapeAppearancePathProvider;
import com.google.android.material.shape.Shapeable;

/** A {@link FrameLayout} than is able to mask itself and all children. */
public class MaskableFrameLayout extends FrameLayout implements Maskable {
public class MaskableFrameLayout extends FrameLayout implements Maskable, Shapeable {

private float maskXPercentage = 0F;
private final RectF maskRect = new RectF();
private final Path maskPath = new Path();

@Nullable private OnMaskChangedListener onMaskChangedListener;

private final ShapeAppearanceModel shapeAppearanceModel;
@NonNull private ShapeAppearanceModel shapeAppearanceModel;
private final MaskableDelegate maskableDelegate = createMaskableDelegate();

public MaskableFrameLayout(@NonNull Context context) {
this(context, null);
Expand All @@ -59,9 +64,17 @@ public MaskableFrameLayout(@NonNull Context context, @Nullable AttributeSet attr
public MaskableFrameLayout(
@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0, 0).build();
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
MaskableImplV21.initMaskOutlineProvider(this);
setShapeAppearanceModel(
ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0, 0).build());
}

private MaskableDelegate createMaskableDelegate() {
if (VERSION.SDK_INT >= VERSION_CODES.R) {
return new MaskableDelegateV30(this);
} else if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
return new MaskableDelegateV21(this);
} else {
return new MaskableDelegateV14();
}
}

Expand All @@ -71,6 +84,30 @@ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
onMaskChanged();
}

@Override
public void setShapeAppearanceModel(@NonNull ShapeAppearanceModel shapeAppearanceModel) {
this.shapeAppearanceModel =
shapeAppearanceModel.withTransformedCornerSizes(
cornerSize -> {
if (cornerSize instanceof AbsoluteCornerSize) {
// Enforce that the corners of the shape appearance are never larger than half the
// width of the shortest edge. As the size of the mask changes, we never want the
// corners to be larger than half the width or height of this view.
return ClampedCornerSize.createFromCornerSize((AbsoluteCornerSize) cornerSize);
} else {
// Relative corner size already enforces a max size based on shortest edge.
return cornerSize;
}
});
maskableDelegate.onShapeAppearanceChanged(this, this.shapeAppearanceModel);
}

@NonNull
@Override
public ShapeAppearanceModel getShapeAppearanceModel() {
return shapeAppearanceModel;
}

/**
* Sets the percentage by which this {@link View} masks by along the x axis.
*
Expand Down Expand Up @@ -115,26 +152,15 @@ private void onMaskChanged() {
// masked away.
float maskWidth = AnimationUtils.lerp(0f, getWidth() / 2F, 0f, 1f, maskXPercentage);
maskRect.set(maskWidth, 0F, (getWidth() - maskWidth), getHeight());
maskableDelegate.onMaskChanged(this, maskRect);
if (onMaskChangedListener != null) {
onMaskChangedListener.onMaskChanged(maskRect);
}
refreshMaskPath();
}

private float getCornerRadiusFromShapeAppearance() {
return shapeAppearanceModel.getTopRightCornerSize().getCornerSize(maskRect);
}

private void refreshMaskPath() {
if (!maskRect.isEmpty()) {
maskPath.rewind();
float cornerRadius = getCornerRadiusFromShapeAppearance();
maskPath.addRoundRect(maskRect, cornerRadius, cornerRadius, Path.Direction.CW);
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
invalidateOutline();
}
invalidate();
}
@VisibleForTesting
void setForceCompatClipping(boolean forceCompatClipping) {
maskableDelegate.setForceCompatClippingEnabled(this, forceCompatClipping);
}

@SuppressLint("ClickableViewAccessibility")
Expand All @@ -153,33 +179,220 @@ public boolean onTouchEvent(MotionEvent event) {

@Override
protected void dispatchDraw(Canvas canvas) {
canvas.save();
if (!maskPath.isEmpty()) {
canvas.clipPath(maskPath);
maskableDelegate.maybeClip(canvas, super::dispatchDraw);
}

/**
* A delegate able to handle logic for when and how to mask a View based on the View's {@link
* ShapeAppearanceModel} and mask bounds.
*/
private abstract static class MaskableDelegate {

boolean forceCompatClippingEnabled = false;
@Nullable ShapeAppearanceModel shapeAppearanceModel;
RectF maskBounds = new RectF();
final Path shapePath = new Path();

/**
* Called due to changes in a delegate's shape, mask bounds or other parameters. Delegate
* implementations should use this as an opportunity to ensure their method of clipping is
* appropriate and invalidate the client view if necessary.
*
* @param view the client view
*/
abstract void invalidateClippingMethod(View view);

/**
* Whether the client view should use canvas clipping to mask itself.
*
* <p>Note: It's important that no significant logic is run in this method as it is called from
* dispatch draw, which should be as performant as possible. Logic for determining whether
* compat clipping is used should be run elsewhere and stored for quick access.
*
* @return true if the client view should clip the canvas
*/
abstract boolean shouldUseCompatClipping();

/**
* Set whether the client would like to always use compat clipping regardless of whether other
* means are available.
*
* @param view the client view
* @param enabled true if the client should always use canvas clipping
*/
void setForceCompatClippingEnabled(View view, boolean enabled) {
if (enabled != this.forceCompatClippingEnabled) {
this.forceCompatClippingEnabled = enabled;
invalidateClippingMethod(view);
}
}

/**
* Called whenever the {@link ShapeAppearanceModel} of the client changes.
*
* @param view the client view
* @param shapeAppearanceModel the update {@link ShapeAppearanceModel}
*/
void onShapeAppearanceChanged(View view, @NonNull ShapeAppearanceModel shapeAppearanceModel) {
this.shapeAppearanceModel = shapeAppearanceModel;
updateShapePath();
invalidateClippingMethod(view);
}

/**
* Called whenever the bounds of the clients mask changes.
*
* @param view the client view
* @param maskBounds the updated bounds
*/
void onMaskChanged(View view, RectF maskBounds) {
this.maskBounds = maskBounds;
updateShapePath();
invalidateClippingMethod(view);
}

private void updateShapePath() {
if (!maskBounds.isEmpty() && shapeAppearanceModel != null) {
ShapeAppearancePathProvider.getInstance()
.calculatePath(shapeAppearanceModel, 1F, maskBounds, shapePath);
}
}

void maybeClip(Canvas canvas, CanvasOperation op) {
if (shouldUseCompatClipping() && !shapePath.isEmpty()) {
canvas.save();
canvas.clipPath(shapePath);
op.run(canvas);
canvas.restore();
} else {
op.run(canvas);
}
}
super.dispatchDraw(canvas);
canvas.restore();
}

/**
* A {@link MaskableDelegate} implementation for API 14-20 that always clips using canvas
* clipping.
*/
private static class MaskableDelegateV14 extends MaskableDelegate {

@Override
boolean shouldUseCompatClipping() {
return true;
}

@Override
void invalidateClippingMethod(View view) {
if (shapeAppearanceModel == null || maskBounds.isEmpty()) {
return;
}

if (shouldUseCompatClipping()) {
view.invalidate();
}
}
}

/**
* A {@link MaskableDelegate} for API 21-29 that uses {@link ViewOutlineProvider} to clip when the
* shape being clipped is a round rect with symmetrical corners and canvas clipping for all other
* shapes.
*
* <p>{@link Outline#setRoundRect(Rect, float)} is only able to clip to a rectangle with a single
* corner radius for all four corners.
*/
@RequiresApi(VERSION_CODES.LOLLIPOP)
private static class MaskableImplV21 {
private static class MaskableDelegateV21 extends MaskableDelegate {

private boolean isShapeRoundRect = false;

MaskableDelegateV21(View view) {
initMaskOutlineProvider(view);
}

@Override
public boolean shouldUseCompatClipping() {
return !isShapeRoundRect || forceCompatClippingEnabled;
}

@Override
void invalidateClippingMethod(View view) {
updateIsShapeRoundRect();
view.setClipToOutline(!shouldUseCompatClipping());
if (shouldUseCompatClipping()) {
view.invalidate();
} else {
view.invalidateOutline();
}
}

private void updateIsShapeRoundRect() {
if (!maskBounds.isEmpty() && shapeAppearanceModel != null) {
isShapeRoundRect = shapeAppearanceModel.isRoundRect(maskBounds);
}
}

private float getCornerRadiusFromShapeAppearance(
@NonNull ShapeAppearanceModel shapeAppearanceModel, @NonNull RectF bounds) {
return shapeAppearanceModel.getTopRightCornerSize().getCornerSize(bounds);
}

@DoNotInline
private static void initMaskOutlineProvider(MaskableFrameLayout maskableFrameLayout) {
maskableFrameLayout.setClipToOutline(true);
maskableFrameLayout.setOutlineProvider(
private void initMaskOutlineProvider(View view) {
view.setOutlineProvider(
new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
RectF maskRect = ((MaskableFrameLayout) view).getMaskRectF();
float cornerSize = ((MaskableFrameLayout) view).getCornerRadiusFromShapeAppearance();
if (!maskRect.isEmpty()) {
if (shapeAppearanceModel != null && !maskBounds.isEmpty()) {
outline.setRoundRect(
(int) maskRect.left,
(int) maskRect.top,
(int) maskRect.right,
(int) maskRect.bottom,
cornerSize);
(int) maskBounds.left,
(int) maskBounds.top,
(int) maskBounds.right,
(int) maskBounds.bottom,
getCornerRadiusFromShapeAppearance(shapeAppearanceModel, maskBounds));
}
}
});
}
}

/**
* A {@link MaskableDelegate} for API 30+ that uses {@link ViewOutlineProvider} to clip for
* all shapes.
*
* <p>{@link Outline#setPath(Path)} was added in API 30 and allows using {@link
* ViewOutlineProvider} to clip for all shapes.
*/
@RequiresApi(VERSION_CODES.R)
private static class MaskableDelegateV30 extends MaskableDelegate {

MaskableDelegateV30(View view) {
initMaskOutlineProvider(view);
}

@Override
public boolean shouldUseCompatClipping() {
return forceCompatClippingEnabled;
}

@Override
void invalidateClippingMethod(View view) {
view.setClipToOutline(!shouldUseCompatClipping());
if (shouldUseCompatClipping()) {
view.invalidate();
} else {
view.invalidateOutline();
}
}

@DoNotInline
private void initMaskOutlineProvider(View view) {
view.setOutlineProvider(
new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
if (!shapePath.isEmpty()) {
outline.setPath(shapePath);
}
}
});
Expand Down

0 comments on commit 733c9e0

Please sign in to comment.