Skip to content

Commit 9d0732b

Browse files
hunterstichpaulfthomas
authored andcommitted
[Carousel] Fixed child index bug causing items to be ordered incorrectly.
When filling the RecyclerView, views need to be added at the correct index (either begginning or end) depending on the direction of fill. PiperOrigin-RevId: 513510079
1 parent e3b493f commit 9d0732b

File tree

7 files changed

+145
-16
lines changed

7 files changed

+145
-16
lines changed

catalog/java/io/material/catalog/carousel/MultiBrowseDemoFragment.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle bundle) {
6868
RecyclerView multiBrowseStartRecyclerView =
6969
view.findViewById(R.id.multi_browse_start_carousel_recycler_view);
7070
CarouselLayoutManager multiBrowseStartCarouselLayoutManager = new CarouselLayoutManager();
71-
multiBrowseStartCarouselLayoutManager.setDrawDebugEnabled(
71+
multiBrowseStartCarouselLayoutManager.setDebuggingEnabled(
7272
multiBrowseStartRecyclerView, debugSwitch.isChecked());
7373
multiBrowseStartRecyclerView.setLayoutManager(multiBrowseStartCarouselLayoutManager);
7474
multiBrowseStartRecyclerView.setNestedScrollingEnabled(false);
@@ -77,7 +77,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle bundle) {
7777
(buttonView, isChecked) -> {
7878
multiBrowseStartRecyclerView.setBackgroundResource(
7979
isChecked ? R.drawable.dashed_outline_rectangle : 0);
80-
multiBrowseStartCarouselLayoutManager.setDrawDebugEnabled(
80+
multiBrowseStartCarouselLayoutManager.setDebuggingEnabled(
8181
multiBrowseStartRecyclerView, isChecked);
8282
});
8383

catalog/java/io/material/catalog/carousel/res/layout/cat_carousel_multi_browse_fragment.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
android:layout_marginHorizontal="16dp"
4848
android:layout_marginVertical="8dp"
4949
android:textAppearance="?attr/textAppearanceBodyLarge"
50-
android:text="@string/cat_carousel_draw_debug_switch_label"/>
50+
android:text="@string/cat_carousel_debug_mode_label"/>
5151

5252
<com.google.android.material.materialswitch.MaterialSwitch
5353
android:id="@+id/force_compact_arrangement_switch"

catalog/java/io/material/catalog/carousel/res/values/strings.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
-->
1717

1818
<resources>
19-
<string name="cat_carousel_draw_debug_switch_label" translatable="false">Draw debug lines</string>
19+
<string name="cat_carousel_debug_mode_label" translatable="false">Debug mode</string>
2020
<string name="cat_carousel_force_compact_arrangement_label" translatable="false">Force compact arrangement</string>
2121
<string name="cat_carousel_draw_dividers_label" translatable="false">Draw dividers</string>
2222
<string name="cat_carousel_adapter_item_count_hint_label" translatable="false">Item count</string>

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

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import androidx.recyclerview.widget.RecyclerView.LayoutParams;
3434
import androidx.recyclerview.widget.RecyclerView.Recycler;
3535
import androidx.recyclerview.widget.RecyclerView.State;
36+
import android.util.Log;
3637
import android.view.View;
3738
import android.view.ViewGroup;
3839
import android.view.accessibility.AccessibilityEvent;
@@ -62,6 +63,8 @@
6263
*/
6364
public class CarouselLayoutManager extends LayoutManager implements Carousel {
6465

66+
private static final String TAG = "CarouselLayoutManager";
67+
6568
private int horizontalScrollOffset;
6669

6770
// Min scroll is the offset number that offsets the list to the right/bottom as much as possible.
@@ -73,6 +76,7 @@ public class CarouselLayoutManager extends LayoutManager implements Carousel {
7376
// will move the list to the start of the container.
7477
private int maxHorizontalScroll;
7578

79+
private boolean isDebuggingEnabled = false;
7680
private final DebugItemDecoration debugItemDecoration = new DebugItemDecoration();
7781
@NonNull private CarouselStrategy carouselStrategy;
7882
@Nullable private KeylineStateList keylineStateList;
@@ -214,6 +218,8 @@ private void fill(Recycler recycler, State state) {
214218
addViewsStart(recycler, firstPosition - 1);
215219
addViewsEnd(recycler, state, lastPosition + 1);
216220
}
221+
222+
validateChildOrderIfDebugging();
217223
}
218224

219225
@Override
@@ -224,6 +230,8 @@ public void onLayoutCompleted(State state) {
224230
} else {
225231
currentFillStartPosition = getPosition(getChildAt(0));
226232
}
233+
234+
validateChildOrderIfDebugging();
227235
}
228236

229237
/**
@@ -247,7 +255,8 @@ private void addViewsStart(Recycler recycler, int startPosition) {
247255
if (isLocOffsetOutOfFillBoundsEnd(calculations.locOffset, calculations.range)) {
248256
continue;
249257
}
250-
addAndLayoutView(calculations.child, calculations.locOffset);
258+
// Add this child to the first index of the RecyclerView.
259+
addAndLayoutView(calculations.child, /* index= */ 0, calculations.locOffset);
251260
}
252261
}
253262

