diff --git a/examples/api/lib/material/carousel/carousel.0.dart b/examples/api/lib/material/carousel/carousel.0.dart new file mode 100644 index 0000000000..005bed704c --- /dev/null +++ b/examples/api/lib/material/carousel/carousel.0.dart @@ -0,0 +1,77 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [Carousel]. + +void main() => runApp(const CarouselExampleApp()); + +class CarouselExampleApp extends StatelessWidget { + const CarouselExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + appBar: AppBar( + title: const Text('Carousel Sample'), + ), + body: const CarouselExample(), + ), + ); + } +} + +class CarouselExample extends StatefulWidget { + const CarouselExample({super.key}); + + @override + State createState() => _CarouselExampleState(); +} + +class _CarouselExampleState extends State { + @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'); + }), + ), + ), + ); + } +} + +class UncontainedLayoutCard extends StatelessWidget { + const UncontainedLayoutCard({ + super.key, + required this.index, + required this.label, + }); + + final int index; + final String label; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.primaries[index % Colors.primaries.length].withOpacity(0.5), + child: Center( + child: Text( + label, + style: const TextStyle(color: Colors.white, fontSize: 20), + overflow: TextOverflow.clip, + softWrap: false, + ), + ), + ); + } +} diff --git a/examples/api/test/material/carousel/carousel.0_test.dart b/examples/api/test/material/carousel/carousel.0_test.dart new file mode 100644 index 0000000000..5f104612ea --- /dev/null +++ b/examples/api/test/material/carousel/carousel.0_test.dart @@ -0,0 +1,19 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +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() { + 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); + }); +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index b53256a562..c13d062ccb 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -46,6 +46,7 @@ export 'src/material/button_theme.dart'; export 'src/material/calendar_date_picker.dart'; export 'src/material/card.dart'; export 'src/material/card_theme.dart'; +export 'src/material/carousel.dart'; export 'src/material/checkbox.dart'; export 'src/material/checkbox_list_tile.dart'; export 'src/material/checkbox_theme.dart'; diff --git a/packages/flutter/lib/src/material/carousel.dart b/packages/flutter/lib/src/material/carousel.dart new file mode 100644 index 0000000000..1a0e586788 --- /dev/null +++ b/packages/flutter/lib/src/material/carousel.dart @@ -0,0 +1,750 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// 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 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'ink_well.dart'; +import 'material.dart'; +import 'theme.dart'; + +/// A Material Design carousel widget. +/// +/// The [CarouselView] present 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. +/// +/// The [CarouselController] is used to control the [CarouselController.initialItem]. +/// +/// 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 +/// visible items may be slightly compressed during scrolling. The [shrinkExtent] +/// 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. +/// +/// ** 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. +/// * [PageView], which is a scrollable list that works page by page. +class CarouselView extends StatefulWidget { + /// Creates a Material Design carousel. + const CarouselView({ + 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.onTap, + required this.itemExtent, + required this.children, + }); + + /// The amount of space to surround each carousel item with. + /// + /// Defaults to [EdgeInsets.all] of 4 pixels. + final EdgeInsets? padding; + + /// The background color for each carousel item. + /// + /// Defaults to [ColorScheme.surface]. + final Color? backgroundColor; + + /// The z-coordinate of each carousel item. + /// + /// Defaults to 0.0. + final double? elevation; + + /// The shape of each carousel item's [Material]. + /// + /// Defines each item's [Material.shape]. + /// + /// Defaults to a [RoundedRectangleBorder] with a circular corner radius + /// of 28.0. + final ShapeBorder? shape; + + /// The highlight color to indicate the carousel items are in pressed, hovered + /// or focused states. + /// + /// The default values are: + /// * [WidgetState.pressed] - [ColorScheme.onSurface] with an opacity of 0.1 + /// * [WidgetState.hovered] - [ColorScheme.onSurface] with an opacity of 0.08 + /// * [WidgetState.focused] - [ColorScheme.onSurface] with an opacity of 0.1 + final WidgetStateProperty? overlayColor; + + /// The minimum allowable extent (size) in the main axis for carousel items + /// during scrolling transitions. + /// + /// As the carousel scrolls, the first visible item is pinned and gradually + /// shrinks until it reaches this minimum extent before scrolling off-screen. + /// Similarly, the last visible item enters the viewport at this minimum size + /// and expands to its full [itemExtent]. + /// + /// 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. + /// + /// 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 + /// 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. + final double shrinkExtent; + + /// Whether the carousel should keep scrolling to the next/previous items to + /// maintain the original layout. + /// + /// Defaults to false. + final bool itemSnapping; + + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + final CarouselController? controller; + + /// The [Axis] along which the scroll view's offset increases with each item. + /// + /// Defaults to [Axis.horizontal]. + final Axis scrollDirection; + + /// Whether the carousel list scrolls in the reading direction. + /// + /// For example, if the reading direction is left-to-right and + /// [scrollDirection] is [Axis.horizontal], then the carousel scrolls from + /// left to right when [reverse] is false and from right to left when + /// [reverse] is true. + /// + /// Similarly, if [scrollDirection] is [Axis.vertical], then the carousel view + /// scrolls from top to bottom when [reverse] is false and from bottom to top + /// when [reverse] is true. + /// + /// Defaults to false. + final bool reverse; + + /// 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 + /// occupies to ensure at least one item is fully visible. + /// + /// This must be non-null. + final double itemExtent; + + /// The child widgets for the carousel. + final List children; + + @override + State createState() => _CarouselViewState(); +} + +class _CarouselViewState extends State { + late double _itemExtent; + CarouselController? _internalController; + CarouselController get _controller => widget.controller ?? _internalController!; + + @override + void initState() { + super.initState(); + 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); + if (widget.controller != oldWidget.controller) { + oldWidget.controller?._detach(this); + if (widget.controller != null) { + _internalController?._detach(this); + _internalController = null; + widget.controller?._attach(this); + } else { // widget.controller == null && oldWidget.controller != null + assert(_internalController == null); + _internalController = CarouselController(); + _controller._attach(this); + } + } + if (widget.itemExtent != oldWidget.itemExtent) { + _itemExtent = widget.itemExtent; + } + } + + @override + void dispose() { + _controller._detach(this); + _internalController?.dispose(); + super.dispose(); + } + + AxisDirection _getDirection(BuildContext context) { + switch (widget.scrollDirection) { + case Axis.horizontal: + assert(debugCheckHasDirectionality(context)); + final TextDirection textDirection = Directionality.of(context); + final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); + return widget.reverse ? flipAxisDirection(axisDirection) : axisDirection; + case Axis.vertical: + return widget.reverse ? AxisDirection.up : AxisDirection.down; + } + } + + @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); + 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; + final ShapeBorder effectiveShape = widget.shape + ?? const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28.0)) + ); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double mainAxisExtent = switch (widget.scrollDirection) { + Axis.horizontal => constraints.maxWidth, + Axis.vertical => constraints.maxHeight, + }; + _itemExtent = clampDouble(_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: [ + _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, + ), + ), + ], + ); + }, + ); + } + ); + } +} + +/// A sliver that displays its box children in a linear array with a fixed extent +/// per item. +/// +/// _To learn more about slivers, see [CustomScrollView.slivers]._ +/// +/// This sliver list arranges its children in a line along the main axis starting +/// 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 +/// 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). +class _SliverFixedExtentCarousel extends SliverMultiBoxAdaptorWidget { + const _SliverFixedExtentCarousel({ + required super.delegate, + required this.minExtent, + required this.itemExtent, + }); + + final double itemExtent; + final double minExtent; + + @override + RenderSliverFixedExtentBoxAdaptor createRenderObject(BuildContext context) { + final SliverMultiBoxAdaptorElement element = context as SliverMultiBoxAdaptorElement; + return _RenderSliverFixedExtentCarousel( + childManager: element, + minExtent: minExtent, + maxExtent: itemExtent, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderSliverFixedExtentCarousel renderObject) { + renderObject.maxExtent = itemExtent; + renderObject.minExtent = itemExtent; + } +} + +class _RenderSliverFixedExtentCarousel extends RenderSliverFixedExtentBoxAdaptor { + _RenderSliverFixedExtentCarousel({ + required super.childManager, + required double maxExtent, + required double minExtent, + }) : _maxExtent = maxExtent, + _minExtent = minExtent; + + double get maxExtent => _maxExtent; + double _maxExtent; + set maxExtent(double value) { + if (_maxExtent == value) { + return; + } + _maxExtent = value; + markNeedsLayout(); + } + + double get minExtent => _minExtent; + double _minExtent; + set minExtent(double value) { + if (_minExtent == value) { + return; + } + _minExtent = value; + markNeedsLayout(); + } + + // This implements the [itemExtentBuilder] callback. + double _buildItemExtent(int index, SliverLayoutDimensions currentLayoutDimensions) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // Calculate how many items have been completely scroll off screen. + final int offscreenItems = (constraints.scrollOffset / maxExtent).floor(); + + // If an item is partially off screen and partially on screen, + // `constraints.scrollOffset` must be greater than + // `offscreenItems * maxExtent`, so the difference between these two is how + // much the current first visible item is off screen. + final double offscreenExtent = constraints.scrollOffset - offscreenItems * maxExtent; + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to ensure a smooth size transition. + final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent); + + // Two special cases are the first and last visible items. Other items' extent + // should all return `maxExtent`. + if (index == firstVisibleIndex) { + final double effectiveExtent = maxExtent - offscreenExtent; + return math.max(effectiveExtent, effectiveMinExtent); + } + + final double scrollOffsetForLastIndex = constraints.scrollOffset + constraints.remainingPaintExtent; + if (index == getMaxChildIndexForScrollOffset(scrollOffsetForLastIndex, maxExtent)) { + return clampDouble(scrollOffsetForLastIndex - maxExtent * index, effectiveMinExtent, maxExtent); + } + + return maxExtent; + } + + late SliverLayoutDimensions _currentLayoutDimensions; + + @override + void performLayout() { + _currentLayoutDimensions = SliverLayoutDimensions( + scrollOffset: constraints.scrollOffset, + precedingScrollExtent: constraints.precedingScrollExtent, + viewportMainAxisExtent: constraints.viewportMainAxisExtent, + crossAxisExtent: constraints.crossAxisExtent, + ); + super.performLayout(); + } + + /// The layout offset for the child with the given index. + @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, + ) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + + // If there is not enough space to place the last visible item but the remaining + // space is larger than `minExtent`, the extent for last item should be at + // least the remaining extent to make sure a smooth size transition. + final double effectiveMinExtent = math.max(constraints.remainingPaintExtent % maxExtent, minExtent); + if (index == firstVisibleIndex) { + final double firstVisibleItemExtent = _buildItemExtent(index, _currentLayoutDimensions); + + // If the first item is squished 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; + } + return constraints.scrollOffset; + } + return maxExtent * index; + } + + /// The minimum child index that is visible at the given scroll offset. + @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, + ) { + final int firstVisibleIndex = (constraints.scrollOffset / maxExtent).floor(); + return math.max(firstVisibleIndex, 0); + } + + /// The maximum child index that is visible at the given scroll offset. + @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, + ) { + if (maxExtent > 0.0) { + final double actual = scrollOffset / maxExtent - 1; + final int round = actual.round(); + if ((actual * maxExtent - round * maxExtent).abs() < precisionErrorTolerance) { + return math.max(0, round); + } + return math.max(0, actual.ceil()); + } + return 0; + } + + @override + double? get itemExtent => null; + + @override + ItemExtentBuilder? get itemExtentBuilder => _buildItemExtent; +} + +/// Scroll physics used by a [CarouselView]. +/// +/// These physics cause the carousel item to snap to item boundaries. +/// +/// See also: +/// +/// * [ScrollPhysics], the base class which defines the API for scrolling +/// physics. +/// * [PageScrollPhysics], scroll physics used by a [PageView]. +class CarouselScrollPhysics extends ScrollPhysics { + /// Creates physics for a [CarouselView]. + const CarouselScrollPhysics({super.parent}); + + @override + CarouselScrollPhysics applyTo(ScrollPhysics? ancestor) { + return CarouselScrollPhysics(parent: buildParent(ancestor)); + } + + double _getTargetPixels( + _CarouselPosition position, + Tolerance tolerance, + double velocity, + ) { + double fraction; + fraction = position.itemExtent! / position.viewportDimension; + + final double itemWidth = position.viewportDimension * fraction; + + final double actual = math.max(0.0, position.pixels) / itemWidth; + final double round = actual.roundToDouble(); + double item; + if ((actual - round).abs() < precisionErrorTolerance) { + item = round; + } else { + item = actual; + } + if (velocity < -tolerance.velocity) { + item -= 0.5; + } else if (velocity > tolerance.velocity) { + item += 0.5; + } + return item.roundToDouble() * itemWidth; + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + assert( + position is _CarouselPosition, + 'CarouselScrollPhysics can only be used with Scrollables that uses ' + 'the CarouselController', + ); + + final _CarouselPosition metrics = position as _CarouselPosition; + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + final Tolerance tolerance = toleranceFor(metrics); + final double target = _getTargetPixels(metrics, tolerance, velocity); + if (target != metrics.pixels) { + return ScrollSpringSimulation( + spring, + metrics.pixels, + target, + velocity, + tolerance: tolerance, + ); + } + return null; + } + + @override + bool get allowImplicitScrolling => true; +} + +/// Metrics for a [CarouselView]. +class _CarouselMetrics extends FixedScrollMetrics { + /// Creates an immutable snapshot of values associated with a [CarouselView]. + _CarouselMetrics({ + required super.minScrollExtent, + required super.maxScrollExtent, + required super.pixels, + required super.viewportDimension, + required super.axisDirection, + this.itemExtent, + required super.devicePixelRatio, + }); + + /// Extent for the carousel item. + /// + /// Used to compute the first item from the current [pixels]. + final double? itemExtent; + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +class _CarouselPosition extends ScrollPositionWithSingleContext implements _CarouselMetrics { + _CarouselPosition({ + required super.physics, + required super.context, + this.initialItem = 0, + required this.itemExtent, + super.oldPosition, + }) : _itemToShowOnStartup = initialItem.toDouble(), + super( + initialPixels: null + ); + + final 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 + // for use when resizing the viewport to non-zero next time. + double? _cachedItem; + + @override + double? itemExtent; + + double getItemFromPixels(double pixels, double viewportDimension) { + assert(viewportDimension > 0.0); + final double fraction = itemExtent! / viewportDimension; + + final double actual = math.max(0.0, pixels) / (viewportDimension * fraction); + final double round = actual.roundToDouble(); + if ((actual - round).abs() < precisionErrorTolerance) { + return round; + } + return actual; + } + + double getPixelsFromItem(double item) { + final double fraction = itemExtent! / viewportDimension; + + return item * viewportDimension * fraction; + } + + @override + bool applyViewportDimension(double viewportDimension) { + final double? oldViewportDimensions = hasViewportDimension ? this.viewportDimension : null; + if (viewportDimension == oldViewportDimensions) { + return true; + } + final bool result = super.applyViewportDimension(viewportDimension); + final double? oldPixels = hasPixels ? pixels : null; + double item; + if (oldPixels == null) { + item = _itemToShowOnStartup; + } 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); + // If the viewportDimension is zero, cache the item + // in case the viewport is resized to be non-zero. + _cachedItem = (viewportDimension == 0.0) ? item : null; + + if (newPixels != oldPixels) { + correctPixels(newPixels); + return false; + } + return result; + } + + @override + _CarouselMetrics copyWith({ + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + AxisDirection? axisDirection, + double? itemExtent, + List? layoutWeights, + double? devicePixelRatio, + }) { + return _CarouselMetrics( + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : null), + maxScrollExtent: maxScrollExtent ?? (hasContentDimensions ? this.maxScrollExtent : null), + pixels: pixels ?? (hasPixels ? this.pixels : null), + viewportDimension: viewportDimension ?? (hasViewportDimension ? this.viewportDimension : null), + axisDirection: axisDirection ?? this.axisDirection, + itemExtent: itemExtent ?? this.itemExtent, + devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + ); + } +} + +/// A controller for [CarouselView]. +/// +/// Using a carousel controller helps to show the first visible item on the +/// carousel list. +class CarouselController extends ScrollController { + /// Creates a carousel controller. + CarouselController({ + this.initialItem = 0, + }); + + /// The item that expands to the maximum size when first creating the [CarouselView]. + final int initialItem; + + _CarouselViewState? _carouselState; + + // ignore: use_setters_to_change_properties + void _attach(_CarouselViewState anchor) { + _carouselState = anchor; + } + + void _detach(_CarouselViewState anchor) { + if (_carouselState == anchor) { + _carouselState = null; + } + } + + @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, + oldPosition: oldPosition, + ); + } + + @override + void attach(ScrollPosition position) { + super.attach(position); + final _CarouselPosition carouselPosition = position as _CarouselPosition; + carouselPosition.itemExtent = _carouselState!._itemExtent; + } +} diff --git a/packages/flutter/test/material/carousel_test.dart b/packages/flutter/test/material/carousel_test.dart new file mode 100644 index 0000000000..e287c3ebb1 --- /dev/null +++ b/packages/flutter/test/material/carousel_test.dart @@ -0,0 +1,461 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('CarouselView defaults', (WidgetTester tester) async { + final ThemeData theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + itemExtent: 200, + children: List.generate(10, (int index) { + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Finder carouselMaterial = find.descendant( + of: find.byType(CarouselView), + matching: find.byType(Material), + ).first; + + final Material material = tester.widget(carouselMaterial); + expect(material.clipBehavior, Clip.antiAlias); + expect(material.color, colorScheme.surface); + expect(material.elevation, 0.0); + expect(material.shape, const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(28.0)) + )); + }); + + testWidgets('CarouselView items customization', (WidgetTester tester) async { + final Key key = UniqueKey(); + final ThemeData theme = ThemeData(); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + padding: const EdgeInsets.all(20.0), + backgroundColor: Colors.amber, + elevation: 10.0, + shape: const StadiumBorder(), + overlayColor: WidgetStateProperty.resolveWith((Set states) { + if (states.contains(WidgetState.pressed)) { + return Colors.yellow; + } + if (states.contains(WidgetState.hovered)) { + return Colors.red; + } + if (states.contains(WidgetState.focused)) { + return Colors.purple; + } + return null; + }), + itemExtent: 200, + children: List.generate(10, (int index) { + if (index == 0) { + return Center( + key: key, + child: Center(child: Text('Item $index')), + ); + } + return Center(child: Text('Item $index')); + }), + ), + ), + ), + ); + + final Finder carouselMaterial = 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(material.color, Colors.amber); + expect(material.elevation, 10.0); + expect(material.shape, const StadiumBorder()); + + RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + + // On hovered. + final TestGesture gesture = await hoverPointerOverCarouselItem(tester, key); + await tester.pumpAndSettle(); + expect(inkFeatures, paints..rect(color: Colors.red.withOpacity(1.0))); + + // On pressed. + await tester.pumpAndSettle(); + await gesture.down(tester.getCenter(find.byKey(key))); + await tester.pumpAndSettle(); + inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect()..rect(color: Colors.yellow.withOpacity(1.0))); + + await tester.pumpAndSettle(); + await gesture.up(); + await gesture.removePointer(); + + // On focused. + final Element inkWellElement = tester.element(find.descendant(of: carouselMaterial, matching: find.byType(InkWell))); + expect(inkWellElement.widget, isA()); + final InkWell inkWell = inkWellElement.widget as InkWell; + + const MaterialState state = MaterialState.focused; + + // Check overlay color in focused state. + expect(inkWell.overlayColor?.resolve({state}), Colors.purple); + }); + + testWidgets('CarouselView respects onTap', (WidgetTester tester) async { + final List keys = List.generate(10, (_) => UniqueKey()); + final ThemeData theme = ThemeData(); + int tapIndex = 0; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: CarouselView( + itemExtent: 50, + onTap: (int index) { + tapIndex = index; + }, + children: List.generate(10, (int index) { + return Center( + key: keys[index], + child: Text('Item $index'), + ); + }), + ), + ), + ), + ); + + final Finder item1 = find.byKey(keys.elementAt(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)); + await tester.tap(find.ancestor(of: item2, matching: find.byType(Stack))); + await tester.pump(); + expect(tapIndex, 2); + }); + + testWidgets('CarouselView layout (Uncontained layout)', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 250, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 0'), findsOneWidget); + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 250.0, 600.0)); + + expect(find.text('Item 1'), findsOneWidget); + final Rect rect1 = tester.getRect(getItem(1)); + expect(rect1, const Rect.fromLTRB(250.0, 0.0, 500.0, 600.0)); + + expect(find.text('Item 2'), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + expect(rect2, const Rect.fromLTRB(500.0, 0.0, 750.0, 600.0)); + + expect(find.text('Item 3'), findsOneWidget); + final Rect rect3 = tester.getRect(getItem(3)); + expect(rect3, const Rect.fromLTRB(750.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + }); + + testWidgets('CarouselController initialItem', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + controller: CarouselController(initialItem: 5), + itemExtent: 400, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + final Size viewportSize = MediaQuery.sizeOf(tester.element(find.byType(CarouselView))); + expect(viewportSize, const Size(800, 600)); + + expect(find.text('Item 5'), findsOneWidget); + final Rect rect5 = tester.getRect(getItem(5)); + // Item width is 400. + expect(rect5, const Rect.fromLTRB(0.0, 0.0, 400.0, 600.0)); + + expect(find.text('Item 6'), findsOneWidget); + final Rect rect6 = tester.getRect(getItem(6)); + // Item width is 400. + expect(rect6, const Rect.fromLTRB(400.0, 0.0, 800.0, 600.0)); + + expect(find.text('Item 4'), findsNothing); + expect(find.text('Item 7'), findsNothing); + }); + + testWidgets('CarouselView respects itemSnapping', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemSnapping: true, + itemExtent: 300, + 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), findsOneWidget); + expect(getItem(3), findsNothing); + } + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(-150, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap back to the original item. + await tester.drag(getItem(0), const Offset(100, 0)); + await tester.pumpAndSettle(); + + checkOriginalExpectations(); + + // Snap to the next item. + await tester.drag(getItem(0), const Offset(-200, 0)); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + }); + + testWidgets('CarouselView respect itemSnapping when fling', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemSnapping: true, + itemExtent: 300, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + + // Show item 0, 1, and 2. + expect(getItem(0), findsOneWidget); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsNothing); + + // Snap to the next item. Show item 1, 2 and 3. + await tester.fling(getItem(0), const Offset(-100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(0), findsNothing); + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + + // Snap to the next item. Show item 2, 3 and 4. + await tester.fling(getItem(1), 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); + expect(getItem(5), findsNothing); + + // Fling back to the previous item. Show item 1, 2 and 3. + await tester.fling(getItem(2), const Offset(100, 0), 800); + await tester.pumpAndSettle(); + + expect(getItem(1), findsOneWidget); + expect(getItem(2), findsOneWidget); + expect(getItem(3), findsOneWidget); + expect(getItem(4), findsNothing); + }); + + testWidgets('CarouselView respects scrollingDirection: Axis.vertical', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + 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 200 of the viewport. + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 800.0, 200.0)); + + // Simulate a scroll up + await tester.drag(find.byType(CarouselView), const Offset(0, -200), 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( + home: Scaffold( + body: CarouselView( + itemExtent: 200, + reverse: true, + padding: EdgeInsets.zero, + 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. + expect(rect0, const Rect.fromLTRB(600.0, 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. + expect(rect1, const Rect.fromLTRB(400.0, 0.0, 600.0, 600.0)); + + expect(getItem(2), findsOneWidget); + final Rect rect2 = tester.getRect(getItem(2)); + // Item 2 should be placed before item 1. + expect(rect2, const Rect.fromLTRB(200.0, 0.0, 400.0, 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, 200.0, 600.0)); + }); + + testWidgets('CarouselView respects shrinkExtent', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CarouselView( + itemExtent: 350, + shrinkExtent: 300, + children: List.generate(10, (int index) { + return Center( + child: Text('Item $index'), + ); + }), + ), + ), + ) + ); + await tester.pumpAndSettle(); + + final Rect rect0 = tester.getRect(getItem(0)); + expect(rect0, const Rect.fromLTRB(0.0, 0.0, 350.0, 600.0)); + + final Rect rect1 = tester.getRect(getItem(1)); + expect(rect1, const Rect.fromLTRB(350.0, 0.0, 700.0, 600.0)); + + final Rect rect2 = tester.getRect(getItem(2)); + // The extent of item 2 is 300, and only 100 is on screen. + expect(rect2, const Rect.fromLTRB(700.0, 0.0, 1000.0, 600.0)); + + await tester.drag(find.byType(CarouselView), const Offset(-50, 0), kind: PointerDeviceKind.trackpad); + await tester.pump(); + // The item 0 should be pinned and has a size change from 350 to 50. + expect(tester.getRect(getItem(0)), const Rect.fromLTRB(0.0, 0.0, 300.0, 600.0)); + // Keep dragging to left, extent of item 0 won't change (still 300) and part of item 0 will + // be off screen. + await tester.drag(find.byType(CarouselView), const Offset(-50, 0), kind: PointerDeviceKind.trackpad); + await tester.pump(); + expect(tester.getRect(getItem(0)), const Rect.fromLTRB(-50, 0.0, 250, 600)); + }); +} + +Finder getItem(int index) { + return find.descendant(of: find.byType(CarouselView), matching: find.ancestor(of: find.text('Item $index'), matching: find.byType(Padding))); +} + +Future hoverPointerOverCarouselItem(WidgetTester tester, Key key) async { + final Offset center = tester.getCenter(find.byKey(key)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + + // On hovered. + await gesture.addPointer(); + await gesture.moveTo(center); + return gesture; +}