diff --git a/examples/api/lib/cupertino/sheet/cupertino_sheet.0.dart b/examples/api/lib/cupertino/sheet/cupertino_sheet.0.dart new file mode 100644 index 0000000000..b3f56438c1 --- /dev/null +++ b/examples/api/lib/cupertino/sheet/cupertino_sheet.0.dart @@ -0,0 +1,86 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [CupertinoSheetRoute]. + +void main() { + runApp(const CupertinoSheetApp()); +} + +class CupertinoSheetApp extends StatelessWidget { + const CupertinoSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(title: 'Cupertino Sheet', home: HomePage()); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoSheetRoute( + builder: (BuildContext context) => const _SheetScaffold(), + ), + ); + }, + child: const Text('Open Bottom Sheet'), + ), + ], + ), + ), + ); + } +} + +class _SheetScaffold extends StatelessWidget { + const _SheetScaffold(); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('CupertinoSheetRoute'), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).maybePop(); + }, + child: const Text('Go Back'), + ), + const Text('You can also close this sheet by dragging downwards'), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoSheetRoute( + builder: (BuildContext context) => const _SheetScaffold(), + ), + ); + }, + child: const Text('Push Another Sheet'), + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/lib/cupertino/sheet/cupertino_sheet.1.dart b/examples/api/lib/cupertino/sheet/cupertino_sheet.1.dart new file mode 100644 index 0000000000..7395e8c274 --- /dev/null +++ b/examples/api/lib/cupertino/sheet/cupertino_sheet.1.dart @@ -0,0 +1,120 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [showCupertinoSheet]. + +void main() { + runApp(const CupertinoSheetApp()); +} + +class CupertinoSheetApp extends StatelessWidget { + const CupertinoSheetApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp(title: 'Cupertino Sheet', home: HomePage()); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoButton.filled( + onPressed: () { + showCupertinoSheet( + context: context, + useNestedNavigation: true, + pageBuilder: (BuildContext context) => const _SheetScaffold(), + ); + }, + child: const Text('Open Bottom Sheet'), + ), + ], + ), + ), + ); + } +} + +class _SheetScaffold extends StatelessWidget { + const _SheetScaffold(); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold(child: _SheetBody(title: 'CupertinoSheetRoute')); + } +} + +class _SheetBody extends StatelessWidget { + const _SheetBody({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(title), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Go Back'), + ), + CupertinoButton.filled( + onPressed: () { + CupertinoSheetRoute.popSheet(context); + }, + child: const Text('Pop Whole Sheet'), + ), + CupertinoButton.filled( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute(builder: (BuildContext context) => const _SheetNextPage()), + ); + }, + child: const Text('Push Nested Page'), + ), + CupertinoButton.filled( + onPressed: () { + showCupertinoSheet( + context: context, + useNestedNavigation: true, + pageBuilder: (BuildContext context) => const _SheetScaffold(), + ); + }, + child: const Text('Push Another Sheet'), + ), + ], + ), + ); + } +} + +class _SheetNextPage extends StatelessWidget { + const _SheetNextPage(); + + @override + Widget build(BuildContext context) { + return const CupertinoPageScaffold( + backgroundColor: CupertinoColors.activeOrange, + child: _SheetBody(title: 'Next Page'), + ); + } +} diff --git a/examples/api/lib/cupertino/sheet/cupertino_sheet.2.dart b/examples/api/lib/cupertino/sheet/cupertino_sheet.2.dart new file mode 100644 index 0000000000..399a00cbec --- /dev/null +++ b/examples/api/lib/cupertino/sheet/cupertino_sheet.2.dart @@ -0,0 +1,278 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; + +/// Flutter code sample for [CupertinoSheetRoute] with restorable state and nested navigation. + +void main() => runApp(const RestorableSheetExampleApp()); + +class RestorableSheetExampleApp extends StatelessWidget { + const RestorableSheetExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return const CupertinoApp( + restorationScopeId: 'sheet-app', + title: 'Restorable Sheet', + home: RestorableSheet(restorationId: 'sheet'), + ); + } +} + +class RestorableSheet extends StatefulWidget { + const RestorableSheet({super.key, this.restorationId}); + + final String? restorationId; + + @override + State createState() => _RestorableSheetState(); +} + +@pragma('vm:entry-point') +class _RestorableSheetState extends State with RestorationMixin { + final RestorableInt _counter = RestorableInt(0); + late RestorableRouteFuture _restorableSheetRouteFuture; + + @override + void initState() { + super.initState(); + _restorableSheetRouteFuture = RestorableRouteFuture( + onComplete: _changeCounter, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush(_counterSheetBuilder, arguments: _counter.value); + }, + ); + } + + @override + String? get restorationId => widget.restorationId; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'count'); + registerForRestoration(_restorableSheetRouteFuture, 'sheet_route_future'); + } + + @override + void dispose() { + _counter.dispose(); + super.dispose(); + } + + @pragma('vm:entry-point') + static Route _counterSheetBuilder(BuildContext context, Object? arguments) { + return CupertinoSheetRoute( + builder: (BuildContext context) { + return Navigator( + restorationScopeId: 'nested-nav', + onGenerateRoute: (RouteSettings settings) { + return CupertinoPageRoute( + settings: settings, + builder: (BuildContext context) { + return PopScope( + canPop: settings.name != '/', + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context).pop(); + }, + child: CounterSheetScaffold(counter: arguments! as int), + ); + }, + ); + }, + ); + }, + ); + } + + void _changeCounter(int? newCounter) { + if (newCounter != null) { + setState(() { + _counter.value = newCounter; + }); + } + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Sheet Example'), + automaticBackgroundVisibility: false, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Counter current value:'), + Text('${_counter.value}'), + CupertinoButton( + child: const Text('Open Sheet'), + onPressed: () { + _restorableSheetRouteFuture.present(); + }, + ), + ], + ), + ), + ); + } +} + +class CounterSheetScaffold extends StatefulWidget { + const CounterSheetScaffold({super.key, required this.counter}); + + final int counter; + + @override + State createState() => _CounterSheetScaffoldState(); +} + +class _CounterSheetScaffoldState extends State with RestorationMixin { + late RestorableInt _counter; + late RestorableRouteFuture _multiplicationRouteFuture; + + @override + void initState() { + super.initState(); + _counter = RestorableInt(widget.counter); + _multiplicationRouteFuture = RestorableRouteFuture( + onComplete: _changeCounter, + onPresent: (NavigatorState navigator, Object? arguments) { + return navigator.restorablePush(_multiplicationRouteBuilder, arguments: _counter.value); + }, + ); + } + + @pragma('vm:entry-point') + static Route _multiplicationRouteBuilder(BuildContext context, Object? arguments) { + return CupertinoPageRoute( + settings: const RouteSettings(name: '/multiplication'), + builder: (BuildContext context) { + return MultiplicationPage(counter: arguments! as int); + }, + ); + } + + void _changeCounter(int? newCounter) { + if (newCounter != null) { + setState(() { + _counter.value = newCounter; + }); + } + } + + @override + String? get restorationId => 'sheet_scaffold'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'sheet_counter'); + registerForRestoration(_multiplicationRouteFuture, 'multiplication_route'); + if (!_counter.enabled) { + _counter = RestorableInt(widget.counter); + } + } + + @override + void dispose() { + _counter.dispose(); + _multiplicationRouteFuture.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('Current Count: ${_counter.value}'), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value - 1); + }, + child: const Text('Decrease'), + ), + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value + 1); + }, + child: const Text('Increase'), + ), + ], + ), + CupertinoButton( + onPressed: () => _multiplicationRouteFuture.present(), + child: const Text('Go to Multiplication Page'), + ), + CupertinoButton( + onPressed: () => Navigator.of(context, rootNavigator: true).pop(_counter.value), + child: const Text('Pop Sheet'), + ), + ], + ), + ), + ); + } +} + +class MultiplicationPage extends StatefulWidget { + const MultiplicationPage({super.key, required this.counter}); + + final int counter; + + @override + State createState() => _MultiplicationPageState(); +} + +class _MultiplicationPageState extends State with RestorationMixin { + late final RestorableInt _counter = RestorableInt(widget.counter); + + @override + String? get restorationId => 'multiplication_page'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(_counter, 'multi_counter'); + } + + @override + void dispose() { + _counter.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Current Count'), + Text(_counter.value.toString()), + CupertinoButton( + onPressed: () { + setState(() => _counter.value = _counter.value * 2); + }, + child: const Text('Double it'), + ), + CupertinoButton( + onPressed: () => Navigator.pop(context, _counter.value), + child: const Text('Pass it on to the last sheet'), + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/cupertino/sheet/cupertino_sheet.0_test.dart b/examples/api/test/cupertino/sheet/cupertino_sheet.0_test.dart new file mode 100644 index 0000000000..83adb56fbc --- /dev/null +++ b/examples/api/test/cupertino/sheet/cupertino_sheet.0_test.dart @@ -0,0 +1,24 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/sheet/cupertino_sheet.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('CupertinoSheetRoute'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Go Back')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); +} diff --git a/examples/api/test/cupertino/sheet/cupertino_sheet.1_test.dart b/examples/api/test/cupertino/sheet/cupertino_sheet.1_test.dart new file mode 100644 index 0000000000..07b25e126d --- /dev/null +++ b/examples/api/test/cupertino/sheet/cupertino_sheet.1_test.dart @@ -0,0 +1,44 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/sheet/cupertino_sheet.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', (WidgetTester tester) async { + await tester.pumpWidget(const example.CupertinoSheetApp()); + + final Finder dialogTitle = find.text('CupertinoSheetRoute'); + final Finder nextPageTitle = find.text('Next Page'); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + expect(nextPageTitle, findsNothing); + + await tester.tap(find.text('Push Nested Page')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Push Another Sheet')); + await tester.pumpAndSettle(); + // Both titles are on the screen, though one is covered by the second sheet. + expect(dialogTitle, findsOneWidget); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Pop Whole Sheet').last); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsOneWidget); + + await tester.tap(find.text('Pop Whole Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + expect(nextPageTitle, findsNothing); + }); +} diff --git a/examples/api/test/cupertino/sheet/cupertino_sheet.2_test.dart b/examples/api/test/cupertino/sheet/cupertino_sheet.2_test.dart new file mode 100644 index 0000000000..3a3d49c389 --- /dev/null +++ b/examples/api/test/cupertino/sheet/cupertino_sheet.2_test.dart @@ -0,0 +1,47 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_api_samples/cupertino/sheet/cupertino_sheet.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Tap on button displays cupertino sheet', (WidgetTester tester) async { + await tester.pumpWidget(const example.RestorableSheetExampleApp()); + + final Finder dialogTitle = find.text('Current Count: 0'); + expect(dialogTitle, findsNothing); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(dialogTitle, findsOneWidget); + + await tester.tap(find.text('Pop Sheet')); + await tester.pumpAndSettle(); + expect(dialogTitle, findsNothing); + }); + + testWidgets('State restoration keeps the counter at the right value', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const example.RestorableSheetExampleApp()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + + expect(find.text('Current Count: 0'), findsOneWidget); + + await tester.tap(find.text('Increase')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Increase')); + await tester.pumpAndSettle(); + + expect(find.text('Current Count: 2'), findsOneWidget); + + await tester.restartAndRestore(); + + expect(find.text('Current Count: 2'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart index 9a49cac683..593d8cb404 100644 --- a/packages/flutter/lib/cupertino.dart +++ b/packages/flutter/lib/cupertino.dart @@ -56,6 +56,7 @@ export 'src/cupertino/route.dart'; export 'src/cupertino/scrollbar.dart'; export 'src/cupertino/search_field.dart'; export 'src/cupertino/segmented_control.dart'; +export 'src/cupertino/sheet.dart'; export 'src/cupertino/slider.dart'; export 'src/cupertino/sliding_segmented_control.dart'; export 'src/cupertino/spell_check_suggestions_toolbar.dart'; diff --git a/packages/flutter/lib/src/cupertino/sheet.dart b/packages/flutter/lib/src/cupertino/sheet.dart new file mode 100644 index 0000000000..797f120fe2 --- /dev/null +++ b/packages/flutter/lib/src/cupertino/sheet.dart @@ -0,0 +1,772 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; + +import 'colors.dart'; +import 'interface_level.dart'; +import 'route.dart'; +import 'theme.dart'; + +// Tween for animating a Cupertino sheet onto the screen. +// +// Begins fully offscreen below the screen and ends onscreen with a small gap at +// the top of the screen. Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kBottomUpTween = Tween( + begin: const Offset(0.0, 1.0), + end: const Offset(0.0, 0.08), +); + +// Offset change for when a new sheet covers another sheet. '0.0' represents the +// top of the space available for the new sheet, but because the previous sheet +// was lowered slightly, the new sheet needs to go slightly higher than that. +// Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kBottomUpTweenWhenCoveringOtherSheet = Tween( + begin: const Offset(0.0, 1.0), + end: const Offset(0.0, -0.02), +); + +// Tween that animates a sheet slightly up when it is covered by a new sheet. +// Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kMidUpTween = Tween( + begin: Offset.zero, + end: const Offset(0.0, -0.005), +); + +// Offset from top of screen to slightly down when a fullscreen page is covered +// by a sheet. Values found from eyeballing a simulator running iOS 18.0. +final Animatable _kTopDownTween = Tween( + begin: Offset.zero, + end: const Offset(0.0, 0.07), +); + +// Opacity of the overlay color put over the sheet as it moves into the background. +// Used to distinguish the sheet from the background. Value derived from eyeballing +// a simulator running iOS 18.0. +final Animatable _kOpacityTween = Tween(begin: 0.0, end: 0.10); + +// The minimum velocity needed for a drag downwards to dismiss the sheet. Eyeballed +// from a comparison against a simulator running iOS 18.0. +const double _kMinFlingVelocity = 2.0; // Screen heights per second. + +// The duration for a page to animate when the user releases it mid-swipe. Eyeballed +// from a comparison against a simulator running iOS 18.0. +const Duration _kDroppedSheetDragAnimationDuration = Duration(milliseconds: 300); + +// Amount the sheet in the background scales down. Found by measuring the width +// of the sheet in the background and comparing against the screen width on the +// iOS simulator showing an iPhone 16 pro running iOS 18.0. The scale transition +// will go from a default of 1.0 to 1.0 - _kSheetScaleFactor. +const double _kSheetScaleFactor = 0.0835; + +final Animatable _kScaleTween = Tween(begin: 1.0, end: 1.0 - _kSheetScaleFactor); + +/// Shows a Cupertino-style sheet widget that slides up from the bottom of the +/// screen and stacks the previous route behind the new sheet. +/// +/// This is a convenience method for displaying [CupertinoSheetRoute] for common, +/// straightforward use cases. The Widget returned from `pageBuilder` will be +/// used to display the content on the [CupertinoSheetRoute]. +/// +/// `useNestedNavigation` allows new routes to be pushed inside of a [CupertinoSheetRoute] +/// by adding a new [Navigator] inside of the [CupertinoSheetRoute]. +/// +/// When `useNestedNavigation` is set to `true`, any route pushed to the stack +/// from within the context of the [CupertinoSheetRoute] will display within that +/// sheet. System back gestures and programatic pops on the initial route in a +/// sheet will also be intercepted to pop the whole [CupertinoSheetRoute]. If +/// a custom [Navigator] setup is needed, like for example to enable named routes +/// or the pages API, then it is recommended to directly push a [CupertinoSheetRoute] +/// to the stack with whatever configuration needed. See [CupertinoSheetRoute] for +/// an example that manually sets up nested navigation. +/// +/// The whole sheet can be popped at once by either dragging down on the sheet, +/// or calling [CupertinoSheetRoute.popSheet]. +/// +/// iOS sheet widgets are generally designed to be tightly coupled to the context +/// of the widget that opened the sheet. As such, it is not recommended to push +/// a non-sheet route that covers the sheet without first popping the sheet. If +/// necessary however, it can be done by pushing to the root [Navigator]. +/// +/// If `useNestedNavigation` is `false` (the default), then a [CupertinoSheetRoute] +/// will be shown with no [Navigator] widget. Multiple calls to `showCupertinoSheet` +/// can still be made to show multiple stacked sheets, if desired. +/// +/// `showCupertinoSheet` always pushes the [CupertinoSheetRoute] to the root +/// [Navigator]. This is to ensure the previous route animates correctly. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the sheet was closed. +/// +/// {@tool dartpad} +/// This example shows how to navigate to use [showCupertinoSheet] to display a +/// Cupertino sheet widget with nested navigation. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [CupertinoSheetRoute] the basic route version of the sheet view. +/// * [showCupertinoDialog] which displays an iOS-styled dialog. +/// * +Future showCupertinoSheet({ + required BuildContext context, + required WidgetBuilder pageBuilder, + bool useNestedNavigation = false, +}) { + final WidgetBuilder builder; + final GlobalKey nestedNavigatorKey = GlobalKey(); + if (!useNestedNavigation) { + builder = pageBuilder; + } else { + builder = (BuildContext context) { + return NavigatorPopHandler( + onPopWithResult: (T? result) { + nestedNavigatorKey.currentState!.maybePop(); + }, + child: Navigator( + key: nestedNavigatorKey, + initialRoute: '/', + onGenerateInitialRoutes: (NavigatorState navigator, String initialRouteName) { + return >[ + CupertinoPageRoute( + builder: (BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, Object? result) { + if (didPop) { + return; + } + Navigator.of(context, rootNavigator: true).pop(result); + }, + child: pageBuilder(context), + ); + }, + ), + ]; + }, + ), + ); + }; + } + + return Navigator.of( + context, + rootNavigator: true, + ).push(CupertinoSheetRoute(builder: builder)); +} + +/// Provides an iOS-style sheet transition. +/// +/// The page slides up and stops below the top of the screen. When covered by +/// another sheet view, it will slide slightly up and scale down to appear +/// stacked behind the new sheet. +class CupertinoSheetTransition extends StatefulWidget { + /// Creates an iOS style sheet transition. + const CupertinoSheetTransition({ + super.key, + required this.primaryRouteAnimation, + required this.secondaryRouteAnimation, + required this.child, + required this.linearTransition, + }); + + /// `primaryRouteAnimation` is a linear route animation from 0.0 to 1.0 when + /// this screen is being pushed. + final Animation primaryRouteAnimation; + + /// `secondaryRouteAnimation` is a linear route animation from 0.0 to 1.0 when + /// another screen is being pushed on top of this one. + final Animation secondaryRouteAnimation; + + /// The widget below this widget in the tree. + final Widget child; + + /// Whether to perform the transition linearly. + /// + /// Used to respond to a drag gesture. + final bool linearTransition; + + /// The primary delegated transition. Will slide a non [CupertinoSheetRoute] page down. + /// + /// Provided to the previous route to coordinate transitions between routes. + /// + /// If a [CupertinoSheetRoute] already exists in the stack, then it will + /// slide the previous sheet upwards instead. + static Widget delegateTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + bool allowSnapshotting, + Widget? child, + ) { + if (CupertinoSheetRoute.hasParentSheet(context)) { + return _delegatedCoverSheetSecondaryTransition(secondaryAnimation, child); + } + final bool linear = Navigator.of(context).userGestureInProgress; + + final Curve curve = linear ? Curves.linear : Curves.linearToEaseOut; + final Curve reverseCurve = linear ? Curves.linear : Curves.easeInToLinear; + final CurvedAnimation curvedAnimation = CurvedAnimation( + curve: curve, + reverseCurve: reverseCurve, + parent: secondaryAnimation, + ); + final double deviceCornerRadius = MediaQuery.maybeViewPaddingOf(context)?.top ?? 0; + + final Animatable decorationTween = Tween( + begin: BorderRadius.circular(deviceCornerRadius), + end: BorderRadius.circular(12), + ); + + final Animation radiusAnimation = curvedAnimation.drive(decorationTween); + final Animation opacityAnimation = curvedAnimation.drive(_kOpacityTween); + final Animation slideAnimation = curvedAnimation.drive(_kTopDownTween); + final Animation scaleAnimation = curvedAnimation.drive(_kScaleTween); + curvedAnimation.dispose(); + + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light); + + final bool isDarkMode = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final Color overlayColor = isDarkMode ? const Color(0xFFc8c8c8) : const Color(0xFF000000); + + final Widget? contrastedChild = + child != null && !secondaryAnimation.isDismissed + ? Stack( + children: [ + child, + FadeTransition( + opacity: opacityAnimation, + child: ColoredBox(color: overlayColor, child: const SizedBox.expand()), + ), + ], + ) + : child; + + return SlideTransition( + position: slideAnimation, + child: ScaleTransition( + scale: scaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: AnimatedBuilder( + animation: radiusAnimation, + child: child, + builder: (BuildContext context, Widget? child) { + return ClipRRect(borderRadius: radiusAnimation.value, child: contrastedChild); + }, + ), + ), + ); + } + + static Widget _delegatedCoverSheetSecondaryTransition( + Animation secondaryAnimation, + Widget? child, + ) { + const Curve curve = Curves.linearToEaseOut; + const Curve reverseCurve = Curves.easeInToLinear; + final CurvedAnimation curvedAnimation = CurvedAnimation( + curve: curve, + reverseCurve: reverseCurve, + parent: secondaryAnimation, + ); + + final Animation slideAnimation = curvedAnimation.drive(_kMidUpTween); + final Animation scaleAnimation = curvedAnimation.drive(_kScaleTween); + curvedAnimation.dispose(); + + return SlideTransition( + position: slideAnimation, + transformHitTests: false, + child: ScaleTransition( + scale: scaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: child, + ), + ), + ); + } + + @override + State createState() => _CupertinoSheetTransitionState(); +} + +class _CupertinoSheetTransitionState extends State { + // The offset animation when this page is being covered by another sheet. + late Animation _secondaryPositionAnimation; + + // The scale animation when this page is being covered by another sheet. + late Animation _secondaryScaleAnimation; + + // Curve of primary page which is coming in to cover another route. + CurvedAnimation? _primaryPositionCurve; + + // Curve of secondary page which is becoming covered by another sheet. + CurvedAnimation? _secondaryPositionCurve; + + @override + void initState() { + super.initState(); + _setupAnimation(); + } + + @override + void didUpdateWidget(covariant CupertinoSheetTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.primaryRouteAnimation != widget.primaryRouteAnimation || + oldWidget.secondaryRouteAnimation != widget.secondaryRouteAnimation) { + _disposeCurve(); + _setupAnimation(); + } + } + + @override + void dispose() { + _disposeCurve(); + super.dispose(); + } + + void _setupAnimation() { + _primaryPositionCurve = CurvedAnimation( + curve: Curves.fastEaseInToSlowEaseOut, + reverseCurve: Curves.fastEaseInToSlowEaseOut.flipped, + parent: widget.primaryRouteAnimation, + ); + _secondaryPositionCurve = CurvedAnimation( + curve: Curves.linearToEaseOut, + reverseCurve: Curves.easeInToLinear, + parent: widget.secondaryRouteAnimation, + ); + _secondaryPositionAnimation = _secondaryPositionCurve!.drive(_kMidUpTween); + _secondaryScaleAnimation = _secondaryPositionCurve!.drive(_kScaleTween); + } + + void _disposeCurve() { + _primaryPositionCurve?.dispose(); + _secondaryPositionCurve?.dispose(); + _primaryPositionCurve = null; + _secondaryPositionCurve = null; + } + + Widget _coverSheetPrimaryTransition( + BuildContext context, + Animation animation, + bool linearTransition, + Widget? child, + ) { + final Animatable offsetTween = + CupertinoSheetRoute.hasParentSheet(context) + ? _kBottomUpTweenWhenCoveringOtherSheet + : _kBottomUpTween; + + final CurvedAnimation curvedAnimation = CurvedAnimation( + parent: animation, + curve: linearTransition ? Curves.linear : Curves.fastEaseInToSlowEaseOut, + reverseCurve: linearTransition ? Curves.linear : Curves.fastEaseInToSlowEaseOut.flipped, + ); + + final Animation positionAnimation = curvedAnimation.drive(offsetTween); + + curvedAnimation.dispose(); + + return SlideTransition(position: positionAnimation, child: child); + } + + Widget _coverSheetSecondaryTransition(Animation secondaryAnimation, Widget? child) { + return SlideTransition( + position: _secondaryPositionAnimation, + transformHitTests: false, + child: ScaleTransition( + scale: _secondaryScaleAnimation, + filterQuality: FilterQuality.medium, + alignment: Alignment.topCenter, + child: ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: child, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: _coverSheetSecondaryTransition( + widget.secondaryRouteAnimation, + _coverSheetPrimaryTransition( + context, + widget.primaryRouteAnimation, + widget.linearTransition, + ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + child: widget.child, + ), + ), + ), + ); + } +} + +/// Route for displaying an iOS sheet styled page. +/// +/// The `CupertinoSheetRoute` will slide up from the bottom of the screen and stop +/// below the top of the screen. If the previous route is a non-sheet route, then +/// it will animate downwards to stack behind the new sheet. If the previous route +/// is a sheet route, then it will animate slightly upwards to look like it is laying +/// on top of the previous stack of sheets. +/// +/// Typically called by [showCupertinoSheet], which provides some boilerplate for +/// pushing the `CupertinoSheetRoute` to the root navigator and providing simple +/// nested navigation. +/// +/// The sheet will be dismissed by dragging downwards on the screen, or a call to +/// [CupertinoSheetRoute.popSheet]. +/// +/// {@tool dartpad} +/// This example shows how to navigate to [CupertinoSheetRoute] by using it the +/// same as a regular route. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to show a Cupertino Sheet with nested navigation manually +/// set up in order to enable restorable state. +/// +/// ** See code in examples/api/lib/cupertino/sheet/cupertino_sheet.2.dart ** +/// {@end-tool} +/// +/// See also: +/// * [showCupertinoSheet], which is a convenience method for pushing a +/// `CupertinoSheetRoute`, with optional nested navigation built in. +class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTransitionMixin { + /// Creates a page route that displays an iOS styled sheet. + CupertinoSheetRoute({required this.builder}); + + /// Builds the primary contents of the sheet route. + final WidgetBuilder builder; + + @override + Widget buildContent(BuildContext context) { + return CupertinoUserInterfaceLevel( + data: CupertinoUserInterfaceLevelData.elevated, + child: _CupertinoSheetScope(child: builder(context)), + ); + } + + /// Checks if a Cupertino sheet view exists in the widget tree above the current + /// context. + static bool hasParentSheet(BuildContext context) { + return _CupertinoSheetScope.maybeOf(context) != null; + } + + /// Pops the entire [CupertinoSheetRoute], if a sheet route exists in the stack. + /// + /// Used if to pop an entire sheet at once, if there is nested navigtion within + /// that sheet. + static void popSheet(BuildContext context) { + if (hasParentSheet(context)) { + Navigator.of(context, rootNavigator: true).pop(); + } + } + + @override + Color? get barrierColor => CupertinoColors.transparent; + + @override + bool get barrierDismissible => false; + + @override + String? get barrierLabel => null; + + @override + bool get maintainState => true; + + @override + bool get opaque => false; +} + +// Internally used to see if another sheet is in the tree already. +class _CupertinoSheetScope extends InheritedWidget { + const _CupertinoSheetScope({required super.child}); + + static _CupertinoSheetScope? maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType<_CupertinoSheetScope>(); + } + + @override + bool updateShouldNotify(_CupertinoSheetScope oldWidget) => false; +} + +/// A mixin that replaces the entire screen with an iOS sheet transition for a +/// [PageRoute]. +/// +/// See also: +/// +/// * [CupertinoSheetRoute], which is a [PageRoute] that leverages this mixin. +mixin _CupertinoSheetRouteTransitionMixin on PageRoute { + /// Builds the primary contents of the route. + @protected + Widget buildContent(BuildContext context); + + @override + Duration get transitionDuration => const Duration(milliseconds: 500); + + @override + DelegatedTransitionBuilder? get delegatedTransition => + CupertinoSheetTransition.delegateTransition; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return buildContent(context); + } + + static _CupertinoDownGestureController _startPopGesture(ModalRoute route) { + return _CupertinoDownGestureController( + navigator: route.navigator!, + getIsCurrent: () => route.isCurrent, + getIsActive: () => route.isActive, + controller: route.controller!, // protected access + ); + } + + /// Returns a [CupertinoSheetTransition]. + static Widget buildPageTransitions( + ModalRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final bool linearTransition = route.popGestureInProgress; + return CupertinoSheetTransition( + primaryRouteAnimation: animation, + secondaryRouteAnimation: secondaryAnimation, + linearTransition: linearTransition, + child: _CupertinoDownGestureDetector( + enabledCallback: () => true, + onStartPopGesture: () => _startPopGesture(route), + child: child, + ), + ); + } + + @override + bool canTransitionTo(TransitionRoute nextRoute) { + return nextRoute is _CupertinoSheetRouteTransitionMixin; + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return buildPageTransitions(this, context, animation, secondaryAnimation, child); + } +} + +class _CupertinoDownGestureDetector extends StatefulWidget { + const _CupertinoDownGestureDetector({ + super.key, + required this.enabledCallback, + required this.onStartPopGesture, + required this.child, + }); + + final Widget child; + + final ValueGetter enabledCallback; + + final ValueGetter<_CupertinoDownGestureController> onStartPopGesture; + + @override + _CupertinoDownGestureDetectorState createState() => _CupertinoDownGestureDetectorState(); +} + +class _CupertinoDownGestureDetectorState extends State<_CupertinoDownGestureDetector> { + _CupertinoDownGestureController? _downGestureController; + + late VerticalDragGestureRecognizer _recognizer; + + @override + void initState() { + super.initState(); + _recognizer = + VerticalDragGestureRecognizer(debugOwner: this) + ..onStart = _handleDragStart + ..onUpdate = _handleDragUpdate + ..onEnd = _handleDragEnd + ..onCancel = _handleDragCancel; + } + + @override + void dispose() { + _recognizer.dispose(); + + // If this is disposed during a drag, call navigator.didStopUserGesture. + if (_downGestureController != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_downGestureController?.navigator.mounted ?? false) { + _downGestureController?.navigator.didStopUserGesture(); + } + _downGestureController = null; + }); + } + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + assert(mounted); + assert(_downGestureController == null); + _downGestureController = widget.onStartPopGesture(); + } + + void _handleDragUpdate(DragUpdateDetails details) { + assert(mounted); + assert(_downGestureController != null); + final double topGapRatio = (_kBottomUpTween as Tween).end?.dy ?? 0.08; + _downGestureController!.dragUpdate( + // Devide by size of the sheet. The gap between the top of the sheet and + // top of the screen is 0.08. + details.primaryDelta! / (context.size!.height - (context.size!.height * topGapRatio)), + ); + } + + void _handleDragEnd(DragEndDetails details) { + assert(mounted); + assert(_downGestureController != null); + _downGestureController!.dragEnd(details.velocity.pixelsPerSecond.dy / context.size!.height); + _downGestureController = null; + } + + void _handleDragCancel() { + assert(mounted); + // This can be called even if start is not called, paired with the "down" event + // that we don't consider here. + _downGestureController?.dragEnd(0.0); + _downGestureController = null; + } + + void _handlePointerDown(PointerDownEvent event) { + if (widget.enabledCallback()) { + _recognizer.addPointer(event); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.passthrough, + children: [ + widget.child, + Listener(onPointerDown: _handlePointerDown, behavior: HitTestBehavior.translucent), + ], + ); + } +} + +class _CupertinoDownGestureController { + /// Creates a controller for an iOS-style back gesture. + _CupertinoDownGestureController({ + required this.navigator, + required this.controller, + required this.getIsActive, + required this.getIsCurrent, + }) { + navigator.didStartUserGesture(); + } + + final AnimationController controller; + final NavigatorState navigator; + final ValueGetter getIsActive; + final ValueGetter getIsCurrent; + + /// The drag gesture has changed by [delta]. The total range of the drag + /// should be 0.0 to 1.0. + void dragUpdate(double delta) { + controller.value -= delta; + } + + /// The drag gesture has ended with a vertical motion of [velocity] as a + /// fraction of screen height per second. + void dragEnd(double velocity) { + // Fling in the appropriate direction. + // + // This curve has been determined through rigorously eyeballing native iOS + // animations on a simulator running iOS 18.0. + const Curve animationCurve = Curves.easeOut; + final bool isCurrent = getIsCurrent(); + final bool animateForward; + + if (!isCurrent) { + // If the page has already been navigated away from, then the animation + // direction depends on whether or not it's still in the navigation stack, + // regardless of velocity or drag position. For example, if a route is + // being slowly dragged back by just a few pixels, but then a programmatic + // pop occurs, the route should still be animated off the screen. + // See https://github.com/flutter/flutter/issues/141268. + animateForward = getIsActive(); + } else if (velocity.abs() >= _kMinFlingVelocity) { + // If the user releases the page before mid screen with sufficient velocity, + // or after mid screen, we should animate the page out. Otherwise, the page + // should be animated back in. + animateForward = velocity <= 0; + } else { + // If the drag is dropped with low velocity, the sheet will pop if the + // the drag goes a little past the halfway point on the screen. This is + // eyeballed on a simulator running iOS 18.0. + animateForward = controller.value > 0.52; + } + + if (animateForward) { + controller.animateTo( + 1.0, + duration: _kDroppedSheetDragAnimationDuration, + curve: animationCurve, + ); + } else { + if (isCurrent) { + // This route is destined to pop at this point. Reuse navigator's pop. + final NavigatorState rootNavigator = Navigator.of(navigator.context, rootNavigator: true); + rootNavigator.pop(); + } + + if (controller.isAnimating) { + controller.animateBack( + 0.0, + duration: _kDroppedSheetDragAnimationDuration, + curve: animationCurve, + ); + } + } + + if (controller.isAnimating) { + // Keep the userGestureInProgress in true state so we don't change the + // curve of the page transition mid-flight since CupertinoPageTransition + // depends on userGestureInProgress. + // late AnimationStatusListener animationStatusCallback; + void animationStatusCallback(AnimationStatus status) { + navigator.didStopUserGesture(); + controller.removeStatusListener(animationStatusCallback); + } + + controller.addStatusListener(animationStatusCallback); + } else { + navigator.didStopUserGesture(); + } + } +} diff --git a/packages/flutter/test/cupertino/sheet_test.dart b/packages/flutter/test/cupertino/sheet_test.dart new file mode 100644 index 0000000000..968676a208 --- /dev/null +++ b/packages/flutter/test/cupertino/sheet_test.dart @@ -0,0 +1,808 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/navigator_utils.dart'; + +void main() { + testWidgets('Sheet route does not cover the whole screen', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push( + scaffoldKey.currentContext!, + CupertinoSheetRoute( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + }); + + testWidgets('Previous route moves slight downward when sheet route is pushed', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push( + scaffoldKey.currentContext!, + CupertinoSheetRoute( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 2')); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + // Previous page is still visible behind the new sheet. + expect(find.text('Page 1'), findsOneWidget); + final Offset pageOneOffset = tester.getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ); + expect(pageOneOffset.dy, greaterThan(0.0)); + expect(pageOneOffset.dx, greaterThan(0.0)); + expect(find.text('Page 2'), findsOneWidget); + final double pageTwoYOffset = + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageTwoYOffset, greaterThan(pageOneOffset.dy)); + }); + + testWidgets('If a sheet covers another sheet, then the previous sheet moves slightly upwards', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + Navigator.push( + scaffoldKey.currentContext!, + CupertinoSheetRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push( + scaffoldKey.currentContext!, + CupertinoSheetRoute( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Text('Page 3')); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + final double previousPageTwoDY = + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + expect(previousPageTwoDY, greaterThan(0.0)); + expect( + previousPageTwoDY, + greaterThan( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + ), + ); + }); + + testWidgets('by default showCupertinoSheet does not enable nested navigation', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: [ + const Text('Page 3'), + CupertinoButton(onPressed: () {}, child: const Text('Pop Page 3')), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: scaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + // New route should be at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + }); + + testWidgets('useNestedNavigation set to true enables nested navigation', ( + WidgetTester tester, + ) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.push( + context, + CupertinoPageRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: [ + const Text('Page 3'), + CupertinoButton(onPressed: () {}, child: const Text('Pop Page 3')), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + final double pageTwoDY = + tester + .getTopLeft( + find.ancestor(of: find.text('Page 2'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageTwoDY, greaterThan(0.0)); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsOneWidget); + + // New route should be at the same height as the previous route. + final double pageThreeDY = + tester + .getTopLeft( + find.ancestor(of: find.text('Page 3'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy; + expect(pageThreeDY, greaterThan(0.0)); + expect(pageThreeDY, equals(pageTwoDY)); + }); + + testWidgets('useNestedNavigation handles programmatic pops', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('Go Back'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + // The first page is at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + // The first page, which is behind the top sheet but still partially visibile, is moved downwards. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Go Back')); + await tester.pumpAndSettle(); + + // The first page would correctly transition back and sit at the top of the screen. + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('useNestedNavigation handles system pop gestures', (WidgetTester tester) async { + final GlobalKey scaffoldKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return CupertinoPageScaffold( + child: Column( + children: [ + const Text('Page 3'), + CupertinoButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Go back'), + ), + ], + ), + ); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: scaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: scaffoldKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold(child: sheetScaffoldContent(context)); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + expect(find.text('Page 1'), findsOneWidget); + // The first page is at the top of the screen. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + // The first page, which is behind the top sheet but still partially visibile, is moved downwards. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + + // Simulate a system back gesture. + await simulateSystemBack(); + await tester.pumpAndSettle(); + + // Go back to the first page within the sheet. + expect(find.text('Page 2'), findsOneWidget); + expect(find.text('Page 3'), findsNothing); + + // The first page is still stacked behind the sheet. + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + greaterThan(0.0), + ); + + await simulateSystemBack(); + await tester.pumpAndSettle(); + + // The first page would correctly transition back and sit at the top of the screen. + expect(find.text('Page 1'), findsOneWidget); + expect( + tester + .getTopLeft( + find.ancestor(of: find.text('Page 1'), matching: find.byType(CupertinoPageScaffold)), + ) + .dy, + equals(0.0), + ); + expect(find.text('Page 2'), findsNothing); + }); + + group('drag dismiss gesture', () { + Widget dragGestureApp(GlobalKey homeScaffoldKey, GlobalKey sheetScaffoldKey) { + return CupertinoApp( + home: CupertinoPageScaffold( + key: homeScaffoldKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: homeScaffoldKey.currentContext!, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetScaffoldKey, + child: const Center(child: Text('Page 2')), + ); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ); + } + + testWidgets('partial drag and drop does not pop the sheet', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + RenderBox box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double initialPosition = box.localToGlobal(Offset.zero).dy; + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + // Partial drag down + await gesture.moveBy(const Offset(0, 200)); + await tester.pump(); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double middlePosition = box.localToGlobal(Offset.zero).dy; + expect(middlePosition, greaterThan(initialPosition)); + + // Release gesture. Sheet should not pop and slide back up. + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + box = tester.renderObject(find.byKey(sheetKey)) as RenderBox; + final double finalPosition = box.localToGlobal(Offset.zero).dy; + + expect(finalPosition, lessThan(middlePosition)); + expect(finalPosition, equals(initialPosition)); + }); + + testWidgets('dropping the drag further down the page pops the sheet', ( + WidgetTester tester, + ) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + }); + + testWidgets('dismissing with a drag pops all nested routes', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + Widget sheetScaffoldContent(BuildContext context) { + return Column( + children: [ + const Text('Page 2'), + CupertinoButton( + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (BuildContext context) { + return const CupertinoPageScaffold(child: Center(child: Text('Page 3'))); + }, + ), + ); + }, + child: const Text('Push Page 3'), + ), + ], + ); + } + + await tester.pumpWidget( + CupertinoApp( + home: CupertinoPageScaffold( + key: homeKey, + child: Center( + child: Column( + children: [ + const Text('Page 1'), + CupertinoButton( + onPressed: () { + showCupertinoSheet( + context: homeKey.currentContext!, + useNestedNavigation: true, + pageBuilder: (BuildContext context) { + return CupertinoPageScaffold( + key: sheetKey, + child: sheetScaffoldContent(context), + ); + }, + ); + }, + child: const Text('Push Page 2'), + ), + ], + ), + ), + ), + ), + ); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsOneWidget); + + await tester.tap(find.text('Push Page 3')); + await tester.pumpAndSettle(); + + expect(find.text('Page 3'), findsOneWidget); + + final TestGesture gesture = await tester.startGesture(const Offset(100, 200)); + await gesture.moveBy(const Offset(0, 350)); + await tester.pump(); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 2'), findsNothing); + expect(find.text('Page 3'), findsNothing); + }); + + testWidgets('Popping the sheet during drag should not crash', (WidgetTester tester) async { + final GlobalKey homeKey = GlobalKey(); + final GlobalKey sheetKey = GlobalKey(); + + await tester.pumpWidget(dragGestureApp(homeKey, sheetKey)); + + await tester.tap(find.text('Push Page 2')); + await tester.pumpAndSettle(); + + final TestGesture gesture = await tester.createGesture(); + + await gesture.down(const Offset(100, 200)); + + // Need 2 events to form a valid drag + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(const Offset(100, 300), timeStamp: const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 200)); + await gesture.moveTo(const Offset(100, 500), timeStamp: const Duration(milliseconds: 200)); + + Navigator.of(homeKey.currentContext!).pop(); + + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsOneWidget); + expect(find.text('Page 2'), findsNothing); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Page 1'), findsOneWidget); + }); + }); +}