Skip to content

Commit 85b6d50

Browse files
hunterstichimhappi
authored andcommitted
[Carousel] Fixed multi browse strategy clipping extra small items before being fully collapsed
This moves mask rect calculation from MaskableFrameLayout into CarouselLayoutManager so CarouselLayoutManager can change the offsetting of the mask inside a child and clip according to both the keylines and the carousel container boundary. PiperOrigin-RevId: 533082558
1 parent 0bcb570 commit 85b6d50

File tree

6 files changed

+223
-58
lines changed

6 files changed

+223
-58
lines changed

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

Lines changed: 76 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@
2121
import static com.google.android.material.animation.AnimationUtils.lerp;
2222
import static java.lang.Math.abs;
2323
import static java.lang.Math.max;
24+
import static java.lang.Math.min;
2425

2526
import android.graphics.Canvas;
2627
import android.graphics.Color;
2728
import android.graphics.Paint;
2829
import android.graphics.PointF;
2930
import android.graphics.Rect;
31+
import android.graphics.RectF;
3032
import androidx.recyclerview.widget.LinearSmoothScroller;
3133
import androidx.recyclerview.widget.RecyclerView;
3234
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
@@ -94,21 +96,25 @@ public class CarouselLayoutManager extends LayoutManager
9496
* RecyclerView and laid out.
9597
*/
9698
private static final class ChildCalculations {
97-
View child;
98-
float locOffset;
99-
KeylineRange range;
99+
final View child;
100+
final float center;
101+
final float offsetCenter;
102+
final KeylineRange range;
100103

101104
/**
102105
* Creates new calculations object.
103106
*
104107
* @param child The child being calculated for
105-
* @param locOffset the offset location along the scrolling axis where this child will be laid
106-
* out
108+
* @param center the location of the center of the {@code child} along the scrolling axis in the
109+
* end-to-end model
110+
* @param offsetCenter the offset location of the center of the {@code child} along the
111+
* scrolling axis where this child will be laid out
107112
* @param range the keyline range that surrounds {@code locOffset}
108113
*/
109-
ChildCalculations(View child, float locOffset, KeylineRange range) {
114+
ChildCalculations(View child, float center, float offsetCenter, KeylineRange range) {
110115
this.child = child;
111-
this.locOffset = locOffset;
116+
this.center = center;
117+
this.offsetCenter = offsetCenter;
112118
this.range = range;
113119
}
114120
}
@@ -250,18 +256,18 @@ private void addViewsStart(Recycler recycler, int startPosition) {
250256
int start = calculateChildStartForFill(startPosition);
251257
for (int i = startPosition; i >= 0; i--) {
252258
ChildCalculations calculations = makeChildCalculations(recycler, start, i);
253-
if (isLocOffsetOutOfFillBoundsStart(calculations.locOffset, calculations.range)) {
259+
if (isLocOffsetOutOfFillBoundsStart(calculations.offsetCenter, calculations.range)) {
254260
break;
255261
}
256262
start = addStart(start, (int) currentKeylineState.getItemSize());
257263

258264
// If this child's start is beyond the end of the container, don't add the child but continue
259265
// to loop so we can eventually get to children that are within bounds.
260-
if (isLocOffsetOutOfFillBoundsEnd(calculations.locOffset, calculations.range)) {
266+
if (isLocOffsetOutOfFillBoundsEnd(calculations.offsetCenter, calculations.range)) {
261267
continue;
262268
}
263269
// Add this child to the first index of the RecyclerView.
264-
addAndLayoutView(calculations.child, /* index= */ 0, calculations.locOffset);
270+
addAndLayoutView(calculations.child, /* index= */ 0, calculations);
265271
}
266272
}
267273

@@ -277,18 +283,18 @@ private void addViewsEnd(Recycler recycler, State state, int startPosition) {
277283
int start = calculateChildStartForFill(startPosition);
278284
for (int i = startPosition; i < state.getItemCount(); i++) {
279285
ChildCalculations calculations = makeChildCalculations(recycler, start, i);
280-
if (isLocOffsetOutOfFillBoundsEnd(calculations.locOffset, calculations.range)) {
286+
if (isLocOffsetOutOfFillBoundsEnd(calculations.offsetCenter, calculations.range)) {
281287
break;
282288
}
283289
start = addEnd(start, (int) currentKeylineState.getItemSize());
284290

285291
// If this child's end is beyond the start of the container, don't add the child but continue
286292
// to loop so we can eventually get to children that are within bounds.
287-
if (isLocOffsetOutOfFillBoundsStart(calculations.locOffset, calculations.range)) {
293+
if (isLocOffsetOutOfFillBoundsStart(calculations.offsetCenter, calculations.range)) {
288294
continue;
289295
}
290296
// Add this child to the last index of the RecyclerView
291-
addAndLayoutView(calculations.child, /* index= */ -1, calculations.locOffset);
297+
addAndLayoutView(calculations.child, /* index= */ -1, calculations);
292298
}
293299
}
294300

@@ -359,14 +365,12 @@ private ChildCalculations makeChildCalculations(Recycler recycler, float start,
359365
View child = recycler.getViewForPosition(position);
360366
measureChildWithMargins(child, 0, 0);
361367

362-
int centerX = addEnd((int) start, (int) halfItemSize);
368+
int center = addEnd((int) start, (int) halfItemSize);
363369
KeylineRange range =
364-
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
365-
366-
float offsetCx = calculateChildOffsetCenterForLocation(child, centerX, range);
367-
updateChildMaskForLocation(child, centerX, range);
370+
getSurroundingKeylineRange(currentKeylineState.getKeylines(), center, false);
368371

369-
return new ChildCalculations(child, offsetCx, range);
372+
float offsetCenter = calculateChildOffsetCenterForLocation(child, center, range);
373+
return new ChildCalculations(child, center, offsetCenter, range);
370374
}
371375

372376
/**
@@ -376,17 +380,18 @@ private ChildCalculations makeChildCalculations(Recycler recycler, float start,
376380
* @param child the child view to add and lay out
377381
* @param index the index at which to add the child to the RecyclerView. Use 0 for adding to the
378382
* start of the list and -1 for adding to the end.
379-
* @param offsetCx where the center of the masked child should be placed along the scrolling axis
383+
* @param calculations the child calculations to be used to layout this view
380384
*/
381-
private void addAndLayoutView(View child, int index, float offsetCx) {
385+
private void addAndLayoutView(View child, int index, ChildCalculations calculations) {
382386
float halfItemSize = currentKeylineState.getItemSize() / 2F;
383387
addView(child, index);
384388
layoutDecoratedWithMargins(
385389
child,
386-
/* left= */ (int) (offsetCx - halfItemSize),
390+
/* left= */ (int) (calculations.offsetCenter - halfItemSize),
387391
/* top= */ getParentTop(),
388-
/* right= */ (int) (offsetCx + halfItemSize),
392+
/* right= */ (int) (calculations.offsetCenter + halfItemSize),
389393
/* bottom= */ getParentBottom());
394+
updateChildMaskForLocation(child, calculations.center, calculations.range);
390395
}
391396

392397
/**
@@ -745,18 +750,42 @@ private float getMaskedItemSizeForLocOffset(float locOffset, KeylineRange range)
745750
*/
746751
private void updateChildMaskForLocation(
747752
View child, float childCenterLocation, KeylineRange range) {
748-
if (child instanceof Maskable) {
749-
// Interpolate the mask value based on the location of this view between it's two
750-
// surrounding keylines.
751-
float maskProgress =
752-
lerp(
753-
range.left.mask,
754-
range.right.mask,
755-
range.left.loc,
756-
range.right.loc,
757-
childCenterLocation);
758-
((Maskable) child).setMaskXPercentage(maskProgress);
753+
if (!(child instanceof Maskable)) {
754+
return;
759755
}
756+
757+
// Interpolate the mask value based on the location of this view between it's two
758+
// surrounding keylines.
759+
float maskProgress =
760+
lerp(
761+
range.left.mask,
762+
range.right.mask,
763+
range.left.loc,
764+
range.right.loc,
765+
childCenterLocation);
766+
767+
float childHeight = child.getHeight();
768+
float childWidth = child.getWidth();
769+
// Translate the percentage into an actual pixel value of how much of this view should be
770+
// masked away.
771+
float maskWidth = lerp(0F, childWidth / 2F, 0F, 1F, maskProgress);
772+
RectF maskRect = new RectF(maskWidth, 0F, (childWidth - maskWidth), childHeight);
773+
774+
// If the carousel is a CONTAINED carousel, ensure the mask collapses against the side of the
775+
// container instead of bleeding and being clipped by the RecyclerView's bounds.
776+
if (carouselStrategy.isContained()) {
777+
float offsetCx = calculateChildOffsetCenterForLocation(child, childCenterLocation, range);
778+
float maskedLeft = offsetCx - (maskRect.width() / 2F);
779+
float maskedRight = offsetCx + (maskRect.width() / 2F);
780+
781+
if (maskedLeft < getParentLeft()) {
782+
maskRect.left = min(maskRect.left + (getParentLeft() - maskedLeft), childWidth / 2F);
783+
}
784+
if (maskedRight > getParentRight()) {
785+
maskRect.right = max(maskRect.right - (maskedRight - getParentRight()), childWidth / 2F);
786+
}
787+
}
788+
((Maskable) child).setMaskRectF(maskRect);
760789
}
761790

762791
@Override
@@ -797,12 +826,20 @@ public void measureChildWithMargins(@NonNull View child, int widthUsed, int heig
797826
child.measure(widthSpec, heightSpec);
798827
}
799828

829+
private int getParentLeft() {
830+
return 0;
831+
}
832+
800833
private int getParentStart() {
801-
return isLayoutRtl() ? getWidth() : 0;
834+
return isLayoutRtl() ? getParentRight() : getParentLeft();
835+
}
836+
837+
private int getParentRight() {
838+
return getWidth();
802839
}
803840

804841
private int getParentEnd() {
805-
return isLayoutRtl() ? 0 : getWidth();
842+
return isLayoutRtl() ? getParentLeft() : getParentRight();
806843
}
807844

808845
private int getParentTop() {
@@ -890,8 +927,7 @@ public void scrollToPosition(int position) {
890927
if (keylineStateList == null) {
891928
return;
892929
}
893-
horizontalScrollOffset =
894-
getScrollOffsetForPosition(position);
930+
horizontalScrollOffset = getScrollOffsetForPosition(position);
895931
currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1));
896932
updateCurrentKeylineStateForScrollOffset();
897933
requestLayout();
@@ -911,8 +947,7 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
911947
public int calculateDxToMakeVisible(View view, int snapPreference) {
912948
// Override dx calculations so the target view is brought all the way into the focal
913949
// range instead of just being made visible.
914-
float targetScrollOffset =
915-
getScrollOffsetForPosition(getPosition(view));
950+
float targetScrollOffset = getScrollOffsetForPosition(getPosition(view));
916951
return (int) (horizontalScrollOffset - targetScrollOffset);
917952
}
918953
};
@@ -1006,13 +1041,12 @@ private void offsetChildLeftAndRight(
10061041
int centerX = addEnd((int) startOffset, (int) halfItemSize);
10071042
KeylineRange range =
10081043
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
1009-
10101044
float offsetCx = calculateChildOffsetCenterForLocation(child, centerX, range);
1011-
updateChildMaskForLocation(child, centerX, range);
10121045

10131046
// Offset the child so its center is at offsetCx
10141047
super.getDecoratedBoundsWithMargins(child, boundsRect);
10151048
float actualCx = boundsRect.left + halfItemSize;
1049+
updateChildMaskForLocation(child, centerX, range);
10161050
child.offsetLeftAndRight((int) (offsetCx - actualCx));
10171051
}
10181052

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,15 @@ abstract KeylineState onFirstChildMeasuredWithMargins(
102102
static float getChildMaskPercentage(float maskedSize, float unmaskedSize, float childMargins) {
103103
return 1F - ((maskedSize - childMargins) / (unmaskedSize - childMargins));
104104
}
105+
106+
/**
107+
* Gets whether this carousel should mask items against the edges of the carousel container.
108+
*
109+
* @return true if items in the carousel should mask/squash against the edges of the carousel
110+
* container. false if the carousel should allow items to bleed past the edges of the
111+
* container and be clipped.
112+
*/
113+
boolean isContained() {
114+
return true;
115+
}
105116
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ interface Maskable {
2828
/**
2929
* Set the percentage by which this {@link View} should mask itself along the x axis.
3030
*
31+
* <p>This method serves the same purpose as {@link #setMaskRectF(RectF)} but requires the
32+
* implementing view to calculate the correct rect given the mask percentage.
33+
*
3134
* @param percentage 0 when this view is fully unmasked. 1 when this view is fully masked.
3235
*/
3336
void setMaskXPercentage(@FloatRange(from = 0F, to = 1F) float percentage);
@@ -40,6 +43,13 @@ interface Maskable {
4043
@FloatRange(from = 0F, to = 1F)
4144
float getMaskXPercentage();
4245

46+
/**
47+
* Sets a {@link RectF} that this {@link View} will mask itself by.
48+
*
49+
* @param maskRect a rect in the view's coordinates to mask by
50+
*/
51+
void setMaskRectF(@NonNull RectF maskRect);
52+
4353
/** Gets a {@link RectF} that this {@link View} is masking itself by. */
4454
@NonNull
4555
RectF getMaskRectF();

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,24 @@ public void setMaskXPercentage(float percentage) {
120120
percentage = MathUtils.clamp(percentage, 0F, 1F);
121121
if (maskXPercentage != percentage) {
122122
this.maskXPercentage = percentage;
123-
onMaskChanged();
123+
// Translate the percentage into an actual pixel value of how much of this view should be
124+
// masked away.
125+
float maskWidth = AnimationUtils.lerp(0f, getWidth() / 2F, 0f, 1f, maskXPercentage);
126+
setMaskRectF(new RectF(maskWidth, 0F, (getWidth() - maskWidth), getHeight()));
124127
}
125128
}
126129

130+
/**
131+
* Sets the {@link RectF} that this {@link View} will be masked by.
132+
*
133+
* @param maskRect a rect in the view's coordinates to mask by
134+
*/
135+
@Override
136+
public void setMaskRectF(@NonNull RectF maskRect) {
137+
this.maskRect.set(maskRect);
138+
onMaskChanged();
139+
}
140+
127141
/**
128142
* Gets the percentage by which this {@link View} is masked by along the x axis.
129143
*
@@ -150,10 +164,6 @@ private void onMaskChanged() {
150164
if (getWidth() == 0) {
151165
return;
152166
}
153-
// Translate the percentage into an actual pixel value of how much of this view should be
154-
// masked away.
155-
float maskWidth = AnimationUtils.lerp(0f, getWidth() / 2F, 0f, 1f, maskXPercentage);
156-
maskRect.set(maskWidth, 0F, (getWidth() - maskWidth), getHeight());
157167
shapeableDelegate.onMaskChanged(this, maskRect);
158168
if (onMaskChangedListener != null) {
159169
onMaskChangedListener.onMaskChanged(maskRect);

0 commit comments

Comments
 (0)