From d0ec518815280b54f375c87cae52737ebe620f95 Mon Sep 17 00:00:00 2001 From: Harry Sild <46851868+Kypsis@users.noreply.github.com> Date: Thu, 22 Jun 2023 02:51:40 +0300 Subject: [PATCH] feat: [MDS-552] Create BottomSheet widget (#198) --- .../src/storybook/stories/bottom_sheet.dart | 107 ++++ example/lib/src/storybook/storybook.dart | 2 + lib/moon_design.dart | 3 + .../bottom_sheet/bottom_sheet_colors.dart | 81 +++ .../bottom_sheet/bottom_sheet_properties.dart | 72 +++ .../bottom_sheet_size_properties.dart | 56 ++ .../bottom_sheet/bottom_sheet_sizes.dart | 71 +++ .../bottom_sheet/bottom_sheet_theme.dart | 70 +++ lib/src/theme/modal/modal_theme.dart | 4 +- lib/src/theme/theme.dart | 10 + .../widgets/bottom_sheet/bottom_sheet.dart | 528 ++++++++++++++++++ .../bottom_sheet/modal_bottom_sheet.dart | 297 ++++++++++ .../utils/bottom_sheet_suspended_curve.dart | 65 +++ .../utils/scroll_to_top_status_bar.dart | 60 ++ lib/src/widgets/modal/modal.dart | 168 +++--- 15 files changed, 1506 insertions(+), 88 deletions(-) create mode 100644 example/lib/src/storybook/stories/bottom_sheet.dart create mode 100644 lib/src/theme/bottom_sheet/bottom_sheet_colors.dart create mode 100644 lib/src/theme/bottom_sheet/bottom_sheet_properties.dart create mode 100644 lib/src/theme/bottom_sheet/bottom_sheet_size_properties.dart create mode 100644 lib/src/theme/bottom_sheet/bottom_sheet_sizes.dart create mode 100644 lib/src/theme/bottom_sheet/bottom_sheet_theme.dart create mode 100644 lib/src/widgets/bottom_sheet/bottom_sheet.dart create mode 100644 lib/src/widgets/bottom_sheet/modal_bottom_sheet.dart create mode 100644 lib/src/widgets/bottom_sheet/utils/bottom_sheet_suspended_curve.dart create mode 100644 lib/src/widgets/bottom_sheet/utils/scroll_to_top_status_bar.dart diff --git a/example/lib/src/storybook/stories/bottom_sheet.dart b/example/lib/src/storybook/stories/bottom_sheet.dart new file mode 100644 index 00000000..5ae00f48 --- /dev/null +++ b/example/lib/src/storybook/stories/bottom_sheet.dart @@ -0,0 +1,107 @@ +import 'package:example/src/storybook/common/color_options.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class BottomSheetStory extends Story { + BottomSheetStory() + : super( + name: "BottomSheet", + builder: (context) { + final backgroundColorsKnob = context.knobs.nullable.options( + label: "backgroundColor", + description: "MoonColors variants for MoonModal background.", + enabled: false, + initial: 0, // piccolo + options: colorOptions, + ); + + final backgroundColor = colorTable(context)[backgroundColorsKnob ?? 40]; + + final barrierColorsKnob = context.knobs.nullable.options( + label: "barrierColor", + description: "MoonColors variants for MoonModal barrier.", + enabled: false, + initial: 0, // piccolo + options: colorOptions, + ); + + final barrierColor = colorTable(context)[barrierColorsKnob ?? 40]; + + final borderRadiusKnob = context.knobs.nullable.sliderInt( + label: "borderRadius", + description: "Border radius for MoonModal.", + enabled: false, + initial: 8, + max: 32, + ); + + Future bottomSheetBuilder(BuildContext context) { + return showMoonModalBottomSheet( + backgroundColor: backgroundColor, + barrierColor: barrierColor, + borderRadius: borderRadiusKnob != null ? BorderRadius.circular(borderRadiusKnob.toDouble()) : null, + context: context, + enableDrag: true, + isDismissible: true, + builder: (context) => SizedBox( + height: 600, + child: Column( + children: [ + Center( + child: Container( + margin: const EdgeInsets.only( + top: 8, + bottom: 16, + ), + height: 4, + width: 41, + decoration: ShapeDecoration( + color: context.moonTheme!.colors.beerus, + shape: const StadiumBorder(), + ), + ), + ), + Expanded( + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: 100, + itemBuilder: (_, index) => Container( + color: Colors.transparent, + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Text("Item nr:"), + const Spacer(), + Text("$index"), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 64), + Builder( + builder: (context) { + return MoonFilledButton( + label: const Text("Tap me"), + onTap: () => bottomSheetBuilder(context), + ); + }, + ), + const SizedBox(height: 64), + ], + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index 98855939..3abb52c0 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -3,6 +3,7 @@ import 'package:example/src/storybook/stories/accordion.dart'; import 'package:example/src/storybook/stories/alert.dart'; 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/checkbox.dart'; import 'package:example/src/storybook/stories/chip.dart'; @@ -84,6 +85,7 @@ class StorybookPage extends StatelessWidget { AlertStory(), AuthCodeStory(), AvatarStory(), + BottomSheetStory(), ButtonStory(), CheckboxStory(), ChipStory(), diff --git a/lib/moon_design.dart b/lib/moon_design.dart index ad960d38..3bc931b4 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -6,6 +6,7 @@ export 'package:moon_design/src/theme/alert/alert_theme.dart'; export 'package:moon_design/src/theme/authcode/authcode_theme.dart'; 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/checkbox/checkbox_theme.dart'; export 'package:moon_design/src/theme/chip/chip_theme.dart'; @@ -48,6 +49,8 @@ export 'package:moon_design/src/widgets/alert/filled_alert.dart'; export 'package:moon_design/src/widgets/alert/outlined_alert.dart'; export 'package:moon_design/src/widgets/authcode/authcode.dart'; export 'package:moon_design/src/widgets/avatar/avatar.dart'; +export 'package:moon_design/src/widgets/bottom_sheet/bottom_sheet.dart'; +export 'package:moon_design/src/widgets/bottom_sheet/modal_bottom_sheet.dart'; 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'; diff --git a/lib/src/theme/bottom_sheet/bottom_sheet_colors.dart b/lib/src/theme/bottom_sheet/bottom_sheet_colors.dart new file mode 100644 index 00000000..7061ea68 --- /dev/null +++ b/lib/src/theme/bottom_sheet/bottom_sheet_colors.dart @@ -0,0 +1,81 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/colors.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 MoonBottomSheetColors extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonBottomSheetColors( + textColor: MoonTypography.light.colors.bodyPrimary, + iconColor: MoonIconTheme.light.colors.primaryColor, + backgroundColor: MoonColors.light.gohan, + barrierColor: MoonColors.light.zeno, + ); + + static final dark = MoonBottomSheetColors( + textColor: MoonTypography.dark.colors.bodyPrimary, + iconColor: MoonIconTheme.dark.colors.primaryColor, + backgroundColor: MoonColors.dark.gohan, + barrierColor: MoonColors.dark.zeno, + ); + + /// BottomSheet text color. + final Color textColor; + + /// BottomSheet icon color. + final Color iconColor; + + /// BottomSheet background color. + final Color backgroundColor; + + /// BottomSheet barrier color. + final Color barrierColor; + + const MoonBottomSheetColors({ + required this.textColor, + required this.iconColor, + required this.backgroundColor, + required this.barrierColor, + }); + + @override + MoonBottomSheetColors copyWith({ + Color? textColor, + Color? iconColor, + Color? backgroundColor, + Color? barrierColor, + }) { + return MoonBottomSheetColors( + textColor: textColor ?? this.textColor, + iconColor: iconColor ?? this.iconColor, + backgroundColor: backgroundColor ?? this.backgroundColor, + barrierColor: barrierColor ?? this.barrierColor, + ); + } + + @override + MoonBottomSheetColors lerp(ThemeExtension? other, double t) { + if (other is! MoonBottomSheetColors) return this; + + return MoonBottomSheetColors( + textColor: colorPremulLerp(textColor, other.textColor, t)!, + iconColor: colorPremulLerp(iconColor, other.iconColor, t)!, + backgroundColor: colorPremulLerp(backgroundColor, other.backgroundColor, t)!, + barrierColor: colorPremulLerp(barrierColor, other.barrierColor, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBottomSheetColors")) + ..add(ColorProperty("textColor", textColor)) + ..add(ColorProperty("iconColor", iconColor)) + ..add(ColorProperty("backgroundColor", backgroundColor)) + ..add(ColorProperty("barrierColor", barrierColor)); + } +} diff --git a/lib/src/theme/bottom_sheet/bottom_sheet_properties.dart b/lib/src/theme/bottom_sheet/bottom_sheet_properties.dart new file mode 100644 index 00000000..94f9c494 --- /dev/null +++ b/lib/src/theme/bottom_sheet/bottom_sheet_properties.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/borders.dart'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; + +@immutable +class MoonBottomSheetProperties extends ThemeExtension with DiagnosticableTreeMixin { + static final properties = MoonBottomSheetProperties( + borderRadius: MoonBorders.borders.surfaceLg, + transitionDuration: const Duration(milliseconds: 200), + transitionCurve: Curves.easeInOutCubic, + textStyle: MoonTextStyles.body.textDefault, + ); + + /// BottomSheet border radius. + final BorderRadiusGeometry borderRadius; + + /// BottomSheet transition duration. + final Duration transitionDuration; + + /// BottomSheet transition curve. + final Curve transitionCurve; + + /// BottomSheet text style. + final TextStyle textStyle; + + const MoonBottomSheetProperties({ + required this.borderRadius, + required this.transitionDuration, + required this.transitionCurve, + required this.textStyle, + }); + + @override + MoonBottomSheetProperties copyWith({ + BorderRadiusGeometry? borderRadius, + Duration? transitionDuration, + Curve? transitionCurve, + TextStyle? textStyle, + }) { + return MoonBottomSheetProperties( + borderRadius: borderRadius ?? this.borderRadius, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + textStyle: textStyle ?? this.textStyle, + ); + } + + @override + MoonBottomSheetProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonBottomSheetProperties) return this; + + return MoonBottomSheetProperties( + borderRadius: BorderRadiusGeometry.lerp(borderRadius, other.borderRadius, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBottomSheetProperties")) + ..add(DiagnosticsProperty("borderRadius", borderRadius)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)) + ..add(DiagnosticsProperty("textStyle", textStyle)); + } +} diff --git a/lib/src/theme/bottom_sheet/bottom_sheet_size_properties.dart b/lib/src/theme/bottom_sheet/bottom_sheet_size_properties.dart new file mode 100644 index 00000000..75e22b98 --- /dev/null +++ b/lib/src/theme/bottom_sheet/bottom_sheet_size_properties.dart @@ -0,0 +1,56 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +@immutable +class MoonBottomSheetSizeProperties extends ThemeExtension with DiagnosticableTreeMixin { + static const sm = MoonBottomSheetSizeProperties( + normalisedHeight: 0.32, + ); + + static const md = MoonBottomSheetSizeProperties( + normalisedHeight: 0.64, + ); + + static const lg = MoonBottomSheetSizeProperties( + normalisedHeight: 0.88, + ); + + static const fullScreen = MoonBottomSheetSizeProperties( + normalisedHeight: 1.0, + ); + + /// The normalised percentage value of the BottomSheet height. + final double normalisedHeight; + + const MoonBottomSheetSizeProperties({ + required this.normalisedHeight, + }); + + @override + MoonBottomSheetSizeProperties copyWith({ + double? normalisedHeight, + }) { + return MoonBottomSheetSizeProperties( + normalisedHeight: normalisedHeight ?? this.normalisedHeight, + ); + } + + @override + MoonBottomSheetSizeProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonBottomSheetSizeProperties) return this; + + return MoonBottomSheetSizeProperties( + normalisedHeight: lerpDouble(normalisedHeight, other.normalisedHeight, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBottomSheetSizeProperties")) + ..add(DoubleProperty("normalisedHeight", normalisedHeight)); + } +} diff --git a/lib/src/theme/bottom_sheet/bottom_sheet_sizes.dart b/lib/src/theme/bottom_sheet/bottom_sheet_sizes.dart new file mode 100644 index 00000000..3e09a0db --- /dev/null +++ b/lib/src/theme/bottom_sheet/bottom_sheet_sizes.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_size_properties.dart'; + +@immutable +class MoonBottomSheetSizes extends ThemeExtension with DiagnosticableTreeMixin { + static const sizes = MoonBottomSheetSizes( + sm: MoonBottomSheetSizeProperties.sm, + md: MoonBottomSheetSizeProperties.md, + lg: MoonBottomSheetSizeProperties.lg, + fullScreen: MoonBottomSheetSizeProperties.fullScreen, + ); + + /// Small BottomSheet properties. + final MoonBottomSheetSizeProperties sm; + + /// Medium BottomSheet properties. + final MoonBottomSheetSizeProperties md; + + /// Large BottomSheet properties. + final MoonBottomSheetSizeProperties lg; + + /// Full screen BottomSheet properties. + final MoonBottomSheetSizeProperties fullScreen; + + const MoonBottomSheetSizes({ + required this.sm, + required this.md, + required this.lg, + required this.fullScreen, + }); + + @override + MoonBottomSheetSizes copyWith({ + MoonBottomSheetSizeProperties? sm, + MoonBottomSheetSizeProperties? md, + MoonBottomSheetSizeProperties? lg, + MoonBottomSheetSizeProperties? fullScreen, + }) { + return MoonBottomSheetSizes( + sm: sm ?? this.sm, + md: md ?? this.md, + lg: lg ?? this.lg, + fullScreen: fullScreen ?? this.fullScreen, + ); + } + + @override + MoonBottomSheetSizes lerp(ThemeExtension? other, double t) { + if (other is! MoonBottomSheetSizes) return this; + + return MoonBottomSheetSizes( + sm: sm.lerp(other.sm, t), + md: md.lerp(other.md, t), + lg: lg.lerp(other.lg, t), + fullScreen: fullScreen.lerp(other.fullScreen, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonBottomSheetSizes")) + ..add(DiagnosticsProperty("sm", sm)) + ..add(DiagnosticsProperty("md", md)) + ..add(DiagnosticsProperty("lg", lg)) + ..add(DiagnosticsProperty("fullScreen", fullScreen)); + } +} diff --git a/lib/src/theme/bottom_sheet/bottom_sheet_theme.dart b/lib/src/theme/bottom_sheet/bottom_sheet_theme.dart new file mode 100644 index 00000000..b0fda7f1 --- /dev/null +++ b/lib/src/theme/bottom_sheet/bottom_sheet_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_colors.dart'; +import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_properties.dart'; +import 'package:moon_design/src/theme/bottom_sheet/bottom_sheet_sizes.dart'; + +@immutable +class MoonBottomSheetTheme extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonBottomSheetTheme( + colors: MoonBottomSheetColors.light, + properties: MoonBottomSheetProperties.properties, + sizes: MoonBottomSheetSizes.sizes, + ); + + static final dark = MoonBottomSheetTheme( + colors: MoonBottomSheetColors.dark, + properties: MoonBottomSheetProperties.properties, + sizes: MoonBottomSheetSizes.sizes, + ); + + /// BottomSheet colors. + final MoonBottomSheetColors colors; + + /// BottomSheet properties. + final MoonBottomSheetProperties properties; + + /// BottomSheet sizes. + final MoonBottomSheetSizes sizes; + + const MoonBottomSheetTheme({ + required this.colors, + required this.properties, + required this.sizes, + }); + + @override + MoonBottomSheetTheme copyWith({ + MoonBottomSheetColors? colors, + MoonBottomSheetProperties? properties, + MoonBottomSheetSizes? sizes, + }) { + return MoonBottomSheetTheme( + colors: colors ?? this.colors, + properties: properties ?? this.properties, + sizes: sizes ?? this.sizes, + ); + } + + @override + MoonBottomSheetTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonBottomSheetTheme) return this; + + return MoonBottomSheetTheme( + colors: colors.lerp(other.colors, t), + properties: properties.lerp(other.properties, t), + sizes: sizes.lerp(other.sizes, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonBottomSheetTheme")) + ..add(DiagnosticsProperty("colors", colors)) + ..add(DiagnosticsProperty("properties", properties)) + ..add(DiagnosticsProperty("sizes", sizes)); + } +} diff --git a/lib/src/theme/modal/modal_theme.dart b/lib/src/theme/modal/modal_theme.dart index c1ebb781..2357ad34 100644 --- a/lib/src/theme/modal/modal_theme.dart +++ b/lib/src/theme/modal/modal_theme.dart @@ -16,10 +16,10 @@ class MoonModalTheme extends ThemeExtension with DiagnosticableT properties: MoonModalProperties.properties, ); - /// Checkbox colors. + /// Modal colors. final MoonModalColors colors; - /// Checkbox properties. + /// Modal properties. final MoonModalProperties properties; const MoonModalTheme({ diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index 09e89020..1fe0150b 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -6,6 +6,7 @@ import 'package:moon_design/src/theme/alert/alert_theme.dart'; import 'package:moon_design/src/theme/authcode/authcode_theme.dart'; 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/checkbox/checkbox_theme.dart'; import 'package:moon_design/src/theme/chip/chip_theme.dart'; @@ -40,6 +41,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: MoonAuthCodeTheme.light, avatarTheme: MoonAvatarTheme.light, borders: MoonBorders.borders, + bottomSheetTheme: MoonBottomSheetTheme.light, buttonTheme: MoonButtonTheme.light, checkboxTheme: MoonCheckboxTheme.light, chipTheme: MoonChipTheme.light, @@ -73,6 +75,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: MoonAuthCodeTheme.dark, avatarTheme: MoonAvatarTheme.dark, borders: MoonBorders.borders, + bottomSheetTheme: MoonBottomSheetTheme.dark, buttonTheme: MoonButtonTheme.dark, checkboxTheme: MoonCheckboxTheme.dark, chipTheme: MoonChipTheme.dark, @@ -115,6 +118,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System borders. final MoonBorders borders; + /// Moon Design System MoonButton widgets theming. + final MoonBottomSheetTheme bottomSheetTheme; + /// Moon Design System MoonButton widgets theming. final MoonButtonTheme buttonTheme; @@ -196,6 +202,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { required this.authCodeTheme, required this.avatarTheme, required this.borders, + required this.bottomSheetTheme, required this.buttonTheme, required this.checkboxTheme, required this.chipTheme, @@ -230,6 +237,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonAuthCodeTheme? authCodeTheme, MoonAvatarTheme? avatarTheme, MoonBorders? borders, + MoonBottomSheetTheme? bottomSheetTheme, MoonButtonTheme? buttonTheme, MoonCheckboxTheme? checkboxTheme, MoonChipTheme? chipTheme, @@ -262,6 +270,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: authCodeTheme ?? this.authCodeTheme, avatarTheme: avatarTheme ?? this.avatarTheme, borders: borders ?? this.borders, + bottomSheetTheme: bottomSheetTheme ?? this.bottomSheetTheme, buttonTheme: buttonTheme ?? this.buttonTheme, checkboxTheme: checkboxTheme ?? this.checkboxTheme, chipTheme: chipTheme ?? this.chipTheme, @@ -300,6 +309,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { authCodeTheme: authCodeTheme.lerp(other.authCodeTheme, t), avatarTheme: avatarTheme.lerp(other.avatarTheme, t), borders: borders.lerp(other.borders, t), + bottomSheetTheme: bottomSheetTheme.lerp(other.bottomSheetTheme, t), buttonTheme: buttonTheme.lerp(other.buttonTheme, t), checkboxTheme: checkboxTheme.lerp(other.checkboxTheme, t), chipTheme: chipTheme.lerp(other.chipTheme, t), diff --git a/lib/src/widgets/bottom_sheet/bottom_sheet.dart b/lib/src/widgets/bottom_sheet/bottom_sheet.dart new file mode 100644 index 00000000..70588178 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/bottom_sheet.dart @@ -0,0 +1,528 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/borders.dart'; +import 'package:moon_design/src/theme/colors.dart'; +import 'package:moon_design/src/theme/icons/icon_theme.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'; +import 'package:moon_design/src/utils/extensions.dart'; +import 'package:moon_design/src/utils/shape_decoration_premul.dart'; +import 'package:moon_design/src/utils/squircle/squircle_border.dart'; +import 'package:moon_design/src/utils/squircle/squircle_border_radius.dart'; +import 'package:moon_design/src/widgets/bottom_sheet/utils/bottom_sheet_suspended_curve.dart'; +import 'package:moon_design/src/widgets/bottom_sheet/utils/scroll_to_top_status_bar.dart'; + +const Curve _decelerateEasing = Cubic(0.0, 0.0, 0.2, 1.0); + +const Duration _bottomSheetDuration = Duration(milliseconds: 400); +const double _minFlingVelocity = 500.0; +const double _closeProgressThreshold = 0.6; +const double _willPopThreshold = 0.8; + +typedef WidgetWithChildBuilder = Widget Function(BuildContext context, Animation animation, Widget child); + +/// A Moon Design bottom sheet. +/// +/// The [MoonBottomSheet] widget itself is rarely used directly. Instead, prefer to create a modal bottom sheet +/// with [showMoonBottomSheet]. +class MoonBottomSheet extends StatefulWidget { + /// The border radius of the bottom sheet. + final BorderRadiusGeometry? borderRadius; + + /// The background color of the bottom sheet. + final Color? backgroundColor; + + /// Custom decoration for the bottom sheet. + final Decoration? decoration; + + /// The semantic label for the bottom sheet. + final String? semanticLabel; + + /// The closeProgressThreshold parameter + /// specifies when the bottom sheet will be dismissed when user drags it. + final double closeProgressThreshold; + + /// The animation controller that controls the bottom sheet's entrance and exit animations. + /// + /// The BottomSheet widget will manipulate the position of this animation. + final AnimationController animationController; + + /// The duration of the animation for showing and dismissing the bottom sheet. + final Duration? transitionDuration; + + /// The curve used by the animation for showing and dismissing the bottom sheet. + final Curve? transitionCurve; + + /// Allows the bottom sheet to go beyond the top boundary of the content, but then bounces the content back to the + /// edge of the top boundary. + final bool hasBounce; + + // Force the widget to fill the maximum size of the viewport or if false it will fit to the content of the widget. + final bool isExpanded; + + /// Called when the bottom sheet begins to close. + /// + /// A bottom sheet might be prevented from closing (e.g., by user interaction) even after this callback is called. + /// For this reason, this callback might be called multiple times for a given bottom sheet. + final void Function() onClosing; + + /// If shouldClose is null, this is ignored. + /// If return value is true => The dialog closes + /// If return value is false => The dialog cancels close + /// Notice that if shouldClose is not null, the dialog will go back to the previous position until the function is + /// solved. + final Future Function()? shouldClose; + + /// A builder for the contents of the sheet. + final Widget child; + + /// If true, the bottom sheet can be dragged up and down and dismissed by swiping downwards. + /// + /// Default is true. + final bool enableDrag; + + /// The scroll controller of the content of the bottom sheet. + final ScrollController scrollController; + + /// Determines how fast the sheet should be flinged before closing. + final double minFlingVelocity; + + /// Determines how far the sheet should be flinged before closing. + final double willPopThreshold; + + /// Creates a Moon Design modal bottom sheet. + const MoonBottomSheet({ + super.key, + required this.animationController, + this.transitionCurve, + this.enableDrag = true, + this.hasBounce = true, + this.shouldClose, + required this.scrollController, + required this.isExpanded, + required this.onClosing, + required this.child, + this.minFlingVelocity = _minFlingVelocity, + double? closeProgressThreshold, + this.willPopThreshold = _willPopThreshold, + this.borderRadius, + this.backgroundColor, + this.decoration, + this.semanticLabel, + this.transitionDuration, + }) : closeProgressThreshold = closeProgressThreshold ?? _closeProgressThreshold; + + @override + MoonBottomSheetState createState() => MoonBottomSheetState(); + + /// Creates an [AnimationController] suitable for a [MoonBottomSheet.animationController]. + /// + /// This API available as a convenience for a Material compliant bottom sheet animation. If alternative animation + /// durations are required, a different animation controller could be provided. + static AnimationController createAnimationController( + TickerProvider vsync, { + Duration? duration, + }) { + return AnimationController( + duration: duration ?? _bottomSheetDuration, + debugLabel: 'BottomSheet', + vsync: vsync, + ); + } +} + +class MoonBottomSheetState extends State with TickerProviderStateMixin { + final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child'); + + late AnimationController _bounceDragController; + + // Used in NotificationListener to detect what the ScrollNotifications are before or after the user stop dragging. + bool _isDragging = false; + bool _isCheckingShouldClose = false; + + DateTime? _startTime; + ParametricCurve transitionCurve = Curves.linear; + + // As we cannot access the DragGesture detector of the scroll view we can not know the DragDownDetails and therefore + // the end velocity. VelocityTracker is used to calculate the end velocity of the scroll when user is trying to close + // the modal by dragging. + VelocityTracker? _velocityTracker; + + bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse; + bool get _hasReachedWillPopThreshold => widget.animationController.value < _willPopThreshold; + bool get _hasReachedCloseThreshold => widget.animationController.value < widget.closeProgressThreshold; + Curve get _defaultCurve => widget.transitionCurve ?? _decelerateEasing; + ScrollController get _scrollController => widget.scrollController; + + double? get _childHeight { + final childContext = _childKey.currentContext; + final renderBox = childContext?.findRenderObject() as RenderBox?; + return renderBox?.size.height; + } + + void _close() { + _isDragging = false; + widget.onClosing(); + } + + void _cancelClose() { + widget.animationController.forward().then((value) { + // When using WillPop, the animation does not end at 1. + if (!widget.animationController.isCompleted) { + widget.animationController.value = 1; + } + }); + + _bounceDragController.reverse(); + } + + FutureOr shouldClose() async { + if (_isCheckingShouldClose) return false; + if (widget.shouldClose == null) return false; + + _isCheckingShouldClose = true; + final result = await widget.shouldClose?.call(); + _isCheckingShouldClose = false; + + return result ?? false; + } + + Future _handleDragUpdate(double primaryDelta) async { + assert(widget.enableDrag, 'Dragging is disabled'); + + transitionCurve = Curves.linear; + + if (_dismissUnderway) return; + _isDragging = true; + + final progress = primaryDelta / (_childHeight ?? primaryDelta); + + if (widget.shouldClose != null && _hasReachedWillPopThreshold) { + _cancelClose(); + + final canClose = await shouldClose(); + + if (canClose) { + _close(); + return; + } else { + _cancelClose(); + } + } + + // Bounce at the top boundary + final hasBounce = widget.hasBounce == true; + final shouldBounce = _bounceDragController.value > 0; + final isBouncing = (widget.animationController.value - progress) > 1; + + if (hasBounce && (shouldBounce || isBouncing)) { + _bounceDragController.value -= progress * 10; + return; + } + + widget.animationController.value -= progress; + } + + Future _handleDragEnd(double velocity) async { + assert(widget.enableDrag, 'Dragging is disabled'); + + transitionCurve = BottomSheetSuspendedCurve( + widget.animationController.value, + curve: _defaultCurve, + ); + + if (_dismissUnderway || !_isDragging) return; + + _isDragging = false; + _bounceDragController.reverse(); + + Future tryClose() async { + if (widget.shouldClose != null) { + _cancelClose(); + final bool canClose = await shouldClose(); + if (canClose) { + _close(); + } + } else { + _close(); + } + } + + // If speed is bigger than _minFlingVelocity try to close it + if (velocity > widget.minFlingVelocity) { + tryClose(); + } else if (_hasReachedCloseThreshold) { + if (widget.animationController.value > 0.0) { + widget.animationController.fling(velocity: -1.0); + } + tryClose(); + } else { + _cancelClose(); + } + } + + void _handleScrollUpdate(ScrollNotification notification) { + assert(notification.context != null); + // Check if ScrollController is used + if (!_scrollController.hasClients) return; + + ScrollPosition scrollPosition; + + if (_scrollController.positions.length > 1) { + scrollPosition = _scrollController.positions.firstWhere( + (p) => p.isScrollingNotifier.value, + orElse: () => _scrollController.positions.first, + ); + } else { + scrollPosition = _scrollController.position; + } + + if (scrollPosition.axis == Axis.horizontal) return; + + final isScrollReversed = scrollPosition.axisDirection == AxisDirection.down; + final offset = isScrollReversed ? scrollPosition.pixels : scrollPosition.maxScrollExtent - scrollPosition.pixels; + + if (offset <= 0) { + // Clamping Scroll Physics ends with a ScrollEndNotification with a DragEndDetail class while + // BouncingScrollPhysics or other physics that overflow will not return a drag end info. + + // We use the velocity from DragEndDetails if it is available. + if (notification is ScrollEndNotification) { + final dragDetails = notification.dragDetails; + if (dragDetails != null) { + _handleDragEnd(dragDetails.primaryVelocity ?? 0); + _velocityTracker = null; + _startTime = null; + return; + } + } + + // Otherwise calculate the velocity with a VelocityTracker. + if (_velocityTracker == null) { + final pointerKind = defaultPointerDeviceKind(context); + _velocityTracker = VelocityTracker.withKind(pointerKind); + _startTime = DateTime.now(); + } + + DragUpdateDetails? dragDetails; + + if (notification is ScrollUpdateNotification) { + dragDetails = notification.dragDetails; + } + + if (notification is OverscrollNotification) { + dragDetails = notification.dragDetails; + } + + assert(_velocityTracker != null); + assert(_startTime != null); + + final startTime = _startTime!; + final velocityTracker = _velocityTracker!; + + if (dragDetails != null) { + final duration = startTime.difference(DateTime.now()); + + velocityTracker.addPosition(duration, Offset(0, offset)); + + _handleDragUpdate(dragDetails.delta.dy); + } else if (_isDragging) { + final velocity = velocityTracker.getVelocity().pixelsPerSecond.dy; + + _velocityTracker = null; + _startTime = null; + + _handleDragEnd(velocity); + } + } + } + + @override + void initState() { + transitionCurve = _defaultCurve; + _bounceDragController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + final BorderRadiusGeometry effectiveBorderRadius = widget.borderRadius ?? + context.moonTheme?.bottomSheetTheme.properties.borderRadius ?? + MoonBorders.borders.surfaceSm; + + final Color effectiveBackgroundColor = + widget.backgroundColor ?? context.moonTheme?.bottomSheetTheme.colors.backgroundColor ?? MoonColors.light.gohan; + + final Color effectiveTextColor = + context.moonTheme?.bottomSheetTheme.colors.textColor ?? MoonTypography.light.colors.bodyPrimary; + + final Color effectiveIconColor = + context.moonTheme?.bottomSheetTheme.colors.iconColor ?? MoonIconTheme.light.colors.primaryColor; + + final TextStyle effectiveTextStyle = + context.moonTheme?.bottomSheetTheme.properties.textStyle ?? MoonTextStyles.body.textDefault; + + final bounceAnimation = CurvedAnimation( + parent: _bounceDragController, + curve: Curves.easeOutSine, + ); + + return ScrollToTopStatusBarHandler( + scrollController: _scrollController, + child: AnimatedBuilder( + animation: widget.animationController, + builder: (context, Widget? child) { + assert(child != null); + + final animationValue = transitionCurve.transform(widget.animationController.value); + + final draggableChild = !widget.enableDrag + ? child + : KeyedSubtree( + key: _childKey, + child: AnimatedBuilder( + animation: bounceAnimation, + builder: (context, _) => CustomSingleChildLayout( + delegate: _BottomSheetChildLayout(bounceAnimation.value), + child: GestureDetector( + onVerticalDragUpdate: (details) => _handleDragUpdate(details.delta.dy), + onVerticalDragEnd: (details) => _handleDragEnd(details.primaryVelocity ?? 0), + child: NotificationListener( + onNotification: (ScrollNotification notification) { + _handleScrollUpdate(notification); + return false; + }, + child: child!, + ), + ), + ), + ), + ); + + return ClipRect( + child: CustomSingleChildLayout( + delegate: _ModalBottomSheetLayout( + progress: animationValue, + isExpanded: widget.isExpanded, + ), + child: draggableChild, + ), + ); + }, + child: ScrollConfiguration( + behavior: const MaterialScrollBehavior().copyWith(overscroll: false), + child: RepaintBoundary( + child: Semantics( + label: widget.semanticLabel, + child: IconTheme( + data: IconThemeData(color: effectiveIconColor), + child: DefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + child: Container( + decoration: widget.decoration ?? + ShapeDecorationWithPremultipliedAlpha( + color: effectiveBackgroundColor, + shape: MoonSquircleBorder( + borderRadius: MoonSquircleBorderRadius.only( + topLeft: effectiveBorderRadius.squircleBorderRadius(context).topLeft, + topRight: effectiveBorderRadius.squircleBorderRadius(context).topRight, + ), + ), + ), + child: widget.child, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ModalBottomSheetLayout extends SingleChildLayoutDelegate { + final bool isExpanded; + final double progress; + + _ModalBottomSheetLayout({ + required this.isExpanded, + required this.progress, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + minHeight: isExpanded ? constraints.maxHeight : 0, + maxHeight: isExpanded ? constraints.maxHeight : constraints.minHeight, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + return Offset(0.0, size.height - childSize.height * progress); + } + + @override + bool shouldRelayout(_ModalBottomSheetLayout oldDelegate) { + return progress != oldDelegate.progress; + } +} + +class _BottomSheetChildLayout extends SingleChildLayoutDelegate { + _BottomSheetChildLayout(this.progress); + + final double progress; + double? childHeight; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return BoxConstraints( + minWidth: constraints.maxWidth, + maxWidth: constraints.maxWidth, + minHeight: constraints.minHeight, + maxHeight: constraints.maxHeight + progress * 8, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + childHeight ??= childSize.height; + return Offset(0.0, size.height - childSize.height); + } + + @override + bool shouldRelayout(_BottomSheetChildLayout oldDelegate) { + if (progress != oldDelegate.progress) { + childHeight = oldDelegate.childHeight; + return true; + } + return false; + } +} + +// Checks the device input type of the OS. +// Mobile platforms will be default to `touch` while desktop will do to `mouse`. +// Used with VelocityTracker +// https://github.com/flutter/flutter/pull/64267#issuecomment-694196304 +PointerDeviceKind defaultPointerDeviceKind(BuildContext context) { + final platform = Theme.of(context).platform; + switch (platform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + return PointerDeviceKind.touch; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return PointerDeviceKind.mouse; + case TargetPlatform.fuchsia: + return PointerDeviceKind.unknown; + } +} diff --git a/lib/src/widgets/bottom_sheet/modal_bottom_sheet.dart b/lib/src/widgets/bottom_sheet/modal_bottom_sheet.dart new file mode 100644 index 00000000..4c64a929 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/modal_bottom_sheet.dart @@ -0,0 +1,297 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:moon_design/moon_design.dart'; + +/// Shows a modal Moon Design bottom sheet. +Future showMoonModalBottomSheet({ + AnimationController? animationController, + bool hasBounce = false, + bool enableDrag = true, + bool isExpanded = false, + bool isDismissible = true, + bool useRootNavigator = false, + BorderRadiusGeometry? borderRadius, + Color? backgroundColor, + Color? barrierColor, + Decoration? decoration, + double? closeProgressThreshold, + RouteSettings? settings, + Duration? transitionDuration, + Curve? transitionCurve, + required BuildContext context, + required WidgetBuilder builder, +}) async { + assert(debugCheckHasMediaQuery(context)); + assert(debugCheckHasMaterialLocalizations(context)); + + final hasMaterialLocalizations = Localizations.of(context, MaterialLocalizations) != null; + final barrierLabel = hasMaterialLocalizations ? MaterialLocalizations.of(context).modalBarrierDismissLabel : ""; + + final CapturedThemes themes = InheritedTheme.capture( + from: context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, + ); + + final Color effectiveBarrierColor = + barrierColor ?? context.moonTheme?.bottomSheetTheme.colors.barrierColor ?? MoonColors.light.zeno; + + final Duration effectiveTransitionDuration = transitionDuration ?? + context.moonTheme?.bottomSheetTheme.properties.transitionDuration ?? + const Duration(milliseconds: 200); + + final Curve effectiveTransitionCurve = + transitionCurve ?? context.moonTheme?.bottomSheetTheme.properties.transitionCurve ?? Curves.easeInOutCubic; + + final result = await Navigator.of(context, rootNavigator: useRootNavigator).push( + MoonModalBottomSheetRoute( + barrierLabel: barrierLabel, + borderRadius: borderRadius, + backgroundColor: backgroundColor, + decoration: decoration, + themes: themes, + animationController: animationController, + hasBounce: hasBounce, + enableDrag: enableDrag, + isExpanded: isExpanded, + isDismissible: isDismissible, + modalBarrierColor: effectiveBarrierColor, + closeProgressThreshold: closeProgressThreshold, + settings: settings, + animationDuration: effectiveTransitionDuration, + animationCurve: effectiveTransitionCurve, + builder: builder, + ), + ); + + return result; +} + +//const Duration _bottomSheetDuration = Duration(milliseconds: 200); + +class MoonModalBottomSheetRoute extends PageRoute { + final AnimationController? animationController; + final bool enableDrag; + final bool isExpanded; + final bool hasBounce; + final bool isDismissible; + final BorderRadiusGeometry? borderRadius; + final CapturedThemes? themes; + final Color? backgroundColor; + final Color? modalBarrierColor; + final Decoration? decoration; + final double? closeProgressThreshold; + final ScrollController? scrollController; + final Duration? animationDuration; + final Curve? animationCurve; + final WidgetBuilder builder; + + MoonModalBottomSheetRoute({ + super.settings, + this.barrierLabel, + this.animationController, + this.enableDrag = true, + required this.isExpanded, + this.hasBounce = false, + this.isDismissible = true, + this.borderRadius, + this.themes, + this.backgroundColor, + this.modalBarrierColor, + this.decoration, + this.closeProgressThreshold, + this.scrollController, + this.animationDuration, + this.animationCurve, + required this.builder, + }); + + AnimationController? _animationController; + + bool get _hasScopedWillPopCallback => hasScopedWillPopCallback; + + @override + final String? barrierLabel; + + @override + Duration get transitionDuration => animationDuration ?? const Duration(milliseconds: 200); + + @override + bool get barrierDismissible => isDismissible; + + @override + bool get maintainState => true; + + @override + bool get opaque => false; + + @override + Color get barrierColor => modalBarrierColor ?? Colors.black.withOpacity(0.35); + + @override + AnimationController createAnimationController() { + assert(_animationController == null); + + _animationController = MoonBottomSheet.createAnimationController( + navigator!.overlay!, + duration: transitionDuration, + ); + + return _animationController!; + } + + @override + bool canTransitionTo(TransitionRoute nextRoute) => nextRoute is MoonModalBottomSheetRoute; + + @override + bool canTransitionFrom(TransitionRoute previousRoute) => + previousRoute is MoonModalBottomSheetRoute || previousRoute is PageRoute; + + @override + Widget buildPage(BuildContext context, Animation animation, Animation secondaryAnimation) { + // By definition, the bottom sheet is aligned to the bottom of the page + // and isn't exposed to the top padding of the MediaQuery. + final Widget bottomSheet = MediaQuery.removePadding( + context: context, + child: _ModalBottomSheet( + borderRadius: borderRadius, + animationController: animationController, + backgroundColor: backgroundColor, + decoration: decoration, + hasBounce: hasBounce, + enableDrag: enableDrag, + isExpanded: isExpanded, + closeProgressThreshold: closeProgressThreshold, + transitionDuration: animationDuration, + transitionCurve: animationCurve, + route: this, + ), + ); + + return themes?.wrap(bottomSheet) ?? bottomSheet; + } +} + +class _ModalBottomSheet extends StatefulWidget { + final AnimationController? animationController; + final BorderRadiusGeometry? borderRadius; + final bool hasBounce; + final bool enableDrag; + final bool isExpanded; + final Color? backgroundColor; + final Decoration? decoration; + final double? closeProgressThreshold; + final Duration? transitionDuration; + final Curve? transitionCurve; + final MoonModalBottomSheetRoute route; + + const _ModalBottomSheet({ + super.key, + this.animationController, + this.borderRadius, + this.hasBounce = false, + this.enableDrag = true, + this.isExpanded = false, + this.backgroundColor, + this.decoration, + this.closeProgressThreshold, + this.transitionDuration, + this.transitionCurve, + required this.route, + }); + + @override + _ModalBottomSheetState createState() => _ModalBottomSheetState(); +} + +class _ModalBottomSheetState extends State<_ModalBottomSheet> { + ScrollController? _scrollController; + + String _getRouteLabel() { + final platform = Theme.of(context).platform; + switch (platform) { + case TargetPlatform.iOS: + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + return ''; + case TargetPlatform.android: + case TargetPlatform.fuchsia: + if (Localizations.of(context, MaterialLocalizations) != null) { + return MaterialLocalizations.of(context).dialogLabel; + } else { + return const DefaultMaterialLocalizations().dialogLabel; + } + } + } + + Future _handleShouldClose() async { + final willPop = await widget.route.willPop(); + return willPop != RoutePopDisposition.doNotPop; + } + + void _updateController() { + final animation = widget.route.animation; + if (animation != null) { + widget.animationController?.value = animation.value; + } + } + + @override + void initState() { + super.initState(); + widget.route.animation?.addListener(_updateController); + } + + @override + void dispose() { + widget.route.animation?.removeListener(_updateController); + _scrollController?.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasMediaQuery(context)); + assert(widget.route._animationController != null); + + final scrollController = PrimaryScrollController.maybeOf(context) ?? (_scrollController ??= ScrollController()); + + return PrimaryScrollController( + controller: scrollController, + child: Builder( + builder: (context) => AnimatedBuilder( + animation: widget.route._animationController!, + builder: (BuildContext context, Widget? child) { + assert(child != null); + return Semantics( + explicitChildNodes: true, + label: _getRouteLabel(), + namesRoute: true, + scopesRoute: true, + child: MoonBottomSheet( + backgroundColor: widget.backgroundColor, + decoration: widget.decoration, + borderRadius: widget.borderRadius, + animationController: widget.route._animationController!, + enableDrag: widget.enableDrag, + hasBounce: widget.hasBounce, + isExpanded: widget.route.isExpanded, + closeProgressThreshold: widget.closeProgressThreshold, + onClosing: () => {if (widget.route.isCurrent) Navigator.of(context).pop()}, + shouldClose: widget.route._hasScopedWillPopCallback ? () => _handleShouldClose() : null, + scrollController: scrollController, + //transitionDuration: widget.transitionDuration, + transitionCurve: widget.transitionCurve, + child: child!, + ), + ); + }, + child: widget.route.builder(context), + ), + ), + ); + } +} diff --git a/lib/src/widgets/bottom_sheet/utils/bottom_sheet_suspended_curve.dart b/lib/src/widgets/bottom_sheet/utils/bottom_sheet_suspended_curve.dart new file mode 100644 index 00000000..bb4a0551 --- /dev/null +++ b/lib/src/widgets/bottom_sheet/utils/bottom_sheet_suspended_curve.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +// Copied from bottom_sheet.dart as is a private class +// https://github.com/flutter/flutter/issues/51627 + +// TODO(guidezpl): Look into making this public. A copy of this class is in +// scaffold.dart, for now, https://github.com/flutter/flutter/issues/51627 + +/// A curve that progresses linearly until a specified [startingPoint], at which +/// point [curve] will begin. Unlike [Interval], [curve] will not start at zero, +/// but will use [startingPoint] as the Y position. +/// +/// For example, if [startingPoint] is set to `0.5`, and [curve] is set to +/// [Curves.easeOut], then the bottom-left quarter of the curve will be a +/// straight line, and the top-right quarter will contain the entire contents of +/// [Curves.easeOut]. +/// +/// This is useful in situations where a widget must track the user's finger +/// (which requires a linear animation), and afterwards can be flung using a +/// curve specified with the [curve] argument, after the finger is released. In +/// such a case, the value of [startingPoint] would be the progress of the +/// animation at the time when the finger was released. +/// +/// The [startingPoint] and [curve] arguments must not be null. +class BottomSheetSuspendedCurve extends Curve { + /// Creates a suspended curve. + const BottomSheetSuspendedCurve( + this.startingPoint, { + this.curve = Curves.easeOutCubic, + }); + + /// The progress value at which [curve] should begin. + /// + /// This defaults to [Curves.easeOutCubic]. + final double startingPoint; + + /// The curve to use when [startingPoint] is reached. + final Curve curve; + + @override + double transform(double t) { + assert(t >= 0.0 && t <= 1.0); + assert(startingPoint >= 0.0 && startingPoint <= 1.0); + + if (t < startingPoint) { + return t; + } + + if (t == 1.0) { + return t; + } + + final curveProgress = (t - startingPoint) / (1 - startingPoint); + final transformed = curve.transform(curveProgress); + return lerpDouble(startingPoint, 1, transformed)!; + } + + @override + String toString() { + return '${describeIdentity(this)}($startingPoint, $curve)'; + } +} diff --git a/lib/src/widgets/bottom_sheet/utils/scroll_to_top_status_bar.dart b/lib/src/widgets/bottom_sheet/utils/scroll_to_top_status_bar.dart new file mode 100644 index 00000000..ca8dd29d --- /dev/null +++ b/lib/src/widgets/bottom_sheet/utils/scroll_to_top_status_bar.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; + +/// Widget that that will scroll to the top of the ScrollController when tapped on the status bar. +/// +/// Extracted from Scaffold and used in modal bottom sheet +class ScrollToTopStatusBarHandler extends StatefulWidget { + final Widget child; + final ScrollController scrollController; + + const ScrollToTopStatusBarHandler({ + super.key, + required this.child, + required this.scrollController, + }); + + @override + ScrollToTopStatusBarState createState() => ScrollToTopStatusBarState(); +} + +class ScrollToTopStatusBarState extends State { + void _handleStatusBarTap(BuildContext context) { + final controller = widget.scrollController; + if (controller.hasClients) { + controller.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + ); + } + } + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + widget.child, + Positioned( + top: 0, + left: 0, + right: 0, + height: MediaQuery.of(context).padding.top, + child: Builder( + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _handleStatusBarTap(context), + // iOS accessibility automatically adds scroll-to-top to the clock in the status bar + excludeFromSemantics: true, + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/widgets/modal/modal.dart b/lib/src/widgets/modal/modal.dart index 964b9813..bd89174c 100644 --- a/lib/src/widgets/modal/modal.dart +++ b/lib/src/widgets/modal/modal.dart @@ -10,96 +10,27 @@ import 'package:moon_design/src/utils/extensions.dart'; import 'package:moon_design/src/utils/shape_decoration_premul.dart'; import 'package:moon_design/src/utils/squircle/squircle_border.dart'; -class MoonModal extends StatelessWidget { - /// The border radius of the modal. - final BorderRadiusGeometry? borderRadius; - - /// The background color of the modal. - final Color? backgroundColor; - - /// Custom decoration for the modal. - final Decoration? decoration; - - /// The semantic label for the modal. - final String? semanticLabel; - - /// The child of the modal. - final Widget child; - - const MoonModal({ - super.key, - this.borderRadius, - this.backgroundColor, - this.decoration, - this.semanticLabel, - required this.child, - }); - - @override - Widget build(BuildContext context) { - final BorderRadiusGeometry effectiveBorderRadius = - borderRadius ?? context.moonTheme?.modalTheme.properties.borderRadius ?? MoonBorders.borders.surfaceSm; - - final Color effectiveBackgroundColor = - backgroundColor ?? context.moonTheme?.modalTheme.colors.backgroundColor ?? MoonColors.light.gohan; - - final Color effectiveTextColor = - context.moonTheme?.modalTheme.colors.textColor ?? MoonTypography.light.colors.bodyPrimary; - - final Color effectiveIconColor = - context.moonTheme?.modalTheme.colors.iconColor ?? MoonIconTheme.light.colors.primaryColor; - - final TextStyle effectiveTextStyle = - context.moonTheme?.modalTheme.properties.textStyle ?? MoonTextStyles.body.textDefault; - - return Semantics( - label: semanticLabel, - child: IconTheme( - data: IconThemeData(color: effectiveIconColor), - child: DefaultTextStyle( - style: effectiveTextStyle.copyWith(color: effectiveTextColor), - child: Center( - child: Container( - decoration: decoration ?? - ShapeDecorationWithPremultipliedAlpha( - color: effectiveBackgroundColor, - shape: MoonSquircleBorder( - borderRadius: effectiveBorderRadius.squircleBorderRadius(context), - ), - ), - child: child, - ), - ), - ), - ), - ); - } -} - /// Displays a modal above the current contents of the app, with entrance and exit animations, modal barrier color, /// and modal barrier behavior (dialog is dismissible with a tap on the barrier). Used together with MoonModal. Future showMoonModal({ - required BuildContext context, - required WidgetBuilder builder, bool barrierDismissible = true, - String? barrierLabel = "Dismiss", + bool useRootNavigator = true, + bool useSafeArea = true, Color? barrierColor, - Duration? transitionDuration, Curve? transitionCurve, - bool useSafeArea = true, - bool useRootNavigator = true, - RouteSettings? routeSettings, + Duration? transitionDuration, Offset? anchorPoint, + RouteSettings? routeSettings, + String? barrierLabel = "Dismiss", + required BuildContext context, + required WidgetBuilder builder, }) { assert(!barrierDismissible || barrierLabel != null); assert(_debugIsActive(context)); final CapturedThemes themes = InheritedTheme.capture( from: context, - to: Navigator.of( - context, - rootNavigator: useRootNavigator, - ).context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, ); final Color effectiveBarrierColor = @@ -146,21 +77,20 @@ bool _debugIsActive(BuildContext context) { } class MoonModalRoute extends RawDialogRoute { - /// A MDS modal route with entrance and exit animations, - /// modal barrier color, and modal barrier behavior (modal is dismissible - /// with a tap on the barrier). + /// A MDS modal route with entrance and exit animations, modal barrier color, and modal barrier behavior + /// (modal is dismissible with a tap on the barrier). MoonModalRoute({ - required BuildContext context, - required WidgetBuilder builder, - CapturedThemes? themes, + super.anchorPoint, required super.barrierColor, super.barrierDismissible, + String? barrierLabel, + super.settings, + CapturedThemes? themes, required super.transitionDuration, required Curve transitionCurve, - String? barrierLabel, bool useSafeArea = true, - super.settings, - super.anchorPoint, + required BuildContext context, + required WidgetBuilder builder, }) : super( barrierLabel: barrierLabel ?? MaterialLocalizations.of(context).modalBarrierDismissLabel, pageBuilder: (BuildContext buildContext, Animation animation, Animation secondaryAnimation) { @@ -188,3 +118,69 @@ class MoonModalRoute extends RawDialogRoute { }, ); } + +class MoonModal extends StatelessWidget { + /// The border radius of the modal. + final BorderRadiusGeometry? borderRadius; + + /// The background color of the modal. + final Color? backgroundColor; + + /// Custom decoration for the modal. + final Decoration? decoration; + + /// The semantic label for the modal. + final String? semanticLabel; + + /// The child of the modal. + final Widget child; + + const MoonModal({ + super.key, + this.borderRadius, + this.backgroundColor, + this.decoration, + this.semanticLabel, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final BorderRadiusGeometry effectiveBorderRadius = + borderRadius ?? context.moonTheme?.modalTheme.properties.borderRadius ?? MoonBorders.borders.surfaceSm; + + final Color effectiveBackgroundColor = + backgroundColor ?? context.moonTheme?.modalTheme.colors.backgroundColor ?? MoonColors.light.gohan; + + final Color effectiveTextColor = + context.moonTheme?.modalTheme.colors.textColor ?? MoonTypography.light.colors.bodyPrimary; + + final Color effectiveIconColor = + context.moonTheme?.modalTheme.colors.iconColor ?? MoonIconTheme.light.colors.primaryColor; + + final TextStyle effectiveTextStyle = + context.moonTheme?.modalTheme.properties.textStyle ?? MoonTextStyles.body.textDefault; + + return Semantics( + label: semanticLabel, + child: IconTheme( + data: IconThemeData(color: effectiveIconColor), + child: DefaultTextStyle( + style: effectiveTextStyle.copyWith(color: effectiveTextColor), + child: Center( + child: Container( + decoration: decoration ?? + ShapeDecorationWithPremultipliedAlpha( + color: effectiveBackgroundColor, + shape: MoonSquircleBorder( + borderRadius: effectiveBorderRadius.squircleBorderRadius(context), + ), + ), + child: child, + ), + ), + ), + ), + ); + } +}