@@ -273,7 +282,58 @@ private void addViewsEnd(Recycler recycler, State state, int startPosition) {
273282
if (isLocOffsetOutOfFillBoundsStart(calculations.locOffset, calculations.range)) {
274283
continue;
275284
}
276-
addAndLayoutView(calculations.child, calculations.locOffset);
285+
// Add this child to the last index of the RecyclerView
286+
addAndLayoutView(calculations.child, /* index= */ -1, calculations.locOffset);
287+
}
288+
}
289+
290+
/** Used for debugging. Logs the internal representation of children to default logger. */
291+
private void logChildrenIfDebugging() {
292+
if (!isDebuggingEnabled) {
293+
return;
294+
}
295+
296+
if (Log.isLoggable(TAG, Log.DEBUG)) {
297+
Log.d(TAG, "internal representation of views on the screen");
298+
for (int i = 0; i < getChildCount(); i++) {
299+
View child = getChildAt(i);
300+
float centerX = getDecoratedCenterXWithMargins(child);
301+
Log.d(
302+
TAG,
303+
"item position " + getPosition(child) + ", center:" + centerX + ", child index:" + i);
304+
}
305+
Log.d(TAG, "==============");
306+
}
307+
}
308+
309+
/**
310+
* Used for debugging. Validates that child views are laid out in correct order. This is important
311+
* because rest of the algorithm relies on this constraint.
312+
*
313+
* <p>Child 0 should be closest to adapter position 0 and last child should be closest to the last
314+
* adapter position.
315+
*/
316+
private void validateChildOrderIfDebugging() {
317+
if (!isDebuggingEnabled || getChildCount() < 1) {
318+
return;
319+
}
320+
321+
for (int i = 0; i < getChildCount() - 1; i++) {
322+
int currPos = getPosition(getChildAt(i));
323+
int nextPos = getPosition(getChildAt(i + 1));
324+
if (currPos > nextPos) {
325+
logChildrenIfDebugging();
326+
throw new IllegalStateException(
327+
"Detected invalid child order. Child at index ["
328+
+ i
329+
+ "] had adapter position ["
330+
+ currPos
331+
+ "] and child at index ["
332+
+ (i + 1)
333+
+ "] had adapter position ["
334+
+ nextPos
335+
+ "].");
336+
}
277337
}
278338
}
279339

