@@ -29,6 +29,13 @@ const double _kRoundedDeviceCornersThreshold = 20.0;
29
29
// iOS 18.0.
30
30
const double _kTopGapRatio = 0.08 ;
31
31
32
+ // The minimum distance (i.e., maximum upward stretch) from the top of the sheet
33
+ // to the top of the screen, as a ratio of total screen height. This value represents
34
+ // how far the sheet can be temporarily pulled upward before snapping back.
35
+ // Determined through visual tuning to feel natural on <iPhone16, iPhone 16 Pro>
36
+ // running iOS 18.0 simulators.
37
+ const double _kStretchedTopGapRatio = 0.072 ;
38
+
32
39
// Tween for animating a Cupertino sheet onto the screen.
33
40
//
34
41
// Begins fully offscreen below the screen and ends onscreen with a small gap at
@@ -353,7 +360,14 @@ class CupertinoSheetTransition extends StatefulWidget {
353
360
State <CupertinoSheetTransition > createState () => _CupertinoSheetTransitionState ();
354
361
}
355
362
356
- class _CupertinoSheetTransitionState extends State <CupertinoSheetTransition > {
363
+ class _CupertinoSheetTransitionState extends State <CupertinoSheetTransition >
364
+ with SingleTickerProviderStateMixin {
365
+ // Controls the top padding animation when the sheet is being slightly stretched upward.
366
+ late AnimationController _stretchDragController;
367
+
368
+ // Animates the top padding of the sheet based on the _stretchDragController’s value.
369
+ late Animation <double > _stretchDragAnimation;
370
+
357
371
// The offset animation when this page is being covered by another sheet.
358
372
late Animation <Offset > _secondaryPositionAnimation;
359
373
@@ -369,6 +383,7 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
369
383
@override
370
384
void initState () {
371
385
super .initState ();
386
+
372
387
_setupAnimation ();
373
388
}
374
389
@@ -399,11 +414,19 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
399
414
reverseCurve: Curves .easeInToLinear,
400
415
parent: widget.secondaryRouteAnimation,
401
416
);
417
+ _stretchDragController = AnimationController (
418
+ duration: const Duration (microseconds: 1 ),
419
+ vsync: this ,
420
+ );
421
+ _stretchDragAnimation = _stretchDragController.drive (
422
+ Tween <double >(begin: _kTopGapRatio, end: _kStretchedTopGapRatio),
423
+ );
402
424
_secondaryPositionAnimation = _secondaryPositionCurve! .drive (_kMidUpTween);
403
425
_secondaryScaleAnimation = _secondaryPositionCurve! .drive (_kScaleTween);
404
426
}
405
427
406
428
void _disposeCurve () {
429
+ _stretchDragController.dispose ();
407
430
_primaryPositionCurve? .dispose ();
408
431
_secondaryPositionCurve? .dispose ();
409
432
_primaryPositionCurve = null ;
@@ -448,20 +471,49 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
448
471
449
472
@override
450
473
Widget build (BuildContext context) {
451
- return SizedBox .expand (
452
- child: _coverSheetSecondaryTransition (
453
- widget.secondaryRouteAnimation,
454
- _coverSheetPrimaryTransition (
455
- context,
456
- widget.primaryRouteAnimation,
457
- widget.linearTransition,
458
- widget.child,
474
+ return _StretchDragControllerProvider (
475
+ controller: _stretchDragController,
476
+ child: SizedBox .expand (
477
+ child: AnimatedBuilder (
478
+ animation: _stretchDragAnimation,
479
+ builder: (BuildContext context, Widget ? child) {
480
+ return Padding (
481
+ padding: EdgeInsets .only (
482
+ top: MediaQuery .heightOf (context) * _stretchDragAnimation.value,
483
+ ),
484
+ child: _coverSheetSecondaryTransition (
485
+ widget.secondaryRouteAnimation,
486
+ _coverSheetPrimaryTransition (
487
+ context,
488
+ widget.primaryRouteAnimation,
489
+ widget.linearTransition,
490
+ widget.child,
491
+ ),
492
+ ),
493
+ );
494
+ },
459
495
),
460
496
),
461
497
);
462
498
}
463
499
}
464
500
501
+ // Internally used to provide the controller for upward stretch animation.
502
+ class _StretchDragControllerProvider extends InheritedWidget {
503
+ const _StretchDragControllerProvider ({required this .controller, required super .child});
504
+
505
+ final AnimationController controller;
506
+
507
+ static _StretchDragControllerProvider ? maybeOf (BuildContext context) {
508
+ return context.getInheritedWidgetOfExactType <_StretchDragControllerProvider >();
509
+ }
510
+
511
+ @override
512
+ bool updateShouldNotify (_StretchDragControllerProvider oldWidget) {
513
+ return false ;
514
+ }
515
+ }
516
+
465
517
/// Route for displaying an iOS sheet styled page.
466
518
///
467
519
/// {@youtube 560 315 https://www.youtube.com/watch?v=5H-WvH5O29I}
@@ -508,18 +560,14 @@ class CupertinoSheetRoute<T> extends PageRoute<T> with _CupertinoSheetRouteTrans
508
560
509
561
@override
510
562
Widget buildContent (BuildContext context) {
511
- final double topPadding = MediaQuery .heightOf (context) * _kTopGapRatio;
512
563
return MediaQuery .removePadding (
513
564
context: context,
514
565
removeTop: true ,
515
- child: Padding (
516
- padding: EdgeInsets .only (top: topPadding),
517
- child: ClipRSuperellipse (
518
- borderRadius: const BorderRadius .vertical (top: Radius .circular (12 )),
519
- child: CupertinoUserInterfaceLevel (
520
- data: CupertinoUserInterfaceLevelData .elevated,
521
- child: _CupertinoSheetScope (child: builder (context)),
522
- ),
566
+ child: ClipRSuperellipse (
567
+ borderRadius: const BorderRadius .vertical (top: Radius .circular (12 )),
568
+ child: CupertinoUserInterfaceLevel (
569
+ data: CupertinoUserInterfaceLevelData .elevated,
570
+ child: _CupertinoSheetScope (child: builder (context)),
523
571
),
524
572
),
525
573
);
@@ -601,12 +649,12 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
601
649
return buildContent (context);
602
650
}
603
651
604
- static _CupertinoDownGestureController <T > _startPopGesture <T >(ModalRoute <T > route) {
605
- return _CupertinoDownGestureController <T >(
652
+ static _CupertinoDragGestureController <T > _startPopGesture <T >(ModalRoute <T > route) {
653
+ return _CupertinoDragGestureController <T >(
606
654
navigator: route.navigator! ,
607
655
getIsCurrent: () => route.isCurrent,
608
656
getIsActive: () => route.isActive,
609
- controller : route.controller! , // protected access
657
+ popDragController : route.controller! , // protected access
610
658
);
611
659
}
612
660
@@ -624,7 +672,7 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
624
672
primaryRouteAnimation: animation,
625
673
secondaryRouteAnimation: secondaryAnimation,
626
674
linearTransition: linearTransition,
627
- child: _CupertinoDownGestureDetector <T >(
675
+ child: _CupertinoDragGestureDetector <T >(
628
676
enabledCallback: () => enableDrag,
629
677
onStartPopGesture: () => _startPopGesture <T >(route),
630
678
child: child,
@@ -648,8 +696,8 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
648
696
}
649
697
}
650
698
651
- class _CupertinoDownGestureDetector <T > extends StatefulWidget {
652
- const _CupertinoDownGestureDetector ({
699
+ class _CupertinoDragGestureDetector <T > extends StatefulWidget {
700
+ const _CupertinoDragGestureDetector ({
653
701
super .key,
654
702
required this .enabledCallback,
655
703
required this .onStartPopGesture,
@@ -660,71 +708,91 @@ class _CupertinoDownGestureDetector<T> extends StatefulWidget {
660
708
661
709
final ValueGetter <bool > enabledCallback;
662
710
663
- final ValueGetter <_CupertinoDownGestureController <T >> onStartPopGesture;
711
+ final ValueGetter <_CupertinoDragGestureController <T >> onStartPopGesture;
664
712
665
713
@override
666
- _CupertinoDownGestureDetectorState <T > createState () => _CupertinoDownGestureDetectorState <T >();
714
+ _CupertinoDragGestureDetectorState <T > createState () => _CupertinoDragGestureDetectorState <T >();
667
715
}
668
716
669
- class _CupertinoDownGestureDetectorState <T > extends State <_CupertinoDownGestureDetector <T >> {
670
- _CupertinoDownGestureController <T >? _downGestureController ;
717
+ class _CupertinoDragGestureDetectorState <T > extends State <_CupertinoDragGestureDetector <T >> {
718
+ _CupertinoDragGestureController <T >? _dragGestureController ;
671
719
672
720
late VerticalDragGestureRecognizer _recognizer;
721
+ _StretchDragControllerProvider ? _stretchDragController;
673
722
674
723
@override
675
724
void initState () {
676
725
super .initState ();
726
+ assert (_stretchDragController == null );
727
+ _stretchDragController = _StretchDragControllerProvider .maybeOf (context);
677
728
_recognizer = VerticalDragGestureRecognizer (debugOwner: this )
678
729
..onStart = _handleDragStart
679
730
..onUpdate = _handleDragUpdate
680
731
..onEnd = _handleDragEnd
681
732
..onCancel = _handleDragCancel;
682
733
}
683
734
735
+ @override
736
+ void didChangeDependencies () {
737
+ super .didChangeDependencies ();
738
+ _stretchDragController = _StretchDragControllerProvider .maybeOf (context);
739
+ }
740
+
684
741
@override
685
742
void dispose () {
686
743
_recognizer.dispose ();
687
744
688
745
// If this is disposed during a drag, call navigator.didStopUserGesture.
689
- if (_downGestureController != null ) {
746
+ if (_dragGestureController != null ) {
690
747
WidgetsBinding .instance.addPostFrameCallback ((_) {
691
- if (_downGestureController ? .navigator.mounted ?? false ) {
692
- _downGestureController ? .navigator.didStopUserGesture ();
748
+ if (_dragGestureController ? .navigator.mounted ?? false ) {
749
+ _dragGestureController ? .navigator.didStopUserGesture ();
693
750
}
694
- _downGestureController = null ;
751
+ _dragGestureController = null ;
695
752
});
696
753
}
697
754
super .dispose ();
698
755
}
699
756
700
757
void _handleDragStart (DragStartDetails details) {
701
758
assert (mounted);
702
- assert (_downGestureController == null );
703
- _downGestureController = widget.onStartPopGesture ();
759
+ assert (_dragGestureController == null );
760
+ _dragGestureController = widget.onStartPopGesture ();
704
761
}
705
762
706
763
void _handleDragUpdate (DragUpdateDetails details) {
707
764
assert (mounted);
708
- assert (_downGestureController != null );
709
- _downGestureController ! . dragUpdate (
710
- // Divide by size of the sheet.
711
- details.primaryDelta ! / (context.size ! .height - (context.size ! .height * _kTopGapRatio)),
712
- );
765
+ assert (_dragGestureController != null );
766
+ if (_stretchDragController == null ) {
767
+ return ;
768
+ }
769
+ _dragGestureController ! . dragUpdate (details.primaryDelta ! , _stretchDragController ! .controller );
713
770
}
714
771
715
772
void _handleDragEnd (DragEndDetails details) {
716
773
assert (mounted);
717
- assert (_downGestureController != null );
718
- _downGestureController! .dragEnd (details.velocity.pixelsPerSecond.dy / context.size! .height);
719
- _downGestureController = null ;
774
+ assert (_dragGestureController != null );
775
+ if (_stretchDragController == null ) {
776
+ _dragGestureController = null ;
777
+ return ;
778
+ }
779
+ _dragGestureController! .dragEnd (
780
+ details.velocity.pixelsPerSecond.dy / context.size! .height,
781
+ _stretchDragController! .controller,
782
+ );
783
+ _dragGestureController = null ;
720
784
}
721
785
722
786
void _handleDragCancel () {
723
787
assert (mounted);
724
788
// This can be called even if start is not called, paired with the "down" event
725
789
// that we don't consider here.
726
- _downGestureController? .dragEnd (0.0 );
727
- _downGestureController = null ;
790
+ if (_stretchDragController == null ) {
791
+ _dragGestureController = null ;
792
+ return ;
793
+ }
794
+ _dragGestureController? .dragEnd (0.0 , _stretchDragController! .controller);
795
+ _dragGestureController = null ;
728
796
}
729
797
730
798
void _handlePointerDown (PointerDownEvent event) {
@@ -743,31 +811,52 @@ class _CupertinoDownGestureDetectorState<T> extends State<_CupertinoDownGestureD
743
811
}
744
812
}
745
813
746
- class _CupertinoDownGestureController <T > {
814
+ class _CupertinoDragGestureController <T > {
747
815
/// Creates a controller for an iOS-style back gesture.
748
- _CupertinoDownGestureController ({
816
+ _CupertinoDragGestureController ({
749
817
required this .navigator,
750
- required this .controller ,
818
+ required this .popDragController ,
751
819
required this .getIsActive,
752
820
required this .getIsCurrent,
753
821
}) {
754
822
navigator.didStartUserGesture ();
755
823
}
756
824
757
- final AnimationController controller ;
825
+ final AnimationController popDragController ;
758
826
final NavigatorState navigator;
759
827
final ValueGetter <bool > getIsActive;
760
828
final ValueGetter <bool > getIsCurrent;
761
829
762
830
/// The drag gesture has changed by [delta] . The total range of the drag
763
831
/// should be 0.0 to 1.0.
764
- void dragUpdate (double delta) {
765
- controller.value -= delta;
832
+ void dragUpdate (double delta, AnimationController upController) {
833
+ if (popDragController.value == 1.0 && delta < 0 ) {
834
+ // Divide by stretchable range (when dragging upward at max extent).
835
+ upController.value -=
836
+ delta / (navigator.context.size! .height * (_kTopGapRatio - _kStretchedTopGapRatio));
837
+ } else {
838
+ // Divide by size of the sheet.
839
+ popDragController.value -=
840
+ delta /
841
+ (navigator.context.size! .height - (navigator.context.size! .height * _kTopGapRatio));
842
+ }
766
843
}
767
844
768
845
/// The drag gesture has ended with a vertical motion of [velocity] as a
769
846
/// fraction of screen height per second.
770
- void dragEnd (double velocity) {
847
+ void dragEnd (double velocity, AnimationController upController) {
848
+ // If the sheet is in a stretched state (dragged upward beyond max size),
849
+ // reverse the stretch to return to the normal max height.
850
+ if (upController.value > 0 ) {
851
+ upController.animateBack (
852
+ 0.0 ,
853
+ duration: const Duration (milliseconds: 180 ),
854
+ curve: Curves .easeOut,
855
+ );
856
+ navigator.didStopUserGesture ();
857
+ return ;
858
+ }
859
+
771
860
// Fling in the appropriate direction.
772
861
//
773
862
// This curve has been determined through rigorously eyeballing native iOS
@@ -793,11 +882,11 @@ class _CupertinoDownGestureController<T> {
793
882
// If the drag is dropped with low velocity, the sheet will pop if the
794
883
// the drag goes a little past the halfway point on the screen. This is
795
884
// eyeballed on a simulator running iOS 18.0.
796
- animateForward = controller .value > 0.52 ;
885
+ animateForward = popDragController .value > 0.52 ;
797
886
}
798
887
799
888
if (animateForward) {
800
- controller .animateTo (
889
+ popDragController .animateTo (
801
890
1.0 ,
802
891
duration: _kDroppedSheetDragAnimationDuration,
803
892
curve: animationCurve,
@@ -809,26 +898,26 @@ class _CupertinoDownGestureController<T> {
809
898
rootNavigator.pop ();
810
899
}
811
900
812
- if (controller .isAnimating) {
813
- controller .animateBack (
901
+ if (popDragController .isAnimating) {
902
+ popDragController .animateBack (
814
903
0.0 ,
815
904
duration: _kDroppedSheetDragAnimationDuration,
816
905
curve: animationCurve,
817
906
);
818
907
}
819
908
}
820
909
821
- if (controller .isAnimating) {
910
+ if (popDragController .isAnimating) {
822
911
// Keep the userGestureInProgress in true state so we don't change the
823
912
// curve of the page transition mid-flight since CupertinoPageTransition
824
913
// depends on userGestureInProgress.
825
914
// late AnimationStatusListener animationStatusCallback;
826
915
void animationStatusCallback (AnimationStatus status) {
827
916
navigator.didStopUserGesture ();
828
- controller .removeStatusListener (animationStatusCallback);
917
+ popDragController .removeStatusListener (animationStatusCallback);
829
918
}
830
919
831
- controller .addStatusListener (animationStatusCallback);
920
+ popDragController .addStatusListener (animationStatusCallback);
832
921
} else {
833
922
navigator.didStopUserGesture ();
834
923
}
0 commit comments