Skip to content


[Carousel] Carousel updates and fixes
Browse files Browse the repository at this point in the history
- If item width is more than twice the item height, limit the width to twice the item height and add a medium item to the hero variant of the carousel.
- Fix snaphelper to snap to closest keyline state instead of always the default keyline state
- Add new KeylineStatePositionList to keep track of which keyline states to be in for each position. Update scrollToPosition methods to take the correct keyline instead of default keyline

PiperOrigin-RevId: 537955672
  • Loading branch information
imhappi authored and afohrman committed Jun 6, 2023
1 parent 7d6a977 commit 16c1575
Show file tree
Hide file tree
Showing 7 changed files with 360 additions and 40 deletions.
Expand Up @@ -235,7 +235,7 @@ private float cost(float targetLargeSize) {
* @return the arrangement that is considered the most desirable and has been adjusted to fit
* within the available space
static Arrangement findLowestCostArrangement(
static Arrangement findLowestCostArrangement(
float availableSpace,
float targetSmallSize,
float minSmallSize,
Expand Down
Expand Up @@ -42,6 +42,7 @@
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.VisibleForTesting;
import androidx.core.math.MathUtils;
import androidx.core.util.Preconditions;
Expand All @@ -50,6 +51,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

* A {@link LayoutManager} that can mask and offset items along the scrolling axis, creating a
Expand All @@ -67,16 +69,19 @@ public class CarouselLayoutManager extends LayoutManager

private static final String TAG = "CarouselLayoutManager";

private int horizontalScrollOffset;
int horizontalScrollOffset;

// Min scroll is the offset number that offsets the list to the right/bottom as much as possible.
// In LTR layouts, this will be the scroll offset to move to the start of the container. In RTL,
// this will move the list to the end of the container.
private int minHorizontalScroll;
int minHorizontalScroll;
// Max scroll is the offset number that moves the list to the left/top of the list as much as
// possible. In LTR layouts, this will move the list to the end of the container. In RTL, this
// will move the list to the start of the container.
private int maxHorizontalScroll;
int maxHorizontalScroll;

private boolean isDebuggingEnabled = false;
private final DebugItemDecoration debugItemDecoration = new DebugItemDecoration();
Expand All @@ -90,6 +95,9 @@ public class CarouselLayoutManager extends LayoutManager
// number of loop iterations to fill the RecyclerView.
private int currentFillStartPosition = 0;

// Tracks the keyline state associated with each item in the RecyclerView.
@Nullable private Map<Integer, KeylineState> keylineStatePositionMap;

* An internal object used to store and run checks on a child to be potentially added to the
* RecyclerView and laid out.
Expand Down Expand Up @@ -176,6 +184,12 @@ public void onLayoutChildren(Recycler recycler, State state) {
if (isInitialLoad) {
// Scroll to the start of the list on first load.
horizontalScrollOffset = startHorizontalScroll;
keylineStatePositionMap =
} else {
// Clamp the horizontal scroll offset by the new min and max by pinging the scroll by
// calculator with a 0 delta.
Expand Down Expand Up @@ -885,9 +899,11 @@ public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) {
* position}'s center at the start-most focal keyline. The returned value might be less or greater
* than the min and max scroll offsets but this will be clamped in {@link #scrollBy(int, Recycler,
* State)} (Recycler, State)} by {@link #calculateShouldHorizontallyScrollBy(int, int, int, int)}.
* @param position The position to get the scroll offset to.
* @param keylineState The keyline state in which to calculate the scroll offset to.
private int getScrollOffsetForPosition(int position) {
KeylineState keylineState = keylineStateList.getDefaultState();
private int getScrollOffsetForPosition(int position, KeylineState keylineState) {
if (isLayoutRtl()) {
return (int)
((getContainerWidth() - keylineState.getLastFocalKeyline().loc)
Expand All @@ -908,7 +924,8 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
return null;

return new PointF(getOffsetToScrollToPosition(targetPosition), 0F);
KeylineState keylineForScroll = getKeylineStateForPosition(targetPosition);
return new PointF(getOffsetToScrollToPosition(targetPosition, keylineForScroll), 0F);

Expand All @@ -917,17 +934,60 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
* <p>This will calculate the horizontal scroll offset needed to place a child at {@code
* position}'s center at the start-most focal keyline.
int getOffsetToScrollToPosition(int position) {
int targetScrollOffset = getScrollOffsetForPosition(position);
int getOffsetToScrollToPosition(int position, @NonNull KeylineState keylineState) {
int targetScrollOffset = getScrollOffsetForPosition(position, keylineState);
return targetScrollOffset - horizontalScrollOffset;

* Gets the offset needed to snap to a position from the current scroll offset.
* <p>This will calculate the horizontal scroll offset needed to place a child at {@code
* position}'s center at the start-most focal keyline of the target keyline state to snap to.
* <p>Sometimes we may want to do a partial snap. Eg. When there is a fling event, the snap
* distance is fetched before it finishes scrolling and the target keyline state is not yet
* updated. Once the fling event finishes scrolling, the snap is triggered again with the correct
* target keyline state. If {@code partialSnap} is true, then we want to snap to whichever is
* smaller between {@code targetKeylineStateForSnap}, which is the closest keyline state step to
* the current keyline state, or the KeylineState at the correct position in {@code
* keylineStatePositionList}. Note that if there is any distance left to be snapped when the
* fling-scroll stops, the snap helper will handle it.
int getOffsetToScrollToPositionForSnap(int position, boolean partialSnap) {
KeylineState targetKeylineStateForSnap = keylineStateList.getShiftedState(
horizontalScrollOffset, minHorizontalScroll, maxHorizontalScroll, true);
int targetSnapOffset = getOffsetToScrollToPosition(position, targetKeylineStateForSnap);
int positionOffset = targetSnapOffset;
if (keylineStatePositionMap != null) {
positionOffset = getOffsetToScrollToPosition(position, getKeylineStateForPosition(position));
if (partialSnap) {
return Math.abs(positionOffset) < Math.abs(targetSnapOffset)
? positionOffset
: targetSnapOffset;
return targetSnapOffset;

private KeylineState getKeylineStateForPosition(int position) {
if (keylineStatePositionMap != null) {
KeylineState keylineState = keylineStatePositionMap.get(
MathUtils.clamp(position, 0, max(0, getItemCount() - 1)));
if (keylineState != null) {
return keylineState;
return keylineStateList.getDefaultState();

public void scrollToPosition(int position) {
if (keylineStateList == null) {
horizontalScrollOffset = getScrollOffsetForPosition(position);
horizontalScrollOffset =
getScrollOffsetForPosition(position, getKeylineStateForPosition(position));
currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1));
Expand All @@ -945,9 +1005,15 @@ public PointF computeScrollVectorForPosition(int targetPosition) {

public int calculateDxToMakeVisible(View view, int snapPreference) {
if (keylineStateList == null) {
return 0;
// Override dx calculations so the target view is brought all the way into the focal
// range instead of just being made visible.
float targetScrollOffset = getScrollOffsetForPosition(getPosition(view));
KeylineState scrollToKeyline = getKeylineStateForPosition(getPosition(view));

float targetScrollOffset =
getScrollOffsetForPosition(getPosition(view), scrollToKeyline);
return (int) (horizontalScrollOffset - targetScrollOffset);
Expand Down Expand Up @@ -976,7 +1042,9 @@ public boolean requestChildRectangleOnScreen(
return false;

int dx = getOffsetToScrollToPosition(getPosition(child));
int dx =
getPosition(child), getKeylineStateForPosition(getPosition(child)));
if (!focusedChildVisible) {
if (dx != 0) {
// TODO(b/266816148): Implement smoothScrollBy when immediate is false.
Expand Down
Expand Up @@ -15,10 +15,15 @@

import static java.lang.Math.max;

import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
import androidx.recyclerview.widget.RecyclerView.SmoothScroller;
import androidx.recyclerview.widget.SnapHelper;
import android.util.DisplayMetrics;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
Expand All @@ -30,6 +35,7 @@
public class CarouselSnapHelper extends SnapHelper {

private final boolean disableFling;
private RecyclerView recyclerView;

public CarouselSnapHelper() {
Expand All @@ -39,10 +45,21 @@ public CarouselSnapHelper(boolean disableFling) {
this.disableFling = disableFling;

public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
this.recyclerView = recyclerView;

public int[] calculateDistanceToFinalSnap(
@NonNull LayoutManager layoutManager, @NonNull View view) {
return calculateDistanceToSnap(layoutManager, view, false);

private int[] calculateDistanceToSnap(
@NonNull LayoutManager layoutManager, @NonNull View view, boolean partialSnap) {
// If the layout manager is not a CarouselLayoutManager, we return with a zero offset
// as there are no keylines to snap to.
if (!(layoutManager instanceof CarouselLayoutManager)) {
Expand All @@ -51,15 +68,17 @@ public int[] calculateDistanceToFinalSnap(

int offset = 0;
if (layoutManager.canScrollHorizontally()) {
offset = distanceToFirstFocalKeyline(view, (CarouselLayoutManager) layoutManager);
offset =
distanceToFirstFocalKeyline(view, (CarouselLayoutManager) layoutManager, partialSnap);
// TODO(b/279088745): Implement snap helper for vertical scrolling.
return new int[] {offset, 0};

private int distanceToFirstFocalKeyline(
@NonNull View targetView, CarouselLayoutManager layoutManager) {
return layoutManager.getOffsetToScrollToPosition(layoutManager.getPosition(targetView));
@NonNull View targetView, CarouselLayoutManager layoutManager, boolean partialSnap) {
return layoutManager.getOffsetToScrollToPositionForSnap(
layoutManager.getPosition(targetView), partialSnap);

Expand Down Expand Up @@ -93,7 +112,7 @@ private View findViewNearestFirstKeyline(LayoutManager layoutManager) {
final View child = layoutManager.getChildAt(i);
final int position = layoutManager.getPosition(child);
final int offset =
Math.abs(carouselLayoutManager.getOffsetToScrollToPositionForSnap(position, false));

// If child center is closer than previous closest, set it as closest
if (offset < absClosest) {
Expand Down Expand Up @@ -130,7 +149,7 @@ public int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, in
final int distance =
distanceToFirstFocalKeyline(child, (CarouselLayoutManager) layoutManager);
distanceToFirstFocalKeyline(child, (CarouselLayoutManager) layoutManager, false);

if (distance <= 0 && distance > distanceBefore) {
// Child is before the keyline and closer then the previous best
Expand Down Expand Up @@ -194,4 +213,42 @@ private boolean isReverseLayout(RecyclerView.LayoutManager layoutManager) {
return false;

* {@inheritDoc}
* <p>This is mostly a copy of {@code SnapHelper#createSnapScroller} with a slight adjustment to
* call {@link CarouselSnapHelper#calculateDistanceToSnap(LayoutManager, View, boolean)}
* (LayoutManager, View)}. We want to do a partial snap since the correct target keyline state may
* not have updated yet since this gets called before the keylines shift.
protected SmoothScroller createScroller(@NonNull LayoutManager layoutManager) {
return layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider
? new LinearSmoothScroller(recyclerView.getContext()) {
protected void onTargetFound(
View targetView,
RecyclerView.State state,
RecyclerView.SmoothScroller.Action action) {
if (recyclerView != null) {
int[] snapDistances =
calculateDistanceToSnap(recyclerView.getLayoutManager(), targetView, true);
int dx = snapDistances[0];
int dy = snapDistances[1];
int time = this.calculateTimeForDeceleration(max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, this.mDecelerateInterpolator);

protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return 100.0F / (float) displayMetrics.densityDpi;
: null;
Expand Up @@ -43,7 +43,7 @@
public class HeroCarouselStrategy extends CarouselStrategy {

private static final int[] SMALL_COUNTS = new int[] {1};
private static final int[] MEDIUM_COUNTS = new int[] {0};
private static final int[] MEDIUM_COUNTS = new int[] {0, 1};

Expand All @@ -56,7 +56,9 @@ KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNul
float smallChildWidthMin = getSmallSizeMin(child.getContext()) + childHorizontalMargins;
float smallChildWidthMax = getSmallSizeMax(child.getContext()) + childHorizontalMargins;

float measuredChildWidth = availableSpace;
float measuredChildHeight = child.getMeasuredHeight();
float measuredChildWidth = measuredChildHeight * 2;

float targetLargeChildWidth = min(measuredChildWidth + childHorizontalMargins, availableSpace);
// Ideally we would like to create a balanced arrangement where a small item is 1/3 the size of
// the large item. Clamp the small target size within our min-max range and as close to 1/3 of
Expand All @@ -79,17 +81,19 @@ KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNul
for (int i = 0; i < largeCounts.length; i++) {
largeCounts[i] = largeCountMin + i;

Arrangement arrangement = Arrangement.findLowestCostArrangement(
return createLeftAlignedKeylineState(
child.getContext(), childHorizontalMargins, availableSpace, arrangement);

0 comments on commit 16c1575

Please sign in to comment.