@@ -294,7 +354,7 @@ private ChildCalculations makeChildCalculations(Recycler recycler, float start,
294354
View child = recycler.getViewForPosition(position);
295355
measureChildWithMargins(child, 0, 0);
296356

297-
float centerX = addEnd((int) start, (int) halfItemSize);
357+
int centerX = addEnd((int) start, (int) halfItemSize);
298358
KeylineRange range =
299359
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
300360

@@ -309,11 +369,13 @@ private ChildCalculations makeChildCalculations(Recycler recycler, float start,
309369
* scrolling axis.
310370
*
311371
* @param child the child view to add and lay out
372+
* @param index the index at which to add the child to the RecyclerView. Use 0 for adding to the
373+
* start of the list and -1 for adding to the end.
312374
* @param offsetCx where the center of the masked child should be placed along the scrolling axis
313375
*/
314-
private void addAndLayoutView(View child, float offsetCx) {
376+
private void addAndLayoutView(View child, int index, float offsetCx) {
315377
float halfItemSize = currentKeylineState.getItemSize() / 2F;
316-
addView(child);
378+
addView(child, index);
317379
layoutDecoratedWithMargins(
318380
child,
319381
/* left= */ (int) (offsetCx - halfItemSize),
@@ -336,7 +398,7 @@ private void addAndLayoutView(View child, float offsetCx) {
336398
*/
337399
private boolean isLocOffsetOutOfFillBoundsStart(float locOffset, KeylineRange range) {
338400
float maskedSize = getMaskedItemSizeForLocOffset(locOffset, range);
339-
int maskedEnd = addEnd((int) locOffset, (int) (maskedSize / 2));
401+
int maskedEnd = addEnd((int) locOffset, (int) (maskedSize / 2F));
340402
return isLayoutRtl() ? maskedEnd > getContainerWidth() : maskedEnd < 0;
341403
}
342404

@@ -354,7 +416,7 @@ private boolean isLocOffsetOutOfFillBoundsStart(float locOffset, KeylineRange ra
354416
*/
355417
private boolean isLocOffsetOutOfFillBoundsEnd(float locOffset, KeylineRange range) {
356418
float maskedSize = getMaskedItemSizeForLocOffset(locOffset, range);
357-
int maskedStart = addStart((int) locOffset, (int) (maskedSize / 2));
419+
int maskedStart = addStart((int) locOffset, (int) (maskedSize / 2F));
358420
return isLayoutRtl() ? maskedStart < 0 : maskedStart > getContainerWidth();
359421
}
360422

@@ -560,7 +622,7 @@ private int calculateStartHorizontalScroll(KeylineStateList stateList) {
560622
Keyline startFocalKeyline =
561623
isRtl ? startState.getLastFocalKeyline() : startState.getFirstFocalKeyline();
562624
float firstItemDistanceFromStart = getPaddingStart() * (isRtl ? 1 : -1);
563-
float firstItemStart =
625+
int firstItemStart =
564626
addStart((int) startFocalKeyline.loc, (int) (startState.getItemSize() / 2F));
565627
return (int) (firstItemDistanceFromStart + getParentStart() - firstItemStart);
566628
}
@@ -922,7 +984,7 @@ private int scrollBy(int distance, Recycler recycler, State state) {
922984
*/
923985
private void offsetChildLeftAndRight(
924986
View child, float startOffset, float halfItemSize, Rect boundsRect) {
925-
float centerX = addEnd((int) startOffset, (int) halfItemSize);
987+
int centerX = addEnd((int) startOffset, (int) halfItemSize);
926988
KeylineRange range =
927989
getSurroundingKeylineRange(currentKeylineState.getKeylines(), centerX, false);
928990

@@ -975,14 +1037,20 @@ public int computeHorizontalScrollRange(@NonNull State state) {
9751037
}
9761038

9771039
/**
978-
* Enables drawing that illustrates keylines and other internal concepts to help debug strategy.
1040+
* Enables features to help debug keylines and other internal layout manager logic.
1041+
*
1042+
* <p>This will draw lines on top of the RecyclerView that show where keylines are placed for the
1043+
* current {@link CarouselStrategy}. Enabling debugging will also throw an exception when an
1044+
* invalid child order is detected (child index and adapter position are incorrectly ordered). See
1045+
* {@link #validateChildOrderIfDebugging()} ()} ()} for more details.
9791046
*
9801047
* @param recyclerView The {@link RecyclerView} this layout manager is attached to.
981-
* @param enabled Whether to draw debug lines.
1048+
* @param enabled Whether to draw debug lines and throw on state errors.
9821049
* @hide
9831050
*/
9841051
@RestrictTo(Scope.LIBRARY_GROUP)
985-
public void setDrawDebugEnabled(@NonNull RecyclerView recyclerView, boolean enabled) {
1052+
public void setDebuggingEnabled(@NonNull RecyclerView recyclerView, boolean enabled) {
1053+
this.isDebuggingEnabled = enabled;
9861054
recyclerView.removeItemDecoration(debugItemDecoration);
9871055
if (enabled) {
9881056
recyclerView.addItemDecoration(debugItemDecoration);

lib/javatests/com/google/android/material/carousel/CarouselHelper.java

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

18+
import static com.google.common.truth.Truth.assertWithMessage;
1819
import static java.util.concurrent.TimeUnit.SECONDS;
1920

2021
import android.graphics.Color;
@@ -39,6 +40,29 @@ class CarouselHelper {
3940

4041
private CarouselHelper() {}
4142

43+
44+
/** Ensure that as child index increases, adapter position also increases. */
45+
static void assertChildrenHaveValidOrder(WrappedCarouselLayoutManager layoutManager) {
46+
// CarouselLayoutManager keeps track of internal start position state and should always have
47+
// an accurate ordering where adapter position increases as child index increases.
48+
for (int i = 0; i < layoutManager.getChildCount() - 1; i++) {
49+
int currentAdapterPosition = layoutManager.getPosition(layoutManager.getChildAt(i));
50+
int nextAdapterPosition = layoutManager.getPosition(layoutManager.getChildAt(i + 1));
51+
assertWithMessage(
52+
"Child at index "
53+
+ i
54+
+ " had a greater adapter position ["
55+
+ currentAdapterPosition
56+
+ "] than child at index "
57+
+ (i + 1)
58+
+ " ["
59+
+ nextAdapterPosition
60+
+ "]")
61+
.that(currentAdapterPosition)
62+
.isLessThan(nextAdapterPosition);
63+
}
64+
}
65+
4266
/**
4367
* Explicitly set a view's size.
4468
*

lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerRtlTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@ public void testChangeAdapterItemCount_shouldAlignFirstItemToStart() throws Thro
144144
assertThat(recyclerView.getChildAt(0).getRight()).isEqualTo(DEFAULT_RECYCLER_VIEW_WIDTH);
145145
}
146146

147+
@Test
148+
public void testScrollToEndThenToStart_childrenHaveValidOrder() throws Throwable {
149+
// TODO(b/271293808): Refactor to use parameterized tests.
150+
setAdapterItems(recyclerView, layoutManager, adapter, CarouselHelper.createDataSetWithSize(10));
151+
scrollToPosition(recyclerView, layoutManager, 9);
152+
scrollToPosition(recyclerView, layoutManager, 2);
153+
154+
CarouselHelper.assertChildrenHaveValidOrder(layoutManager);
155+
}
156+
147157
/**
148158
* Assigns explicit sizes to fixtures being used to construct the testing environment.
149159
*

lib/javatests/com/google/android/material/carousel/CarouselLayoutManagerTest.java

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

18+
import static com.google.android.material.carousel.CarouselHelper.assertChildrenHaveValidOrder;
1819
import static com.google.android.material.carousel.CarouselHelper.createDataSetWithSize;
1920
import static com.google.android.material.carousel.CarouselHelper.scrollHorizontallyBy;
2021
import static com.google.android.material.carousel.CarouselHelper.scrollToPosition;
@@ -232,6 +233,32 @@ public void testChangeAdapterItemCount_shouldAlignFirstItemToStart() throws Thro
232233
assertThat(recyclerView.getChildAt(0).getLeft()).isEqualTo(0);
233234
}
234235

236+
@Test
237+
public void testScrollToEnd_childrenHaveValidOrder() throws Throwable {
238+
setAdapterItems(recyclerView, layoutManager, adapter, CarouselHelper.createDataSetWithSize(10));
239+
scrollToPosition(recyclerView, layoutManager, 9);
240+
241+
assertChildrenHaveValidOrder(layoutManager);
242+
}
243+
244+
@Test
245+
public void testScrollToMiddle_childrenHaveValidOrder() throws Throwable {
246+
setAdapterItems(
247+
recyclerView, layoutManager, adapter, CarouselHelper.createDataSetWithSize(200));
248+
scrollToPosition(recyclerView, layoutManager, 99);
249+
250+
assertChildrenHaveValidOrder(layoutManager);
251+
}
252+
253+
@Test
254+
public void testScrollToEndThenToStart_childrenHaveValidOrder() throws Throwable {
255+
setAdapterItems(recyclerView, layoutManager, adapter, CarouselHelper.createDataSetWithSize(10));
256+
scrollToPosition(recyclerView, layoutManager, 9);
257+
scrollToPosition(recyclerView, layoutManager, 2);
258+
259+
assertChildrenHaveValidOrder(layoutManager);
260+
}
261+
235262
/**
236263
* Assigns explicit sizes to fixtures being used to construct the testing environment.
237264
*

0 commit comments

Comments
 (0)