diff --git a/examples/api/lib/material/carousel/carousel.0.dart b/examples/api/lib/material/carousel/carousel.0.dart index 02f1d60050f..b4dd3158a6d 100644 --- a/examples/api/lib/material/carousel/carousel.0.dart +++ b/examples/api/lib/material/carousel/carousel.0.dart @@ -17,7 +17,14 @@ class CarouselExampleApp extends StatelessWidget { debugShowCheckedModeBanner: false, home: Scaffold( appBar: AppBar( - title: const Text('Carousel Sample'), + leading: const Icon(Icons.cast), + title: const Text('Flutter TV'), + actions: const [ + Padding( + padding: EdgeInsetsDirectional.only(end: 16.0), + child: CircleAvatar(child: Icon(Icons.account_circle)), + ), + ], ), body: const CarouselExample(), ), @@ -33,19 +40,140 @@ class CarouselExample extends StatefulWidget { } class _CarouselExampleState extends State { + final CarouselController controller = CarouselController(initialItem: 1); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200), - child: CarouselView( - itemExtent: 330, - shrinkExtent: 200, - children: List.generate(20, (int index) { - return UncontainedLayoutCard(index: index, label: 'Item $index'); - }), + final double height = MediaQuery.sizeOf(context).height; + + return ListView( + children: [ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: height / 2), + child: CarouselView.weighted( + controller: controller, + itemSnapping: true, + flexWeights: const [1, 7, 1], + children: ImageInfo.values.map((ImageInfo image) { + return HeroLayoutCard(imageInfo: image); + }).toList(), + ), ), - ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsetsDirectional.only(top: 8.0, start: 8.0), + child: Text('Multi-browse layout'), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 50), + child: CarouselView.weighted( + flexWeights: const [1, 2, 3, 2, 1], + consumeMaxWeight: false, + children: List.generate(20, (int index) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length].withOpacity(0.8), + child: const SizedBox.expand(), + ); + }), + ), + ), + const SizedBox(height: 20), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView.weighted( + flexWeights: const [3, 3, 3, 2, 1], + consumeMaxWeight: false, + children: CardInfo.values.map((CardInfo info) { + return ColoredBox( + color: info.backgroundColor, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(info.icon, color: info.color, size: 32.0), + Text(info.label, style: const TextStyle(fontWeight: FontWeight.bold), overflow: TextOverflow.clip, softWrap: false), + ], + ), + ), + ); + }).toList() + ), + ), + const SizedBox(height: 20), + const Padding( + padding: EdgeInsetsDirectional.only(top: 8.0, start: 8.0), + child: Text('Uncontained layout'), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: CarouselView( + itemExtent: 330, + shrinkExtent: 200, + children: List.generate(20, (int index){ + return UncontainedLayoutCard(index: index, label: 'Show $index'); + }), + ), + ) + ], + ); + } +} + +class HeroLayoutCard extends StatelessWidget { + const HeroLayoutCard({ + super.key, + required this.imageInfo, + }); + + final ImageInfo imageInfo; + + @override + Widget build(BuildContext context) { + final double width = MediaQuery.sizeOf(context).width; + return Stack( + alignment: AlignmentDirectional.bottomStart, + children: [ + ClipRect( + child: OverflowBox( + maxWidth: width * 7 / 8, + minWidth: width * 7 / 8, + child: Image( + fit: BoxFit.cover, + image: NetworkImage( + 'https://flutter.github.io/assets-for-api-docs/assets/material/${imageInfo.url}' + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + imageInfo.title, + overflow: TextOverflow.clip, + softWrap: false, + style: Theme.of(context).textTheme.headlineLarge?.copyWith(color: Colors.white), + ), + const SizedBox(height: 10), + Text( + imageInfo.subtitle, + overflow: TextOverflow.clip, + softWrap: false, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.white), + ) + ], + ), + ), + ] ); } } @@ -75,3 +203,34 @@ class UncontainedLayoutCard extends StatelessWidget { ); } } + +enum CardInfo { + camera('Cameras', Icons.video_call, Color(0xff2354C7), Color(0xffECEFFD)), + lighting('Lighting', Icons.lightbulb, Color(0xff806C2A), Color(0xffFAEEDF)), + climate('Climate', Icons.thermostat, Color(0xffA44D2A), Color(0xffFAEDE7)), + wifi('Wifi', Icons.wifi, Color(0xff417345), Color(0xffE5F4E0)), + media('Media', Icons.library_music, Color(0xff2556C8), Color(0xffECEFFD)), + security('Security', Icons.crisis_alert, Color(0xff794C01), Color(0xffFAEEDF)), + safety('Safety', Icons.medical_services, Color(0xff2251C5), Color(0xffECEFFD)), + more('', Icons.add, Color(0xff201D1C), Color(0xffE3DFD8)); + + const CardInfo(this.label, this.icon, this.color, this.backgroundColor); + final String label; + final IconData icon; + final Color color; + final Color backgroundColor; +} + +enum ImageInfo { + image0('The Flow', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_1.png'), + image1('Through the Pane', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_2.png'), + image2('Iridescence', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_3.png'), + image3('Sea Change', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_4.png'), + image4('Blue Symphony', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_5.png'), + image5('When It Rains', 'Sponsored | Season 1 Now Streaming', 'content_based_color_scheme_6.png'); + + const ImageInfo(this.title, this.subtitle, this.url); + final String title; + final String subtitle; + final String url; +} diff --git a/examples/api/test/material/carousel/carousel.0_test.dart b/examples/api/test/material/carousel/carousel.0_test.dart index 5f104612ea9..9f50b20f2a9 100644 --- a/examples/api/test/material/carousel/carousel.0_test.dart +++ b/examples/api/test/material/carousel/carousel.0_test.dart @@ -2,18 +2,44 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_api_samples/material/carousel/carousel.0.dart' as example; import 'package:flutter_test/flutter_test.dart'; void main() { + + // The app being tested loads images via HTTP which the test + // framework defeats by default. + setUpAll(() { + HttpOverrides.global = null; + }); + testWidgets('Carousel Smoke Test', (WidgetTester tester) async { await tester.pumpWidget( const example.CarouselExampleApp(), ); - expect(find.byType(CarouselView), findsOneWidget); - expect(find.widgetWithText(example.UncontainedLayoutCard, 'Item 0'), findsOneWidget); - expect(find.widgetWithText(example.UncontainedLayoutCard, 'Item 1'), findsOneWidget); + expect(find.widgetWithText(example.HeroLayoutCard, 'Through the Pane'), findsOneWidget); + final Finder firstCarousel = find.byType(CarouselView).first; + await tester.drag(firstCarousel, const Offset(150, 0)); + await tester.pumpAndSettle(); + expect(find.widgetWithText(example.HeroLayoutCard, 'The Flow'), findsOneWidget); + + await tester.drag(firstCarousel, const Offset(0, -200)); + await tester.pumpAndSettle(); + + expect(find.widgetWithText(CarouselView, 'Cameras'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Lighting'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Climate'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Wifi'), findsOneWidget); + + await tester.drag(find.widgetWithText(CarouselView, 'Cameras'), const Offset(0, -200)); + await tester.pumpAndSettle(); + + expect(find.text('Uncontained layout'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Show 0'), findsOneWidget); + expect(find.widgetWithText(CarouselView, 'Show 1'), findsOneWidget); }); } diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart index 1a0e5867888..ddf82e136de 100644 --- a/packages/flutter/lib/src/material/carousel.dart +++ b/packages/flutter/lib/src/material/carousel.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; @@ -13,16 +14,86 @@ import 'ink_well.dart'; import 'material.dart'; import 'theme.dart'; +// Examples can assume: +// late BuildContext context; + /// A Material Design carousel widget. /// -/// The [CarouselView] present a scrollable list of items, each of which can dynamically +/// The [CarouselView] presents a scrollable list of items, each of which can dynamically /// change size based on the chosen layout. /// -/// This widget supports uncontained carousel layout. It shows items that scroll -/// to the edge of the container, behaving similarly to a [ListView] where all -/// children are a uniform size. +/// Material Design 3 introduced 4 carousel layouts: +/// * Multi-browse: This layout shows at least one large, medium, and small +/// carousel item at a time. This layout is supported by [CarouselView.weighted]. +/// * Uncontained (default): This layout show items that scroll to the edge of the +/// container. This layout is supported by [CarouselView]. +/// * Hero: This layout shows at least one large and one small item at a time. +/// This layout is supported by [CarouselView.weighted]. +/// * Full-screen: This layout shows one edge-to-edge large item at a time and +/// scrolls vertically. The full-screen layout can be supported by both +/// constructors. /// -/// The [CarouselController] is used to control the [CarouselController.initialItem]. +/// The default constructor implements the uncontained layout model. It shows +/// items that scroll to the edge of the container, behaving similarly to a +/// [ListView] where all children are a uniform size. [CarouselView.weighted] +/// enables dynamic item sizing. Each item is assigned a weight that determines +/// the portion of the viewport it occupies. This constructor helps to create +/// layouts like multi-browse, and hero. In order to have a full-screen layout, +/// if [CarouselView] is used, then set the [itemExtent] to screen size; if +/// [CarouselView.weighted] is used, then set the [flexWeights] to only have +/// one integer in the array. +/// +/// {@tool snippet} +/// +/// This code snippet shows how to get a vertical full-screen carousel by using +/// [itemExtent] in [CarouselView]. +/// +/// ```dart +/// Scaffold( +/// body: CarouselView( +/// scrollDirection: Axis.vertical, +/// itemExtent: double.infinity, +/// children: List.generate(10, (int index) { +/// return Center(child: Text('Item $index')); +/// }), +/// ), +/// ), +/// ``` +/// +/// This code snippet below shows how to achieve the same vertical full-screen +/// carousel by using [flexWeights] in [CarouselView.weighted]. +/// +/// ```dart +/// Scaffold( +/// body: CarouselView.weighted( +/// scrollDirection: Axis.vertical, +/// flexWeights: const [1], // Or any positive integers as long as the length of the array is 1. +/// children: List.generate(10, (int index) { +/// return Center(child: Text('Item $index')); +/// }), +/// ), +/// ), +/// ``` +/// {@end-tool} +/// +/// In [CarouselView.weighted], weights are relative proportions. For example, +/// if the layout weights is `[3, 2, 1]`, it means the first visible item occupies +/// 3/6 of the viewport; the second visible item occupies 2/6 of the viewport; +/// the last visible item occupies 1/6 of the viewport. As the carousel scrolls, +/// the size of the latter one gradually changes to the size of the former one. +/// As a result, when the first visible item is completely off-screen, the +/// following items will follow the same layout as before. Using [CarouselView.weighted] +/// helps build the multi-browse, hero, center-aligned hero and full-screen layouts, +/// as indicated in [Carousel sepcs](https://m3.material.io/components/carousel/specs). +/// +/// The [CarouselController] is used to control the +/// [CarouselController.initialItem], which determines the first fully expanded +/// item when the [CarouselView] or [CarouselView.weighted] is initially displayed. +/// This is straightforward for [CarouselView] because each item in the view +/// has fixed size. In [CarouselView.weighted], for instance, if the layout +/// weights are `[1, 2, 3, 2, 1]` and the initial item is 4 (the fourth item), the +/// view will display items 2, 3, 4, 5, and 6 with weights 1, 2, 3, 2 and 1 +/// respectively. /// /// The [CarouselView.itemExtent] property must be non-null and defines the base /// size of items. While items typically maintain this size, the first and last @@ -30,16 +101,16 @@ import 'theme.dart'; /// property controls the minimum allowable size for these compressed items. /// /// {@tool dartpad} -/// Here is an example of [CarouselView] to show the uncontained layout. Each carousel -/// item has the same size but can be "squished" to the [shrinkExtent] when they -/// are show on the view and out of view. +/// Here is an example to show different carousel layouts that [CarouselView] +/// and [CarouselView.weighted] can build. /// /// ** See code in examples/api/lib/material/carousel/carousel.0.dart ** /// {@end-tool} /// /// See also: /// -/// * [CarouselController], which controls the first visible item in the carousel. +/// * [CarouselController], which controls the first fully visible item in the +/// view. /// * [PageView], which is a scrollable list that works page by page. class CarouselView extends StatefulWidget { /// Creates a Material Design carousel. @@ -56,9 +127,69 @@ class CarouselView extends StatefulWidget { this.scrollDirection = Axis.horizontal, this.reverse = false, this.onTap, - required this.itemExtent, + required double this.itemExtent, required this.children, - }); + }) : consumeMaxWeight = true, + flexWeights = null; + + /// Creates a scrollable list where the size of each child widget is dynamically + /// determined by the provided [flexWeights]. + /// + /// The [flexWeights] parameter is required and defines the relative size + /// proportions of each child widget. + /// + /// While scrolling, the main-axis extent (size) of each visible item changes + /// dynamically based on the scrolling progress. The cross-axis extent is determined + /// by the parent constraints. As the first visible item scrolls completely + /// off-screen, the next item becomes the first visible item, and has the same + /// size as the previously first item. The rest of the visible items maintain + /// their relative layout. + /// + /// For example, if the layout weights are `[1, 6, 1]`, the length of [flexWeights] + /// indicates three items will be visible at a time. The layout of these items + /// would be: + /// * First item: Extent is (1 / (1 + 6 + 1)) * viewport extent. + /// * Second item: Extent is (6 / (1 + 6 + 1)) * viewport extent. + /// * Third item: Extent is (1 / (1 + 6 + 1)) * viewport extent. + /// + /// Assuming a viewport extent of 800 in the main axis and the first item is + /// item 0, there would be three visible items with extents of 100, 600, and 100. + /// As item 0 scrolls off-screen, the extent of item 1 smoothly decreases from 600 + /// to 100. For instance, if item 0 is 30% off-screen, item 1 should have decreased + /// its size to 30% of the difference from 600 to 100; its extent would be + /// 600 - 0.3 * (600 - 100). Similarly, item 2's extent would increase from 100 + /// to 600, becoming 100 + 0.3 * (600 - 100). + /// + /// As the initially visible items change size during scrolling, item 3 enters + /// the view to fill the remaining space. Its extent starts at a minimum of + /// [shrinkExtent] (or 0 if [shrinkExtent] is not provided) and gradually + /// increases to match the extent of the last visible item (100 in this example). + /// + /// When [consumeMaxWeight] is set to `true`, each child can be expanded to occupy + /// the maximum weight while scrolling. For example, with [flexWeights] of `[1, 7, 1]`, + /// the initial weight of the first item is 1. However, by enabling + /// [consumeMaxWeight] and scrolling forward, the first item can expand to occupy + /// a weight of 7, leaving a weight of 1 as some empty space before it. This feature + /// is particularly useful for achieving [Hero](https://m3.material.io/components/carousel/specs#b33a5579-d648-42a9-b934-98718d65454f) + /// and [Center-aligned hero](https://m3.material.io/components/carousel/specs#92c779ce-de8b-4dee-8201-95d3e429204f) + /// layouts indicated in the Material Design 3. + const CarouselView.weighted({ + super.key, + this.padding, + this.backgroundColor, + this.elevation, + this.shape, + this.overlayColor, + this.itemSnapping = false, + this.shrinkExtent = 0.0, + this.controller, + this.scrollDirection = Axis.horizontal, + this.reverse = false, + this.consumeMaxWeight = true, + this.onTap, + required List this.flexWeights, + required this.children, + }) : itemExtent = null; /// The amount of space to surround each carousel item with. /// @@ -105,7 +236,7 @@ class CarouselView extends StatefulWidget { /// adjusted to match this remaining space, ensuring a smooth size transition. /// /// Defaults to 0.0. Setting to 0.0 allows items to shrink/expand completely, - /// transitioning between 0.0 and the full [itemExtent]. In cases where the + /// transitioning between 0.0 and the full item size. In cases where the /// remaining viewport space for the last visible item is larger than the /// defined [shrinkExtent], the [shrinkExtent] is dynamically adjusted to match /// this remaining space, ensuring a smooth size transition. @@ -140,16 +271,40 @@ class CarouselView extends StatefulWidget { /// Defaults to false. final bool reverse; + /// Whether the collapsed items are allowed to expand to the max size. + /// + /// If this is false, the layout of the carousel doesn't change. This is especially + /// useful when a weight list in [CarouselView.weighted] has a max item in the + /// middle and at least one small item on either side, such as `[1, 7, 1, 1]`. + /// In this case, if this is false, the first and the last two items cannot + /// expand to the max size. If this is true, there will be some space before + /// the first item or after the last item coming so every item has a chance to + /// be fully expanded. + /// + /// Defaults to true. + final bool consumeMaxWeight; + /// Called when one of the [children] is tapped. final ValueChanged? onTap; /// The extent the children are forced to have in the main axis. /// - /// The item extent should not exceed the available space that the carousel + /// The item extent should not exceed the available space that the carousel view /// occupies to ensure at least one item is fully visible. /// - /// This must be non-null. - final double itemExtent; + /// This is required for [CarouselView]. In [CarouselView.weighted], this is null. + final double? itemExtent; + + /// The weights that each visible child should occupy in the viewport. + /// + /// The length of [flexWeights] represents how many items should be visible + /// at a time in the viewport. For example, setting [flexWeights] to + /// `[3, 2, 1]` means there are 3 carousel items and their extents are + /// 3/6, 2/6 and 1/6 of the viewport extent. + /// + /// This is a required property in [CarouselView.weighted]. This is null + /// for default [CarouselView]. The integers must be greater than 0. + final List? flexWeights; /// The child widgets for the carousel. final List children; @@ -159,25 +314,22 @@ class CarouselView extends StatefulWidget { } class _CarouselViewState extends State { - late double _itemExtent; + double? _itemExtent; + List? get _flexWeights => widget.flexWeights; + bool get _consumeMaxWeight => widget.consumeMaxWeight; CarouselController? _internalController; CarouselController get _controller => widget.controller ?? _internalController!; @override void initState() { super.initState(); + _itemExtent = widget.itemExtent; if (widget.controller == null) { _internalController = CarouselController(); } _controller._attach(this); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _itemExtent = widget.itemExtent; - } - @override void didUpdateWidget(covariant CarouselView oldWidget) { super.didUpdateWidget(oldWidget); @@ -193,8 +345,15 @@ class _CarouselViewState extends State { _controller._attach(this); } } + if (widget.flexWeights != oldWidget.flexWeights) { + (_controller.position as _CarouselPosition).flexWeights = _flexWeights; + } if (widget.itemExtent != oldWidget.itemExtent) { _itemExtent = widget.itemExtent; + (_controller.position as _CarouselPosition).itemExtent = _itemExtent; + } + if (widget.consumeMaxWeight != oldWidget.consumeMaxWeight) { + (_controller.position as _CarouselPosition).consumeMaxWeight = _consumeMaxWeight; } } @@ -217,13 +376,7 @@ class _CarouselViewState extends State { } } - @override - Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final AxisDirection axisDirection = _getDirection(context); - final ScrollPhysics physics = widget.itemSnapping - ? const CarouselScrollPhysics() - : ScrollConfiguration.of(context).getScrollPhysics(context); + Widget _buildCarouselItem(ThemeData theme, int index) { final EdgeInsets effectivePadding = widget.padding ?? const EdgeInsets.all(4.0); final Color effectiveBackgroundColor = widget.backgroundColor ?? Theme.of(context).colorScheme.surface; final double effectiveElevation = widget.elevation ?? 0.0; @@ -231,6 +384,81 @@ class _CarouselViewState extends State { ?? const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(28.0)) ); + final WidgetStateProperty effectiveOverlayColor = widget.overlayColor + ?? WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.pressed)) { + return theme.colorScheme.onSurface.withOpacity(0.1); + } + if (states.contains(WidgetState.hovered)) { + return theme.colorScheme.onSurface.withOpacity(0.08); + } + if (states.contains(WidgetState.focused)) { + return theme.colorScheme.onSurface.withOpacity(0.1); + } + return null; + }); + + return Padding( + padding: effectivePadding, + child: Material( + clipBehavior: Clip.antiAlias, + color: effectiveBackgroundColor, + elevation: effectiveElevation, + shape: effectiveShape, + child: Stack( + fit: StackFit.expand, + children: [ + widget.children[index], + Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + widget.onTap?.call(index); + }, + overlayColor: effectiveOverlayColor, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSliverCarousel(ThemeData theme) { + if (_itemExtent != null) { + return _SliverFixedExtentCarousel( + itemExtent: _itemExtent!, + minExtent: widget.shrinkExtent, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return _buildCarouselItem(theme, index); + }, + childCount: widget.children.length, + ), + ); + } + + assert(_flexWeights != null && _flexWeights!.every((int weight) => weight > 0), 'flexWeights is null or it contains non-positive integers'); + return _SliverWeightedCarousel( + consumeMaxWeight: _consumeMaxWeight, + shrinkExtent: widget.shrinkExtent, + weights: _flexWeights!, + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + return _buildCarouselItem(theme, index); + }, + childCount: widget.children.length, + ), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final AxisDirection axisDirection = _getDirection(context); + final ScrollPhysics physics = widget.itemSnapping + ? const CarouselScrollPhysics() + : ScrollConfiguration.of(context).getScrollPhysics(context); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { @@ -238,7 +466,7 @@ class _CarouselViewState extends State { Axis.horizontal => constraints.maxWidth, Axis.vertical => constraints.maxHeight, }; - _itemExtent = clampDouble(_itemExtent, 0, mainAxisExtent); + _itemExtent = _itemExtent == null ? _itemExtent : clampDouble(_itemExtent!, 0, mainAxisExtent); return Scrollable( axisDirection: axisDirection, @@ -252,50 +480,7 @@ class _CarouselViewState extends State { offset: position, clipBehavior: Clip.antiAlias, slivers: [ - _SliverFixedExtentCarousel( - itemExtent: _itemExtent, - minExtent: widget.shrinkExtent, - delegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - return Padding( - padding: effectivePadding, - child: Material( - clipBehavior: Clip.antiAlias, - color: effectiveBackgroundColor, - elevation: effectiveElevation, - shape: effectiveShape, - child: Stack( - fit: StackFit.expand, - children: [ - widget.children.elementAt(index), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - widget.onTap?.call(index); - }, - overlayColor: widget.overlayColor ?? WidgetStateProperty.resolveWith((Set states) { - if (states.contains(WidgetState.pressed)) { - return theme.colorScheme.onSurface.withOpacity(0.1); - } - if (states.contains(WidgetState.hovered)) { - return theme.colorScheme.onSurface.withOpacity(0.08); - } - if (states.contains(WidgetState.focused)) { - return theme.colorScheme.onSurface.withOpacity(0.1); - } - return null; - }), - ), - ), - ], - ), - ), - ); - }, - childCount: widget.children.length, - ), - ), + _buildSliverCarousel(theme), ], ); }, @@ -314,7 +499,7 @@ class _CarouselViewState extends State { /// at offset zero and without gaps. Each child is constrained to a fixed extent /// along the main axis and the [SliverConstraints.crossAxisExtent] /// along the cross axis. The difference between this and a list view with a fixed -/// extent is the first item and last item can be squished a little during scrolling +/// extent is the first item and last item can be collapsed a little during scrolling /// transition. This compression is controlled by the `minExtent` property and /// aligns with the [Material Design Carousel specifications] /// (https://m3.material.io/components/carousel/guidelines#96c5c157-fe5b-4ee3-a9b4-72bf8efab7e9). @@ -438,7 +623,7 @@ class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor if (index == firstVisibleIndex) { final double firstVisibleItemExtent = _buildItemExtent(index, _currentLayoutDimensions); - // If the first item is squished to be less than `effectievMinExtent`, + // If the first item is collapsed to be less than `effectievMinExtent`, // then it should stop changinng its size and should start to scroll off screen. if (firstVisibleItemExtent <= effectiveMinExtent) { return maxExtent * index - effectiveMinExtent + maxExtent; @@ -490,6 +675,482 @@ class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; } +/// A sliver that arranges its box children in a linear array, constraining them +/// to specific weights determined by the [weights] property. +/// +/// _To learn more about slivers, see [CustomScrollView.slivers]._ +/// +/// This sliver arranges its children in a line along the main axis, starting +/// at offset zero without gaps. Each child is constrained to its corresponding +/// weight along the main axis and to the [SliverConstraints.crossAxisExtent] +/// along the cross axis. +/// +/// See [CarouselView.weighted] to get more calculation explanations. +class _SliverWeightedCarousel extends SliverMultiBoxAdaptorWidget { + const _SliverWeightedCarousel({ + required super.delegate, + required this.consumeMaxWeight, + required this.shrinkExtent, + required this.weights, + }); + + // Determine whether extra scroll offset should be calculate so that every + // item have a chance to scroll to the maximum extent. + // + // This is useful when the leading/trailing items have smaller weights, such + // as [1, 7], and [3, 2, 1]. + final bool consumeMaxWeight; + + // The starting extent for items when they gradually show on/off screen. + // + // This is useful to avoid a hairline shape. This value should also smaller + // than the last item extent to make sure a smooth transition. So in calculation, + // this is limited to [0, weight for the last visible item]. + final double shrinkExtent; + + // The layout arrangement. + // + // When items are laying out, each item will be arranged based on the order of + // the weights and the extent is based on the corresponding weight out of the + // sum of weights. The length of weights means how many items we can put in the + // view at a time. + final List weights; + + @override + RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) { + final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement; + return _RenderSliverWeightedCarousel( + childManager: element, + consumeMaxWeight: consumeMaxWeight, + shrinkExtent: shrinkExtent, + weights: weights, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverWeightedCarousel renderObject) { + renderObject + ..consumeMaxWeight = consumeMaxWeight + ..shrinkExtent = shrinkExtent + ..weights = weights; + } +} + +// A sliver that places its box children in a linear array and constrains them +// to have the corresponding weight which is determined by [weights]. +class _RenderSliverWeightedCarousel extends RenderSliverFixedExtentBoxAdaptor { + _RenderSliverWeightedCarousel({ + required super.childManager, + required bool consumeMaxWeight, + required double shrinkExtent, + required List weights, + }) : _consumeMaxWeight = consumeMaxWeight, + _shrinkExtent = shrinkExtent, + _weights = weights; + + bool get consumeMaxWeight => _consumeMaxWeight; + bool _consumeMaxWeight; + set consumeMaxWeight(bool value) { + if (_consumeMaxWeight == value) { + return; + } + _consumeMaxWeight = value; + markNeedsLayout(); + } + + double get shrinkExtent => _shrinkExtent; + double _shrinkExtent; + set shrinkExtent(double value) { + if (_shrinkExtent == value) { + return; + } + _shrinkExtent = value; + markNeedsLayout(); + } + + List get weights => _weights; + List _weights; + set weights(List value) { + if (_weights == value) { + return; + } + _weights = value; + markNeedsLayout(); + } + + late SliverLayoutDimensions _currentLayoutDimensions; + + // This is to implement the itemExtentBuilder callback to return each item extent + // while scrolling. + // + // The given `index` is compared with `_firstVisibleItemIndex` to know how + // many items are placed before the current one in the view. + double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) { + double extent; + if (index == _firstVisibleItemIndex) { + extent = math.max(_distanceToLeadingEdge, effectiveShrinkExtent); + } + + // Calculate the extents of items located within the range defined by the + // weights array relative to the first visible item. This allows us to + // precisely determine each item's extent based on its initial extent + // (calculated from the weights) and the scrolling progress (the off-screen + // portion of the first item). + else if (index > _firstVisibleItemIndex + && index - _firstVisibleItemIndex + 1 <= weights.length + ) { + assert(index - _firstVisibleItemIndex < weights.length); + final int currIndexOnWeightList = index - _firstVisibleItemIndex; + final int currWeight = weights[currIndexOnWeightList]; + extent = extentUnit * currWeight; // initial extent + final double progress = _firstVisibleItemOffscreenExtent / firstChildExtent; + + final int prevWeight = weights[currIndexOnWeightList - 1]; + final double finalIncrease = (prevWeight - currWeight) / weights.max; + extent = extent + finalIncrease * progress * maxChildExtent; + } + // Calculate the extents of items located beyond the range defined by the + // weights array relative to the first visible item. During scrolling transiton, + // it is possible that the number of visible items is larger than the length + // of `weights`. The extra item extent should be calculated here to fill + // the remaining space. + else if (index > _firstVisibleItemIndex + && index - _firstVisibleItemIndex + 1 > weights.length) + { + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < index; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, currentLayoutDimensions); + } + extent = math.max(constraints.remainingPaintExtent - visibleItemsTotalExtent, effectiveShrinkExtent); + } + else { + extent = math.max(minChildExtent, effectiveShrinkExtent); + } + return extent; + } + + // To ge the extent unit based on the viewport exten and the sum of weights. + double get extentUnit => constraints.viewportMainAxisExtent / (weights.reduce((int total, int extent) => total + extent)); + + double get firstChildExtent => weights.first * extentUnit; + double get maxChildExtent => weights.max * extentUnit; + double get minChildExtent => weights.min * extentUnit; + + // The shrink extent for first and last visible items should be no larger + // than [minChildExtent] to ensure a smooth transition. + double get effectiveShrinkExtent => clampDouble(shrinkExtent, 0, minChildExtent); + + // The index of the first visible item. The returned value can be negative when + // the leading items with smaller weights need to be fully expanded. For example, + // assuming a weights [1, 7, 1], when item 0 is expanding to the maximum size + // (with weight 7), we leave some space before item 0 assuming there is another + // item -1 as the first visible item. + int get _firstVisibleItemIndex { + int smallerWeightCount = 0; + for (final int weight in weights) { + if (weight == weights.max) { + break; + } + smallerWeightCount += 1; + } + int index; + + final double actual = constraints.scrollOffset / firstChildExtent; + final int round = (constraints.scrollOffset / firstChildExtent).round(); + if ((actual - round).abs() < precisionErrorTolerance) { + index = round; + } else { + index = actual.floor(); + } + return consumeMaxWeight ? index - smallerWeightCount : index; + } + + // This value indicates the scrolling progress of items following the first + // item. It informs them how much the first item has moved off-screen, + // enabling them to adjust their sizes (grow or shrink) accordingly. + double get _firstVisibleItemOffscreenExtent { + int index; + final double actual = constraints.scrollOffset / firstChildExtent; + final int round = (constraints.scrollOffset / firstChildExtent).round(); + if ((actual - round).abs() < precisionErrorTolerance) { + index = round; + } else { + index = actual.floor(); + } + return constraints.scrollOffset - index * firstChildExtent; + } + + // Given the off-screen extent for the first visible item, we can know the + // on-screen extent for the first visible item. + double get _distanceToLeadingEdge => firstChildExtent - _firstVisibleItemOffscreenExtent; + + // Given an index, this method returns the layout offset for the item. The `index` + // is firstly compared to `_firstVisibleItemIndex` and compute the distance + // between them, then compute all the current extents for items that are located + // in front. + @override + double indexToLayoutOffset( + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.' + ) + double itemExtent, + int index, + ) { + if (index == _firstVisibleItemIndex) { + if (_distanceToLeadingEdge <= effectiveShrinkExtent) { + return constraints.scrollOffset - effectiveShrinkExtent + _distanceToLeadingEdge; + } + return constraints.scrollOffset; + } + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < index; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, _currentLayoutDimensions); + } + return constraints.scrollOffset + visibleItemsTotalExtent; + } + + @override + int getMinChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.' + ) + double itemExtent, + ) { + return math.max(_firstVisibleItemIndex, 0); + } + + @override + int getMaxChildIndexForScrollOffset( + double scrollOffset, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.' + ) + double itemExtent, + ) { + final int? childCount = childManager.estimatedChildCount; + if (childCount != null) { + double visibleItemsTotalExtent = _distanceToLeadingEdge; + for (int i = _firstVisibleItemIndex + 1; i < childCount; i++) { + visibleItemsTotalExtent += _buildItemExtent(i, _currentLayoutDimensions); + if (visibleItemsTotalExtent >= constraints.viewportMainAxisExtent) { + return i; + } + } + } + return childCount ?? 0; + } + + @override + double computeMaxScrollOffset( + SliverConstraints constraints, + @Deprecated( + 'The itemExtent is already available within the scope of this function. ' + 'This feature was deprecated after v3.20.0-7.0.pre.' + ) + double itemExtent, + ) { + return childManager.childCount * maxChildExtent; + } + + BoxConstraints _getChildConstraints(int index) { + final double extent = itemExtentBuilder!(index, _currentLayoutDimensions)!; + return constraints.asBoxConstraints( + minExtent: extent, + maxExtent: extent, + ); + } + + // This method is mostly the same as its parent class [RenderSliverFixedExtentList]. + // The difference is when we allow some space before the leading items or after + // the trailing items with smaller weights, we leave extra scroll offset. + // TODO(quncCccccc): add the calculation for the extra scroll offset on the super class to simplify the implementation here. + @override + void performLayout() { + assert((itemExtent != null && itemExtentBuilder == null) || + (itemExtent == null && itemExtentBuilder != null)); + assert(itemExtentBuilder != null || (itemExtent!.isFinite && itemExtent! >= 0)); + + final SliverConstraints constraints = this.constraints; + childManager.didStartLayout(); + childManager.setDidUnderflow(false); + + final double scrollOffset = constraints.scrollOffset + constraints.cacheOrigin; + assert(scrollOffset >= 0.0); + final double remainingExtent = constraints.remainingCacheExtent; + assert(remainingExtent >= 0.0); + final double targetEndScrollOffset = scrollOffset + remainingExtent; + _currentLayoutDimensions = SliverLayoutDimensions( + scrollOffset: constraints.scrollOffset, + precedingScrollExtent: constraints.precedingScrollExtent, + viewportMainAxisExtent: constraints.viewportMainAxisExtent, + crossAxisExtent: constraints.crossAxisExtent + ); + // TODO(Piinks): Clean up when deprecation expires. + const double deprecatedExtraItemExtent = -1; + + final int firstIndex = getMinChildIndexForScrollOffset(scrollOffset, deprecatedExtraItemExtent); + final int? targetLastIndex = targetEndScrollOffset.isFinite ? + getMaxChildIndexForScrollOffset(targetEndScrollOffset, deprecatedExtraItemExtent) : null; + + if (firstChild != null) { + final int leadingGarbage = calculateLeadingGarbage(firstIndex: firstIndex); + final int trailingGarbage = targetLastIndex != null ? calculateTrailingGarbage(lastIndex: targetLastIndex) : 0; + collectGarbage(leadingGarbage, trailingGarbage); + } else { + collectGarbage(0, 0); + } + + if (firstChild == null) { + final double layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + if (!addInitialChild(index: firstIndex, layoutOffset: layoutOffset)) { + // There are either no children, or we are past the end of all our children. + final double max; + if (firstIndex <= 0) { + max = 0.0; + } else { + max = computeMaxScrollOffset(constraints, deprecatedExtraItemExtent); + } + geometry = SliverGeometry( + scrollExtent: max, + maxPaintExtent: max, + ); + childManager.didFinishLayout(); + return; + } + } + + RenderBox? trailingChildWithLayout; + + for (int index = indexOf(firstChild!) - 1; index >= firstIndex; --index) { + final RenderBox? child = insertAndLayoutLeadingChild(_getChildConstraints(index)); + if (child == null) { + // Items before the previously first child are no longer present. + // Reset the scroll offset to offset all items prior and up to the + // missing item. Let parent re-layout everything. + geometry = SliverGeometry(scrollOffsetCorrection: indexToLayoutOffset(deprecatedExtraItemExtent, index)); + return; + } + final SliverMultiBoxAdaptorParentData childParentData = child.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, index); + assert(childParentData.index == index); + trailingChildWithLayout ??= child; + } + + if (trailingChildWithLayout == null) { + firstChild!.layout(_getChildConstraints(indexOf(firstChild!))); + final SliverMultiBoxAdaptorParentData childParentData = firstChild!.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + trailingChildWithLayout = firstChild; + } + + // From the last item to the firstly encountered max item + double extraLayoutOffset = 0; + if (consumeMaxWeight) { + for (int i = weights.length - 1; i >= 0; i--) { + if (weights[i] == weights.max) { + break; + } + extraLayoutOffset += weights[i] * extentUnit; + } + } + + double estimatedMaxScrollOffset = double.infinity; + // Layout visible items after the first visible item. + for (int index = indexOf(trailingChildWithLayout!) + 1; targetLastIndex == null || index <= targetLastIndex; ++index) { + RenderBox? child = childAfter(trailingChildWithLayout!); + if (child == null || indexOf(child) != index) { + child = insertAndLayoutChild(_getChildConstraints(index), after: trailingChildWithLayout); + if (child == null) { + // We have run out of children. + estimatedMaxScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, index) + extraLayoutOffset; + break; + } + } else { + child.layout(_getChildConstraints(index)); + } + trailingChildWithLayout = child; + final SliverMultiBoxAdaptorParentData childParentData = child.parentData! as SliverMultiBoxAdaptorParentData; + assert(childParentData.index == index); + childParentData.layoutOffset = indexToLayoutOffset(deprecatedExtraItemExtent, childParentData.index!); + } + + final int lastIndex = indexOf(lastChild!); + final double leadingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, firstIndex); + double trailingScrollOffset; + + if (lastIndex + 1 == childManager.childCount) { + trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex); + + trailingScrollOffset += math.max(weights.last * extentUnit, _buildItemExtent(lastIndex, _currentLayoutDimensions)); + trailingScrollOffset += extraLayoutOffset; + } else { + trailingScrollOffset = indexToLayoutOffset(deprecatedExtraItemExtent, lastIndex + 1); + } + + assert(debugAssertChildListIsNonEmptyAndContiguous()); + assert(indexOf(firstChild!) == firstIndex); + assert(targetLastIndex == null || lastIndex <= targetLastIndex); + + estimatedMaxScrollOffset = math.min( + estimatedMaxScrollOffset, + estimateMaxScrollOffset( + constraints, + firstIndex: firstIndex, + lastIndex: lastIndex, + leadingScrollOffset: leadingScrollOffset, + trailingScrollOffset: trailingScrollOffset, + ), + ); + + final double paintExtent = calculatePaintOffset( + constraints, + from: consumeMaxWeight ? 0 : leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double cacheExtent = calculateCacheOffset( + constraints, + from: consumeMaxWeight ? 0 : leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double targetEndScrollOffsetForPaint = constraints.scrollOffset + constraints.remainingPaintExtent; + final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite ? + getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint, deprecatedExtraItemExtent) : null; + + geometry = SliverGeometry( + scrollExtent: estimatedMaxScrollOffset, + paintExtent: paintExtent, + cacheExtent: cacheExtent, + maxPaintExtent: estimatedMaxScrollOffset, + // Conservative to avoid flickering away the clip during scroll. + hasVisualOverflow: (targetLastIndexForPaint != null && lastIndex >= targetLastIndexForPaint) + || constraints.scrollOffset > 0.0, + ); + + // We may have started the layout while scrolled to the end, which would not + // expose a new child. + if (estimatedMaxScrollOffset == trailingScrollOffset) { + childManager.setDidUnderflow(true); + } + childManager.didFinishLayout(); + } + + @override + double? get itemExtent => null; + + /// The main-axis extent builder of each item. + /// + /// If this is non-null, the [itemExtent] must be null. + /// If this is null, the [itemExtent] must be non-null. + @override + ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; +} + /// Scroll physics used by a [CarouselView]. /// /// These physics cause the carousel item to snap to item boundaries. @@ -514,7 +1175,13 @@ class CarouselScrollPhysics extends ScrollPhysics { double velocity, ) { double fraction; - fraction = position.itemExtent! / position.viewportDimension; + + if (position.itemExtent != null) { + fraction = position.itemExtent! / position.viewportDimension; + } else { + assert(position.flexWeights != null); + fraction = position.flexWeights!.first / position.flexWeights!.sum; + } final double itemWidth = position.viewportDimension * fraction; @@ -579,6 +1246,8 @@ class _CarouselMetrics extends FixedScrollMetrics { required super.viewportDimension, required super.axisDirection, this.itemExtent, + this.flexWeights, + this.consumeMaxWeight, required super.devicePixelRatio, }); @@ -587,6 +1256,14 @@ class _CarouselMetrics extends FixedScrollMetrics { /// Used to compute the first item from the current [pixels]. final double? itemExtent; + /// The fraction of the viewport that the first item occupies. + /// + /// Used to compute [item] from the current [pixels]. + final List? flexWeights; + + /// Determine whether each child can be expanded to occupy the maximum weight while scrolling. + final bool? consumeMaxWeight; + @override _CarouselMetrics copyWith({ double? minScrollExtent, @@ -595,6 +1272,8 @@ class _CarouselMetrics extends FixedScrollMetrics { double? viewportDimension, AxisDirection? axisDirection, double? itemExtent, + List? flexWeights, + bool? consumeMaxWeight, double? devicePixelRatio, }) { return _CarouselMetrics( @@ -604,6 +1283,8 @@ class _CarouselMetrics extends FixedScrollMetrics { viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), axisDirection: axisDirection ?? this.axisDirection, itemExtent: itemExtent ?? this.itemExtent, + flexWeights: flexWeights ?? this.flexWeights, + consumeMaxWeight: consumeMaxWeight ?? this.consumeMaxWeight, devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, ); } @@ -614,14 +1295,19 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro required super.physics, required super.context, this.initialItem = 0, - required this.itemExtent, + double? itemExtent, + List? flexWeights, + bool consumeMaxWeight = true, super.oldPosition, - }) : _itemToShowOnStartup = initialItem.toDouble(), + }) : assert(flexWeights != null && itemExtent == null + || flexWeights == null && itemExtent != null), + _itemToShowOnStartup = initialItem.toDouble(), + _consumeMaxWeight = consumeMaxWeight, super( - initialPixels: null + initialPixels: null, ); - final int initialItem; + int initialItem; final double _itemToShowOnStartup; // When the viewport has a zero-size, the item can not // be retrieved by `getItemFromPixels`, so we need to cache the item @@ -629,11 +1315,83 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro double? _cachedItem; @override - double? itemExtent; + bool get consumeMaxWeight => _consumeMaxWeight; + bool _consumeMaxWeight; + set consumeMaxWeight(bool value) { + if (_consumeMaxWeight == value) { + return; + } + if (hasPixels && flexWeights != null) { + final double leadingItem = updateLeadingItem(flexWeights, value); + final double newPixel = getPixelsFromItem(leadingItem, flexWeights, itemExtent); + forcePixels(newPixel); + } + _consumeMaxWeight = value; + } + + @override + double? get itemExtent => _itemExtent; + double? _itemExtent; + set itemExtent(double? value) { + if (_itemExtent == value) { + return; + } + if (hasPixels) { + final double leadingItem = getItemFromPixels(pixels, viewportDimension); + final double newPixel = getPixelsFromItem(leadingItem, flexWeights, value); + forcePixels(newPixel); + } + _itemExtent = value; + } + + @override + List? get flexWeights => _flexWeights; + List? _flexWeights; + set flexWeights(List? value) { + if (flexWeights == value) { + return; + } + final List? oldWeights = _flexWeights; + if (hasPixels && oldWeights != null) { + final double leadingItem = updateLeadingItem(value, consumeMaxWeight); + final double newPixel = getPixelsFromItem(leadingItem, value, itemExtent); + forcePixels(newPixel); + } + _flexWeights = value; + } + + double updateLeadingItem(List? newFlexWeights, bool newConsumeMaxWeight) { + final double maxItem; + if (hasPixels && flexWeights != null) { + final double leadingItem = getItemFromPixels(pixels, viewportDimension); + maxItem = consumeMaxWeight + ? leadingItem + : leadingItem + flexWeights!.indexOf(flexWeights!.max); + } else { + maxItem = _itemToShowOnStartup; + } + if (newFlexWeights != null && !newConsumeMaxWeight) { + int smallerWeights = 0; + for (final int weight in newFlexWeights) { + if (weight == newFlexWeights.max) { + break; + } + smallerWeights += 1; + } + return maxItem - smallerWeights; + } + return maxItem; + } double getItemFromPixels(double pixels, double viewportDimension) { assert(viewportDimension > 0.0); - final double fraction = itemExtent! / viewportDimension; + double fraction; + if (itemExtent != null) { + fraction = itemExtent! / viewportDimension; + } else { // If itemExtent is null, flexWeights cannot be null. + assert(flexWeights != null); + fraction = flexWeights!.first / flexWeights!.sum; + } final double actual = math.max(0.0, pixels) / (viewportDimension * fraction); final double round = actual.roundToDouble(); @@ -643,8 +1401,14 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro return actual; } - double getPixelsFromItem(double item) { - final double fraction = itemExtent! / viewportDimension; + double getPixelsFromItem(double item, List? flexWeights, double? itemExtent) { + double fraction; + if (itemExtent != null) { + fraction = itemExtent / viewportDimension; + } else { // If itemExtent is null, flexWeights cannot be null. + assert(flexWeights != null); + fraction = flexWeights!.first / flexWeights.sum; + } return item * viewportDimension * fraction; } @@ -659,14 +1423,14 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro final double? oldPixels = hasPixels ? pixels : null; double item; if (oldPixels == null) { - item = _itemToShowOnStartup; + item = updateLeadingItem(flexWeights, consumeMaxWeight); } else if (oldViewportDimensions == 0.0) { // If resize from zero, we should use the _cachedItem to recover the state. item = _cachedItem!; } else { item = getItemFromPixels(oldPixels, oldViewportDimensions!); } - final double newPixels = getPixelsFromItem(item); + final double newPixels = getPixelsFromItem(item, flexWeights, itemExtent); // If the viewportDimension is zero, cache the item // in case the viewport is resized to be non-zero. _cachedItem = (viewportDimension == 0.0) ? item : null; @@ -686,7 +1450,8 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro double? viewportDimension, AxisDirection? axisDirection, double? itemExtent, - List? layoutWeights, + List? flexWeights, + bool? consumeMaxWeight, double? devicePixelRatio, }) { return _CarouselMetrics( @@ -696,6 +1461,8 @@ class _CarouselPosition extends ScrollPositionWithSingleContext implements _Caro viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), axisDirection: axisDirection ?? this.axisDirection, itemExtent: itemExtent ?? this.itemExtent, + flexWeights: flexWeights ?? this.flexWeights, + consumeMaxWeight: consumeMaxWeight ?? this.consumeMaxWeight, devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, ); } @@ -730,13 +1497,13 @@ class CarouselController extends ScrollController { @override ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { assert(_carouselState != null); - final double itemExtent = _carouselState!._itemExtent; - return _CarouselPosition( physics: physics, context: context, initialItem: initialItem, - itemExtent: itemExtent, + itemExtent: _carouselState!._itemExtent, + consumeMaxWeight: _carouselState!._consumeMaxWeight, + flexWeights: _carouselState!._flexWeights, oldPosition: oldPosition, ); } @@ -745,6 +1512,8 @@ class CarouselController extends ScrollController { void attach(ScrollPosition position) { super.attach(position); final _CarouselPosition carouselPosition = position as _CarouselPosition; + carouselPosition.flexWeights = _carouselState!._flexWeights; carouselPosition.itemExtent = _carouselState!._itemExtent; + carouselPosition.consumeMaxWeight = _carouselState!._consumeMaxWeight; } } diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart index bb87c04a57c..b47a560cca6 100644 --- a/packages/flutter/test/material/carousel_test.dart +++ b/packages/flutter/test/material/carousel_test.dart @@ -26,12 +26,12 @@ void main() { ), ); - final Finder carouselMaterial = find.descendant( + final Finder carouselViewMaterial = find.descendant( of: find.byType(CarouselView), matching: find.byType(Material), ).first; - final Material material = tester.widget(carouselMaterial); + final Material material = tester.widget(carouselViewMaterial); expect(material.clipBehavior, Clip.antiAlias); expect(material.color, colorScheme.surface); expect(material.elevation, 0.0); @@ -80,13 +80,13 @@ void main() { ), ); - final Finder carouselMaterial = find.descendant( + final Finder carouselViewMaterial = find.descendant( of: find.byType(CarouselView), matching: find.byType(Material), ).first; - expect(tester.getSize(carouselMaterial).width, 200 - 20 - 20); // Padding is 20 on both side. - final Material material = tester.widget(carouselMaterial); + expect(tester.getSize(carouselViewMaterial).width, 200 - 20 - 20); // Padding is 20 on both side. + final Material material = tester.widget(carouselViewMaterial); expect(material.color, Colors.amber); expect(material.elevation, 10.0); expect(material.shape, const StadiumBorder()); @@ -110,7 +110,7 @@ void main() { await gesture.removePointer(); // On focused. - final Element inkWellElement = tester.element(find.descendant(of: carouselMaterial, matching: find.byType(InkWell))); + final Element inkWellElement = tester.element(find.descendant(of: carouselViewMaterial, matching: find.byType(InkWell))); expect(inkWellElement.widget, isA()); final InkWell inkWell = inkWellElement.widget as InkWell; @@ -145,12 +145,12 @@ void main() { ), ); - final Finder item1 = find.byKey(keys.elementAt(1)); + final Finder item1 = find.byKey(keys[1]); await tester.tap(find.ancestor(of: item1, matching: find.byType(Stack))); await tester.pump(); expect(tapIndex, 1); - final Finder item2 = find.byKey(keys.elementAt(2)); + final Finder item2 = find.byKey(keys[2]); await tester.tap(find.ancestor(of: item2, matching: find.byType(Stack))); await tester.pump(); expect(tapIndex, 2); @@ -194,15 +194,73 @@ void main() { expect(find.text('Item 4'), findsNothing); }); - testWidgets('CarouselController initialItem', (WidgetTester tester) async { - final CarouselController controller = CarouselController(initialItem: 5); - addTearDown(controller.dispose); + testWidgets('CarouselView.weighted layout', (WidgetTester tester) async { + Widget buildCarouselView({ required List weights }) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: weights, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ); + } + await tester.pumpWidget(buildCarouselView(weights: [4, 3, 2, 1])); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + Rect rect0 = tester.getRect(getItem(0)); + // Item width is 4/10 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 320.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + Rect rect1 = tester.getRect(getItem(1)); + // Item width is 3/10 of the viewport. + expect(rect1, const Rect.fromLTRB(320.0, 0.0, 560.0, 600.0)); + + expect(find.text('Item 2'), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item width is 2/10 of the viewport. + expect(rect2, const Rect.fromLTRB(560.0, 0.0, 720.0, 600.0)); + + expect(find.text('Item 3'), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item width is 1/10 of the viewport. + expect(rect3, const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + + // Test shorter weight list. + await tester.pumpWidget(buildCarouselView(weights: [7, 1])); + await tester.pumpAndSettle(); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + rect0 = tester.getRect(getItem(0)); + // Item width is 7/8 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + rect1 = tester.getRect(getItem(1)); + // Item width is 1/8 of the viewport. + expect(rect1, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 2'), findsNothing); + }); + + testWidgets('CarouselController initialItem', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( body: CarouselView( - controller: controller, + controller: CarouselController(initialItem: 5), itemExtent: 400, children: List.generate(10, (int index) { return Center( @@ -231,6 +289,79 @@ void main() { expect(find.text('Item 7'), findsNothing); }); + testWidgets('CarouselView.weighted respects CarouselController.initialItem', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: CarouselController(initialItem: 5), + flexWeights: const [7, 1], + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 7/8 of the viewport. + expect(rect5, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 1/8 of the viewport. + expect(rect6, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('The initialItem should be the first item with expanded size(max extent)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: CarouselController(initialItem: 5), + flexWeights: const [1, 8, 1], + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + final Size viewportSize = MediaQuery.of(tester.element(find.byType(CarouselView))).size; + expect(viewportSize, const Size(800, 600)); + + // Item 5 should have be the expanded item. + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 8/10 of the viewport. + expect(rect5, const Rect.fromLTRB(80.0, 0.0, 720.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 1/10 of the viewport. + expect(rect6, const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsOneWidget); + final Rect rect4 = tester.getRect(getItem(4)); + // Item width is 1/10 of the viewport. + expect(rect4, const Rect.fromLTRB(0.0, 0.0, 80.0, 600.0)); + + expect(find.text('Item 7'), findsNothing); + }); + testWidgets('CarouselView respects itemSnapping', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -279,6 +410,54 @@ void main() { expect(getItem(3), findsOneWidget); }); + testWidgets('CarouselView.weighted respects itemSnapping', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + consumeMaxWeight: false, + flexWeights: const [1, 7], + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + void checkOriginalExpectations() { + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsNothing); + } + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(-20, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(50, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap to the next item. + await tester.drag(getItem(0), const Offset(-70, 0)); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + }); + testWidgets('CarouselView respect itemSnapping when fling', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -333,6 +512,69 @@ void main() { expect(getItem(4), findsNothing); }); + testWidgets('CarouselView.weighted respect itemSnapping when fling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + consumeMaxWeight: false, + flexWeights: const [1, 8, 1], + children: List.generate(10, (int index) { + return Center( + child: Text('$index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + Finder getItem(int index) + => find.descendant( + of: find.byType(CarouselView), + matching: find.ancestor( + of: find.text('$index'), + matching: find.byType(Padding) + ), + ); + + // Show item 0, 1, and 2. + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Should snap to item 2 because of a long drag(-100). Show item 2, 3 and 4. + await tester.fling(getItem(0), const Offset(-100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsNothing); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + + // Fling to the next item (item 3). Show item 3, 4 and 5. + await tester.fling(getItem(2), const Offset(-50, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(2), findsNothing); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + expect(getItem(5), findsOneWidget); + + // Fling back to the previous item. Show item 2, 3 and 4. + await tester.fling(getItem(3), const Offset(50, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsOneWidget); + expect(getItem(5), findsNothing); + }); + testWidgets('CarouselView respects scrollingDirection: Axis.vertical', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -367,6 +609,86 @@ void main() { expect(getItem(3), findsOneWidget); }); + testWidgets('CarouselView.weighted respects scrollingDirection: Axis.vertical', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [3, 2, 1], + padding: EdgeInsets.zero, + scrollDirection: Axis.vertical, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item width is 3/6 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0)); + + // Simulate a scroll up + await tester.drag(find.byType(CarouselView), const Offset(0, -300), kind: PointerDeviceKind.trackpad); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(3), findsOneWidget); + }); + + testWidgets('CarouselView.weighted respects scrollingDirection: Axis.vertical + itemSnapping: true', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + itemSnapping: true, + flexWeights: const [3, 2, 1], + scrollDirection: Axis.vertical, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item width is 3/6 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 300.0)); + + // Simulate a scroll up but less than half of the leading item, the leading + // item should go back to the original position because itemSnapping is set + // to true. + await tester.drag(find.byType(CarouselView), const Offset(0, -149), kind: PointerDeviceKind.trackpad); + await tester.pumpAndSettle(); + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Simulate a scroll up more than half of the leading item, the leading + // item continue to scrolling and will disappear when animation ends because + // itemSnapping is set to true. + await tester.drag(find.byType(CarouselView), const Offset(0, -151), kind: PointerDeviceKind.trackpad); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(3), findsOneWidget); + }); + testWidgets('CarouselView respects reverse', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -374,7 +696,6 @@ void main() { body: CarouselView( itemExtent: 200, reverse: true, - padding: EdgeInsets.zero, children: List.generate(10, (int index) { return Center( child: Text('Item $index'), @@ -407,6 +728,140 @@ void main() { expect(rect3, const Rect.fromLTRB(0.0, 0.0, 200.0, 600.0)); }); + testWidgets('CarouselView.weighted respects reverse', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [4, 3, 2, 1], + reverse: true, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + // Item 0 should be placed on the end of the screen. + const int item0Width = 80 * 4; + expect(rect0, const Rect.fromLTRB(800.0 - item0Width, 0.0, 800.0, 600.0)); + + expect(getItem(1), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + // Item 1 should be placed before item 0. + const int item1Width = 80 * 3; + expect(rect1, const Rect.fromLTRB(800.0 - item0Width - item1Width, 0.0, 800.0 - item0Width, 600.0)); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + const int item2Width = 80 * 2; + expect(rect2, const Rect.fromLTRB(800.0 - item0Width - item1Width - item2Width, 0.0, 800.0 - item0Width - item1Width, 600.0)); + + expect(getItem(3), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item 3 should be placed before item 2. + expect(rect3, const Rect.fromLTRB(0.0, 0.0, 800.0 - item0Width - item1Width - item2Width, 600.0)); + }); + + testWidgets('CarouselView.weighted respects reverse + vertical scroll direction', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + reverse: true, + flexWeights: const [4, 3, 2, 1], + scrollDirection: Axis.vertical, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + // Item 0 should be placed on the end of the screen. + const int item0Height = 60 * 4; + expect(rect0, const Rect.fromLTRB(0.0, 600.0 - item0Height, 800.0, 600.0)); + + expect(getItem(1), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + // Item 1 should be placed before item 0. + const int item1Height = 60 * 3; + expect(rect1, const Rect.fromLTRB(0.0, 600.0 - item0Height - item1Height, 800.0, 600.0 - item0Height)); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + const int item2Height = 60 * 2; + expect(rect2, const Rect.fromLTRB(0.0, 600.0 - item0Height - item1Height - item2Height, 800.0, 600.0 - item0Height - item1Height)); + + expect(getItem(3), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + // Item 3 should be placed before item 2. + expect(rect3, const Rect.fromLTRB(0.0, 0.0, 800.0, 600.0 - item0Height - item1Height - item2Height)); + }); + + testWidgets('CarouselView.weighted respects reverse + vertical scroll direction + itemSnapping', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + reverse: true, + flexWeights: const [4, 3, 2, 1], + scrollDirection: Axis.vertical, + itemSnapping: true, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + final Rect rect0 = tester.getRect(getItem(0)); + // Item height is 4/10 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 360.0, 800.0, 600.0)); + + // Simulate a scroll down but less than half of the leading item, the leading + // item should go back to the original position because itemSnapping is set + // to true. + await tester.drag(find.byType(CarouselView), const Offset(0, 240 / 2 - 1), kind: PointerDeviceKind.trackpad); + await tester.pumpAndSettle(); + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + + // Simulate a scroll down more than half of the leading item, the leading + // item continue to scrolling and will disappear when animation ends because + // itemSnapping is set to true. + await tester.drag(find.byType(CarouselView), const Offset(0, 240 / 2 + 1), kind: PointerDeviceKind.trackpad); + await tester.pumpAndSettle(); + expect(getItem(0), findsNothing); + expect(getItem(4), findsOneWidget); + }); + testWidgets('CarouselView respects shrinkExtent', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -445,6 +900,240 @@ void main() { await tester.pump(); expect(tester.getRect(getItem(0)), const Rect.fromLTRB(-50, 0.0, 250, 600)); }); + + testWidgets('CarouselView.weighted respects consumeMaxWeight', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [1, 2, 4, 2, 1], + itemSnapping: true, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + // The initial item is item 0. To make sure the layout stays the same, the + // first item should be placed at the middle of the screen and there are some + // white space as if there are two more shinked items before the first item. + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(240.0, 0.0, 560.0, 600.0)); + + for (int i = 0; i < 7; i++) { + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + } + + // After scrolling the carousel 7 times, the last item(item 9) should be on + // the end of the screen. + expect(getItem(9), findsOneWidget); + expect(tester.getRect(getItem(9)), const Rect.fromLTRB(720.0, 0.0, 800.0, 600.0)); + + // Keep snapping twice. Item 9 should be fully expanded to the max size. + for (int i = 0; i < 2; i++) { + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + } + expect(getItem(9), findsOneWidget); + expect(tester.getRect(getItem(9)), const Rect.fromLTRB(240.0, 0.0, 560.0, 600.0)); + }); + + testWidgets('The initialItem stays when the flexWeights is updated', (WidgetTester tester) async { + final CarouselController controller = CarouselController(initialItem: 3); + Widget buildCarousel(List flexWeights) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: flexWeights, + itemSnapping: true, + children: List.generate(20, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel([1, 1, 6, 1, 1])); + await tester.pumpAndSettle(); + + expect(find.text('Item 0'), findsNothing); + for (int i = 1; i <= 5; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + Rect rect3 = tester.getRect(getItem(3)); + expect(rect3.center.dx, 400.0); + expect(rect3.center.dy, 300.0); + + expect(find.text('Item 6'), findsNothing); + + await tester.pumpWidget(buildCarousel([7, 1])); + await tester.pumpAndSettle(); + + expect(find.text('Item 2'), findsNothing); + expect(find.text('Item 3'), findsOneWidget); + expect(find.text('Item 4'), findsOneWidget); + expect(find.text('Item 5'), findsNothing); + + rect3 = tester.getRect(getItem(3)); + expect(rect3, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + final Rect rect4 = tester.getRect(getItem(4)); + expect(rect4, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + }); + + testWidgets('The item that currently occupies max weight stays when the flexWeights is updated', (WidgetTester tester) async { + final CarouselController controller = CarouselController(initialItem: 3); + Widget buildCarousel(List flexWeights) { + return MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + controller: controller, + flexWeights: flexWeights, + itemSnapping: true, + children: List.generate(20, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel([1, 1, 6, 1, 1])); + await tester.pumpAndSettle(); + // Item 3 is centered. + final Rect rect3 = tester.getRect(getItem(3)); + expect(rect3.center.dx, 400.0); + expect(rect3.center.dy, 300.0); + + // Simulate scroll to right and show item 4 to be the centered max item. + await tester.drag(find.byType(CarouselView), const Offset(-80.0, 0.0)); + await tester.pumpAndSettle(); + + expect(find.text('Item 1'), findsNothing); + for (int i = 2; i <= 6; i++) { + expect(find.text('Item $i'), findsOneWidget); + } + Rect rect4 = tester.getRect(getItem(4)); + expect(rect4.center.dx, 400.0); + expect(rect4.center.dy, 300.0); + + await tester.pumpWidget(buildCarousel([7, 1])); + await tester.pumpAndSettle(); + + rect4 = tester.getRect(getItem(4)); + expect(rect4, const Rect.fromLTRB(0.0, 0.0, 700.0, 600.0)); + final Rect rect5 = tester.getRect(getItem(5)); + expect(rect5, const Rect.fromLTRB(700.0, 0.0, 800.0, 600.0)); + }); + + testWidgets('The initialItem stays when the itemExtent is updated', (WidgetTester tester) async { + final CarouselController controller = CarouselController(initialItem: 3); + Widget buildCarousel(double itemExtent) { + return MaterialApp( + home: Scaffold( + body: CarouselView( + controller: controller, + itemExtent: itemExtent, + itemSnapping: true, + children: List.generate(20, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ); + } + + await tester.pumpWidget(buildCarousel(234.0)); + await tester.pumpAndSettle(); + + Offset rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 234.0); + expect(rect3BottomRight.dy, 600.0); + + await tester.pumpWidget(buildCarousel(400.0)); + await tester.pumpAndSettle(); + + rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 400.0); + expect(rect3BottomRight.dy, 600.0); + + await tester.pumpWidget(buildCarousel(100.0)); + await tester.pumpAndSettle(); + + rect3BottomRight = tester.getRect(getItem(3)).bottomRight; + expect(rect3BottomRight.dx, 100.0); + expect(rect3BottomRight.dy, 600.0); + }); + + testWidgets('While scrolling, one extra item will show at the end of the screen during items transition', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView.weighted( + flexWeights: const [1, 2, 4, 2, 1], + consumeMaxWeight: false, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + for (int i = 0; i < 5; i++) { + expect(getItem(i), findsOneWidget); + } + + // Drag the first item to the middle. So the progress for the first item size change + // is 50%, original width is 80. + await tester.drag(getItem(0), const Offset(-40.0, 0.0), kind: PointerDeviceKind.trackpad); + await tester.pump(); + expect(tester.getRect(getItem(0)).width, 40.0); + + // The size of item 1 is changing to the size of item 0, so the size of item 1 + // now should be item1.originalExtent - 50% * (item1.extent - item0.extent). + // Item1 originally should be 2/(1+2+4+2+1) * 800 = 160.0. + expect(tester.getRect(getItem(1)).width, 160 - 0.5 * (160 - 80)); + + // The extent of item 2 should be: item2.originalExtent - 50% * (item2.extent - item1.extent). + // the extent of item 2 originally should be 4/(1+2+4+2+1) * 800 = 320.0. + expect(tester.getRect(getItem(2)).width, 320 - 0.5 * (320 - 160)); + + // The extent of item 3 should be: item3.originalExtent + 50% * (item2.extent - item3.extent). + // the extent of item 3 originally should be 2/(1+2+4+2+1) * 800 = 160.0. + expect(tester.getRect(getItem(3)).width, 160 + 0.5 * (320 - 160)); + + // The extent of item 4 should be: item4.originalExtent + 50% * (item3.extent - item4.extent). + // the extent of item 4 originally should be 1/(1+2+4+2+1) * 800 = 80.0. + expect(tester.getRect(getItem(4)).width, 80 + 0.5 * (160 - 80)); + + // The sum of the first 5 items during transition is less than the screen width. + double sum = 0; + for (int i = 0; i < 5; i++) { + sum += tester.getRect(getItem(i)).width; + } + expect(sum, lessThan(MediaQuery.of(tester.element(find.byType(CarouselView))).size.width)); + final double difference = MediaQuery.of(tester.element(find.byType(CarouselView))).size.width - sum; + + // One more item should show on screen to fill the rest of the viewport. + expect(getItem(5), findsOneWidget); + expect(tester.getRect(getItem(5)).width, difference); + }); } Finder getItem(int index) {