Skip to content

Commit a4669fd

Browse files
hunterstichpaulfthomas
authored andcommitted
[Carousel] Updated MaskableFrameLayout to clip more performantly.
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 (cherry picked from commit 733c9e0)
1 parent 3c37522 commit a4669fd

File tree

9 files changed

+475
-50
lines changed

9 files changed

+475
-50
lines changed

lib/java/com/google/android/material/canvas/CanvasCompat.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,12 @@ public static int saveLayerAlpha(
6161
return canvas.saveLayerAlpha(left, top, right, bottom, alpha, Canvas.ALL_SAVE_FLAG);
6262
}
6363
}
64+
65+
/**
66+
* Helper interface to allow delegates to alter the canvas before and after a canvas operation.
67+
*/
68+
public interface CanvasOperation {
69+
void run(@NonNull Canvas canvas);
70+
}
71+
6472
}

lib/java/com/google/android/material/carousel/MaskableFrameLayout.java

Lines changed: 253 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import android.graphics.Canvas;
2222
import android.graphics.Outline;
2323
import android.graphics.Path;
24+
import android.graphics.Rect;
2425
import android.graphics.RectF;
2526
import android.os.Build.VERSION;
2627
import android.os.Build.VERSION_CODES;
@@ -33,20 +34,24 @@
3334
import androidx.annotation.NonNull;
3435
import androidx.annotation.Nullable;
3536
import androidx.annotation.RequiresApi;
37+
import androidx.annotation.VisibleForTesting;
3638
import androidx.core.math.MathUtils;
3739
import com.google.android.material.animation.AnimationUtils;
40+
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
41+
import com.google.android.material.shape.AbsoluteCornerSize;
42+
import com.google.android.material.shape.ClampedCornerSize;
3843
import com.google.android.material.shape.ShapeAppearanceModel;
44+
import com.google.android.material.shape.ShapeAppearancePathProvider;
45+
import com.google.android.material.shape.Shapeable;
3946

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

4350
private float maskXPercentage = 0F;
4451
private final RectF maskRect = new RectF();
45-
private final Path maskPath = new Path();
46-
4752
@Nullable private OnMaskChangedListener onMaskChangedListener;
48-
49-
private final ShapeAppearanceModel shapeAppearanceModel;
53+
@NonNull private ShapeAppearanceModel shapeAppearanceModel;
54+
private final MaskableDelegate maskableDelegate = createMaskableDelegate();
5055

5156
public MaskableFrameLayout(@NonNull Context context) {
5257
this(context, null);
@@ -59,9 +64,17 @@ public MaskableFrameLayout(@NonNull Context context, @Nullable AttributeSet attr
5964
public MaskableFrameLayout(
6065
@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
6166
super(context, attrs, defStyleAttr);
62-
shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0, 0).build();
63-
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
64-
MaskableImplV21.initMaskOutlineProvider(this);
67+
setShapeAppearanceModel(
68+
ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0, 0).build());
69+
}
70+
71+
private MaskableDelegate createMaskableDelegate() {
72+
if (VERSION.SDK_INT >= VERSION_CODES.R) {
73+
return new MaskableDelegateV30(this);
74+
} else if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
75+
return new MaskableDelegateV21(this);
76+
} else {
77+
return new MaskableDelegateV14();
6578
}
6679
}
6780

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

