diff --git a/packages/flutter/lib/src/cupertino/sheet.dart b/packages/flutter/lib/src/cupertino/sheet.dart index 79237ae512f..d1881ee9e32 100644 --- a/packages/flutter/lib/src/cupertino/sheet.dart +++ b/packages/flutter/lib/src/cupertino/sheet.dart @@ -124,6 +124,9 @@ final Animatable _kScaleTween = Tween(begin: 1.0, end: 1.0 - _kS /// means the sheet takes up only the bottom 10% of the screen. If not provided, defaults /// to 0.08 (8% of screen height). /// +/// When `showDragHandle` is set to `true`, then a drag handle will be placed at +/// the top of the sheet. This flag will default to false. +/// /// 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 @@ -162,6 +165,7 @@ Future showCupertinoSheet({ bool useNestedNavigation = false, bool enableDrag = true, double? topGap, + bool showDragHandle = false, }) { assert(topGap == null || (topGap >= 0.0 && topGap <= 0.9), 'topGap must be between 0.0 and 0.9'); assert(pageBuilder != null || builder != null); @@ -203,10 +207,14 @@ Future showCupertinoSheet({ }; } - return Navigator.of( - context, - rootNavigator: true, - ).push(CupertinoSheetRoute(builder: widgetBuilder, enableDrag: enableDrag, topGap: topGap)); + return Navigator.of(context, rootNavigator: true).push( + CupertinoSheetRoute( + builder: widgetBuilder, + enableDrag: enableDrag, + showDragHandle: showDragHandle, + topGap: topGap, + ), + ); } /// Provides an iOS-style sheet transition. @@ -575,6 +583,7 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans required this.builder, this.enableDrag = true, double? topGap, + this.showDragHandle = false, }) : assert( topGap == null || (topGap >= 0.0 && topGap <= 0.9), 'topGap must be between 0.0 and 0.9', @@ -596,6 +605,50 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans @override bool get _hasCustomTopGap => _topGap != null; + /// Shows a drag handle at the top of the sheet. + /// + /// Defaults to false. + final bool showDragHandle; + + Widget _sheetWithDragHandle(BuildContext context) { + if (!showDragHandle) { + return builder(context); + } + + // Values derived from Apple's Figma files and a simulator running iOS 18.2. + const dragHandleTopPadding = 5.0; + const dragHandleHeight = 5.0; + const dragHandleWidth = 36.0; + const dragHandlePadding = 15.0; + + return Stack( + fit: StackFit.expand, + children: [ + MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(padding: const EdgeInsets.only(top: dragHandlePadding)), + child: builder(context), + ), + const Align( + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsetsGeometry.only(top: dragHandleTopPadding), + child: DecoratedBox( + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadiusGeometry.all(Radius.circular(dragHandleWidth / 2)), + ), + color: CupertinoColors.tertiaryLabel, + ), + child: SizedBox(height: dragHandleHeight, width: dragHandleWidth), + ), + ), + ), + ], + ); + } + @override Widget buildContent(BuildContext context) { return MediaQuery.removePadding( @@ -605,7 +658,7 @@ class CupertinoSheetRoute extends PageRoute with _CupertinoSheetRouteTrans borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), child: CupertinoUserInterfaceLevel( data: CupertinoUserInterfaceLevelData.elevated, - child: _CupertinoSheetScope(child: builder(context)), + child: _CupertinoSheetScope(child: _sheetWithDragHandle(context)), ), ), ); diff --git a/packages/flutter/test/cupertino/sheet_test.dart b/packages/flutter/test/cupertino/sheet_test.dart index 4819252bb95..c13bbd22e51 100644 --- a/packages/flutter/test/cupertino/sheet_test.dart +++ b/packages/flutter/test/cupertino/sheet_test.dart @@ -62,6 +62,109 @@ void main() { ); }); + testWidgets('showDragHandle adds a drag handle to the top of the sheet', ( + 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( + showDragHandle: true, + 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); + final Finder dragHandleFinder = find.byWidgetPredicate((Widget widget) { + return widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == CupertinoColors.tertiaryLabel; + }); + expect(dragHandleFinder, findsOneWidget); + }); + + testWidgets('showDragHandle adds a MediaQuery padding so content can render below the handle', ( + 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( + showDragHandle: true, + builder: (BuildContext context) { + return const CupertinoPageScaffold( + child: SafeArea(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); + final Finder dragHandleFinder = find.byWidgetPredicate((Widget widget) { + return widget is DecoratedBox && + widget.decoration is ShapeDecoration && + (widget.decoration as ShapeDecoration).color == CupertinoColors.tertiaryLabel; + }); + + final Offset dragHandleOffset = tester.getTopLeft(dragHandleFinder); + final Offset sheetContentOffset = tester.getTopLeft(find.text('Page 2')); + expect(sheetContentOffset.dy, greaterThan(dragHandleOffset.dy)); + }); + testWidgets('Previous route moves slight downward when sheet route is pushed', ( WidgetTester tester, ) async {