feat: add infinite carousel support#175710
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces infinite scrolling functionality to the CarouselView widget by adding an infinite property. The implementation correctly adapts the render objects and controllers to handle an unbounded number of items. However, I've identified a critical issue in the index wrapping logic that can cause a crash with a RangeError when scrolling backwards in an infinite carousel. I've provided a fix for this. Additionally, I've suggested enhancing the new tests to cover this specific failure case, which would help prevent similar regressions in the future.
| Widget _buildCarouselItem(int index) { | ||
| // For infinite scrolling, wrap the index to the actual children range | ||
| if (widget.infinite && widget.children.isNotEmpty) { | ||
| index = (index + widget.children.length) % widget.children.length; |
There was a problem hiding this comment.
The current logic for wrapping the index, (index + widget.children.length) % widget.children.length, is incorrect for negative indices in Dart. The % operator can return a negative value if the dividend is negative. For example, if index is -4 and widget.children.length is 3, the expression evaluates to (-4 + 3) % 3, which is -1 % 3, resulting in -1. This will lead to a RangeError when trying to access widget.children[-1].
A more robust approach that correctly handles both positive and negative indices is to use the (a % n + n) % n pattern, which ensures the result is always a valid non-negative index.
index = (index % widget.children.length + widget.children.length) % widget.children.length;| // Verify animating to an index beyond the array length. | ||
| controller.animateToItem(5); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // Should show last item 2 times based on size. | ||
| expect(find.textContaining('Item 2'), findsAtLeastNWidgets(2)); |
There was a problem hiding this comment.
The current test for animateToItem only covers positive indices. To ensure the infinite scrolling is robust, especially when scrolling backwards, it's important to also test with negative indices. The bug in the index-wrapping logic would have been caught by a test case that animates to a negative index like -4 (when there are 3 children). I suggest adding assertions for negative indices to make the test suite more comprehensive.
// Verify animating to indices outside the actual children range.
controller.animateToItem(5);
await tester.pumpAndSettle();
expect(find.textContaining('Item 2'), findsAtLeastNWidgets(2));
// Verify animating to a negative index.
controller.animateToItem(-4);
await tester.pumpAndSettle();
expect(find.textContaining('Item 2'), findsAtLeastNWidgets(2));05d352b to
c56ac35
Compare
QuncCccccc
left a comment
There was a problem hiding this comment.
Sorry for the late reply! I tried the change in carousel.0.dart but seems when we set infinite: true, CarouselView.weighted() will throw error and the carousel items cannot show up correctly. Could you double check whether the behavior is working?
| ); | ||
|
|
||
| // Initial state - should show items | ||
| expect(find.textContaining('Item'), findsAtLeastNWidgets(1)); |
There was a problem hiding this comment.
Can we try to expect all item 0, 1 and 2 are on the screen?
Let me take a look at it, I will also update test to capture this case. |
48d5a3b to
f4499b7
Compare
Sounds good! Let me know whenever it's ready to review:) |
Hey, @QuncCccccc, I have pushed the changes, you can review. |
| if (index < 0) { | ||
| index += widget.children.length; | ||
| } |
There was a problem hiding this comment.
Is this possible at all? If not we can remove this check.
There was a problem hiding this comment.
Have removed this, was adding this for bidirectional infinite carousel(droped that idea).
| // Determine the child count and builder based on whether we're using lazy loading | ||
| final int? childCount = widget.itemBuilder != null ? widget.itemCount : widget.children.length; | ||
| final int? childCount = widget.infinite | ||
| ? null |
There was a problem hiding this comment.
In the API doc, we can also mention itemBuilder and itemCount will be ignored when infinite is not true:)
| } | ||
|
|
||
| if (infinite) { | ||
| return (scrollOffset / maxExtent).floor(); |
There was a problem hiding this comment.
This calculation seems the same as the non-infinite version.
There was a problem hiding this comment.
Merged both condition.
| return (scrollOffset / maxExtent).floor(); | ||
| } | ||
|
|
||
| final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); |
There was a problem hiding this comment.
I think we can just use scrollOffset here instead of constraints.scrollOffset.
| if (infinite) { | ||
| // For infinite scrolling, calculate the max visible index | ||
| // based on the viewport size and allow negative indices | ||
| final double viewportItems = constraints.viewportMainAxisExtent / maxExtent; | ||
| return (scrollOffset / maxExtent + viewportItems).ceil(); | ||
| } |
There was a problem hiding this comment.
This seems not accurate as well. My understanding is the max index should just be scrollOffset / maxExtent - 1. scrollOffset / maxExtent is how many items we can include and - 1 is to indicate the item index.
There was a problem hiding this comment.
Again, this was for bidirectional carousel. Have removed it.
| int index = _firstVisibleItemIndex + 1; | ||
| // Estimate a reasonable number of items to check | ||
| while (visibleItemsTotalExtent < constraints.viewportMainAxisExtent && | ||
| index < _firstVisibleItemIndex + 100) { |
There was a problem hiding this comment.
Putting a hard-code number might be not safe here. We can get the upper bound by using the firstVisibleItemIndex and weights.
| ); | ||
| trailingScrollOffset += extraLayoutOffset; | ||
| } else { | ||
| trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex + 1); |
There was a problem hiding this comment.
When infinite, we can just go here, instead of create another if statement.
f4499b7 to
29e1b64
Compare
29e1b64 to
dd3a822
Compare
|
Hey @rkishan516 sorry for the late response! I was OOO last week. I will take a look ASAP this week:) |
| /// | ||
| /// If null, the carousel will continue to build items until [itemBuilder] returns null. | ||
| /// | ||
| /// When [infinite] is true, this parameter is ignored and the carousel will loop indefinitely. |
There was a problem hiding this comment.
| /// When [infinite] is true, this parameter is ignored and the carousel will loop indefinitely. | |
| /// When [infinite] is true, this parameter is ignored and the carousel will loop infinitely. |
| final int estimatedUpperBound = | ||
| _firstVisibleItemIndex + | ||
| clampDouble( | ||
| ((constraints.viewportMainAxisExtent / safeMinExtent).ceil() * weights.length) |
There was a problem hiding this comment.
(constraints.viewportMainAxisExtent / safeMinExtent).ceil() is how many items with minimum extent in the viewport, but why do we need to multiply it by weights length? Feels the result doesn't make sense.
seems (constraints.viewportMainAxisExtent / safeMinExtent).ceil() is safe enough?
There was a problem hiding this comment.
Logically, it should be good, I just gave extra buffer, but i will change.
| } else { | ||
| index = index.clamp(0, _carouselState!.widget.children.length - 1); | ||
| if (!_carouselState!.widget.infinite) { | ||
| if (_carouselState!.widget.itemBuilder != null) { |
There was a problem hiding this comment.
The original if else is to prevent the index to be out of range. Don't we need to constrain the index when infinite is true?
There was a problem hiding this comment.
for infinite, its already coming in constrain, it will be unnecessary operation, but I can remove the condition.
| await tester.pumpAndSettle(); | ||
|
|
||
| // Should see one of the items | ||
| expect(find.textContaining('Item'), findsAtLeastNWidgets(1)); |
There was a problem hiding this comment.
We should make the tests more accurate, like checking the layout rects for each item are correct. If scrolling forward by 500, each item extent is 200, then the visible item should be "item 2", "item 0", "item 1", and "item 2", right?
There was a problem hiding this comment.
Here and everywhere else.
There was a problem hiding this comment.
I have pushed the change.
dd3a822 to
005b501
Compare
QuncCccccc
left a comment
There was a problem hiding this comment.
Just left a few more comments.
Do we have any reference for the behavior of infinite Carousel? Such as any examples from real app. I'm just thinking if we can see the infinite scrolling when we scroll forward, should we also see infinite scrolling when we scroll back? For example, if we layout item [0, 1, 2] initially, then we scroll back, should we see item [2, 0, 1] instead of seeing the boundary? WDYT?
| if (infinite && childCount == null) { | ||
| double visibleItemsTotalExtent = _distanceToLeadingEdge; | ||
| int index = _firstVisibleItemIndex + 1; | ||
| // Calculate upper bound based on viewport extent and minimum possible item extent |
There was a problem hiding this comment.
| // Calculate upper bound based on viewport extent and minimum possible item extent | |
| // Calculate upper bound based on viewport extent and minimum possible item extent. |
| int index = _firstVisibleItemIndex + 1; | ||
| // Calculate upper bound based on viewport extent and minimum possible item extent | ||
| // In worst case, all items would be at minimum extent (minChildExtent) | ||
| // Using weights.length as a multiplier provides a reasonable buffer |
There was a problem hiding this comment.
Remove this line if we don't use the multiplier.
| final int estimatedUpperBound = | ||
| _firstVisibleItemIndex + | ||
| clampDouble( | ||
| (constraints.viewportMainAxisExtent / safeMinExtent).ceil().toDouble(), |
There was a problem hiding this comment.
I think no need to use clampDouble. _firstVisibleItemIndex + constraints.viewportMainAxisExtent / safeMinExtent).ceil() is safe enough.
005b501 to
6e9d413
Compare
This package has infinite carousel and I see many people are using it. And by looking at it, I think we should have scroll back also. |
6e9d413 to
1dacb7d
Compare
|
|
|
auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed. |
3 similar comments
|
auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed. |
|
auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed. |
|
auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed. |
Add infinite carousel
fixes: #161369
Pre-launch Checklist
///).