87+
@Override
88+
public void setShapeAppearanceModel(@NonNull ShapeAppearanceModel shapeAppearanceModel) {
89+
this.shapeAppearanceModel =
90+
shapeAppearanceModel.withTransformedCornerSizes(
91+
cornerSize -> {
92+
if (cornerSize instanceof AbsoluteCornerSize) {
93+
// Enforce that the corners of the shape appearance are never larger than half the
94+
// width of the shortest edge. As the size of the mask changes, we never want the
95+
// corners to be larger than half the width or height of this view.
96+
return ClampedCornerSize.createFromCornerSize((AbsoluteCornerSize) cornerSize);
97+
} else {
98+
// Relative corner size already enforces a max size based on shortest edge.
99+
return cornerSize;
100+
}
101+
});
102+
maskableDelegate.onShapeAppearanceChanged(this, this.shapeAppearanceModel);
103+
}
104+
105+
@NonNull
106+
@Override
107+
public ShapeAppearanceModel getShapeAppearanceModel() {
108+
return shapeAppearanceModel;
109+
}
110+
74111
/**
75112
* Sets the percentage by which this {@link View} masks by along the x axis.
76113
*
@@ -115,26 +152,15 @@ private void onMaskChanged() {
115152
// masked away.
116153
float maskWidth = AnimationUtils.lerp(0f, getWidth() / 2F, 0f, 1f, maskXPercentage);
117154
maskRect.set(maskWidth, 0F, (getWidth() - maskWidth), getHeight());
155+
maskableDelegate.onMaskChanged(this, maskRect);
118156
if (onMaskChangedListener != null) {
119157
onMaskChangedListener.onMaskChanged(maskRect);
120158
}
121-
refreshMaskPath();
122-
}
123-
124-
private float getCornerRadiusFromShapeAppearance() {
125-
return shapeAppearanceModel.getTopRightCornerSize().getCornerSize(maskRect);
126159
}
127160

128-
private void refreshMaskPath() {
129-
if (!maskRect.isEmpty()) {
130-
maskPath.rewind();
131-
float cornerRadius = getCornerRadiusFromShapeAppearance();
132-
maskPath.addRoundRect(maskRect, cornerRadius, cornerRadius, Path.Direction.CW);
133-
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
134-
invalidateOutline();
135-
}
136-
invalidate();
137-
}
161+
@VisibleForTesting
162+
void setForceCompatClipping(boolean forceCompatClipping) {
163+
maskableDelegate.setForceCompatClippingEnabled(this, forceCompatClipping);
138164
}
139165

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

154180
@Override
155181
protected void dispatchDraw(Canvas canvas) {
156-
canvas.save();
157-
if (!maskPath.isEmpty()) {
158-
canvas.clipPath(maskPath);
182+
maskableDelegate.maybeClip(canvas, super::dispatchDraw);
183+
}
184+
185+
/**
186+
* A delegate able to handle logic for when and how to mask a View based on the View's {@link
187+
* ShapeAppearanceModel} and mask bounds.
188+
*/
189+
private abstract static class MaskableDelegate {
190+
191+
boolean forceCompatClippingEnabled = false;
192+
@Nullable ShapeAppearanceModel shapeAppearanceModel;
193+
RectF maskBounds = new RectF();
194+
final Path shapePath = new Path();
195+
196+
/**
197+
* Called due to changes in a delegate's shape, mask bounds or other parameters. Delegate
198+
* implementations should use this as an opportunity to ensure their method of clipping is
199+
* appropriate and invalidate the client view if necessary.
200+
*
201+
* @param view the client view
202+
*/
203+
abstract void invalidateClippingMethod(View view);
204+
205+
/**
206+
* Whether the client view should use canvas clipping to mask itself.
207+
*
208+
* <p>Note: It's important that no significant logic is run in this method as it is called from
209+
* dispatch draw, which should be as performant as possible. Logic for determining whether
210+
* compat clipping is used should be run elsewhere and stored for quick access.
211+
*
212+
* @return true if the client view should clip the canvas
213+
*/
214+
abstract boolean shouldUseCompatClipping();
215+
216+
/**
217+
* Set whether the client would like to always use compat clipping regardless of whether other
218+
* means are available.
219+
*
220+
* @param view the client view
221+
* @param enabled true if the client should always use canvas clipping
222+
*/
223+
void setForceCompatClippingEnabled(View view, boolean enabled) {
224+
if (enabled != this.forceCompatClippingEnabled) {
225+
this.forceCompatClippingEnabled = enabled;
226+
invalidateClippingMethod(view);
227+
}
228+
}
229+
230+
/**
231+
* Called whenever the {@link ShapeAppearanceModel} of the client changes.
232+
*
233+
* @param view the client view
234+
* @param shapeAppearanceModel the update {@link ShapeAppearanceModel}
235+
*/
236+
void onShapeAppearanceChanged(View view, @NonNull ShapeAppearanceModel shapeAppearanceModel) {
237+
this.shapeAppearanceModel = shapeAppearanceModel;
238+
updateShapePath();
239+
invalidateClippingMethod(view);
240+
}
241+
242+
/**
243+
* Called whenever the bounds of the clients mask changes.
244+
*
245+
* @param view the client view
246+
* @param maskBounds the updated bounds
247+
*/
248+
void onMaskChanged(View view, RectF maskBounds) {
249+
this.maskBounds = maskBounds;
250+
updateShapePath();
251+
invalidateClippingMethod(view);
252+
}
253+
254+
private void updateShapePath() {
255+
if (!maskBounds.isEmpty() && shapeAppearanceModel != null) {
256+
ShapeAppearancePathProvider.getInstance()
257+
.calculatePath(shapeAppearanceModel, 1F, maskBounds, shapePath);
258+
}
259+
}
260+
261+
void maybeClip(Canvas canvas, CanvasOperation op) {
262+
if (shouldUseCompatClipping() && !shapePath.isEmpty()) {
263+
canvas.save();
264+
canvas.clipPath(shapePath);
265+
op.run(canvas);
266+
canvas.restore();
267+
} else {
268+
op.run(canvas);
269+
}
159270
}
160-
super.dispatchDraw(canvas);
161-
canvas.restore();
162271
}
163272

