21
21
import android .graphics .Canvas ;
22
22
import android .graphics .Outline ;
23
23
import android .graphics .Path ;
24
+ import android .graphics .Rect ;
24
25
import android .graphics .RectF ;
25
26
import android .os .Build .VERSION ;
26
27
import android .os .Build .VERSION_CODES ;
33
34
import androidx .annotation .NonNull ;
34
35
import androidx .annotation .Nullable ;
35
36
import androidx .annotation .RequiresApi ;
37
+ import androidx .annotation .VisibleForTesting ;
36
38
import androidx .core .math .MathUtils ;
37
39
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 ;
38
43
import com .google .android .material .shape .ShapeAppearanceModel ;
44
+ import com .google .android .material .shape .ShapeAppearancePathProvider ;
45
+ import com .google .android .material .shape .Shapeable ;
39
46
40
47
/** 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 {
42
49
43
50
private float maskXPercentage = 0F ;
44
51
private final RectF maskRect = new RectF ();
45
- private final Path maskPath = new Path ();
46
-
47
52
@ Nullable private OnMaskChangedListener onMaskChangedListener ;
48
-
49
- private final ShapeAppearanceModel shapeAppearanceModel ;
53
+ @ NonNull private ShapeAppearanceModel shapeAppearanceModel ;
54
+ private final MaskableDelegate maskableDelegate = createMaskableDelegate () ;
50
55
51
56
public MaskableFrameLayout (@ NonNull Context context ) {
52
57
this (context , null );
@@ -59,9 +64,17 @@ public MaskableFrameLayout(@NonNull Context context, @Nullable AttributeSet attr
59
64
public MaskableFrameLayout (
60
65
@ NonNull Context context , @ Nullable AttributeSet attrs , int defStyleAttr ) {
61
66
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 ();
65
78
}
66
79
}
67
80
@@ -71,6 +84,30 @@ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
71
84
onMaskChanged ();
72
85
}
73
86
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
+
74
111
/**
75
112
* Sets the percentage by which this {@link View} masks by along the x axis.
76
113
*
@@ -115,26 +152,15 @@ private void onMaskChanged() {
115
152
// masked away.
116
153
float maskWidth = AnimationUtils .lerp (0f , getWidth () / 2F , 0f , 1f , maskXPercentage );
117
154
maskRect .set (maskWidth , 0F , (getWidth () - maskWidth ), getHeight ());
155
+ maskableDelegate .onMaskChanged (this , maskRect );
118
156
if (onMaskChangedListener != null ) {
119
157
onMaskChangedListener .onMaskChanged (maskRect );
120
158
}
121
- refreshMaskPath ();
122
- }
123
-
124
- private float getCornerRadiusFromShapeAppearance () {
125
- return shapeAppearanceModel .getTopRightCornerSize ().getCornerSize (maskRect );
126
159
}
127
160
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 );
138
164
}
139
165
140
166
@ SuppressLint ("ClickableViewAccessibility" )
@@ -153,33 +179,220 @@ public boolean onTouchEvent(MotionEvent event) {
153
179
154
180
@ Override
155
181
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
+ }
159
270
}
160
- super .dispatchDraw (canvas );
161
- canvas .restore ();
162
271
}
163
272
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
+ */
164
304
@ 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
+ }
166
339
167
340
@ DoNotInline
168
- private static void initMaskOutlineProvider (MaskableFrameLayout maskableFrameLayout ) {
169
- maskableFrameLayout .setClipToOutline (true );
170
- maskableFrameLayout .setOutlineProvider (
341
+ private void initMaskOutlineProvider (View view ) {
342
+ view .setOutlineProvider (
171
343
new ViewOutlineProvider () {
172
344
@ Override
173
345
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 ()) {
177
347
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 );
183
396
}
184
397
}
185
398
});
0 commit comments