Skip to content

Commit 16c1575

Browse files
imhappiafohrman
authored andcommitted
[Carousel] Carousel updates and fixes
- 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
1 parent 7d6a977 commit 16c1575

File tree

7 files changed

+360
-40
lines changed

7 files changed

+360
-40
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ private float cost(float targetLargeSize) {
235235
* @return the arrangement that is considered the most desirable and has been adjusted to fit
236236
* within the available space
237237
*/
238-
static Arrangement findLowestCostArrangement(
238+
static Arrangement findLowestCostArrangement(
239239
float availableSpace,
240240
float targetSmallSize,
241241
float minSmallSize,

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

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
import androidx.annotation.Nullable;
4343
import androidx.annotation.RestrictTo;
4444
import androidx.annotation.RestrictTo.Scope;
45+
import androidx.annotation.VisibleForTesting;
4546
import androidx.core.graphics.ColorUtils;
4647
import androidx.core.math.MathUtils;
4748
import androidx.core.util.Preconditions;
@@ -50,6 +51,7 @@
5051
import java.util.ArrayList;
5152
import java.util.Collections;
5253
import java.util.List;
54+
import java.util.Map;
5355

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

6870
private static final String TAG = "CarouselLayoutManager";
6971

70-
private int horizontalScrollOffset;
72+
@VisibleForTesting
73+
int horizontalScrollOffset;
7174

7275
// Min scroll is the offset number that offsets the list to the right/bottom as much as possible.
7376
// In LTR layouts, this will be the scroll offset to move to the start of the container. In RTL,
7477
// this will move the list to the end of the container.
75-
private int minHorizontalScroll;
78+
@VisibleForTesting
79+
int minHorizontalScroll;
7680
// Max scroll is the offset number that moves the list to the left/top of the list as much as
7781
// possible. In LTR layouts, this will move the list to the end of the container. In RTL, this
7882
// will move the list to the start of the container.
79-
private int maxHorizontalScroll;
83+
@VisibleForTesting
84+
int maxHorizontalScroll;
8085

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

98+
// Tracks the keyline state associated with each item in the RecyclerView.
99+
@Nullable private Map<Integer, KeylineState> keylineStatePositionMap;
100+
93101
/**
94102
* An internal object used to store and run checks on a child to be potentially added to the
95103
* RecyclerView and laid out.
@@ -176,6 +184,12 @@ public void onLayoutChildren(Recycler recycler, State state) {
176184
if (isInitialLoad) {
177185
// Scroll to the start of the list on first load.
178186
horizontalScrollOffset = startHorizontalScroll;
187+
keylineStatePositionMap =
188+
keylineStateList.getKeylineStateForPositionMap(
189+
getItemCount(),
190+
minHorizontalScroll,
191+
maxHorizontalScroll,
192+
isLayoutRtl());
179193
} else {
180194
// Clamp the horizontal scroll offset by the new min and max by pinging the scroll by
181195
// calculator with a 0 delta.
@@ -885,9 +899,11 @@ public void onInitializeAccessibilityEvent(@NonNull AccessibilityEvent event) {
885899
* position}'s center at the start-most focal keyline. The returned value might be less or greater
886900
* than the min and max scroll offsets but this will be clamped in {@link #scrollBy(int, Recycler,
887901
* State)} (Recycler, State)} by {@link #calculateShouldHorizontallyScrollBy(int, int, int, int)}.
902+
*
903+
* @param position The position to get the scroll offset to.
904+
* @param keylineState The keyline state in which to calculate the scroll offset to.
888905
*/
889-
private int getScrollOffsetForPosition(int position) {
890-
KeylineState keylineState = keylineStateList.getDefaultState();
906+
private int getScrollOffsetForPosition(int position, KeylineState keylineState) {
891907
if (isLayoutRtl()) {
892908
return (int)
893909
((getContainerWidth() - keylineState.getLastFocalKeyline().loc)
@@ -908,7 +924,8 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
908924
return null;
909925
}
910926

911-
return new PointF(getOffsetToScrollToPosition(targetPosition), 0F);
927+
KeylineState keylineForScroll = getKeylineStateForPosition(targetPosition);
928+
return new PointF(getOffsetToScrollToPosition(targetPosition, keylineForScroll), 0F);
912929
}
913930

914931
/**
@@ -917,17 +934,60 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
917934
* <p>This will calculate the horizontal scroll offset needed to place a child at {@code
918935
* position}'s center at the start-most focal keyline.
919936
*/
920-
int getOffsetToScrollToPosition(int position) {
921-
int targetScrollOffset = getScrollOffsetForPosition(position);
937+
int getOffsetToScrollToPosition(int position, @NonNull KeylineState keylineState) {
938+
int targetScrollOffset = getScrollOffsetForPosition(position, keylineState);
922939
return targetScrollOffset - horizontalScrollOffset;
923940
}
924941

942+
/**
943+
* Gets the offset needed to snap to a position from the current scroll offset.
944+
*
945+
* <p>This will calculate the horizontal scroll offset needed to place a child at {@code
946+
* position}'s center at the start-most focal keyline of the target keyline state to snap to.
947+
*
948+
* <p>Sometimes we may want to do a partial snap. Eg. When there is a fling event, the snap
949+
* distance is fetched before it finishes scrolling and the target keyline state is not yet
950+
* updated. Once the fling event finishes scrolling, the snap is triggered again with the correct
951+
* target keyline state. If {@code partialSnap} is true, then we want to snap to whichever is
952+
* smaller between {@code targetKeylineStateForSnap}, which is the closest keyline state step to
953+
* the current keyline state, or the KeylineState at the correct position in {@code
954+
* keylineStatePositionList}. Note that if there is any distance left to be snapped when the
955+
* fling-scroll stops, the snap helper will handle it.
956+
*/
957+
int getOffsetToScrollToPositionForSnap(int position, boolean partialSnap) {
958+
KeylineState targetKeylineStateForSnap = keylineStateList.getShiftedState(
959+
horizontalScrollOffset, minHorizontalScroll, maxHorizontalScroll, true);
960+
int targetSnapOffset = getOffsetToScrollToPosition(position, targetKeylineStateForSnap);
961+
int positionOffset = targetSnapOffset;
962+
if (keylineStatePositionMap != null) {
963+
positionOffset = getOffsetToScrollToPosition(position, getKeylineStateForPosition(position));
964+
}
965+
if (partialSnap) {
966+
return Math.abs(positionOffset) < Math.abs(targetSnapOffset)
967+
? positionOffset
968+
: targetSnapOffset;
969+
}
970+
return targetSnapOffset;
971+
}
972+
973+
private KeylineState getKeylineStateForPosition(int position) {
974+
if (keylineStatePositionMap != null) {
975+
KeylineState keylineState = keylineStatePositionMap.get(
976+
MathUtils.clamp(position, 0, max(0, getItemCount() - 1)));
977+
if (keylineState != null) {
978+
return keylineState;
979+
}
980+
}
981+
return keylineStateList.getDefaultState();
982+
}
983+
925984
@Override
926985
public void scrollToPosition(int position) {
927986
if (keylineStateList == null) {
928987
return;
929988
}
930-
horizontalScrollOffset = getScrollOffsetForPosition(position);
989+
horizontalScrollOffset =
990+
getScrollOffsetForPosition(position, getKeylineStateForPosition(position));
931991
currentFillStartPosition = MathUtils.clamp(position, 0, max(0, getItemCount() - 1));
932992
updateCurrentKeylineStateForScrollOffset();
933993
requestLayout();
@@ -945,9 +1005,15 @@ public PointF computeScrollVectorForPosition(int targetPosition) {
9451005

9461006
@Override
9471007
public int calculateDxToMakeVisible(View view, int snapPreference) {
1008+
if (keylineStateList == null) {
1009+
return 0;
1010+
}
9481011
// Override dx calculations so the target view is brought all the way into the focal
9491012
// range instead of just being made visible.
950-
float targetScrollOffset = getScrollOffsetForPosition(getPosition(view));
1013+
KeylineState scrollToKeyline = getKeylineStateForPosition(getPosition(view));
1014+
1015+
float targetScrollOffset =
1016+
getScrollOffsetForPosition(getPosition(view), scrollToKeyline);
9511017
return (int) (horizontalScrollOffset - targetScrollOffset);
9521018
}
9531019
};
@@ -976,7 +1042,9 @@ public boolean requestChildRectangleOnScreen(
9761042
return false;
9771043
}
9781044

979-
int dx = getOffsetToScrollToPosition(getPosition(child));
1045+
int dx =
1046+
getOffsetToScrollToPosition(
1047+
getPosition(child), getKeylineStateForPosition(getPosition(child)));
9801048
if (!focusedChildVisible) {
9811049
if (dx != 0) {
9821050
// TODO(b/266816148): Implement smoothScrollBy when immediate is false.

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

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
*/
1616
package com.google.android.material.carousel;
1717

18+
import static java.lang.Math.max;
19+
1820
import android.graphics.PointF;
21+
import androidx.recyclerview.widget.LinearSmoothScroller;
1922
import androidx.recyclerview.widget.RecyclerView;
2023
import androidx.recyclerview.widget.RecyclerView.LayoutManager;
24+
import androidx.recyclerview.widget.RecyclerView.SmoothScroller;
2125
import androidx.recyclerview.widget.SnapHelper;
26+
import android.util.DisplayMetrics;
2227
import android.view.View;
2328
import androidx.annotation.NonNull;
2429
import androidx.annotation.Nullable;
@@ -30,6 +35,7 @@
3035
public class CarouselSnapHelper extends SnapHelper {
3136

3237
private final boolean disableFling;
38+
private RecyclerView recyclerView;
3339

3440
public CarouselSnapHelper() {
3541
this(true);
@@ -39,10 +45,21 @@ public CarouselSnapHelper(boolean disableFling) {
3945
this.disableFling = disableFling;
4046
}
4147

48+
@Override
49+
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
50+
super.attachToRecyclerView(recyclerView);
51+
this.recyclerView = recyclerView;
52+
}
53+
4254
@Nullable
4355
@Override
4456
public int[] calculateDistanceToFinalSnap(
4557
@NonNull LayoutManager layoutManager, @NonNull View view) {
58+
return calculateDistanceToSnap(layoutManager, view, false);
59+
}
60+
61+
private int[] calculateDistanceToSnap(
62+
@NonNull LayoutManager layoutManager, @NonNull View view, boolean partialSnap) {
4663
// If the layout manager is not a CarouselLayoutManager, we return with a zero offset
4764
// as there are no keylines to snap to.
4865
if (!(layoutManager instanceof CarouselLayoutManager)) {
@@ -51,15 +68,17 @@ public int[] calculateDistanceToFinalSnap(
5168

5269
int offset = 0;
5370
if (layoutManager.canScrollHorizontally()) {
54-
offset = distanceToFirstFocalKeyline(view, (CarouselLayoutManager) layoutManager);
71+
offset =
72+
distanceToFirstFocalKeyline(view, (CarouselLayoutManager) layoutManager, partialSnap);
5573
}
5674
// TODO(b/279088745): Implement snap helper for vertical scrolling.
5775
return new int[] {offset, 0};
5876
}
5977

6078
private int distanceToFirstFocalKeyline(
61-
@NonNull View targetView, CarouselLayoutManager layoutManager) {
62-
return layoutManager.getOffsetToScrollToPosition(layoutManager.getPosition(targetView));
79+
@NonNull View targetView, CarouselLayoutManager layoutManager, boolean partialSnap) {
80+
return layoutManager.getOffsetToScrollToPositionForSnap(
81+
layoutManager.getPosition(targetView), partialSnap);
6382
}
6483

6584
@Nullable
@@ -93,7 +112,7 @@ private View findViewNearestFirstKeyline(LayoutManager layoutManager) {
93112
final View child = layoutManager.getChildAt(i);
94113
final int position = layoutManager.getPosition(child);
95114
final int offset =
96-
Math.abs(carouselLayoutManager.getOffsetToScrollToPosition(position));
115+
Math.abs(carouselLayoutManager.getOffsetToScrollToPositionForSnap(position, false));
97116

98117
// If child center is closer than previous closest, set it as closest
99118
if (offset < absClosest) {
@@ -130,7 +149,7 @@ public int findTargetSnapPosition(LayoutManager layoutManager, int velocityX, in
130149
continue;
131150
}
132151
final int distance =
133-
distanceToFirstFocalKeyline(child, (CarouselLayoutManager) layoutManager);
152+
distanceToFirstFocalKeyline(child, (CarouselLayoutManager) layoutManager, false);
134153

135154
if (distance <= 0 && distance > distanceBefore) {
136155
// Child is before the keyline and closer then the previous best
@@ -194,4 +213,42 @@ private boolean isReverseLayout(RecyclerView.LayoutManager layoutManager) {
194213
}
195214
return false;
196215
}
216+
217+
/**
218+
* {@inheritDoc}
219+
*
220+
* <p>This is mostly a copy of {@code SnapHelper#createSnapScroller} with a slight adjustment to
221+
* call {@link CarouselSnapHelper#calculateDistanceToSnap(LayoutManager, View, boolean)}
222+
* (LayoutManager, View)}. We want to do a partial snap since the correct target keyline state may
223+
* not have updated yet since this gets called before the keylines shift.
224+
*/
225+
@Nullable
226+
@Override
227+
protected SmoothScroller createScroller(@NonNull LayoutManager layoutManager) {
228+
return layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider
229+
? new LinearSmoothScroller(recyclerView.getContext()) {
230+
@Override
231+
protected void onTargetFound(
232+
View targetView,
233+
RecyclerView.State state,
234+
RecyclerView.SmoothScroller.Action action) {
235+
if (recyclerView != null) {
236+
int[] snapDistances =
237+
calculateDistanceToSnap(recyclerView.getLayoutManager(), targetView, true);
238+
int dx = snapDistances[0];
239+
int dy = snapDistances[1];
240+
int time = this.calculateTimeForDeceleration(max(Math.abs(dx), Math.abs(dy)));
241+
if (time > 0) {
242+
action.update(dx, dy, time, this.mDecelerateInterpolator);
243+
}
244+
}
245+
}
246+
247+
@Override
248+
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
249+
return 100.0F / (float) displayMetrics.densityDpi;
250+
}
251+
}
252+
: null;
253+
}
197254
}

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
public class HeroCarouselStrategy extends CarouselStrategy {
4444

4545
private static final int[] SMALL_COUNTS = new int[] {1};
46-
private static final int[] MEDIUM_COUNTS = new int[] {0};
46+
private static final int[] MEDIUM_COUNTS = new int[] {0, 1};
4747

4848
@Override
4949
@NonNull
@@ -56,7 +56,9 @@ KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNul
5656
float smallChildWidthMin = getSmallSizeMin(child.getContext()) + childHorizontalMargins;
5757
float smallChildWidthMax = getSmallSizeMax(child.getContext()) + childHorizontalMargins;
5858

59-
float measuredChildWidth = availableSpace;
59+
float measuredChildHeight = child.getMeasuredHeight();
60+
float measuredChildWidth = measuredChildHeight * 2;
61+
6062
float targetLargeChildWidth = min(measuredChildWidth + childHorizontalMargins, availableSpace);
6163
// Ideally we would like to create a balanced arrangement where a small item is 1/3 the size of
6264
// the large item. Clamp the small target size within our min-max range and as close to 1/3 of
@@ -79,17 +81,19 @@ KeylineState onFirstChildMeasuredWithMargins(@NonNull Carousel carousel, @NonNul
7981
for (int i = 0; i < largeCounts.length; i++) {
8082
largeCounts[i] = largeCountMin + i;
8183
}
84+
8285
Arrangement arrangement = Arrangement.findLowestCostArrangement(
83-
availableSpace,
84-
targetSmallChildWidth,
85-
smallChildWidthMin,
86-
smallChildWidthMax,
87-
SMALL_COUNTS,
88-
targetMediumChildWidth,
89-
MEDIUM_COUNTS,
90-
targetLargeChildWidth,
91-
largeCounts);
86+
availableSpace,
87+
targetSmallChildWidth,
88+
smallChildWidthMin,
89+
smallChildWidthMax,
90+
SMALL_COUNTS,
91+
targetMediumChildWidth,
92+
MEDIUM_COUNTS,
93+
targetLargeChildWidth,
94+
largeCounts);
9295
return createLeftAlignedKeylineState(
9396
child.getContext(), childHorizontalMargins, availableSpace, arrangement);
9497
}
9598
}
99+

0 commit comments

Comments
 (0)