diff --git a/example/lib/src/storybook/stories/carousel.dart b/example/lib/src/storybook/stories/carousel.dart new file mode 100644 index 00000000..25077644 --- /dev/null +++ b/example/lib/src/storybook/stories/carousel.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class CarouselStory extends Story { + CarouselStory() + : super( + name: "Carousel", + builder: (context) { + final itemExtentKnob = context.knobs.nullable.sliderInt( + label: "itemExtent", + description: "MoonCarousel item extent.", + enabled: false, + initial: 120, + max: MediaQuery.of(context).size.width.round(), + ); + + final gapKnob = context.knobs.nullable.sliderInt( + label: "gap", + description: "The gap between MoonCarousel items.", + enabled: false, + initial: 0, + max: 64, + ); + + final anchorKnob = context.knobs.nullable.slider( + label: "anchor", + description: "MoonCarousel anchor placement.", + enabled: false, + initial: 0, + ); + + final velocityFactorKnob = context.knobs.nullable.slider( + label: "velocityFactor", + description: "The velocity factor for MoonCarousel.", + enabled: false, + min: 0.1, + initial: 0.5, + ); + + final autoPlayKnob = context.knobs.boolean( + label: "autoPlay", + description: "Whether the MoonCarousel is auto playing.", + ); + + final isCenteredKnob = context.knobs.boolean( + label: "isCentered", + description: "Whether the MoonCarousel items are centered.", + initial: true, + ); + + final isLoopedKnob = context.knobs.boolean( + label: "loop", + description: "Whether the MoonCarousel is looped or not (infinite scroll).", + ); + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 64), + SizedBox( + height: 120, + child: OverflowBox( + maxWidth: MediaQuery.of(context).size.width, + child: MoonCarousel( + velocityFactor: velocityFactorKnob ?? 0.5, + gap: gapKnob?.toDouble() ?? 8, + //controller: carouselController, + autoPlay: autoPlayKnob, + itemCount: 10, + itemExtent: itemExtentKnob?.toDouble() ?? 120, + isCentered: isCenteredKnob, + anchor: anchorKnob ?? 0, + loop: isLoopedKnob, + itemBuilder: (context, itemIndex, realIndex) => Container( + decoration: ShapeDecoration( + color: context.moonTheme?.colors.gohan, + shape: MoonSquircleBorder( + borderRadius: BorderRadius.circular(12).squircleBorderRadius(context), + ), + ), + child: Center( + child: Text(itemIndex.toString()), + ), + ), + ), + ), + ), + const SizedBox(height: 64), + ], + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index c5a07cad..97ea3d6e 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -5,6 +5,7 @@ import 'package:example/src/storybook/stories/authcode.dart'; import 'package:example/src/storybook/stories/avatar.dart'; import 'package:example/src/storybook/stories/bottom_sheet.dart'; import 'package:example/src/storybook/stories/button.dart'; +import 'package:example/src/storybook/stories/carousel.dart'; import 'package:example/src/storybook/stories/checkbox.dart'; import 'package:example/src/storybook/stories/chip.dart'; import 'package:example/src/storybook/stories/circular_loader.dart'; @@ -88,6 +89,7 @@ class StorybookPage extends StatelessWidget { AvatarStory(), BottomSheetStory(), ButtonStory(), + CarouselStory(), CheckboxStory(), ChipStory(), CircularLoaderStory(), diff --git a/lib/moon_design.dart b/lib/moon_design.dart index 09267b0d..e773b22e 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -8,6 +8,7 @@ export 'package:moon_design/src/theme/avatar/avatar_theme.dart'; export 'package:moon_design/src/theme/borders.dart'; export 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_theme.dart'; export 'package:moon_design/src/theme/button/button_theme.dart'; +export 'package:moon_design/src/theme/carousel/carousel_theme.dart'; export 'package:moon_design/src/theme/checkbox/checkbox_theme.dart'; export 'package:moon_design/src/theme/chip/chip_theme.dart'; export 'package:moon_design/src/theme/colors.dart'; @@ -56,6 +57,7 @@ export 'package:moon_design/src/widgets/buttons/button.dart'; export 'package:moon_design/src/widgets/buttons/filled_button.dart'; export 'package:moon_design/src/widgets/buttons/outlined_button.dart'; export 'package:moon_design/src/widgets/buttons/text_button.dart'; +export 'package:moon_design/src/widgets/carousel/carousel.dart'; export 'package:moon_design/src/widgets/checkbox/checkbox.dart'; export 'package:moon_design/src/widgets/chips/chip.dart'; export 'package:moon_design/src/widgets/common/animated_icon_theme.dart'; diff --git a/lib/src/theme/carousel/carousel_colors.dart b/lib/src/theme/carousel/carousel_colors.dart new file mode 100644 index 00000000..c74fd0f1 --- /dev/null +++ b/lib/src/theme/carousel/carousel_colors.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/src/theme/icons/icon_theme.dart'; +import 'package:moon_design/src/theme/typography/typography.dart'; +import 'package:moon_design/src/utils/color_premul_lerp.dart'; + +@immutable +class MoonCarouselColors extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonCarouselColors( + textColor: MoonTypography.light.colors.bodyPrimary, + iconColor: MoonIconTheme.light.colors.primaryColor, + ); + + static final dark = MoonCarouselColors( + textColor: MoonTypography.dark.colors.bodyPrimary, + iconColor: MoonIconTheme.dark.colors.primaryColor, + ); + + /// Default text color of Carousel items. + final Color textColor; + + /// Carousel item icon color. + final Color iconColor; + + const MoonCarouselColors({ + required this.textColor, + required this.iconColor, + }); + + @override + MoonCarouselColors copyWith({ + Color? textColor, + Color? iconColor, + }) { + return MoonCarouselColors( + textColor: textColor ?? this.textColor, + iconColor: iconColor ?? this.iconColor, + ); + } + + @override + MoonCarouselColors lerp(ThemeExtension? other, double t) { + if (other is! MoonCarouselColors) return this; + + return MoonCarouselColors( + textColor: colorPremulLerp(textColor, other.textColor, t)!, + iconColor: colorPremulLerp(iconColor, other.iconColor, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonCarouselColors")) + ..add(ColorProperty("textColor", textColor)) + ..add(ColorProperty("iconColor", iconColor)); + } +} diff --git a/lib/src/theme/carousel/carousel_properties.dart b/lib/src/theme/carousel/carousel_properties.dart new file mode 100644 index 00000000..0081b37e --- /dev/null +++ b/lib/src/theme/carousel/carousel_properties.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/sizes.dart'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; + +@immutable +class MoonCarouselProperties extends ThemeExtension with DiagnosticableTreeMixin { + static final properties = MoonCarouselProperties( + gap: MoonSizes.sizes.x2s, + textStyle: MoonTextStyles.body.textDefault, + autoPlayDelay: const Duration(seconds: 3), + transitionDuration: const Duration(milliseconds: 800), + transitionCurve: Curves.fastOutSlowIn, + ); + + /// Gap between Carousel items. + final double gap; + + /// Carousel item text style. + final TextStyle textStyle; + + /// Carousel auto play delay between items. + final Duration autoPlayDelay; + + /// Carousel transition duration. + final Duration transitionDuration; + + /// Carousel transition curve. + final Curve transitionCurve; + + const MoonCarouselProperties({ + required this.gap, + required this.textStyle, + required this.autoPlayDelay, + required this.transitionDuration, + required this.transitionCurve, + }); + + @override + MoonCarouselProperties copyWith({ + double? gap, + TextStyle? textStyle, + Duration? autoPlayDelay, + Duration? transitionDuration, + Curve? transitionCurve, + }) { + return MoonCarouselProperties( + gap: gap ?? this.gap, + textStyle: textStyle ?? this.textStyle, + autoPlayDelay: autoPlayDelay ?? this.autoPlayDelay, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + ); + } + + @override + MoonCarouselProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonCarouselProperties) return this; + + return MoonCarouselProperties( + gap: lerpDouble(gap, other.gap, t)!, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + autoPlayDelay: lerpDuration(autoPlayDelay, other.autoPlayDelay, t), + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonCarouselProperties")) + ..add(DoubleProperty("gap", gap)) + ..add(DiagnosticsProperty("textStyle", textStyle)) + ..add(DiagnosticsProperty("autoPlayDelay", autoPlayDelay)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)); + } +} diff --git a/lib/src/theme/carousel/carousel_theme.dart b/lib/src/theme/carousel/carousel_theme.dart new file mode 100644 index 00000000..ef0cf4a9 --- /dev/null +++ b/lib/src/theme/carousel/carousel_theme.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/carousel/carousel_colors.dart'; +import 'package:moon_design/src/theme/carousel/carousel_properties.dart'; + +@immutable +class MoonCarouselTheme extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonCarouselTheme( + colors: MoonCarouselColors.light, + properties: MoonCarouselProperties.properties, + ); + + static final dark = MoonCarouselTheme( + colors: MoonCarouselColors.dark, + properties: MoonCarouselProperties.properties, + ); + + /// Carousel colors. + final MoonCarouselColors colors; + + /// Carousel properties. + final MoonCarouselProperties properties; + + const MoonCarouselTheme({ + required this.colors, + required this.properties, + }); + + @override + MoonCarouselTheme copyWith({ + MoonCarouselColors? colors, + MoonCarouselProperties? properties, + }) { + return MoonCarouselTheme( + colors: colors ?? this.colors, + properties: properties ?? this.properties, + ); + } + + @override + MoonCarouselTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonCarouselTheme) return this; + + return MoonCarouselTheme( + properties: properties.lerp(other.properties, t), + colors: colors.lerp(other.colors, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonCarouselTheme")) + ..add(DiagnosticsProperty("colors", colors)) + ..add(DiagnosticsProperty("properties", properties)); + } +} diff --git a/lib/src/theme/opacity.dart b/lib/src/theme/opacity.dart index 2d277107..620d5ce0 100644 --- a/lib/src/theme/opacity.dart +++ b/lib/src/theme/opacity.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; @immutable class MoonOpacity extends ThemeExtension with DiagnosticableTreeMixin { - static const opacities = MoonOpacity(disabled: 0.32); + static const opacities = MoonOpacity(disabled: 0.6); /// Disabled opacity value. final double disabled; diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index f531a3ef..70f51809 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -8,6 +8,7 @@ import 'package:moon_design/src/theme/avatar/avatar_theme.dart'; import 'package:moon_design/src/theme/borders.dart'; import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_theme.dart'; import 'package:moon_design/src/theme/button/button_theme.dart'; +import 'package:moon_design/src/theme/carousel/carousel_theme.dart'; import 'package:moon_design/src/theme/checkbox/checkbox_theme.dart'; import 'package:moon_design/src/theme/chip/chip_theme.dart'; import 'package:moon_design/src/theme/colors.dart'; @@ -44,6 +45,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { borders: MoonBorders.borders, bottomSheetTheme: MoonBottomSheetTheme.light, buttonTheme: MoonButtonTheme.light, + carouselTheme: MoonCarouselTheme.light, checkboxTheme: MoonCheckboxTheme.light, chipTheme: MoonChipTheme.light, circularLoaderTheme: MoonCircularLoaderTheme.light, @@ -79,6 +81,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { borders: MoonBorders.borders, bottomSheetTheme: MoonBottomSheetTheme.dark, buttonTheme: MoonButtonTheme.dark, + carouselTheme: MoonCarouselTheme.dark, checkboxTheme: MoonCheckboxTheme.dark, chipTheme: MoonChipTheme.dark, circularLoaderTheme: MoonCircularLoaderTheme.dark, @@ -127,6 +130,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System MoonButton widgets theming. final MoonButtonTheme buttonTheme; + /// Moon Design System MoonCarousel widget theming. + final MoonCarouselTheme carouselTheme; + /// Moon Design System MoonCheckbox widget theming. final MoonCheckboxTheme checkboxTheme; @@ -210,6 +216,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { required this.borders, required this.bottomSheetTheme, required this.buttonTheme, + required this.carouselTheme, required this.checkboxTheme, required this.chipTheme, required this.circularLoaderTheme, @@ -246,6 +253,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonBorders? borders, MoonBottomSheetTheme? bottomSheetTheme, MoonButtonTheme? buttonTheme, + MoonCarouselTheme? carouselTheme, MoonCheckboxTheme? checkboxTheme, MoonChipTheme? chipTheme, MoonCircularLoaderTheme? circularLoaderTheme, @@ -280,6 +288,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { borders: borders ?? this.borders, bottomSheetTheme: bottomSheetTheme ?? this.bottomSheetTheme, buttonTheme: buttonTheme ?? this.buttonTheme, + carouselTheme: carouselTheme ?? this.carouselTheme, checkboxTheme: checkboxTheme ?? this.checkboxTheme, chipTheme: chipTheme ?? this.chipTheme, circularLoaderTheme: circularLoaderTheme ?? this.circularLoaderTheme, @@ -320,6 +329,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { borders: borders.lerp(other.borders, t), bottomSheetTheme: bottomSheetTheme.lerp(other.bottomSheetTheme, t), buttonTheme: buttonTheme.lerp(other.buttonTheme, t), + carouselTheme: carouselTheme.lerp(other.carouselTheme, t), checkboxTheme: checkboxTheme.lerp(other.checkboxTheme, t), chipTheme: chipTheme.lerp(other.chipTheme, t), circularLoaderTheme: circularLoaderTheme.lerp(other.circularLoaderTheme, t), @@ -359,6 +369,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonAvatarTheme", avatarTheme)) ..add(DiagnosticsProperty("MoonBorders", borders)) ..add(DiagnosticsProperty("MoonButtonTheme", buttonTheme)) + ..add(DiagnosticsProperty("MoonCarouselTheme", carouselTheme)) ..add(DiagnosticsProperty("MoonCheckboxTheme", checkboxTheme)) ..add(DiagnosticsProperty("MoonChipTheme", chipTheme)) ..add(DiagnosticsProperty("MoonCircularLoaderTheme", circularLoaderTheme)) diff --git a/lib/src/theme/typography/text_styles.dart b/lib/src/theme/typography/text_styles.dart index c7ff90ba..9bc694a6 100644 --- a/lib/src/theme/typography/text_styles.dart +++ b/lib/src/theme/typography/text_styles.dart @@ -42,6 +42,9 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT text32: TextStyle( fontSize: 32, ), + text40: TextStyle( + fontSize: 40, + ), text48: TextStyle( fontSize: 48, ), @@ -105,6 +108,10 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT fontSize: 32, fontWeight: _semiBold, ), + text40: TextStyle( + fontSize: 40, + fontWeight: _semiBold, + ), text48: TextStyle( fontSize: 48, fontWeight: _semiBold, @@ -196,6 +203,12 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT letterSpacing: 1, fontWeight: _semiBold, ), + text40: TextStyle( + fontSize: 40, + height: 1.2, + letterSpacing: 1, + fontWeight: _semiBold, + ), text48: TextStyle( fontSize: 48, height: 1.2, @@ -258,6 +271,9 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT /// Text size 32. final TextStyle text32; + /// Text size 40. + final TextStyle text40; + /// Text size 48. final TextStyle text48; @@ -283,6 +299,7 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT required this.text20, required this.text24, required this.text32, + required this.text40, required this.text48, required this.text56, required this.text64, @@ -303,6 +320,7 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT TextStyle? text20, TextStyle? text24, TextStyle? text32, + TextStyle? text40, TextStyle? text48, TextStyle? text56, TextStyle? text64, @@ -321,6 +339,7 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT text20: text20 ?? this.text20, text24: text24 ?? this.text24, text32: text32 ?? this.text32, + text40: text40 ?? this.text40, text48: text48 ?? this.text48, text56: text56 ?? this.text56, text64: text64 ?? this.text64, @@ -345,6 +364,7 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT text20: TextStyle.lerp(text20, other.text20, t)!, text24: TextStyle.lerp(text24, other.text24, t)!, text32: TextStyle.lerp(text32, other.text32, t)!, + text40: TextStyle.lerp(text40, other.text40, t)!, text48: TextStyle.lerp(text48, other.text48, t)!, text56: TextStyle.lerp(text56, other.text56, t)!, text64: TextStyle.lerp(text64, other.text64, t)!, @@ -369,6 +389,7 @@ class MoonTextStyles extends ThemeExtension with DiagnosticableT ..add(DiagnosticsProperty("text20", text20)) ..add(DiagnosticsProperty("text24", text24)) ..add(DiagnosticsProperty("text32", text32)) + ..add(DiagnosticsProperty("text40", text40)) ..add(DiagnosticsProperty("text48", text48)) ..add(DiagnosticsProperty("text56", text56)) ..add(DiagnosticsProperty("text64", text64)) diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 97afa114..8012bdbf 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -5,7 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:moon_design/src/utils/squircle/squircle_border_radius.dart'; import 'package:moon_design/src/utils/squircle/squircle_radius.dart'; -extension DarkModeX on BuildContext { +extension BuildContextX on BuildContext { /// Is dark mode currently active. bool get isDarkMode { final brightness = MediaQuery.of(this).platformBrightness; @@ -13,7 +13,7 @@ extension DarkModeX on BuildContext { } } -extension BorderRadiusX on BorderRadiusGeometry { +extension BorderRadiusGeometryX on BorderRadiusGeometry { /// Returns MoonSquircleBorderRadius. MoonSquircleBorderRadius squircleBorderRadius(BuildContext context) { final borderRadius = resolve(Directionality.of(context)); diff --git a/lib/src/widgets/carousel/carousel.dart b/lib/src/widgets/carousel/carousel.dart new file mode 100644 index 00000000..e301e388 --- /dev/null +++ b/lib/src/widgets/carousel/carousel.dart @@ -0,0 +1,739 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'package:flutter/rendering.dart'; + +import 'package:moon_design/src/theme/icons/icon_theme.dart'; +import 'package:moon_design/src/theme/sizes.dart'; +import 'package:moon_design/src/theme/theme.dart'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; +import 'package:moon_design/src/theme/typography/typography.dart'; + +class MoonCarousel extends StatefulWidget { + /// Axis direction of the carousel. Defaults to `Axis.horizontal`. + final Axis axisDirection; + + /// Whether to automatically scroll the carousel. Defaults to `false`. + final bool autoPlay; + + /// Align selected item to the center of the viewport. When this is true, anchor property is ignored. + final bool isCentered; + + /// Whether to defer the calculation of `maxExtent`. NOTE: This makes the carousel behave like [ListView] in terms of + /// how the `maxExtent` is calculated and makes the last item(s) unreachable for the purposes of [onIndexChanged] + /// callback. + /// + /// Defaults to `false`. + final bool deferMaxExtent; + + /// Whether to create an infinite looping list. Defaults to `true`. + final bool loop; + + /// Where to place selected item in the viewport. Ranges from 0 to 1. + /// + /// 0.0 means selected item is aligned to start of the viewport, and + /// 1.0 meaning selected item is aligned to end of the viewport. + /// Defaults to 0.0. + /// + /// This property is ignored when isCentered is set to true. + final double anchor; + + /// Gap between items in the viewport. + final double? gap; + + /// Maximum width for single item in the viewport. + final double itemExtent; + + /// Multiply velocity of carousel scrolling by this factor. Defaults to `0.5`. + final double velocityFactor; + + /// Carousel auto play delay between items. + final Duration? autoPlayDelay; + + /// Carousel transition duration (auto play duration). + final Duration? transitionDuration; + + /// Accordion transition curve (auto play curve). + final Curve? transitionCurve; + + /// Total items to build for the carousel. + final int itemCount; + + /// Scroll controller for [MoonCarousel]. + final ScrollController? controller; + + /// Physics for [MoonCarousel]. Defaults to [MoonCarouselScrollPhysics], which makes sure we always land on a + /// particular item after scrolling. + final ScrollPhysics? physics; + + /// Scroll behavior for [MoonCarousel]. + final ScrollBehavior? scrollBehavior; + + /// Callback which is fired when item index has changed. + final void Function(int)? onIndexChanged; + + /// For lazily building items in the viewport. + /// + /// When `loop: false`, `itemIndex` is equal to `realIndex` (i.e, index of element). + /// + /// When `loop: true`, two indexes are exposed by the `itemBuilder`. + /// + /// First one is the `itemIndex`, that is the modded item index, i.e. for list of 10, position(11) = 1, and + /// position(-1) = 9. + /// + /// Second one is the `realIndex`, that is the actual index, i.e. [..., -2, -1, 0, 1, 2, ...] in loop. + /// `realIndex` is necessary to support `jumpToItem` by tapping on it. + final Widget Function(BuildContext context, int itemIndex, int realIndex) itemBuilder; + + /// MDS Carousel widget. + const MoonCarousel({ + super.key, + this.axisDirection = Axis.horizontal, + this.autoPlay = false, + this.isCentered = true, + this.deferMaxExtent = false, + this.loop = false, + this.anchor = 0.0, + this.gap, + required this.itemExtent, + this.velocityFactor = 0.5, + this.autoPlayDelay, + this.transitionDuration, + this.transitionCurve, + required this.itemCount, + this.controller, + this.physics, + this.scrollBehavior, + this.onIndexChanged, + required this.itemBuilder, + }) : assert(itemExtent > 0), + assert(itemCount > 0), + assert(velocityFactor > 0.0 && velocityFactor <= 1.0); + + @override + State createState() => _MoonCarouselState(); +} + +class _MoonCarouselState extends State { + final Key _forwardListKey = const ValueKey("moon_carousel_key"); + + late int _lastReportedItemIndex; + late MoonCarouselScrollController _scrollController; + + // Get the anchor for the viewport to place the item at the isCentered. + double _getCenteredAnchor(BoxConstraints constraints) { + if (!widget.isCentered) return widget.anchor; + + final maxExtent = widget.axisDirection == Axis.horizontal ? constraints.maxWidth : constraints.maxHeight; + + return ((maxExtent / 2) - (widget.itemExtent / 2)) / maxExtent; + } + + AxisDirection _getDirection(BuildContext context) { + switch (widget.axisDirection) { + case Axis.horizontal: + assert(debugCheckHasDirectionality(context)); + + final TextDirection textDirection = Directionality.of(context); + final AxisDirection axisDirection = textDirectionToAxisDirection(textDirection); + + return axisDirection; + + case Axis.vertical: + return AxisDirection.down; + } + } + + @override + void initState() { + super.initState(); + + _scrollController = (widget.controller as MoonCarouselScrollController?) ?? MoonCarouselScrollController(); + + _lastReportedItemIndex = _scrollController.initialItem; + + if (widget.autoPlay) { + WidgetsBinding.instance.addPostFrameCallback((_) { + final Duration effectiveAutoPlayDelay = widget.autoPlayDelay ?? + context.moonTheme?.carouselTheme.properties.autoPlayDelay ?? + const Duration(seconds: 3); + + final Duration effectiveTransitionDuration = widget.transitionDuration ?? + context.moonTheme?.carouselTheme.properties.transitionDuration ?? + const Duration(milliseconds: 800); + + final Curve effectiveTransitionCurve = widget.transitionCurve ?? + context.moonTheme?.carouselTheme.properties.transitionCurve ?? + Curves.fastOutSlowIn; + + _scrollController.startAutoPlay( + delay: effectiveAutoPlayDelay, + duration: effectiveTransitionDuration, + curve: effectiveTransitionCurve, + ); + }); + } + } + + @override + void didUpdateWidget(MoonCarousel oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.autoPlay != oldWidget.autoPlay) { + if (widget.autoPlay) { + final Duration effectiveAutoPlayDelay = widget.autoPlayDelay ?? + context.moonTheme?.carouselTheme.properties.autoPlayDelay ?? + const Duration(seconds: 3); + + final Duration effectiveTransitionDuration = widget.transitionDuration ?? + context.moonTheme?.carouselTheme.properties.transitionDuration ?? + const Duration(milliseconds: 800); + + final Curve effectiveTransitionCurve = widget.transitionCurve ?? + context.moonTheme?.carouselTheme.properties.transitionCurve ?? + Curves.fastOutSlowIn; + + _scrollController.startAutoPlay( + delay: effectiveAutoPlayDelay, + duration: effectiveTransitionDuration, + curve: effectiveTransitionCurve, + ); + } else { + _scrollController.stopAutoplay(); + } + } + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + List _buildSlivers(BuildContext context) { + final double effectiveGap = widget.gap ?? context.moonTheme?.carouselTheme.properties.gap ?? MoonSizes.sizes.x2s; + + /// Delegate for lazily building items in the forward direction. + final SliverChildDelegate childDelegate = SliverChildBuilderDelegate( + (context, index) => Padding( + padding: EdgeInsets.symmetric(horizontal: effectiveGap / 2), + child: widget.itemBuilder(context, index.abs() % widget.itemCount, index), + ), + childCount: widget.loop ? null : widget.itemCount, + ); + + /// Delegate for lazily building items in the reverse direction. + final SliverChildDelegate? reversedChildDelegate = widget.loop + ? SliverChildBuilderDelegate( + (context, index) => Padding( + padding: EdgeInsets.symmetric(horizontal: effectiveGap / 2), + child: widget.itemBuilder(context, widget.itemCount - (index.abs() % widget.itemCount) - 1, -(index + 1)), + ), + ) + : null; + + final Widget forward = + SliverFixedExtentList(key: _forwardListKey, delegate: childDelegate, itemExtent: widget.itemExtent); + + if (!widget.loop) return [forward]; + + final Widget reversed = SliverFixedExtentList(delegate: reversedChildDelegate!, itemExtent: widget.itemExtent); + + return [reversed, forward]; + } + + @override + Widget build(BuildContext context) { + final Color effectiveTextColor = + context.moonTheme?.carouselTheme.colors.textColor ?? MoonTypography.light.colors.bodyPrimary; + + final Color effectiveIconColor = + context.moonTheme?.carouselTheme.colors.iconColor ?? MoonIconTheme.light.colors.primaryColor; + + final TextStyle effectiveTextStyle = + context.moonTheme?.carouselTheme.properties.textStyle ?? MoonTextStyles.body.textDefault; + + final AxisDirection axisDirection = _getDirection(context); + + final ScrollBehavior effectiveScrollBehavior = widget.scrollBehavior ?? + (kIsWeb + ? ScrollConfiguration.of(context).copyWith( + scrollbars: false, + dragDevices: { + PointerDeviceKind.touch, + PointerDeviceKind.mouse, + }, + ) + : ScrollConfiguration.of(context).copyWith(scrollbars: false)); + + return NotificationListener( + onNotification: (ScrollUpdateNotification notification) { + final MoonCarouselExtentMetrics metrics = notification.metrics as MoonCarouselExtentMetrics; + final int currentItem = metrics.itemIndex; + + if (currentItem != _lastReportedItemIndex) { + _lastReportedItemIndex = currentItem; + final int trueIndex = _getTrueIndex(_lastReportedItemIndex, widget.itemCount); + + if (widget.onIndexChanged != null) { + // ignore: prefer_null_aware_method_calls + widget.onIndexChanged!(trueIndex); + } + } + + return false; + }, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final centeredAnchor = _getCenteredAnchor(constraints); + + return IconTheme( + data: IconThemeData( + color: effectiveIconColor, + ), + child: DefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + child: _MoonCarouselScrollable( + axisDirection: axisDirection, + controller: _scrollController, + deferMaxExtent: widget.deferMaxExtent, + itemCount: widget.itemCount, + itemExtent: widget.itemExtent, + loop: widget.loop, + physics: widget.physics ?? const MoonCarouselScrollPhysics(), + scrollBehavior: effectiveScrollBehavior, + velocityFactor: widget.velocityFactor, + viewportBuilder: (BuildContext context, ViewportOffset position) { + return Viewport( + anchor: centeredAnchor, + axisDirection: axisDirection, + center: _forwardListKey, + offset: position, + slivers: _buildSlivers(context), + ); + }, + ), + ), + ); + }, + ), + ); + } +} + +/// Extends Scrollable to also include viewport children's itemExtent, itemCount, loop and other values. +/// This is done so that the ScrollPosition and Physics can also access these values via scroll context. +class _MoonCarouselScrollable extends Scrollable { + final bool deferMaxExtent; + final bool loop; + final double itemExtent; + final double velocityFactor; + final int itemCount; + + const _MoonCarouselScrollable({ + super.axisDirection = AxisDirection.right, + super.controller, + super.physics, + super.scrollBehavior, + required super.viewportBuilder, + required this.deferMaxExtent, + required this.loop, + required this.itemExtent, + required this.velocityFactor, + required this.itemCount, + }); + + @override + _MoonCarouselScrollableState createState() => _MoonCarouselScrollableState(); +} + +class _MoonCarouselScrollableState extends ScrollableState { + bool get deferMaxExtent => (widget as _MoonCarouselScrollable).deferMaxExtent; + bool get loop => (widget as _MoonCarouselScrollable).loop; + double get itemExtent => (widget as _MoonCarouselScrollable).itemExtent; + double get velocityFactor => (widget as _MoonCarouselScrollable).velocityFactor; + int get itemCount => (widget as _MoonCarouselScrollable).itemCount; +} + +/// Scroll controller for [MoonCarousel]. +class MoonCarouselScrollController extends ScrollController { + /// Initial item index for [MoonCarouselScrollController]. Defaults to `0`. + final int initialItem; + + /// Scroll controller for [MoonCarousel]. + MoonCarouselScrollController({this.initialItem = 0}); + + // Timer for autoplay. + Timer? _autoplayTimer; + + void startAutoPlay({ + Duration delay = const Duration(seconds: 3), + Duration? duration, + Curve? curve, + }) { + _autoplayTimer?.cancel(); + + _autoplayTimer = Timer.periodic(delay, (timer) { + // If at end of carousel, animate back to the beginning. + if (offset >= position.maxScrollExtent && !position.outOfRange) { + animateToItem(0, duration: duration, curve: curve); + } else { + nextItem(duration: duration, curve: curve); + } + }); + } + + void stopAutoplay() { + _autoplayTimer?.cancel(); + } + + @override + void dispose() { + stopAutoplay(); + super.dispose(); + } + + /// Returns the selected item's index. If `loop: true`, then it returns the modded index value. + int get selectedItem => _getTrueIndex( + (position as _MoonCarouselScrollPosition).itemIndex, + (position as _MoonCarouselScrollPosition).itemCount, + ); + + /// Animate to specific item index. + Future animateToItem( + int itemIndex, { + Duration? duration, + Curve? curve, + }) async { + if (!hasClients) return; + + await Future.wait([ + for (final position in positions.cast<_MoonCarouselScrollPosition>()) + position.animateTo( + itemIndex * position.itemExtent, + duration: duration ?? const Duration(milliseconds: 800), + curve: curve ?? Curves.fastOutSlowIn, + ), + ]); + } + + /// Jump to specific item index. + void jumpToItem(int itemIndex) { + for (final position in positions.cast<_MoonCarouselScrollPosition>()) { + position.jumpTo(itemIndex * position.itemExtent); + } + } + + /// Animate to the next item in the viewport. + Future nextItem({ + Duration? duration, + Curve? curve, + }) async { + if (!hasClients) return; + + await Future.wait([ + for (final position in positions.cast<_MoonCarouselScrollPosition>()) + position.animateTo( + offset + position.itemExtent, + duration: duration ?? const Duration(milliseconds: 800), + curve: curve ?? Curves.fastOutSlowIn, + ), + ]); + } + + /// Animate to the previous item in the viewport. + Future previousItem({required BuildContext context, Duration? duration, Curve? curve}) async { + if (!hasClients) return; + + final Duration effectiveTransitionDuration = + duration ?? context.moonTheme?.carouselTheme.properties.transitionDuration ?? const Duration(milliseconds: 200); + + final Curve effectiveTransitionCurve = + curve ?? context.moonTheme?.carouselTheme.properties.transitionCurve ?? Curves.easeInOutCubic; + + await Future.wait([ + for (final position in positions.cast<_MoonCarouselScrollPosition>()) + position.animateTo( + offset - position.itemExtent, + duration: effectiveTransitionDuration, + curve: effectiveTransitionCurve, + ), + ]); + } + + @override + ScrollPosition createScrollPosition(ScrollPhysics physics, ScrollContext context, ScrollPosition? oldPosition) { + return _MoonCarouselScrollPosition( + context: context, + initialItem: initialItem, + oldPosition: oldPosition, + physics: physics, + ); + } +} + +/// Metrics for the [MoonCarouselScrollController]. +class MoonCarouselExtentMetrics extends FixedScrollMetrics { + /// The scroll view's currently selected item index. + final int itemIndex; + + /// This is an immutable snapshot of the current values of scroll positions. This can directly be accessed by + /// [ScrollNotification] to get currently selected real item index at any time. + MoonCarouselExtentMetrics({ + required super.axisDirection, + required super.maxScrollExtent, + required super.minScrollExtent, + required super.pixels, + required super.viewportDimension, + //TODO: uncomment when 3.10: required double devicePixelRatio, + required this.itemIndex, + }); + + @override + MoonCarouselExtentMetrics copyWith({ + AxisDirection? axisDirection, + //TODO: uncomment when 3.10: double? devicePixelRatio, + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + int? itemIndex, + }) { + return MoonCarouselExtentMetrics( + axisDirection: axisDirection ?? this.axisDirection, + //TODO: uncomment when 3.10: devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : 0.0), + maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent, + pixels: pixels ?? this.pixels, + viewportDimension: viewportDimension ?? this.viewportDimension, + itemIndex: itemIndex ?? this.itemIndex, + ); + } +} + +int _getItemFromOffset({ + required double itemExtent, + required double minScrollExtent, + required double maxScrollExtent, + required double offset, +}) { + return (_clipOffsetToScrollableRange(offset, minScrollExtent, maxScrollExtent) / itemExtent).round(); +} + +double _clipOffsetToScrollableRange(double offset, double minScrollExtent, double maxScrollExtent) { + return math.min(math.max(offset, minScrollExtent), maxScrollExtent); +} + +/// Get the modded item index from the real index. +int _getTrueIndex(int currentIndex, int totalCount) { + if (currentIndex >= 0) { + return currentIndex % totalCount; + } + + return (totalCount + (currentIndex % totalCount)) % totalCount; +} + +class _MoonCarouselScrollPosition extends ScrollPositionWithSingleContext implements MoonCarouselExtentMetrics { + _MoonCarouselScrollPosition({ + required super.physics, + required super.context, + required int initialItem, + super.oldPosition, + }) : assert(context is _MoonCarouselScrollableState), + super(initialPixels: _getItemExtentFromScrollContext(context) * initialItem); + + double get itemExtent => _getItemExtentFromScrollContext(context); + static double _getItemExtentFromScrollContext(ScrollContext context) { + return (context as _MoonCarouselScrollableState).itemExtent; + } + + int get itemCount => _getItemCountFromScrollContext(context); + static int _getItemCountFromScrollContext(ScrollContext context) { + return (context as _MoonCarouselScrollableState).itemCount; + } + + bool get deferMaxExtent => _getDeferMaxExtentFromScrollContext(context); + static bool _getDeferMaxExtentFromScrollContext(ScrollContext context) { + return (context as _MoonCarouselScrollableState).deferMaxExtent; + } + + bool get loop => _getLoopFromScrollContext(context); + static bool _getLoopFromScrollContext(ScrollContext context) { + return (context as _MoonCarouselScrollableState).loop; + } + + double get velocityFactor => _getVelocityFactorFromScrollContext(context); + static double _getVelocityFactorFromScrollContext(ScrollContext context) { + return (context as _MoonCarouselScrollableState).velocityFactor; + } + + @override + int get itemIndex { + return _getItemFromOffset( + itemExtent: itemExtent, + minScrollExtent: hasContentDimensions ? minScrollExtent : 0.0, + maxScrollExtent: maxScrollExtent, + offset: pixels, + ); + } + + @override + double get maxScrollExtent => loop /* || deferMaxExtent */ + ? (super.hasContentDimensions ? super.maxScrollExtent + 8 : 0.0) + : itemExtent * (itemCount - 1); + + @override + MoonCarouselExtentMetrics copyWith({ + AxisDirection? axisDirection, + //TODO: uncomment when 3.10: double? devicePixelRatio, + double? minScrollExtent, + double? maxScrollExtent, + double? pixels, + double? viewportDimension, + int? itemIndex, + }) { + return MoonCarouselExtentMetrics( + axisDirection: axisDirection ?? this.axisDirection, + //TODO: uncomment when 3.10: devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio, + minScrollExtent: minScrollExtent ?? (hasContentDimensions ? this.minScrollExtent : 0.0), + maxScrollExtent: maxScrollExtent ?? this.maxScrollExtent, + pixels: pixels ?? this.pixels, + viewportDimension: viewportDimension ?? this.viewportDimension, + itemIndex: itemIndex ?? this.itemIndex, + ); + } +} + +/// Physics for the [MoonCarousel]. +class MoonCarouselScrollPhysics extends ScrollPhysics { + /// Based on Flutter's [FixedExtentScrollPhysics]. Hence, it always lands on a particular item. + /// + /// If `loop: false`, friction is applied when user tries to go beyond viewport. The friction factor is calculated the + /// same way as in [BouncingScrollPhysics]. + const MoonCarouselScrollPhysics({super.parent}); + + @override + MoonCarouselScrollPhysics applyTo(ScrollPhysics? ancestor) { + return MoonCarouselScrollPhysics( + parent: buildParent(ancestor), + ); + } + + @override + double applyBoundaryConditions(ScrollMetrics position, double value) => 0.0; + + /// Increase friction for scrolling in out-of-bound areas. + double frictionFactor(double overscrollFraction) => 0.12 * math.pow(1 - overscrollFraction, 2); + + @override + double applyPhysicsToUserOffset(ScrollMetrics position, double offset) { + if (position.pixels > position.minScrollExtent && position.pixels < position.maxScrollExtent) { + return offset; + } + + final double overscrollPastStart = math.max(position.minScrollExtent - position.pixels, 0.0); + final double overscrollPastEnd = math.max(position.pixels - position.maxScrollExtent, 0.0); + final double overscrollPast = math.max(overscrollPastStart, overscrollPastEnd); + + final bool easing = (overscrollPastStart > 0.0 && offset < 0.0) || (overscrollPastEnd > 0.0 && offset > 0.0); + + final double friction = easing + // Apply less resistance when easing the overscroll vs tensioning. + ? frictionFactor((overscrollPast - offset.abs()) / position.viewportDimension) + : frictionFactor(overscrollPast / position.viewportDimension); + + final double direction = offset.sign; + + return direction * _applyFriction(overscrollPast, offset.abs(), friction); + } + + static double _applyFriction(double extentOutside, double absDelta, double gamma) { + assert(absDelta > 0); + + double total = 0.0; + + if (extentOutside > 0) { + final double deltaToLimit = extentOutside / gamma; + + if (absDelta < deltaToLimit) return absDelta * gamma; + + total += extentOutside; + // ignore: parameter_assignments + absDelta -= deltaToLimit; + } + + return total + absDelta; + } + + @override + Simulation? createBallisticSimulation(ScrollMetrics position, double velocity) { + final _MoonCarouselScrollPosition metrics = position as _MoonCarouselScrollPosition; + + // Scenario 1: + // If we're out of range and not headed back in range, defer to the parent ballistics, which should put us back in + // range at the scrollable's boundary. + if ((velocity <= 0.0 && metrics.pixels <= metrics.minScrollExtent) || + (velocity >= 0.0 && metrics.pixels >= metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // Create a test simulation to see where it would have ballistically fallen naturally without settling onto items. + final Simulation? testFrictionSimulation = super.createBallisticSimulation( + metrics, + velocity * math.min(metrics.velocityFactor + 0.15, 1.0), + ); + + // Scenario 2: + // If it was going to end up past the scroll extent, defer back to the parent physics' ballistics again which should + // put us on the scrollable's boundary. + if (testFrictionSimulation != null && + (testFrictionSimulation.x(double.infinity) == metrics.minScrollExtent || + testFrictionSimulation.x(double.infinity) == metrics.maxScrollExtent)) { + return super.createBallisticSimulation(metrics, velocity); + } + + // From the natural final position, find the nearest item it should have settled to. + final int settlingItemIndex = _getItemFromOffset( + itemExtent: metrics.itemExtent, + minScrollExtent: metrics.minScrollExtent, + maxScrollExtent: metrics.maxScrollExtent, + offset: testFrictionSimulation?.x(double.infinity) ?? metrics.pixels, + ); + + final double settlingPixels = settlingItemIndex * metrics.itemExtent; + + // Scenario 3: + // If there's no velocity and we're already at where we intend to land, do nothing. + //TODO: uncomment when 3.10: final tolerance = toleranceFor(metrics); + if (velocity.abs() < tolerance.velocity && (settlingPixels - metrics.pixels).abs() < tolerance.distance) { + return null; + } + + // Scenario 4: + // If we're going to end back at the same item because initial velocity is too low to break past it, use a spring + // simulation to get back. + if (settlingItemIndex == metrics.itemIndex) { + return SpringSimulation( + spring, + metrics.pixels, + settlingPixels, + velocity * metrics.velocityFactor, + tolerance: tolerance, + ); + } + + // Scenario 5: + // Create a new friction simulation except the drag will be tweaked to land exactly on the item closest to the + // natural stopping point. + return FrictionSimulation.through( + metrics.pixels, + settlingPixels, + velocity * metrics.velocityFactor, + tolerance.velocity * metrics.velocityFactor * velocity.sign, + ); + } +}