Skip to content

Commit

Permalink
[Carousel] Support unclipped padding for uncontained variant of carousel
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 625101250
  • Loading branch information
imhappi committed Apr 16, 2024
1 parent d056cc3 commit 9393b97
Show file tree
Hide file tree
Showing 9 changed files with 252 additions and 93 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@
android:id="@+id/uncontained_carousel_recycler_view"
android:layout_width="match_parent"
android:layout_height="196dp"
android:layout_marginHorizontal="16dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:layout_marginVertical="16dp"
android:clipChildren="false"
android:clipToPadding="false" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
import androidx.core.math.MathUtils;
import androidx.core.util.Preconditions;
import androidx.core.view.ViewCompat;
import com.google.android.material.carousel.CarouselStrategy.StrategyType;
import com.google.android.material.carousel.KeylineState.Keyline;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
Expand Down Expand Up @@ -98,6 +99,7 @@ public class CarouselLayoutManager extends LayoutManager
private boolean isDebuggingEnabled = false;
private final DebugItemDecoration debugItemDecoration = new DebugItemDecoration();
@NonNull private CarouselStrategy carouselStrategy;

@Nullable private KeylineStateList keylineStateList;
// A KeylineState shifted for any current scroll offset.
@Nullable private KeylineState currentKeylineState;
Expand Down Expand Up @@ -227,8 +229,7 @@ public int getCarouselAlignment() {
private int getLeftOrTopPaddingForKeylineShift() {
// TODO(b/316969331): Fix keyline shifting by decreasing carousel size when carousel is clipped
// to padding.
// TODO(b/316968490): Fix keyline shifting by adjusting cutoffs if strategy is not contained.
if (getClipToPadding() || !carouselStrategy.isContained()) {
if (getClipToPadding()) {
return 0;
}
if (getOrientation() == VERTICAL) {
Expand All @@ -240,8 +241,7 @@ private int getLeftOrTopPaddingForKeylineShift() {
private int getRightOrBottomPaddingForKeylineShift() {
// TODO(b/316969331): Fix keyline shifting by decreasing carousel size when carousel is clipped
// to padding.
// TODO(b/316968490): Fix keyline shifting by adjusting cutoffs if strategy is not contained.
if (getClipToPadding() || !carouselStrategy.isContained()) {
if (getClipToPadding()) {
return 0;
}
if (getOrientation() == VERTICAL) {
Expand Down Expand Up @@ -348,7 +348,8 @@ private void recalculateKeylineStateList(Recycler recycler) {
isLayoutRtl() ? KeylineState.reverse(keylineState, getContainerSize()) : keylineState,
getItemMargins(),
getLeftOrTopPaddingForKeylineShift(),
getRightOrBottomPaddingForKeylineShift());
getRightOrBottomPaddingForKeylineShift(),
carouselStrategy.getStrategyType());
}

private int getItemMargins() {
Expand Down Expand Up @@ -853,10 +854,7 @@ private int calculateEndScroll(State state, KeylineStateList stateList) {
float lastItemDistanceFromFirstItem =
((state.getItemCount() - 1) * endState.getItemSize()) * (isRtl ? -1F : 1F);

float endPadding =
isRtl ? -endFocalKeyline.leftOrTopPaddingShift : endFocalKeyline.rightOrBottomPaddingShift;
float endFocalLocDistanceFromStart = endFocalKeyline.loc - getParentStart();
float endFocalLocDistanceFromEnd = getParentEnd() - endFocalKeyline.loc;

// We want the last item in the list to only be able to scroll to the end of the list. Subtract
// the distance to the end focal keyline and then add the distance needed to let the last
Expand All @@ -865,11 +863,7 @@ private int calculateEndScroll(State state, KeylineStateList stateList) {
(int)
(lastItemDistanceFromFirstItem
- endFocalLocDistanceFromStart
+ endFocalLocDistanceFromEnd
// If there is padding, adjust for the extra padding offset since offset is
// implicitly added from both endFocalLocDistance calculations.
+ endPadding);

+ (isRtl ? -1 : 1) * endFocalKeyline.maskedItemSize / 2F);
return isRtl ? min(0, endScroll) : max(0, endScroll);
}

Expand Down Expand Up @@ -994,7 +988,7 @@ private void updateChildMaskForLocation(
// container instead of bleeding and being clipped by the RecyclerView's bounds.
// Only do this if there is only one side of the mask that is out of bounds; if
// both sides are out of bounds on the same side, then the whole mask is out of view.
if (carouselStrategy.isContained()) {
if (carouselStrategy.getStrategyType() == StrategyType.CONTAINED) {
orientationHelper.containMaskWithinBounds(maskRect, offsetMaskRect, parentBoundsRect);
}

Expand Down Expand Up @@ -1060,10 +1054,6 @@ private int getParentRight() {
return orientationHelper.getParentRight();
}

private int getParentEnd() {
return orientationHelper.getParentEnd();
}

private int getParentTop() {
return orientationHelper.getParentTop();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public abstract class CarouselStrategy {

private float smallSizeMax;

/**
* Enum that defines whether or not the strategy is contained or uncontained. Contained strategies
* will always have all of its items within bounds of the carousel width.
*/
enum StrategyType {
CONTAINED,
UNCONTAINED
}

void initialize(Context context) {
smallSizeMin =
smallSizeMin > 0 ? smallSizeMin : CarouselStrategyHelper.getSmallSizeMin(context);
Expand Down Expand Up @@ -130,14 +139,16 @@ static int[] doubleCounts(int[] count) {
}

/**
* Gets whether this carousel should mask items against the edges of the carousel container.
* Gets the strategy type of this strategy. Contained strategies should mask items against the
* edges of the carousel container.
*
* @return true if items in the carousel should mask/squash against the edges of the carousel
* container. false if the carousel should allow items to bleed past the edges of the
* container and be clipped.
* @return the {@link StrategyType} of this strategy. A value of {@link StrategyType#CONTAINED}
* means items in the carousel should mask/squash against the edges of the carousel container.
* {@link StrategyType#UNCONTAINED} means the carousel should allow items to bleed past the edges
* of the container and be clipped.
*/
boolean isContained() {
return true;
StrategyType getStrategyType() {
return StrategyType.CONTAINED;
}

/**
Expand Down
109 changes: 88 additions & 21 deletions lib/java/com/google/android/material/carousel/KeylineStateList.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

package com.google.android.material.carousel;

import static java.lang.Math.max;
import static java.lang.Math.min;

import androidx.annotation.NonNull;
import androidx.core.math.MathUtils;
import com.google.android.material.animation.AnimationUtils;
Expand Down Expand Up @@ -82,11 +85,17 @@ private KeylineStateList(
}

/** Creates a new {@link KeylineStateList} from a {@link KeylineState}. */
static KeylineStateList from(Carousel carousel, KeylineState state, float itemMargins,
float leftOrTopPadding, float rightOrBottomPadding) {
static KeylineStateList from(
Carousel carousel,
KeylineState state,
float itemMargins,
float leftOrTopPadding,
float rightOrBottomPadding,
CarouselStrategy.StrategyType strategyType) {
return new KeylineStateList(
state, getStateStepsStart(carousel, state, itemMargins, leftOrTopPadding),
getStateStepsEnd(carousel, state, itemMargins, rightOrBottomPadding));
state,
getStateStepsStart(carousel, state, itemMargins, leftOrTopPadding, strategyType),
getStateStepsEnd(carousel, state, itemMargins, rightOrBottomPadding, strategyType));
}

/** Returns the default state for this state list. */
Expand Down Expand Up @@ -155,14 +164,17 @@ KeylineState getShiftedState(
float startShiftOffset = minScrollOffset + startShiftRange;
float endShiftOffset = maxScrollOffset - endShiftRange;
float startPaddingShift = getStartState().getFirstFocalKeyline().leftOrTopPaddingShift;
float endPaddingShift = getEndState().getLastFocalKeyline().rightOrBottomPaddingShift;
float endPaddingShift = getEndState().getFirstFocalKeyline().rightOrBottomPaddingShift;

// Normally we calculate the interpolation such that by scrollShiftOffset, we are always at the
// default state but in this case, we want to start shifting earlier/increase startShiftOffset
// so that the interpolation will choose the start state instead of the default state when the
// scroll offset is equal to startPaddingShift. This is so we are always at the start state
// at the beginning of the carousel, instead of getting to a state where the start state is only
// default state. In the case where the start state is equal to the default state but with
// padding, we want to start shifting earlier/increase startShiftOffset so that the
// interpolation will choose the start state instead of the default state when the scroll offset
// is equal to startPaddingShift. This is so we are always at the start state with padding at
// the beginning of the carousel, instead of getting to a state where the start state is only
// when scrollOffset <= startPaddingShift.
// We know that the start state is equal to the default state with padding if the start shift
// range is equal to the padding.
if (startShiftRange == startPaddingShift) {
startShiftOffset += startPaddingShift;
}
Expand Down Expand Up @@ -362,7 +374,56 @@ private static boolean isLastFocalItemVisibleAtRightOfContainer(
&& state.getLastFocalKeyline() == state.getLastNonAnchorKeyline();
}

@NonNull
private static KeylineState shiftKeylineStateForPadding(
@NonNull KeylineState keylineState, float padding, float carouselSize, boolean leftShift,
float childMargins, CarouselStrategy.StrategyType strategyType) {
switch (strategyType) {
case CONTAINED:
return shiftKeylineStateForPaddingContained(
keylineState, padding, carouselSize, leftShift, childMargins);
default:
return shiftKeylineStateForPaddingUncontained(
keylineState, padding, carouselSize, leftShift);
}
}

@NonNull
private static KeylineState shiftKeylineStateForPaddingUncontained(
@NonNull KeylineState keylineState, float padding, float carouselSize, boolean leftShift) {
List<Keyline> tmpKeylines = new ArrayList<>(keylineState.getKeylines());
KeylineState.Builder builder =
new KeylineState.Builder(keylineState.getItemSize(), carouselSize);
int unchangingAnchorPosition = leftShift ? 0 : tmpKeylines.size() - 1;
for (int j = 0; j < tmpKeylines.size(); j++) {
Keyline k = tmpKeylines.get(j);
if (k.isAnchor && j == unchangingAnchorPosition) {
builder.addKeyline(k.locOffset, k.mask, k.maskedItemSize, false, true, k.cutoff);
continue;
}
float newOffset = leftShift ? k.locOffset + padding : k.locOffset - padding;
float leftOrTopPadding = leftShift ? padding : 0;
float rightOrBottomPadding = leftShift ? 0 : padding;
boolean isFocal =
j >= keylineState.getFirstFocalKeylineIndex()
&& j <= keylineState.getLastFocalKeylineIndex();
builder.addKeyline(
newOffset,
k.mask,
k.maskedItemSize,
isFocal,
k.isAnchor,
Math.abs(
leftShift
? max(0, newOffset + k.maskedItemSize / 2 - carouselSize)
: min(0, newOffset - k.maskedItemSize / 2)),
leftOrTopPadding,
rightOrBottomPadding);
}
return builder.build();
}

private static KeylineState shiftKeylineStateForPaddingContained(
KeylineState keylineState, float padding, float carouselSize, boolean leftShift,
float childMargins) {

Expand Down Expand Up @@ -400,7 +461,7 @@ private static KeylineState shiftKeylineStateForPadding(
maskedItemSize, keylineState.getItemSize(), childMargins);
float locOffset = nextOffset + maskedItemSize / 2F;

float actualPaddingShift = locOffset - k.locOffset;
float actualPaddingShift = Math.abs(locOffset - k.locOffset);

builder.addKeyline(
locOffset,
Expand Down Expand Up @@ -434,7 +495,7 @@ private static KeylineState shiftKeylineStateForPadding(
*/
private static List<KeylineState> getStateStepsStart(
Carousel carousel, KeylineState defaultState, float itemMargins,
float leftOrTopPaddingForKeylineShift) {
float leftOrTopPaddingForKeylineShift, CarouselStrategy.StrategyType strategyType) {
List<KeylineState> steps = new ArrayList<>();
steps.add(defaultState);
int firstNonAnchorKeylineIndex = findFirstNonAnchorKeylineIndex(defaultState);
Expand All @@ -453,7 +514,8 @@ private static List<KeylineState> getStateStepsStart(
leftOrTopPaddingForKeylineShift,
carouselSize,
true,
itemMargins));
itemMargins,
strategyType));
}
return steps;
}
Expand All @@ -471,8 +533,10 @@ private static List<KeylineState> getStateStepsStart(
// view.
float cutoffs = defaultState.getFirstFocalKeyline().cutoff;
steps.add(
shiftKeylinesAndCreateKeylineState(defaultState, originalStart + cutoffs, carouselSize));
// TODO(b/316968490): If there is padding, this should affect keylines and the cutoffs
shiftKeylinesAndCreateKeylineState(
defaultState,
originalStart + cutoffs + leftOrTopPaddingForKeylineShift,
carouselSize));
return steps;
}

Expand Down Expand Up @@ -512,7 +576,8 @@ private static List<KeylineState> getStateStepsStart(
leftOrTopPaddingForKeylineShift,
carouselSize,
true,
itemMargins);
itemMargins,
strategyType);
}
steps.add(shifted);
}
Expand All @@ -536,7 +601,8 @@ private static List<KeylineState> getStateStepsStart(
* carousel.
*/
private static List<KeylineState> getStateStepsEnd(Carousel carousel, KeylineState defaultState,
float itemMargins, float rightOrBottomPaddingForKeylineShift) {
float itemMargins, float rightOrBottomPaddingForKeylineShift,
CarouselStrategy.StrategyType strategyType) {
List<KeylineState> steps = new ArrayList<>();
steps.add(defaultState);
int lastNonAnchorKeylineIndex = findLastNonAnchorKeylineIndex(defaultState);
Expand All @@ -556,7 +622,8 @@ private static List<KeylineState> getStateStepsEnd(Carousel carousel, KeylineSta
rightOrBottomPaddingForKeylineShift,
carouselSize,
false,
itemMargins));
itemMargins,
strategyType));
}
return steps;
}
Expand All @@ -573,9 +640,8 @@ private static List<KeylineState> getStateStepsEnd(Carousel carousel, KeylineSta
// view. Add a step that shifts all the keylines over to bring the last focal item into full
// view.
float cutoffs = defaultState.getLastFocalKeyline().cutoff;
steps.add(
shiftKeylinesAndCreateKeylineState(defaultState, originalStart - cutoffs, carouselSize));
// TODO(b/316968490): If there is padding, this should affect keylines and the cutoffs
steps.add(shiftKeylinesAndCreateKeylineState(defaultState,
originalStart - cutoffs - rightOrBottomPaddingForKeylineShift, carouselSize));
return steps;
}

Expand Down Expand Up @@ -614,7 +680,8 @@ private static List<KeylineState> getStateStepsEnd(Carousel carousel, KeylineSta
rightOrBottomPaddingForKeylineShift,
carouselSize,
false,
itemMargins);
itemMargins,
strategyType);
}
steps.add(shifted);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ private KeylineState createLeftAlignedKeylineState(
}

@Override
boolean isContained() {
return false;
StrategyType getStrategyType() {
return StrategyType.UNCONTAINED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
import androidx.test.core.app.ApplicationProvider;
import com.google.android.material.carousel.CarouselHelper.CarouselTestAdapter;
import com.google.android.material.carousel.CarouselHelper.WrappedCarouselLayoutManager;
import com.google.android.material.carousel.CarouselStrategy.StrategyType;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
Expand Down Expand Up @@ -100,7 +101,7 @@ public void testScrollBeyondMaxHorizontalScroll_shouldLimitToMaxScrollOffset() t
KeylineState leftState =
KeylineStateList.from(
layoutManager, KeylineState.reverse(keylineState, DEFAULT_RECYCLER_VIEW_WIDTH),
0, 0, 0)
0, 0, 0, StrategyType.CONTAINED)
.getStartState();

MaskableFrameLayout child =
Expand Down
Loading

0 comments on commit 9393b97

Please sign in to comment.