273+
/**
274+
* A {@link MaskableDelegate} implementation for API 14-20 that always clips using canvas
275+
* clipping.
276+
*/
277+
private static class MaskableDelegateV14 extends MaskableDelegate {
278+
279+
@Override
280+
boolean shouldUseCompatClipping() {
281+
return true;
282+
}
283+
284+
@Override
285+
void invalidateClippingMethod(View view) {
286+
if (shapeAppearanceModel == null || maskBounds.isEmpty()) {
287+
return;
288+
}
289+
290+
if (shouldUseCompatClipping()) {
291+
view.invalidate();
292+
}
293+
}
294+
}
295+
296+
/**
297+
* A {@link MaskableDelegate} for API 21-29 that uses {@link ViewOutlineProvider} to clip when the
298+
* shape being clipped is a round rect with symmetrical corners and canvas clipping for all other
299+
* shapes.
300+
*
301+
* <p>{@link Outline#setRoundRect(Rect, float)} is only able to clip to a rectangle with a single
302+
* corner radius for all four corners.
303+
*/
164304
@RequiresApi(VERSION_CODES.LOLLIPOP)
165-
private static class MaskableImplV21 {
305+
private static class MaskableDelegateV21 extends MaskableDelegate {
306+
307+
private boolean isShapeRoundRect = false;
308+
309+
MaskableDelegateV21(View view) {
310+
initMaskOutlineProvider(view);
311+
}
312+
313+
@Override
314+
public boolean shouldUseCompatClipping() {
315+
return !isShapeRoundRect || forceCompatClippingEnabled;
316+
}
317+
318+
@Override
319+
void invalidateClippingMethod(View view) {
320+
updateIsShapeRoundRect();
321+
view.setClipToOutline(!shouldUseCompatClipping());
322+
if (shouldUseCompatClipping()) {
323+
view.invalidate();
324+
} else {
325+
view.invalidateOutline();
326+
}
327+
}
328+
329+
private void updateIsShapeRoundRect() {
330+
if (!maskBounds.isEmpty() && shapeAppearanceModel != null) {
331+
isShapeRoundRect = shapeAppearanceModel.isRoundRect(maskBounds);
332+
}
333+
}
334+
335+
private float getCornerRadiusFromShapeAppearance(
336+
@NonNull ShapeAppearanceModel shapeAppearanceModel, @NonNull RectF bounds) {
337+
return shapeAppearanceModel.getTopRightCornerSize().getCornerSize(bounds);
338+
}
166339

167340
@DoNotInline
168-
private static void initMaskOutlineProvider(MaskableFrameLayout maskableFrameLayout) {
169-
maskableFrameLayout.setClipToOutline(true);
170-
maskableFrameLayout.setOutlineProvider(
341+
private void initMaskOutlineProvider(View view) {
342+
view.setOutlineProvider(
171343
new ViewOutlineProvider() {
172344
@Override
173345
public void getOutline(View view, Outline outline) {
174-
RectF maskRect = ((MaskableFrameLayout) view).getMaskRectF();
175-
float cornerSize = ((MaskableFrameLayout) view).getCornerRadiusFromShapeAppearance();
176-
if (!maskRect.isEmpty()) {
346+
if (shapeAppearanceModel != null && !maskBounds.isEmpty()) {
177347
outline.setRoundRect(
178-
(int) maskRect.left,
179-
(int) maskRect.top,
180-
(int) maskRect.right,
181-
(int) maskRect.bottom,
182-
cornerSize);
348+
(int) maskBounds.left,
349+
(int) maskBounds.top,
350+
(int) maskBounds.right,
351+
(int) maskBounds.bottom,
352+
getCornerRadiusFromShapeAppearance(shapeAppearanceModel, maskBounds));
353+
}
354+
}
355+
});
356+
}
357+
}
358+
359+
/**
360+
* A {@link MaskableDelegate} for API 30+ that uses {@link ViewOutlineProvider} to clip for
361+
* all shapes.
362+
*
363+
* <p>{@link Outline#setPath(Path)} was added in API 30 and allows using {@link
364+
* ViewOutlineProvider} to clip for all shapes.
365+
*/
366+
@RequiresApi(VERSION_CODES.R)
367+
private static class MaskableDelegateV30 extends MaskableDelegate {
368+
369+
MaskableDelegateV30(View view) {
370+
initMaskOutlineProvider(view);
371+
}
372+
373+
@Override
374+
public boolean shouldUseCompatClipping() {
375+
return forceCompatClippingEnabled;
376+
}
377+
378+
@Override
379+
void invalidateClippingMethod(View view) {
380+
view.setClipToOutline(!shouldUseCompatClipping());
381+
if (shouldUseCompatClipping()) {
382+
view.invalidate();
383+
} else {
384+
view.invalidateOutline();
385+
}
386+
}
387+
388+
@DoNotInline
389+
private void initMaskOutlineProvider(View view) {
390+
view.setOutlineProvider(
391+
new ViewOutlineProvider() {
392+
@Override
393+
public void getOutline(View view, Outline outline) {
394+
if (!shapePath.isEmpty()) {
395+
outline.setPath(shapePath);
183396
}
184397
}
185398
});

0 commit comments

Comments
 (0)