diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 17172f7509e48..24f74cee40944 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -145,6 +145,7 @@ class CarouselView extends StatefulWidget { this.enableSplash = true, required double this.itemExtent, required this.children, + this.onIndexChanged, }) : consumeMaxWeight = true, flexWeights = null, itemBuilder = null, @@ -209,6 +210,7 @@ class CarouselView extends StatefulWidget { this.enableSplash = true, required List this.flexWeights, required this.children, + this.onIndexChanged, }) : itemExtent = null, itemBuilder = null, itemCount = null; @@ -252,6 +254,7 @@ class CarouselView extends StatefulWidget { required double this.itemExtent, required this.itemBuilder, this.itemCount, + this.onIndexChanged, }) : consumeMaxWeight = true, flexWeights = null, children = const []; @@ -309,6 +312,7 @@ class CarouselView extends StatefulWidget { required List this.flexWeights, required this.itemBuilder, this.itemCount, + this.onIndexChanged, }) : itemExtent = null, children = const []; @@ -448,6 +452,46 @@ class CarouselView extends StatefulWidget { /// The child widgets for the carousel. final List children; + /// {@template flutter.material.CarouselView.onIndexChanged} + /// A callback invoked when the leading item changes. + /// + /// The “leading” item is the one that the carousel resolves as primary for + /// the current frame according to its layout algorithm. This item can be only + /// partially visible while scrolling. + /// + /// - In a standard [CarouselView], the leading item is the one positioned at + /// the leading edge of the viewport based on the current scroll offset. + /// + /// - In a [CarouselView.weighted], the leading item is chosen by the weighted + /// layout algorithm (typically the one with the greatest effective weight; + /// ties are resolved using proximity to the leading edge). + /// + /// If `itemSnapping` is enabled, scrolling settles with the resolved leading + /// item fully visible when possible. + /// + /// The callback fires only when the resolved leading index actually changes, + /// whether due to user interaction or programmatic scrolling. + /// {@endtemplate} + /// + /// {@tool dartpad} + /// Example: + /// + /// ```dart + /// CarouselView( + /// itemExtent: 200.0, + /// onIndexChanged: (int index) { + /// print('Leading item changed to: $index'); + /// }, + /// children: [ + /// Container(color: Colors.red), + /// Container(color: Colors.green), + /// Container(color: Colors.blue), + /// ], + /// ) + /// ``` + /// {@end-tool} + final ValueChanged? onIndexChanged; + /// Called to build carousel item on demand. /// /// Will be called only for indices greater than or equal to zero and less @@ -472,6 +516,7 @@ class _CarouselViewState extends State { bool get _consumeMaxWeight => widget.consumeMaxWeight; CarouselController? _internalController; CarouselController get _controller => widget.controller ?? _internalController!; + late int _lastReportedLeadingItem; @override void initState() { @@ -480,6 +525,7 @@ class _CarouselViewState extends State { if (widget.controller == null) { _internalController = CarouselController(); } + _lastReportedLeadingItem = _getInitialLeadingItem(); _controller._attach(this); } @@ -518,6 +564,15 @@ class _CarouselViewState extends State { super.dispose(); } + int _getInitialLeadingItem() { + if (widget.flexWeights != null) { + final int maxWeight = widget.flexWeights!.max; + final int firstMaxWeightIndex = widget.flexWeights!.indexOf(maxWeight); + return _controller.initialItem - firstMaxWeightIndex; + } + return _controller.initialItem; + } + AxisDirection _getDirection(BuildContext context) { switch (widget.scrollDirection) { case Axis.horizontal: @@ -636,21 +691,35 @@ class _CarouselViewState extends State { _itemExtent = widget.itemExtent == null ? null : clampDouble(widget.itemExtent!, 0, mainAxisExtent); - - return Scrollable( - axisDirection: axisDirection, - controller: _controller, - physics: physics, - viewportBuilder: (BuildContext context, ViewportOffset position) { - return Viewport( - cacheExtent: 0.0, - cacheExtentStyle: CacheExtentStyle.viewport, - axisDirection: axisDirection, - offset: position, - clipBehavior: Clip.antiAlias, - slivers: [_buildSliverCarousel(theme)], - ); + return NotificationListener( + onNotification: (ScrollNotification notification) { + if (notification.depth == 0 && + widget.onIndexChanged != null && + notification is ScrollUpdateNotification) { + final ScrollPosition position = _controller.position; + final int currentLeadingIndex = (position as _CarouselPosition).leadingItem; + if (currentLeadingIndex != _lastReportedLeadingItem) { + _lastReportedLeadingItem = currentLeadingIndex; + widget.onIndexChanged?.call(currentLeadingIndex); + } + } + return false; }, + child: Scrollable( + axisDirection: axisDirection, + controller: _controller, + physics: physics, + viewportBuilder: (BuildContext context, ViewportOffset position) { + return Viewport( + cacheExtent: 0.0, + cacheExtentStyle: CacheExtentStyle.viewport, + axisDirection: axisDirection, + offset: position, + clipBehavior: Clip.antiAlias, + slivers: [_buildSliverCarousel(theme)], + ); + }, + ), ); }, ); @@ -1567,6 +1636,12 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro _flexWeights = value; } + // The index of the leading item in the carousel. + // getItemFromPixels may return a fractional value (e.g., 0.6 when mid-scroll). + // We use toInt() to truncate the fractional part, ensuring the leading item + // only advances after fully crossing the next item's boundary. + int get leadingItem => getItemFromPixels(pixels, viewportDimension).toInt(); + double updateLeadingItem(List? newFlexWeights, bool newConsumeMaxWeight) { final double maxItem; if (hasPixels && flexWeights != null) { @@ -1704,6 +1779,22 @@ class CarouselController extends ScrollController { /// The item that expands to the maximum size when first creating the [CarouselView]. final int initialItem; + /// The current leading item index in the [CarouselView]. + /// + /// {@macro flutter.material.CarouselView.onIndexChanged} + int get leadingItem { + assert( + positions.isNotEmpty, + 'CarouselController.leadingItem cannot be accessed before a CarouselView is built with it.', + ); + assert( + positions.length == 1, + 'CarouselController.leadingItem cannot be read when multiple CarouselViews ' + 'are attached to the same controller.', + ); + return (position as _CarouselPosition).leadingItem; + } + _CarouselViewState? _carouselState; // ignore: use_setters_to_change_properties diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index 77b8838b9ebe0..2e9fb5199087e 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:math' as math; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -1977,6 +1978,299 @@ void main() { }); }); + group('CarouselView onIndexChanged callback', () { + testWidgets('CarouselView shows correct item after animation', (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 300, + controller: controller, + itemSnapping: true, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(6, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 3, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(3)); + expect(leadingIndex, equals(3)); + + controller.animateToItem( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + + await tester.pumpAndSettle(); + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + }); + + testWidgets('CarouselView shows correct item after animation with symmetric flexWeights', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [2, 5, 2], + controller: controller, + itemSnapping: true, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(6, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 4, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(4)); + expect(leadingIndex, equals(4)); + + final double visible4 = visiblePortionOf(tester, 'Item 4'); + final double visible3 = visiblePortionOf(tester, 'Item 3'); + final double visible5 = visiblePortionOf(tester, 'Item 5'); + expect(visible4, greaterThan(visible3)); + expect(visible4, greaterThan(visible5)); + + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(2)); + expect(leadingIndex, equals(2)); + + final double visible2 = visiblePortionOf(tester, 'Item 2'); + final double visible1 = visiblePortionOf(tester, 'Item 1'); + final double visible3After = visiblePortionOf(tester, 'Item 3'); + expect(visible2, greaterThan(visible1)); + expect(visible2, greaterThan(visible3After)); + }); + + testWidgets('CarouselView shows correct item after animation with asymmetric flexWeights', ( + WidgetTester tester, + ) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [1, 2, 3, 4], + controller: controller, + itemSnapping: true, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(6, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.animateToItem( + 4, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(4)); + expect(leadingIndex, equals(4)); + expect(find.text('Item 4'), findsOneWidget); + + final double visible4 = visiblePortionOf(tester, 'Item 4'); + final double visible3 = visiblePortionOf(tester, 'Item 3'); + final double visible5 = visiblePortionOf(tester, 'Item 5'); + expect(visible4, greaterThan(visible3)); + expect(visible4, greaterThan(visible5)); + + controller.animateToItem( + 2, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(2)); + expect(leadingIndex, equals(2)); + expect(find.text('Item 2'), findsOneWidget); + + final double visible2 = visiblePortionOf(tester, 'Item 2'); + final double visible1 = visiblePortionOf(tester, 'Item 1'); + final double visible3After = visiblePortionOf(tester, 'Item 3'); + expect(visible2, greaterThan(visible1)); + expect(visible2, greaterThan(visible3After)); + }); + + testWidgets('CarouselView shows the correct item after dragging', (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [2, 5, 2], + controller: controller, + itemSnapping: true, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(5, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Drag to the left to move to the next item. + await tester.drag(find.byType(CarouselView), const Offset(-300, 0)); + await tester.pumpAndSettle(); + + // Verify the new index based on controller and callback. + expect(controller.leadingItem, equals(2)); + expect(leadingIndex, equals(2)); + + // Validate that the dragged item is now the most visible in the viewport. + final visibleAreasAfterLeftDrag = List.generate( + 5, + (int i) => visiblePortionOf(tester, 'Item $i'), + ); + final int mostVisibleIndexAfterLeftDrag = visibleAreasAfterLeftDrag.indexWhere( + (double area) => area == visibleAreasAfterLeftDrag.reduce(math.max), + ); + expect(mostVisibleIndexAfterLeftDrag, equals(controller.leadingItem)); + + // Drag to the right to return to the previous item. + await tester.drag(find.byType(CarouselView), const Offset(150, 0)); + await tester.pumpAndSettle(); + + // Verify the updated index. + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + + // Validate again which item is most visible after dragging back. + final visibleAreasAfterRightDrag = List.generate( + 5, + (int i) => visiblePortionOf(tester, 'Item $i'), + ); + final int mostVisibleIndexAfterRightDrag = visibleAreasAfterRightDrag.indexWhere( + (double area) => area == visibleAreasAfterRightDrag.reduce(math.max), + ); + expect(mostVisibleIndexAfterRightDrag, equals(controller.leadingItem)); + }); + + testWidgets( + 'CarouselView.weighted resolves leading item correctly when multiple items share the largest weight', + (WidgetTester tester) async { + final controller = CarouselController(); + addTearDown(controller.dispose); + var leadingIndex = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [5, 5, 2, 3], + controller: controller, + itemSnapping: true, + onIndexChanged: (int index) { + leadingIndex = index; + }, + children: List.generate(4, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Initially, the first item with max weight should be leading (index 0). + expect(controller.leadingItem, equals(0)); + expect(leadingIndex, equals(0)); + + final double visible0 = visiblePortionOf(tester, 'Item 0'); + final double visible1 = visiblePortionOf(tester, 'Item 1'); + expect(visible0, greaterThanOrEqualTo(visible1)); + + // Scroll forward so the second max weight item is more visible. + controller.animateToItem( + 1, + duration: const Duration(milliseconds: 200), + curve: Curves.linear, + ); + await tester.pumpAndSettle(); + + // Leading should now be the second item with max weight (index 1). + expect(controller.leadingItem, equals(1)); + expect(leadingIndex, equals(1)); + }, + ); + + testWidgets('CarouselView starts with the correct initial item', (WidgetTester tester) async { + final controller = CarouselController(initialItem: 2); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [2, 5, 2], + controller: controller, + itemSnapping: true, + children: List.generate(5, (int i) => Text('Item $i')), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.leadingItem, equals(2)); + expect(find.text('Item 2'), findsOneWidget); + + // Verify that the initial item is centered. + final Rect itemRect = tester.getRect(find.text('Item 2')); + final double centerX = tester.getCenter(find.byType(CarouselView)).dx; + expect(itemRect.center.dx, closeTo(centerX, 1.0)); // Allow a small margin of error. + }); + }); + group('CarouselView item clipBehavior', () { testWidgets('CarouselView Item clipBehavior defaults to Clip.antiAlias', ( WidgetTester tester, @@ -2214,3 +2508,29 @@ Future runCarouselTest({ expect(find.text('Item 0'), findsOneWidget); expect(realOffset(), controller.offset); } + +double visiblePortionOf(WidgetTester tester, String label) { + // Locate the CarouselView widget that defines the visible viewport. + final Finder carouselFinder = find.byType(CarouselView); + + // If the item with the given label is not currently in the render tree, + // return 0.0 because it is outside the visible area. + if (find.text(label).evaluate().isEmpty) { + return 0.0; + } + + // Get the RenderBox of the CarouselView to determine its visible boundaries. + final RenderBox carouselBox = tester.renderObject(carouselFinder); + final Rect viewportRect = carouselBox.paintBounds; + + // Get the bounding rectangle of the target item. + final Rect itemRect = tester.getRect(find.text(label)); + + // Calculate the intersection between the item’s rectangle and the viewport. + // This represents the visible portion of the item currently on screen. + final Rect intersection = itemRect.intersect(viewportRect); + + // Return the visible area (width * height) of the intersected region. + // This helps determine which item is most visible in the Carousel. + return intersection.width * intersection.height; +}