Skip to content

feat: add infinite carousel support#175710

Merged
auto-submit[bot] merged 12 commits into
flutter:masterfrom
rkishan516:infinite-carousel
Mar 28, 2026
Merged

feat: add infinite carousel support#175710
auto-submit[bot] merged 12 commits into
flutter:masterfrom
rkishan516:infinite-carousel

Conversation

@rkishan516

Copy link
Copy Markdown
Contributor

Add infinite carousel

fixes: #161369

Pre-launch Checklist

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant documentation (doc comments with ///).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

@github-actions github-actions Bot added framework flutter/packages/flutter repository. See also f: labels. f: material design flutter/packages/flutter/material repository. labels Sep 21, 2025

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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;

Comment on lines +1911 to +1916
// 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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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));

@QuncCccccc QuncCccccc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we try to expect all item 0, 1 and 2 are on the screen?

@rkishan516

Copy link
Copy Markdown
Contributor Author

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?

Let me take a look at it, I will also update test to capture this case.

@rkishan516 rkishan516 force-pushed the infinite-carousel branch 5 times, most recently from 48d5a3b to f4499b7 Compare November 1, 2025 07:46
@QuncCccccc

Copy link
Copy Markdown
Contributor

Let me take a look at it, I will also update test to capture this case.

Sounds good! Let me know whenever it's ready to review:)

@rkishan516

rkishan516 commented Nov 12, 2025

Copy link
Copy Markdown
Contributor Author

Sounds good! Let me know whenever it's ready to review:)

Hey, @QuncCccccc, I have pushed the changes, you can review.

Comment on lines +552 to +554
if (index < 0) {
index += widget.children.length;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this possible at all? If not we can remove this check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the API doc, we can also mention itemBuilder and itemCount will be ignored when infinite is not true:)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update API doc

}

if (infinite) {
return (scrollOffset / maxExtent).floor();

@QuncCccccc QuncCccccc Nov 18, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calculation seems the same as the non-infinite version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged both condition.

return (scrollOffset / maxExtent).floor();
}

final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor();

@QuncCccccc QuncCccccc Nov 18, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just use scrollOffset here instead of constraints.scrollOffset.

Comment on lines +899 to +904
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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting a hard-code number might be not safe here. We can get the upper bound by using the firstVisibleItemIndex and weights.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated this

);
trailingScrollOffset += extraLayoutOffset;
} else {
trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When infinite, we can just go here, instead of create another if statement.

@QuncCccccc

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here and everywhere else.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have pushed the change.

@QuncCccccc QuncCccccc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this line if we don't use the multiplier.

final int estimatedUpperBound =
_firstVisibleItemIndex +
clampDouble(
(constraints.viewportMainAxisExtent / safeMinExtent).ceil().toDouble(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think no need to use clampDouble. _firstVisibleItemIndex + constraints.viewportMainAxisExtent / safeMinExtent).ceil() is safe enough.

@rkishan516

Copy link
Copy Markdown
Contributor Author

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?

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.

QuncCccccc
QuncCccccc previously approved these changes Mar 18, 2026

@QuncCccccc QuncCccccc left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

@QuncCccccc

QuncCccccc commented Mar 26, 2026

Copy link
Copy Markdown
Contributor

Hi @rkishan516 could you help fix the conflict? I'll ask for a second review for this change! Thank you:)! Resolved!

QuncCccccc
QuncCccccc previously approved these changes Mar 26, 2026
Comment thread packages/flutter/lib/src/material/carousel.dart Outdated
@auto-submit

auto-submit Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed.

3 similar comments
@auto-submit

auto-submit Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed.

@auto-submit

auto-submit Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed.

@auto-submit

auto-submit Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

auto label is removed for flutter/flutter/175710, Failed to enqueue flutter/flutter/175710 with HTTP 400: GraphQL mutate failed.

@hannah-hyj hannah-hyj left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD f: material design flutter/packages/flutter/material repository. framework flutter/packages/flutter repository. See also f: labels. will affect goldens Changes to golden files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CarouselView infinite scroll

5 participants