children;
+
+ @override
+ Widget build(BuildContext context) {
+ return CupertinoListSection.insetGrouped(
+ margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+ children: children,
+ );
+ }
+}
+
+class _MenuListItem extends StatelessWidget {
+ const _MenuListItem({
+ required this.title,
+ required this.icon,
+ });
+
+ final String title;
+ final IconData icon;
+
+ @override
+ Widget build(BuildContext context) {
+ return CupertinoListTile.notched(
+ title: Text(title),
+ trailing: Icon(icon, color: CupertinoColors.black),
+ onTap: () {
+ DefaultSheetController.maybeOf(context)
+ ?.animateTo(const Extent.proportional(1));
+ showEditBookmarkSheet(context);
+ },
+ );
+ }
+}
+
+class _TopBar extends StatelessWidget {
+ const _TopBar({
+ required this.pageTitle,
+ required this.displayUrl,
+ required this.faviconUrl,
+ });
+
+ final String pageTitle;
+ final String displayUrl;
+ final String faviconUrl;
+
+ @override
+ Widget build(BuildContext context) {
+ final pageTitle = Text(
+ this.pageTitle,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.titleMedium,
+ );
+ final displayUrl = Text(
+ this.displayUrl,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context)
+ .textTheme
+ .bodyMedium
+ ?.copyWith(color: CupertinoColors.secondaryLabel),
+ );
+
+ return SheetDraggable(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 16,
+ vertical: 16,
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SiteIcon(url: faviconUrl),
+ const SizedBox(width: 16),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [pageTitle, displayUrl],
+ ),
+ ),
+ const SizedBox(width: 16),
+ const _CloseButton(),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _CloseButton extends StatelessWidget {
+ const _CloseButton();
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onTap: () => Navigator.pop(context),
+ child: Container(
+ width: 36,
+ height: 36,
+ decoration: const ShapeDecoration(
+ shape: CircleBorder(),
+ color: CupertinoColors.systemGrey5,
+ ),
+ child: const Center(
+ child: Icon(
+ CupertinoIcons.xmark,
+ size: 18,
+ color: CupertinoColors.black,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/cookbook/lib/tutorial/cupertino_modal_sheet.dart b/cookbook/lib/tutorial/cupertino_modal_sheet.dart
new file mode 100644
index 00000000..d63c82e1
--- /dev/null
+++ b/cookbook/lib/tutorial/cupertino_modal_sheet.dart
@@ -0,0 +1,137 @@
+import 'package:flutter/cupertino.dart';
+import 'package:smooth_sheets/smooth_sheets.dart';
+
+void main() {
+ runApp(const _CupertinoModalSheetExample());
+}
+
+class _CupertinoModalSheetExample extends StatelessWidget {
+ const _CupertinoModalSheetExample();
+
+ @override
+ Widget build(BuildContext context) {
+ // Cupertino widgets are used in this example,
+ // but of course you can use material widgets as well.
+ return const CupertinoApp(
+ home: _ExampleHome(),
+ );
+ }
+}
+
+class _ExampleHome extends StatelessWidget {
+ const _ExampleHome();
+
+ @override
+ Widget build(BuildContext context) {
+ // It is recommended to wrap the top most non-modal page within a navigator
+ // with `CupertinoStackedTransition` to create more accurate ios 15 style
+ // transition animation; that is, while the first modal sheet goes to fullscreen,
+ // a non-modal page behind it will gradually reduce its size and the corner radius.
+ return CupertinoStackedTransition(
+ // The start and end values of the corner radius animation can be specified
+ // as the `cornerRadius` property. If `null` is specified (the default value),
+ // no corner radius animation is performed.
+ cornerRadius: Tween(begin: 0.0, end: 16.0),
+ child: CupertinoPageScaffold(
+ child: Center(
+ child: CupertinoButton.filled(
+ onPressed: () => _showModalSheet(context, isFullScreen: false),
+ child: const Text('Show Modal Sheet'),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+void _showModalSheet(BuildContext context, {required bool isFullScreen}) {
+ // Use `CupertinoModalSheetRoute` to show an ios 15 style modal sheet.
+ // For declarative navigation (Navigator 2.0), use `CupertinoModalSheetPage` instead.
+ final modalRoute = CupertinoModalSheetRoute(
+ builder: (context) => switch (isFullScreen) {
+ true => const _FullScreenSheet(),
+ false => const _HalfScreenSheet(),
+ },
+ );
+
+ Navigator.push(context, modalRoute);
+}
+
+class _HalfScreenSheet extends StatelessWidget {
+ const _HalfScreenSheet();
+
+ @override
+ Widget build(BuildContext context) {
+ // `CupertinoStackedTransition` won't start the transition animation until
+ // the visible height of a modal sheet (the extent) exceeds 50% of the screen height.
+ return const DraggableSheet(
+ initialExtent: Extent.proportional(0.5),
+ minExtent: Extent.proportional(0.5),
+ physics: StretchingSheetPhysics(
+ parent: SnappingSheetPhysics(
+ snappingBehavior: SnapToNearest(
+ snapTo: [
+ Extent.proportional(0.5),
+ Extent.proportional(1),
+ ],
+ ),
+ ),
+ ),
+ child: _SheetContent(),
+ );
+ }
+}
+
+class _FullScreenSheet extends StatelessWidget {
+ const _FullScreenSheet();
+
+ @override
+ Widget build(BuildContext context) {
+ return const DraggableSheet(
+ child: _SheetContent(),
+ );
+ }
+}
+
+class _SheetContent extends StatelessWidget {
+ const _SheetContent();
+
+ @override
+ Widget build(BuildContext context) {
+ // Nothing special here, just a simple modal sheet content.
+ return DecoratedBox(
+ decoration: const ShapeDecoration(
+ color: CupertinoColors.white,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(
+ Radius.circular(16),
+ ),
+ ),
+ ),
+ child: SizedBox.expand(
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ CupertinoButton.filled(
+ onPressed: () {
+ // `DefaultSheetController.of` is a handy way to obtain a `SheetController`
+ // that is exposed by the parent `CupertinoModalSheetRoute`.
+ DefaultSheetController.maybeOf(context)
+ ?.animateTo(const Extent.proportional(1));
+ _showModalSheet(context, isFullScreen: true);
+ },
+ child: const Text('Stack'),
+ ),
+ const SizedBox(height: 16),
+ CupertinoButton(
+ onPressed: () => Navigator.pop(context),
+ child: const Text('Close'),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/cookbook/pubspec.yaml b/cookbook/pubspec.yaml
index 82c475ec..4d2d5ca1 100644
--- a/cookbook/pubspec.yaml
+++ b/cookbook/pubspec.yaml
@@ -7,6 +7,7 @@ environment:
dependencies:
animations: ^2.0.10
+ cupertino_icons: ^1.0.6
faker: ^2.1.0
flutter:
sdk: flutter
@@ -23,4 +24,4 @@ dev_dependencies:
flutter:
uses-material-design: true
assets:
- - assets/fake_map.png
+ - assets/
diff --git a/package/README.md b/package/README.md
index f97ec8eb..4fd380b3 100644
--- a/package/README.md
+++ b/package/README.md
@@ -17,6 +17,10 @@
>
> This library is currently in the experimental stage. The API may undergo changes without prior notice.
+> [!NOTE]
+>
+> For documentation of the latest published version, please visit the [package page](https://pub.dev/packages/smooth_sheets) on pub.dev.
+
## Showcases
@@ -27,12 +31,24 @@
AI Playlist Generator
An AI assistant that helps create a music playlist based on the user's preferences. See the cookbook for more details.
- Used components:
+ Key components:
- NavigationSheet
- ModalSheetPage
- - SheetContentScaffold
- - SheetPhysics
+ - DraggableNavigationSheetPage
+ - ScrollableNavigationSheetPage
+
+ |
+
+
+ |
+
+ Safari app
+ A practical example of ios-style modal sheets. See the cookbook for more details.
+ Key components:
+
+ - CupertinoStackedTransition
+ - CupertinoModalSheetRoute
|
@@ -40,13 +56,12 @@
|
Airbnb mobile app clone
- A partial clone of the Airbnb mobile app. The user can drag the house list down to reveal the map behind it. See the cookbook for more details.
- Used components:
+ A partial clone of the Airbnb mobile app. The user can drag the house list down to reveal the map behind it. See the cookbook for more details.
+ Key components:
- ScrollableSheet
- SheetPhysics
- SheetController
- - SheetDraggable
- ExtentDrivenAnimation
|
@@ -55,7 +70,7 @@
|
Todo List
- A simple Todo app that shows how a sheet handles the on-screen keyboard. See the cookbook for more details.
+ A simple Todo app that shows how a sheet handles the on-screen keyboard. See the cookbook for more details.
Used components:
- ScrollableSheet
@@ -163,14 +178,23 @@ See also:
+
A sheet can be displayed as a modal sheet using ModalSheetRoute for imperative navigation, or ModalSheetPage for declarative navigation. A modal sheet offers the *pull-to-dismiss* action; the user can dismiss the sheet by swiping it down.
+
+
+
+
+Furthermore, [the modal sheets in the style of iOS 15](https://medium.com/surf-dev/bottomsheet-in-ios-15-uisheetpresentationcontroller-and-its-capabilities-5e913661c9f) are also supported. For imperative navigation, use CupertinoModalSheetRoute, and for declarative navigation, use CupertinoModalSheetPage, respectively.
+
+
See also:
- [declarative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/declarative_modal_sheet.dart), a tutorial of integration with declarative navigation using [go_router](https://pub.dev/packages/go_router) package.
- [imperative_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/imperative_modal_sheet.dart), a tutorial of integration with imperative Navigator API.
+- [cupertino_modal_sheet.dart](https://github.com/fujidaiti/smooth_sheets/blob/main/cookbook/lib/tutorial/cupertino_modal_sheet.dart), a tutorial of iOS style modal sheets.
@@ -322,7 +346,7 @@ See also:
- [ ] feat: Provide a way to interrupt a modal route popping
- [ ] feat: Support shared appbars in NavigationSheet
- [x] feat: Dispatch a [Notification](https://api.flutter.dev/flutter/widgets/Notification-class.html) when the sheet extent changes
-- [ ] feat: Implement modal sheet route adapted for iOS
+- [x] feat: Implement modal sheet route adapted for iOS
diff --git a/package/lib/smooth_sheets.dart b/package/lib/smooth_sheets.dart
index 3811a163..417d621f 100644
--- a/package/lib/smooth_sheets.dart
+++ b/package/lib/smooth_sheets.dart
@@ -3,13 +3,14 @@ export 'src/draggable/sheet_draggable.dart';
export 'src/foundation/animation.dart';
export 'src/foundation/framework.dart';
export 'src/foundation/keyboard_dismissible.dart';
-export 'src/foundation/modal_sheet.dart';
export 'src/foundation/notification.dart';
export 'src/foundation/sheet_activity.dart';
export 'src/foundation/sheet_content_scaffold.dart';
export 'src/foundation/sheet_controller.dart' hide SheetControllerScope;
export 'src/foundation/sheet_extent.dart';
export 'src/foundation/sheet_physics.dart';
+export 'src/modal/cupertino.dart';
+export 'src/modal/modal_sheet.dart';
export 'src/navigation/navigation_route.dart';
export 'src/navigation/navigation_routes.dart';
export 'src/navigation/navigation_sheet.dart';
diff --git a/package/lib/src/draggable/sheet_draggable.dart b/package/lib/src/draggable/sheet_draggable.dart
index 47c060b3..780274f3 100644
--- a/package/lib/src/draggable/sheet_draggable.dart
+++ b/package/lib/src/draggable/sheet_draggable.dart
@@ -104,7 +104,7 @@ class UserDragSheetActivity extends SheetActivity {
insets.bottom != oldInsets.bottom) {
// Append a delta of the bottom inset (typically the keyboard height)
// to keep the visual position of the sheet unchanged.
- setPixels(pixels! + (oldInsets.bottom - insets.bottom));
+ correctPixels(pixels! + (oldInsets.bottom - insets.bottom));
}
}
}
diff --git a/package/lib/src/foundation/sheet_controller.dart b/package/lib/src/foundation/sheet_controller.dart
index a2d4c1f9..d43cf9ec 100644
--- a/package/lib/src/foundation/sheet_controller.dart
+++ b/package/lib/src/foundation/sheet_controller.dart
@@ -8,6 +8,13 @@ class SheetController extends ChangeNotifier
implements ValueListenable {
SheetExtent? _client;
+ /// A notifier which notifies listeners immediately when the [_client] fires.
+ ///
+ /// This is necessary to keep separate the listeners that should be
+ /// notified immediately when the [_client] fires, and the ones that should
+ /// not be notified during the middle of a frame.
+ final _immediateListeners = ChangeNotifier();
+
@override
double? get value => _client?.pixels;
@@ -15,6 +22,21 @@ class SheetController extends ChangeNotifier
return _client?.hasPixels == true ? _client!.metrics : null;
}
+ @override
+ void addListener(VoidCallback listener, {bool fireImmediately = false}) {
+ if (fireImmediately) {
+ _immediateListeners.addListener(listener);
+ } else {
+ super.addListener(listener);
+ }
+ }
+
+ @override
+ void removeListener(VoidCallback listener) {
+ _immediateListeners.removeListener(listener);
+ super.removeListener(listener);
+ }
+
void attach(SheetExtent extent) {
if (_client case final oldExtent?) {
detach(oldExtent);
@@ -47,6 +69,8 @@ class SheetController extends ChangeNotifier
@override
void notifyListeners() {
+ _immediateListeners.notifyListeners();
+
// Avoid notifying listeners during the middle of a frame.
switch (SchedulerBinding.instance.schedulerPhase) {
case SchedulerPhase.idle:
@@ -56,7 +80,9 @@ class SheetController extends ChangeNotifier
case SchedulerPhase.persistentCallbacks:
case SchedulerPhase.midFrameMicrotasks:
- break;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ super.notifyListeners();
+ });
}
}
}
diff --git a/package/lib/src/foundation/sheet_extent.dart b/package/lib/src/foundation/sheet_extent.dart
index 4312dd84..a678626f 100644
--- a/package/lib/src/foundation/sheet_extent.dart
+++ b/package/lib/src/foundation/sheet_extent.dart
@@ -127,9 +127,15 @@ abstract class SheetExtent with ChangeNotifier, MaybeSheetMetrics {
@mustCallSuper
void applyNewViewportDimensions(ViewportDimensions viewportDimensions) {
if (_viewportDimensions != viewportDimensions) {
+ final oldPixels = pixels;
+ final oldViewPixels = viewPixels;
final oldDimensions = _viewportDimensions;
+
_viewportDimensions = viewportDimensions;
_activity!.didChangeViewportDimensions(oldDimensions);
+ if (oldPixels != pixels || oldViewPixels != viewPixels) {
+ notifyListeners();
+ }
}
}
@@ -174,15 +180,20 @@ abstract class SheetExtent with ChangeNotifier, MaybeSheetMetrics {
Duration duration = const Duration(milliseconds: 300),
}) {
assert(hasPixels);
- final activity = DrivenSheetActivity(
- from: pixels!,
- to: newExtent.resolve(contentDimensions!),
- duration: duration,
- curve: curve,
- );
+ final destination = newExtent.resolve(contentDimensions!);
+ if (pixels == destination) {
+ return Future.value();
+ } else {
+ final activity = DrivenSheetActivity(
+ from: pixels!,
+ to: destination,
+ duration: duration,
+ curve: curve,
+ );
- beginActivity(activity);
- return activity.done;
+ beginActivity(activity);
+ return activity.done;
+ }
}
}
@@ -222,6 +233,11 @@ mixin MaybeSheetMetrics {
Size? get contentDimensions;
ViewportDimensions? get viewportDimensions;
+ double? get viewPixels => switch ((pixels, viewportDimensions)) {
+ (final pixels?, final viewport?) => pixels + viewport.insets.bottom,
+ _ => null,
+ };
+
bool get hasPixels =>
pixels != null &&
minPixels != null &&
@@ -233,6 +249,7 @@ mixin MaybeSheetMetrics {
String toString() => (
hasPixels: hasPixels,
pixels: pixels,
+ viewPixels: viewPixels,
minPixels: minPixels,
maxPixels: maxPixels,
contentDimensions: contentDimensions,
@@ -240,7 +257,7 @@ mixin MaybeSheetMetrics {
).toString();
}
-mixin SheetMetrics implements MaybeSheetMetrics {
+mixin SheetMetrics on MaybeSheetMetrics {
@override
double get pixels;
@@ -257,20 +274,10 @@ mixin SheetMetrics implements MaybeSheetMetrics {
ViewportDimensions get viewportDimensions;
@override
- bool get hasPixels => true;
-
- @override
- String toString() => (
- hasPixels: hasPixels,
- pixels: pixels,
- minPixels: minPixels,
- maxPixels: maxPixels,
- contentDimensions: contentDimensions,
- viewportDimensions: viewportDimensions,
- ).toString();
+ double get viewPixels => super.viewPixels!;
}
-class SheetMetricsSnapshot with SheetMetrics {
+class SheetMetricsSnapshot with MaybeSheetMetrics, SheetMetrics {
const SheetMetricsSnapshot({
required this.pixels,
required this.minPixels,
@@ -304,6 +311,9 @@ class SheetMetricsSnapshot with SheetMetrics {
@override
final ViewportDimensions viewportDimensions;
+ @override
+ bool get hasPixels => true;
+
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
@@ -337,7 +347,7 @@ class SheetMetricsSnapshot with SheetMetrics {
).toString();
}
-class _SheetMetricsBox with SheetMetrics {
+class _SheetMetricsBox with MaybeSheetMetrics, SheetMetrics {
_SheetMetricsBox(this._source);
final MaybeSheetMetrics _source;
diff --git a/package/lib/src/internal/double_utils.dart b/package/lib/src/internal/double_utils.dart
index 9137dab5..f388d31c 100644
--- a/package/lib/src/internal/double_utils.dart
+++ b/package/lib/src/internal/double_utils.dart
@@ -25,3 +25,7 @@ extension DoubleUtils on double {
double nearest(double a, double b) =>
(a - this).abs() < (b - this).abs() ? a : b;
}
+
+double inverseLerp(double min, double max, double value) {
+ return min == max ? 1.0 : (value - min) / (max - min);
+}
diff --git a/package/lib/src/modal/cupertino.dart b/package/lib/src/modal/cupertino.dart
new file mode 100644
index 00000000..fd67b468
--- /dev/null
+++ b/package/lib/src/modal/cupertino.dart
@@ -0,0 +1,517 @@
+import 'dart:math';
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:smooth_sheets/smooth_sheets.dart';
+import 'package:smooth_sheets/src/internal/double_utils.dart';
+import 'package:smooth_sheets/src/modal/modal_sheet.dart';
+
+const _minimizedViewportScale = 0.92;
+const _cupertinoBarrierColor = Color(0x18000000);
+const _cupertinoTransitionDuration = Duration(milliseconds: 300);
+const _cupertinoTransitionCurve = Curves.fastEaseInToSlowEaseOut;
+const _cupertinoStackedTransitionCurve = Curves.easeIn;
+
+class _TransitionController extends ValueNotifier {
+ _TransitionController(super._value);
+
+ @override
+ set value(double newValue) {
+ super.value = newValue.clamp(0, 1);
+ }
+}
+
+/// Animates the corner radius of the [child] widget.
+///
+/// The associated render object ([_RenderCornerRadiusTransition]) observes
+/// the [animation] and updates the [RenderClipRRect.borderRadius] property
+/// when the animation value changes, which in turn updates the corner radius
+/// of the [child] widget.
+///
+/// Although we can achieve the same effect by simply rebuilding a [ClipRRect]
+/// when the [animation] value changes, this class is necessary because,
+/// in our usecase, the [animation] may be updated during a layout phase
+/// (e.g. when a [MediaQueryData.viewInsets] is changed), which is too late
+/// to rebuild the widget tree.
+class _CornerRadiusTransition extends SingleChildRenderObjectWidget {
+ const _CornerRadiusTransition({
+ required this.animation,
+ required this.cornerRadius,
+ required super.child,
+ });
+
+ final Animation animation;
+ final Tween cornerRadius;
+
+ @override
+ RenderObject createRenderObject(BuildContext context) {
+ return _RenderCornerRadiusTransition(
+ animation: animation,
+ cornerRadius: cornerRadius,
+ );
+ }
+
+ @override
+ void updateRenderObject(
+ BuildContext context,
+ _RenderCornerRadiusTransition renderObject,
+ ) {
+ super.updateRenderObject(context, renderObject);
+ renderObject
+ ..animation = animation
+ ..cornerRadius = cornerRadius;
+ }
+}
+
+class _RenderCornerRadiusTransition extends RenderClipRRect {
+ _RenderCornerRadiusTransition({
+ required Animation animation,
+ required Tween cornerRadius,
+ }) : _animation = animation,
+ _cornerRadius = cornerRadius,
+ super(clipBehavior: Clip.antiAlias) {
+ _animation.addListener(_invalidateBorderRadius);
+ }
+
+ Animation _animation;
+ // ignore: avoid_setters_without_getters
+ set animation(Animation value) {
+ if (_animation != value) {
+ _animation.removeListener(_invalidateBorderRadius);
+ _animation = value..addListener(_invalidateBorderRadius);
+ _invalidateBorderRadius();
+ }
+ }
+
+ Tween _cornerRadius;
+ // ignore: avoid_setters_without_getters
+ set cornerRadius(Tween value) {
+ if (_cornerRadius != value) {
+ _cornerRadius = value;
+ _invalidateBorderRadius();
+ }
+ }
+
+ @override
+ void dispose() {
+ _animation.removeListener(_invalidateBorderRadius);
+ super.dispose();
+ }
+
+ void _invalidateBorderRadius() {
+ borderRadius = BorderRadius.circular(
+ _cornerRadius.transform(_animation.value),
+ );
+ }
+}
+
+class _TransformTransition extends SingleChildRenderObjectWidget {
+ const _TransformTransition({
+ required this.animation,
+ required this.offset,
+ required this.scaleFactor,
+ required super.child,
+ });
+
+ final Animation animation;
+ final Tween offset;
+ final Tween scaleFactor;
+
+ @override
+ RenderObject createRenderObject(BuildContext context) {
+ return _RenderTransformTransition(
+ animation: animation,
+ scaleTween: scaleFactor,
+ translateTween: offset,
+ );
+ }
+
+ @override
+ void updateRenderObject(
+ BuildContext context,
+ _RenderTransformTransition renderObject,
+ ) {
+ super.updateRenderObject(context, renderObject);
+ renderObject
+ ..animation = animation
+ ..scaleFactor = scaleFactor
+ ..offset = offset;
+ }
+}
+
+class _RenderTransformTransition extends RenderTransform {
+ _RenderTransformTransition({
+ required Animation animation,
+ required Tween scaleTween,
+ required Tween translateTween,
+ }) : _animation = animation,
+ _scaleFactor = scaleTween,
+ _offset = translateTween,
+ super(
+ transform: Matrix4.identity(),
+ alignment: Alignment.topCenter,
+ transformHitTests: true,
+ ) {
+ _animation.addListener(_invalidateMatrix);
+ }
+
+ Animation _animation;
+ // ignore: avoid_setters_without_getters
+ set animation(Animation value) {
+ if (_animation != value) {
+ _animation.removeListener(_invalidateMatrix);
+ _animation = value..addListener(_invalidateMatrix);
+ _invalidateMatrix();
+ }
+ }
+
+ Tween _scaleFactor;
+ // ignore: avoid_setters_without_getters
+ set scaleFactor(Tween value) {
+ if (_scaleFactor != value) {
+ _scaleFactor = value;
+ _invalidateMatrix();
+ }
+ }
+
+ Tween _offset;
+ // ignore: avoid_setters_without_getters
+ set offset(Tween value) {
+ if (_offset != value) {
+ _offset = value;
+ _invalidateMatrix();
+ }
+ }
+
+ @override
+ void dispose() {
+ _animation.removeListener(_invalidateMatrix);
+ super.dispose();
+ }
+
+ void _invalidateMatrix() {
+ final scaleFactor = _scaleFactor.transform(_animation.value);
+ final offset = _offset.transform(_animation.value);
+ transform = Matrix4.translationValues(0.0, offset, 0.0)
+ ..scale(scaleFactor, scaleFactor, 1.0);
+ }
+}
+
+/// A mapping of [PageRoute] to its associated [_TransitionController].
+///
+/// This is used to modify the transition progress of the previous route
+/// from the current [_BaseCupertinoModalSheetRoute].
+final _cupertinoTransitionControllerOf =
+ , _TransitionController>{};
+
+mixin _CupertinoStackedTransitionStateMixin
+ on State {
+ late final _TransitionController _controller;
+ PageRoute? _parentRoute;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = _TransitionController(0.0);
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ final parentRoute = ModalRoute.of(context);
+
+ assert(
+ parentRoute is PageRoute,
+ '$CupertinoModalStackedTransition can only be used with PageRoutes.',
+ );
+ assert(
+ _cupertinoTransitionControllerOf[parentRoute] == null ||
+ _cupertinoTransitionControllerOf[parentRoute] == _controller,
+ 'Only one $CupertinoModalStackedTransition can be used per route.',
+ );
+
+ _cupertinoTransitionControllerOf.remove(_parentRoute);
+ _parentRoute = parentRoute! as PageRoute;
+ _cupertinoTransitionControllerOf[_parentRoute!] = _controller;
+ }
+
+ @override
+ void dispose() {
+ _cupertinoTransitionControllerOf.remove(_parentRoute);
+ _controller.dispose();
+ super.dispose();
+ }
+}
+
+class CupertinoModalStackedTransition extends StatefulWidget {
+ const CupertinoModalStackedTransition({
+ super.key,
+ required this.child,
+ });
+
+ final Widget child;
+
+ @override
+ State createState() =>
+ _CupertinoModalStackedTransitionState();
+}
+
+class _CupertinoModalStackedTransitionState
+ extends State
+ with
+ _CupertinoStackedTransitionStateMixin {
+ @override
+ Widget build(BuildContext context) {
+ const extraMargin = 12.0;
+
+ return Padding(
+ padding: EdgeInsets.only(
+ top: MediaQuery.viewPaddingOf(context).top + extraMargin,
+ ),
+ child: MediaQuery.removeViewPadding(
+ context: context,
+ removeTop: true,
+ child: _TransformTransition(
+ animation: Animation.fromValueListenable(_controller)
+ .drive(CurveTween(curve: _cupertinoStackedTransitionCurve)),
+ offset: Tween(begin: 0.0, end: -extraMargin),
+ scaleFactor: Tween(begin: 1.0, end: _minimizedViewportScale),
+ child: widget.child,
+ ),
+ ),
+ );
+ }
+}
+
+class CupertinoStackedTransition extends StatefulWidget {
+ const CupertinoStackedTransition({
+ super.key,
+ this.cornerRadius,
+ required this.child,
+ });
+
+ final Tween? cornerRadius;
+ final Widget child;
+
+ @override
+ State createState() =>
+ _CupertinoStackedTransitionState();
+}
+
+class _CupertinoStackedTransitionState extends State
+ with _CupertinoStackedTransitionStateMixin {
+ @override
+ Widget build(BuildContext context) {
+ final topViewPadding = MediaQuery.viewPaddingOf(context).top;
+ final animation = Animation.fromValueListenable(_controller)
+ .drive(CurveTween(curve: _cupertinoStackedTransitionCurve));
+
+ final result = switch (widget.cornerRadius) {
+ // Some optimizations to avoid unnecessary animations.
+ null => widget.child,
+ Tween(begin: null, end: null) => widget.child,
+ Tween(begin: 0.0, end: 0.0) => widget.child,
+ Tween(:final begin, :final end) when begin == end => ClipRRect(
+ borderRadius: BorderRadius.circular(begin ?? 0.0),
+ clipBehavior: Clip.antiAlias,
+ child: widget.child,
+ ),
+ final cornerRadius => _CornerRadiusTransition(
+ animation: animation,
+ cornerRadius: cornerRadius,
+ child: widget.child,
+ ),
+ };
+
+ return _TransformTransition(
+ animation: animation,
+ offset: Tween(begin: 0.0, end: topViewPadding),
+ scaleFactor: Tween(begin: 1.0, end: _minimizedViewportScale),
+ child: result,
+ );
+ }
+}
+
+abstract class _BaseCupertinoModalSheetRoute extends PageRoute
+ with ModalSheetRouteMixin {
+ _BaseCupertinoModalSheetRoute({super.settings});
+
+ PageRoute? _previousRoute;
+
+ @override
+ void didChangePrevious(Route? previousRoute) {
+ super.didChangePrevious(previousRoute);
+ _previousRoute = previousRoute as PageRoute?;
+ }
+
+ @override
+ void install() {
+ super.install();
+ controller!.addListener(_invalidateTransitionProgress);
+ sheetController.addListener(
+ _invalidateTransitionProgress,
+ fireImmediately: true,
+ );
+ }
+
+ @override
+ void dispose() {
+ sheetController.removeListener(_invalidateTransitionProgress);
+ controller!.removeListener(_invalidateTransitionProgress);
+ super.dispose();
+ }
+
+ void _invalidateTransitionProgress() {
+ switch (controller!.status) {
+ case AnimationStatus.forward:
+ case AnimationStatus.completed:
+ if (sheetController.metrics case final metrics?) {
+ _cupertinoTransitionControllerOf[_previousRoute]?.value = min(
+ controller!.value,
+ inverseLerp(
+ metrics.viewportDimensions.height / 2,
+ metrics.viewportDimensions.height,
+ metrics.viewPixels,
+ ),
+ );
+ }
+
+ case AnimationStatus.reverse:
+ case AnimationStatus.dismissed:
+ _cupertinoTransitionControllerOf[_previousRoute]?.value = min(
+ controller!.value,
+ _cupertinoTransitionControllerOf[_previousRoute]!.value,
+ );
+ }
+ }
+
+ @override
+ Widget buildPage(
+ BuildContext context,
+ Animation animation,
+ Animation secondaryAnimation,
+ ) {
+ return CupertinoModalStackedTransition(
+ child: super.buildPage(context, animation, secondaryAnimation),
+ );
+ }
+}
+
+class CupertinoModalSheetPage extends Page {
+ const CupertinoModalSheetPage({
+ super.key,
+ super.name,
+ super.arguments,
+ super.restorationId,
+ this.maintainState = true,
+ this.enablePullToDismiss = true,
+ this.barrierDismissible = true,
+ this.barrierLabel,
+ this.barrierColor = _cupertinoBarrierColor,
+ this.transitionDuration = _cupertinoTransitionDuration,
+ this.transitionCurve = _cupertinoTransitionCurve,
+ required this.child,
+ });
+
+ /// The content to be shown in the [Route] created by this page.
+ final Widget child;
+
+ /// {@macro flutter.widgets.ModalRoute.maintainState}
+ final bool maintainState;
+
+ final Color? barrierColor;
+
+ final bool barrierDismissible;
+
+ final String? barrierLabel;
+
+ final bool enablePullToDismiss;
+
+ final Duration transitionDuration;
+
+ final Curve transitionCurve;
+
+ @override
+ Route createRoute(BuildContext context) {
+ return _PageBasedCupertinoModalSheetRoute(page: this);
+ }
+}
+
+class _PageBasedCupertinoModalSheetRoute
+ extends _BaseCupertinoModalSheetRoute {
+ _PageBasedCupertinoModalSheetRoute({
+ required CupertinoModalSheetPage page,
+ }) : super(settings: page);
+
+ CupertinoModalSheetPage get _page =>
+ settings as CupertinoModalSheetPage;
+
+ @override
+ bool get maintainState => _page.maintainState;
+
+ @override
+ Color? get barrierColor => _page.barrierColor;
+
+ @override
+ String? get barrierLabel => _page.barrierLabel;
+
+ @override
+ bool get barrierDismissible => _page.barrierDismissible;
+
+ @override
+ bool get enablePullToDismiss => _page.enablePullToDismiss;
+
+ @override
+ Curve get transitionCurve => _page.transitionCurve;
+
+ @override
+ Duration get transitionDuration => _page.transitionDuration;
+
+ @override
+ String get debugLabel => '${super.debugLabel}(${_page.name})';
+
+ @override
+ Widget buildContent(BuildContext context) => _page.child;
+}
+
+class CupertinoModalSheetRoute extends _BaseCupertinoModalSheetRoute {
+ CupertinoModalSheetRoute({
+ required this.builder,
+ this.enablePullToDismiss = true,
+ this.maintainState = true,
+ this.barrierDismissible = true,
+ this.barrierLabel,
+ this.barrierColor = _cupertinoBarrierColor,
+ this.transitionDuration = _cupertinoTransitionDuration,
+ this.transitionCurve = _cupertinoTransitionCurve,
+ });
+
+ final WidgetBuilder builder;
+
+ @override
+ final Color? barrierColor;
+
+ @override
+ final bool barrierDismissible;
+
+ @override
+ final String? barrierLabel;
+
+ @override
+ final bool enablePullToDismiss;
+
+ @override
+ final bool maintainState;
+
+ @override
+ final Duration transitionDuration;
+
+ @override
+ final Curve transitionCurve;
+
+ @override
+ Widget buildContent(BuildContext context) {
+ return builder(context);
+ }
+}
diff --git a/package/lib/src/foundation/modal_sheet.dart b/package/lib/src/modal/modal_sheet.dart
similarity index 93%
rename from package/lib/src/foundation/modal_sheet.dart
rename to package/lib/src/modal/modal_sheet.dart
index 99cc6688..b0d00a33 100644
--- a/package/lib/src/foundation/modal_sheet.dart
+++ b/package/lib/src/modal/modal_sheet.dart
@@ -137,6 +137,20 @@ mixin ModalSheetRouteMixin on ModalRoute {
@override
bool get opaque => false;
+ late final SheetController sheetController;
+
+ @override
+ void install() {
+ super.install();
+ sheetController = SheetController();
+ }
+
+ @override
+ void dispose() {
+ sheetController.dispose();
+ super.dispose();
+ }
+
Widget buildContent(BuildContext context);
@override
@@ -145,15 +159,21 @@ mixin ModalSheetRouteMixin on ModalRoute {
Animation animation,
Animation secondaryAnimation,
) {
- return switch (enablePullToDismiss) {
- true => _SheetDismissible(
- transitionAnimation: controller!,
- transitionDuration: transitionDuration,
- navigator: navigator!,
- child: buildContent(context),
- ),
- false => buildContent(context),
- };
+ var content = buildContent(context);
+
+ if (enablePullToDismiss) {
+ content = _SheetDismissible(
+ transitionAnimation: controller!,
+ transitionDuration: transitionDuration,
+ navigator: navigator!,
+ child: content,
+ );
+ }
+
+ return SheetControllerScope(
+ controller: sheetController,
+ child: content,
+ );
}
@override
@@ -211,7 +231,7 @@ class _SheetDismissible extends StatefulWidget {
}
class _SheetDismissibleState extends State<_SheetDismissible> {
- late final SheetController _sheetController;
+ late SheetController _sheetController;
late final _PullToDismissGestureRecognizer _gestureRecognizer;
ScrollMetrics? _lastReportedScrollMetrics;
AsyncValueGetter? _shouldDismissCallback;
@@ -219,7 +239,6 @@ class _SheetDismissibleState extends State<_SheetDismissible> {
@override
void initState() {
super.initState();
- _sheetController = SheetController();
_gestureRecognizer = _PullToDismissGestureRecognizer(target: this)
..onStart = _handleDragStart
..onUpdate = handleDragUpdate
@@ -227,18 +246,18 @@ class _SheetDismissibleState extends State<_SheetDismissible> {
..onCancel = handleDragCancel;
}
+ @override
+ void dispose() {
+ _gestureRecognizer.dispose();
+ super.dispose();
+ }
+
@override
void didChangeDependencies() {
super.didChangeDependencies();
_gestureRecognizer.gestureSettings =
MediaQuery.maybeGestureSettingsOf(context);
- }
-
- @override
- void dispose() {
- _sheetController.dispose();
- _gestureRecognizer.dispose();
- super.dispose();
+ _sheetController = DefaultSheetController.of(context);
}
double _draggedDistance = 0;
@@ -336,10 +355,7 @@ class _SheetDismissibleState extends State<_SheetDismissible> {
children: [
NotificationListener(
onNotification: _handleScrollUpdate,
- child: SheetControllerScope(
- controller: _sheetController,
- child: widget.child,
- ),
+ child: widget.child,
),
Listener(
behavior: HitTestBehavior.translucent,
diff --git a/package/lib/src/scrollable/scrollable_sheet_extent.dart b/package/lib/src/scrollable/scrollable_sheet_extent.dart
index e736012f..63920787 100644
--- a/package/lib/src/scrollable/scrollable_sheet_extent.dart
+++ b/package/lib/src/scrollable/scrollable_sheet_extent.dart
@@ -293,7 +293,7 @@ class _ContentUserScrollDrivenSheetActivity
insets.bottom != oldInsets.bottom) {
// Append a delta of the bottom inset (typically the keyboard height)
// to keep the visual position of the sheet unchanged.
- setPixels(pixels! + (oldInsets.bottom - insets.bottom));
+ correctPixels(pixels! + (oldInsets.bottom - insets.bottom));
}
}
}
diff --git a/resources/cookbook-cupertino-modal-sheet.gif b/resources/cookbook-cupertino-modal-sheet.gif
new file mode 100644
index 00000000..2b5921c3
Binary files /dev/null and b/resources/cookbook-cupertino-modal-sheet.gif differ
diff --git a/resources/cookbook-safari.gif b/resources/cookbook-safari.gif
new file mode 100644
index 00000000..8096254d
Binary files /dev/null and b/resources/cookbook-safari.gif differ
diff --git a/resources/index.md b/resources/index.md
index 7af270c6..b5c8c2d7 100644
--- a/resources/index.md
+++ b/resources/index.md
@@ -11,3 +11,5 @@
![cookbook-declarative-navigation-sheet](https://github.com/fujidaiti/smooth_sheets/assets/68946713/3367d3bc-a895-42be-8154-2f6fc83b30b5)
![cookbook-airbnb-mobile-app](https://github.com/fujidaiti/smooth_sheets/assets/68946713/1fb3f047-c993-42be-9a7e-b3efc89be635)
![cookbook-extent-driven-animation](https://github.com/fujidaiti/smooth_sheets/assets/68946713/8b9ed0ef-675e-4468-8a3f-cd3f1ed3dfb0)
+![cookbook-cupertino-modal-sheet](https://github.com/fujidaiti/smooth_sheets/assets/68946713/242a8d32-a355-4d4a-8248-4572a03c64eb)
+![cookbook-safari](https://github.com/fujidaiti/smooth_sheets/assets/68946713/ad3f0ec1-fd7b-45d3-94a3-0b17c12b5889)
|