@@ -145,6 +145,7 @@ class CarouselView extends StatefulWidget {
145145 this .enableSplash = true ,
146146 required double this .itemExtent,
147147 required this .children,
148+ this .onIndexChanged,
148149 }) : consumeMaxWeight = true ,
149150 flexWeights = null ,
150151 itemBuilder = null ,
@@ -209,6 +210,7 @@ class CarouselView extends StatefulWidget {
209210 this .enableSplash = true ,
210211 required List <int > this .flexWeights,
211212 required this .children,
213+ this .onIndexChanged,
212214 }) : itemExtent = null ,
213215 itemBuilder = null ,
214216 itemCount = null ;
@@ -252,6 +254,7 @@ class CarouselView extends StatefulWidget {
252254 required double this .itemExtent,
253255 required this .itemBuilder,
254256 this .itemCount,
257+ this .onIndexChanged,
255258 }) : consumeMaxWeight = true ,
256259 flexWeights = null ,
257260 children = const < Widget > [];
@@ -309,6 +312,7 @@ class CarouselView extends StatefulWidget {
309312 required List <int > this .flexWeights,
310313 required this .itemBuilder,
311314 this .itemCount,
315+ this .onIndexChanged,
312316 }) : itemExtent = null ,
313317 children = const < Widget > [];
314318
@@ -448,6 +452,46 @@ class CarouselView extends StatefulWidget {
448452 /// The child widgets for the carousel.
449453 final List <Widget > children;
450454
455+ /// {@template flutter.material.CarouselView.onIndexChanged}
456+ /// A callback invoked when the leading item changes.
457+ ///
458+ /// The “leading” item is the one that the carousel resolves as primary for
459+ /// the current frame according to its layout algorithm. This item can be only
460+ /// partially visible while scrolling.
461+ ///
462+ /// - In a standard [CarouselView] , the leading item is the one positioned at
463+ /// the leading edge of the viewport based on the current scroll offset.
464+ ///
465+ /// - In a [CarouselView.weighted] , the leading item is chosen by the weighted
466+ /// layout algorithm (typically the one with the greatest effective weight;
467+ /// ties are resolved using proximity to the leading edge).
468+ ///
469+ /// If `itemSnapping` is enabled, scrolling settles with the resolved leading
470+ /// item fully visible when possible.
471+ ///
472+ /// The callback fires only when the resolved leading index actually changes,
473+ /// whether due to user interaction or programmatic scrolling.
474+ /// {@endtemplate}
475+ ///
476+ /// {@tool dartpad}
477+ /// Example:
478+ ///
479+ /// ```dart
480+ /// CarouselView(
481+ /// itemExtent: 200.0,
482+ /// onIndexChanged: (int index) {
483+ /// print('Leading item changed to: $index');
484+ /// },
485+ /// children: <Widget>[
486+ /// Container(color: Colors.red),
487+ /// Container(color: Colors.green),
488+ /// Container(color: Colors.blue),
489+ /// ],
490+ /// )
491+ /// ```
492+ /// {@end-tool}
493+ final ValueChanged <int >? onIndexChanged;
494+
451495 /// Called to build carousel item on demand.
452496 ///
453497 /// Will be called only for indices greater than or equal to zero and less
@@ -472,6 +516,7 @@ class _CarouselViewState extends State<CarouselView> {
472516 bool get _consumeMaxWeight => widget.consumeMaxWeight;
473517 CarouselController ? _internalController;
474518 CarouselController get _controller => widget.controller ?? _internalController! ;
519+ late int _lastReportedLeadingItem;
475520
476521 @override
477522 void initState () {
@@ -480,6 +525,7 @@ class _CarouselViewState extends State<CarouselView> {
480525 if (widget.controller == null ) {
481526 _internalController = CarouselController ();
482527 }
528+ _lastReportedLeadingItem = _getInitialLeadingItem ();
483529 _controller._attach (this );
484530 }
485531
@@ -518,6 +564,15 @@ class _CarouselViewState extends State<CarouselView> {
518564 super .dispose ();
519565 }
520566
567+ int _getInitialLeadingItem () {
568+ if (widget.flexWeights != null ) {
569+ final int maxWeight = widget.flexWeights! .max;
570+ final int firstMaxWeightIndex = widget.flexWeights! .indexOf (maxWeight);
571+ return _controller.initialItem - firstMaxWeightIndex;
572+ }
573+ return _controller.initialItem;
574+ }
575+
521576 AxisDirection _getDirection (BuildContext context) {
522577 switch (widget.scrollDirection) {
523578 case Axis .horizontal:
@@ -636,21 +691,35 @@ class _CarouselViewState extends State<CarouselView> {
636691 _itemExtent = widget.itemExtent == null
637692 ? null
638693 : clampDouble (widget.itemExtent! , 0 , mainAxisExtent);
639-
640- return Scrollable (
641- axisDirection: axisDirection,
642- controller: _controller,
643- physics: physics,
644- viewportBuilder: (BuildContext context, ViewportOffset position) {
645- return Viewport (
646- cacheExtent: 0.0 ,
647- cacheExtentStyle: CacheExtentStyle .viewport,
648- axisDirection: axisDirection,
649- offset: position,
650- clipBehavior: Clip .antiAlias,
651- slivers: < Widget > [_buildSliverCarousel (theme)],
652- );
694+ return NotificationListener <ScrollNotification >(
695+ onNotification: (ScrollNotification notification) {
696+ if (notification.depth == 0 &&
697+ widget.onIndexChanged != null &&
698+ notification is ScrollUpdateNotification ) {
699+ final ScrollPosition position = _controller.position;
700+ final int currentLeadingIndex = (position as _CarouselPosition ).leadingItem;
701+ if (currentLeadingIndex != _lastReportedLeadingItem) {
702+ _lastReportedLeadingItem = currentLeadingIndex;
703+ widget.onIndexChanged? .call (currentLeadingIndex);
704+ }
705+ }
706+ return false ;
653707 },
708+ child: Scrollable (
709+ axisDirection: axisDirection,
710+ controller: _controller,
711+ physics: physics,
712+ viewportBuilder: (BuildContext context, ViewportOffset position) {
713+ return Viewport (
714+ cacheExtent: 0.0 ,
715+ cacheExtentStyle: CacheExtentStyle .viewport,
716+ axisDirection: axisDirection,
717+ offset: position,
718+ clipBehavior: Clip .antiAlias,
719+ slivers: < Widget > [_buildSliverCarousel (theme)],
720+ );
721+ },
722+ ),
654723 );
655724 },
656725 );
@@ -1567,6 +1636,12 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro
15671636 _flexWeights = value;
15681637 }
15691638
1639+ // The index of the leading item in the carousel.
1640+ // getItemFromPixels may return a fractional value (e.g., 0.6 when mid-scroll).
1641+ // We use toInt() to truncate the fractional part, ensuring the leading item
1642+ // only advances after fully crossing the next item's boundary.
1643+ int get leadingItem => getItemFromPixels (pixels, viewportDimension).toInt ();
1644+
15701645 double updateLeadingItem (List <int >? newFlexWeights, bool newConsumeMaxWeight) {
15711646 final double maxItem;
15721647 if (hasPixels && flexWeights != null ) {
@@ -1704,6 +1779,22 @@ class CarouselController extends ScrollController {
17041779 /// The item that expands to the maximum size when first creating the [CarouselView] .
17051780 final int initialItem;
17061781
1782+ /// The current leading item index in the [CarouselView] .
1783+ ///
1784+ /// {@macro flutter.material.CarouselView.onIndexChanged}
1785+ int get leadingItem {
1786+ assert (
1787+ positions.isNotEmpty,
1788+ 'CarouselController.leadingItem cannot be accessed before a CarouselView is built with it.' ,
1789+ );
1790+ assert (
1791+ positions.length == 1 ,
1792+ 'CarouselController.leadingItem cannot be read when multiple CarouselViews '
1793+ 'are attached to the same controller.' ,
1794+ );
1795+ return (position as _CarouselPosition ).leadingItem;
1796+ }
1797+
17071798 _CarouselViewState ? _carouselState;
17081799
17091800 // ignore: use_setters_to_change_properties
0 commit comments