2121import android .graphics .Canvas ;
2222import android .graphics .Outline ;
2323import android .graphics .Path ;
24+ import android .graphics .Rect ;
2425import android .graphics .RectF ;
2526import android .os .Build .VERSION ;
2627import android .os .Build .VERSION_CODES ;
3334import androidx .annotation .NonNull ;
3435import androidx .annotation .Nullable ;
3536import androidx .annotation .RequiresApi ;
37+ import androidx .annotation .VisibleForTesting ;
3638import androidx .core .math .MathUtils ;
3739import 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 ;
3843import 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