Skip to content

Commit 733c9e0

Browse files
hunterstichpaulfthomas
authored andcommittedMar 13, 2023
[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
1 parent 0c62df4 commit 733c9e0

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

+8
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

+253-40
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
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.material.shape;
18+
19+
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP;
20+
import static java.lang.Math.min;
21+
22+
import android.graphics.RectF;
23+
import androidx.annotation.NonNull;
24+
import androidx.annotation.RestrictTo;
25+
import java.util.Arrays;
26+
27+
/**
28+
* A {@link CornerSize} that takes a desired absolute corner size and clamps the value to be no
29+
* larger than half the length of the shortest edge (fully rounded/cut).
30+
*
31+
* @hide
32+
*/
33+
@RestrictTo(LIBRARY_GROUP)
34+
public final class ClampedCornerSize implements CornerSize {
35+
36+
private final float target;
37+
38+
/**
39+
* Create a new {@link ClampedCornerSize} from an {@link AbsoluteCornerSize}.
40+
*
41+
* @param cornerSize the absolute corner size to clamp
42+
* @return a new clamped corner size
43+
*/
44+
@NonNull
45+
public static ClampedCornerSize createFromCornerSize(
46+
@NonNull final AbsoluteCornerSize cornerSize) {
47+
return new ClampedCornerSize(cornerSize.getCornerSize());
48+
}
49+
50+
private static float getMaxCornerSize(@NonNull RectF bounds) {
51+
return min(bounds.width() / 2F, bounds.height() / 2F);
52+
}
53+
54+
public ClampedCornerSize(float target) {
55+
this.target = target;
56+
}
57+
58+
@Override
59+
public float getCornerSize(@NonNull RectF bounds) {
60+
return min(target, getMaxCornerSize(bounds));
61+
}
62+
63+
@Override
64+
public boolean equals(Object o) {
65+
if (this == o) {
66+
return true;
67+
}
68+
if (!(o instanceof ClampedCornerSize)) {
69+
return false;
70+
}
71+
ClampedCornerSize that = (ClampedCornerSize) o;
72+
return target == that.target;
73+
}
74+
75+
@Override
76+
public int hashCode() {
77+
Object[] hashedFields = {target};
78+
return Arrays.hashCode(hashedFields);
79+
}
80+
}

‎lib/java/com/google/android/material/transition/MaterialContainerTransform.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@
7070
import androidx.transition.Transition;
7171
import androidx.transition.TransitionValues;
7272
import com.google.android.material.animation.AnimationUtils;
73+
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
7374
import com.google.android.material.internal.ViewUtils;
7475
import com.google.android.material.shape.MaterialShapeDrawable;
7576
import com.google.android.material.shape.ShapeAppearanceModel;
7677
import com.google.android.material.shape.Shapeable;
77-
import com.google.android.material.transition.TransitionUtils.CanvasOperation;
7878
import java.lang.annotation.Retention;
7979
import java.lang.annotation.RetentionPolicy;
8080

‎lib/java/com/google/android/material/transition/TransitionUtils.java

+1-4
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import androidx.transition.PatternPathMotion;
4040
import androidx.transition.Transition;
4141
import androidx.transition.TransitionSet;
42+
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
4243
import com.google.android.material.motion.MotionUtils;
4344
import com.google.android.material.shape.AbsoluteCornerSize;
4445
import com.google.android.material.shape.CornerSize;
@@ -351,10 +352,6 @@ static void transform(
351352
canvas.restoreToCount(checkpoint);
352353
}
353354

354-
interface CanvasOperation {
355-
void run(Canvas canvas);
356-
}
357-
358355
static void maybeAddTransition(TransitionSet transitionSet, @Nullable Transition transition) {
359356
if (transition != null) {
360357
transitionSet.addTransition(transition);

‎lib/java/com/google/android/material/transition/platform/MaterialContainerTransform.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@
7474
import android.transition.Transition;
7575
import android.transition.TransitionValues;
7676
import com.google.android.material.animation.AnimationUtils;
77+
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
7778
import com.google.android.material.internal.ViewUtils;
7879
import com.google.android.material.shape.MaterialShapeDrawable;
7980
import com.google.android.material.shape.ShapeAppearanceModel;
8081
import com.google.android.material.shape.Shapeable;
81-
import com.google.android.material.transition.platform.TransitionUtils.CanvasOperation;
8282
import java.lang.annotation.Retention;
8383
import java.lang.annotation.RetentionPolicy;
8484

‎lib/java/com/google/android/material/transition/platform/TransitionUtils.java

+1-4
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import android.transition.PatternPathMotion;
4444
import android.transition.Transition;
4545
import android.transition.TransitionSet;
46+
import com.google.android.material.canvas.CanvasCompat.CanvasOperation;
4647
import com.google.android.material.motion.MotionUtils;
4748
import com.google.android.material.shape.AbsoluteCornerSize;
4849
import com.google.android.material.shape.CornerSize;
@@ -356,10 +357,6 @@ static void transform(
356357
canvas.restoreToCount(checkpoint);
357358
}
358359

359-
interface CanvasOperation {
360-
void run(Canvas canvas);
361-
}
362-
363360
static void maybeAddTransition(TransitionSet transitionSet, @Nullable Transition transition) {
364361
if (transition != null) {
365362
transitionSet.addTransition(transition);

‎lib/javatests/com/google/android/material/carousel/MaskableFrameLayoutTest.java

+77
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,28 @@
1616

1717
package com.google.android.material.carousel;
1818

19+
import static com.google.common.truth.Truth.assertThat;
1920
import static org.mockito.Mockito.verify;
2021

2122
import android.graphics.RectF;
23+
import android.os.Build.VERSION_CODES;
2224
import androidx.recyclerview.widget.RecyclerView.LayoutParams;
2325
import android.view.View.MeasureSpec;
26+
import androidx.annotation.RequiresApi;
2427
import androidx.test.core.app.ApplicationProvider;
28+
import com.google.android.material.shape.AbsoluteCornerSize;
29+
import com.google.android.material.shape.ClampedCornerSize;
30+
import com.google.android.material.shape.CornerSize;
31+
import com.google.android.material.shape.CutCornerTreatment;
32+
import com.google.android.material.shape.ShapeAppearanceModel;
2533
import org.junit.Rule;
2634
import org.junit.Test;
2735
import org.junit.runner.RunWith;
2836
import org.mockito.Mock;
2937
import org.mockito.junit.MockitoJUnit;
3038
import org.mockito.junit.MockitoRule;
3139
import org.robolectric.RobolectricTestRunner;
40+
import org.robolectric.annotation.Config;
3241
import org.robolectric.annotation.internal.DoNotInstrument;
3342

3443
/** Tests for {@link MaskableFrameLayout}. */
@@ -51,6 +60,74 @@ public void testSetMaskXPercentage_shouldTriggerMaskChangedListeners() {
5160
verify(listener).onMaskChanged(expected);
5261
}
5362

63+
@Test
64+
public void testShapeAppearanceWithAbsoluteCornerSizes_shouldBeClamped() {
65+
MaskableFrameLayout maskableFrameLayout = createMaskableFrameLayoutWithSize(50, 50);
66+
67+
ShapeAppearanceModel model =
68+
new ShapeAppearanceModel.Builder().setAllCornerSizes(new AbsoluteCornerSize(200F)).build();
69+
maskableFrameLayout.setShapeAppearanceModel(model);
70+
CornerSize topRightCornerSize =
71+
maskableFrameLayout.getShapeAppearanceModel().getTopRightCornerSize();
72+
73+
assertThat(topRightCornerSize).isInstanceOf(ClampedCornerSize.class);
74+
assertThat(topRightCornerSize.getCornerSize(maskableFrameLayout.getMaskRectF())).isEqualTo(25F);
75+
}
76+
77+
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
78+
@Config(sdk = VERSION_CODES.LOLLIPOP)
79+
@Test
80+
public void testForceCompatClipping_shouldNotUseViewOutlineProvider() {
81+
MaskableFrameLayout maskableFrameLayout = createMaskableFrameLayoutWithSize(50, 50);
82+
ShapeAppearanceModel model = new ShapeAppearanceModel.Builder().setAllCornerSizes(10F).build();
83+
maskableFrameLayout.setShapeAppearanceModel(model);
84+
85+
assertThat(maskableFrameLayout.getClipToOutline()).isTrue();
86+
maskableFrameLayout.setForceCompatClipping(true);
87+
assertThat(maskableFrameLayout.getClipToOutline()).isFalse();
88+
}
89+
90+
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
91+
@Config(sdk = VERSION_CODES.LOLLIPOP)
92+
@Test
93+
public void testRoundedCornersApi21_usesViewOutlineProvider() {
94+
MaskableFrameLayout maskableFrameLayout = createMaskableFrameLayoutWithSize(50, 50);
95+
ShapeAppearanceModel model = new ShapeAppearanceModel.Builder().setAllCornerSizes(10F).build();
96+
maskableFrameLayout.setShapeAppearanceModel(model);
97+
98+
assertThat(maskableFrameLayout.getClipToOutline()).isTrue();
99+
}
100+
101+
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
102+
@Config(sdk = VERSION_CODES.LOLLIPOP)
103+
@Test
104+
public void testCutCornersApi21_doesNotUseViewOutlineProvider() {
105+
MaskableFrameLayout maskableFrameLayout = createMaskableFrameLayoutWithSize(50, 50);
106+
ShapeAppearanceModel model =
107+
new ShapeAppearanceModel.Builder()
108+
.setAllCornerSizes(10F)
109+
.setAllCorners(new CutCornerTreatment())
110+
.build();
111+
maskableFrameLayout.setShapeAppearanceModel(model);
112+
113+
assertThat(maskableFrameLayout.getClipToOutline()).isFalse();
114+
}
115+
116+
@RequiresApi(api = VERSION_CODES.R)
117+
@Config(sdk = VERSION_CODES.R)
118+
@Test
119+
public void testCutCornersApi30_usesViewOutlineProvider() {
120+
MaskableFrameLayout maskableFrameLayout = createMaskableFrameLayoutWithSize(50, 50);
121+
ShapeAppearanceModel model =
122+
new ShapeAppearanceModel.Builder()
123+
.setAllCornerSizes(10F)
124+
.setAllCorners(new CutCornerTreatment())
125+
.build();
126+
maskableFrameLayout.setShapeAppearanceModel(model);
127+
128+
assertThat(maskableFrameLayout.getClipToOutline()).isTrue();
129+
}
130+
54131
private static MaskableFrameLayout createMaskableFrameLayoutWithSize(int width, int height) {
55132
MaskableFrameLayout maskableFrameLayout =
56133
new MaskableFrameLayout(ApplicationProvider.getApplicationContext());
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* Copyright (C) 2023 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.android.material.shape;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
21+
import android.graphics.RectF;
22+
import org.junit.Test;
23+
import org.junit.runner.RunWith;
24+
import org.robolectric.RobolectricTestRunner;
25+
import org.robolectric.annotation.internal.DoNotInstrument;
26+
27+
/** Tests for {@link ClampedCornerSize}. */
28+
@RunWith(RobolectricTestRunner.class)
29+
@DoNotInstrument
30+
public class ClampedCornerSizeTest {
31+
32+
@Test
33+
public void oversizedCorner_shouldBeClampedToHalfShortestEdge() {
34+
ClampedCornerSize cornerSize =
35+
ClampedCornerSize.createFromCornerSize(new AbsoluteCornerSize(100F));
36+
37+
RectF bounds = new RectF(0F, 0F, 50F, 50F);
38+
float size = cornerSize.getCornerSize(bounds);
39+
40+
assertThat(size).isEqualTo(25F);
41+
}
42+
43+
@Test
44+
public void validCornerSize_shouldNotBeAltered() {
45+
ClampedCornerSize cornerSize =
46+
ClampedCornerSize.createFromCornerSize(new AbsoluteCornerSize(100F));
47+
48+
RectF bounds = new RectF(0, 0, 400F, 400F);
49+
float size = cornerSize.getCornerSize(bounds);
50+
51+
assertThat(size).isEqualTo(100F);
52+
}
53+
}

0 commit comments

Comments
 (0)
Please sign in to comment.