Skip to content

Commit 4051fab

Browse files
masal9psedkwingsmt
andauthored
feat: Cupertino sheet implement upward stretch on full sheet (flutter#168547)
implimented(flutter#161686) This PR implements a subtle stretch effect when the user drags the sheet upward. It achieves this by dynamically adjusting the top padding while the sheet is fully expanded. https://github.com/user-attachments/assets/ee98ab82-bc84-40b5-839f-82ae6de59e36 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --------- Co-authored-by: Tong Mu <[email protected]>
1 parent 61a4d24 commit 4051fab

File tree

2 files changed

+179
-58
lines changed

2 files changed

+179
-58
lines changed

packages/flutter/lib/src/cupertino/sheet.dart

Lines changed: 147 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ const double _kRoundedDeviceCornersThreshold = 20.0;
2929
// iOS 18.0.
3030
const double _kTopGapRatio = 0.08;
3131

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+
3239
// Tween for animating a Cupertino sheet onto the screen.
3340
//
3441
// Begins fully offscreen below the screen and ends onscreen with a small gap at
@@ -353,7 +360,14 @@ class CupertinoSheetTransition extends StatefulWidget {
353360
State<CupertinoSheetTransition> createState() => _CupertinoSheetTransitionState();
354361
}
355362

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+
357371
// The offset animation when this page is being covered by another sheet.
358372
late Animation<Offset> _secondaryPositionAnimation;
359373

@@ -369,6 +383,7 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
369383
@override
370384
void initState() {
371385
super.initState();
386+
372387
_setupAnimation();
373388
}
374389

@@ -399,11 +414,19 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
399414
reverseCurve: Curves.easeInToLinear,
400415
parent: widget.secondaryRouteAnimation,
401416
);
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+
);
402424
_secondaryPositionAnimation = _secondaryPositionCurve!.drive(_kMidUpTween);
403425
_secondaryScaleAnimation = _secondaryPositionCurve!.drive(_kScaleTween);
404426
}
405427

406428
void _disposeCurve() {
429+
_stretchDragController.dispose();
407430
_primaryPositionCurve?.dispose();
408431
_secondaryPositionCurve?.dispose();
409432
_primaryPositionCurve = null;
@@ -448,20 +471,49 @@ class _CupertinoSheetTransitionState extends State<CupertinoSheetTransition> {
448471

449472
@override
450473
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+
},
459495
),
460496
),
461497
);
462498
}
463499
}
464500

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+
465517
/// Route for displaying an iOS sheet styled page.
466518
///
467519
/// {@youtube 560 315 https://www.youtube.com/watch?v=5H-WvH5O29I}
@@ -508,18 +560,14 @@ class CupertinoSheetRoute<T> extends PageRoute<T> with _CupertinoSheetRouteTrans
508560

509561
@override
510562
Widget buildContent(BuildContext context) {
511-
final double topPadding = MediaQuery.heightOf(context) * _kTopGapRatio;
512563
return MediaQuery.removePadding(
513564
context: context,
514565
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)),
523571
),
524572
),
525573
);
@@ -601,12 +649,12 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
601649
return buildContent(context);
602650
}
603651

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>(
606654
navigator: route.navigator!,
607655
getIsCurrent: () => route.isCurrent,
608656
getIsActive: () => route.isActive,
609-
controller: route.controller!, // protected access
657+
popDragController: route.controller!, // protected access
610658
);
611659
}
612660

@@ -624,7 +672,7 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
624672
primaryRouteAnimation: animation,
625673
secondaryRouteAnimation: secondaryAnimation,
626674
linearTransition: linearTransition,
627-
child: _CupertinoDownGestureDetector<T>(
675+
child: _CupertinoDragGestureDetector<T>(
628676
enabledCallback: () => enableDrag,
629677
onStartPopGesture: () => _startPopGesture<T>(route),
630678
child: child,
@@ -648,8 +696,8 @@ mixin _CupertinoSheetRouteTransitionMixin<T> on PageRoute<T> {
648696
}
649697
}
650698

651-
class _CupertinoDownGestureDetector<T> extends StatefulWidget {
652-
const _CupertinoDownGestureDetector({
699+
class _CupertinoDragGestureDetector<T> extends StatefulWidget {
700+
const _CupertinoDragGestureDetector({
653701
super.key,
654702
required this.enabledCallback,
655703
required this.onStartPopGesture,
@@ -660,71 +708,91 @@ class _CupertinoDownGestureDetector<T> extends StatefulWidget {
660708

661709
final ValueGetter<bool> enabledCallback;
662710

663-
final ValueGetter<_CupertinoDownGestureController<T>> onStartPopGesture;
711+
final ValueGetter<_CupertinoDragGestureController<T>> onStartPopGesture;
664712

665713
@override
666-
_CupertinoDownGestureDetectorState<T> createState() => _CupertinoDownGestureDetectorState<T>();
714+
_CupertinoDragGestureDetectorState<T> createState() => _CupertinoDragGestureDetectorState<T>();
667715
}
668716

669-
class _CupertinoDownGestureDetectorState<T> extends State<_CupertinoDownGestureDetector<T>> {
670-
_CupertinoDownGestureController<T>? _downGestureController;
717+
class _CupertinoDragGestureDetectorState<T> extends State<_CupertinoDragGestureDetector<T>> {
718+
_CupertinoDragGestureController<T>? _dragGestureController;
671719

672720
late VerticalDragGestureRecognizer _recognizer;
721+
_StretchDragControllerProvider? _stretchDragController;
673722

674723
@override
675724
void initState() {
676725
super.initState();
726+
assert(_stretchDragController == null);
727+
_stretchDragController = _StretchDragControllerProvider.maybeOf(context);
677728
_recognizer = VerticalDragGestureRecognizer(debugOwner: this)
678729
..onStart = _handleDragStart
679730
..onUpdate = _handleDragUpdate
680731
..onEnd = _handleDragEnd
681732
..onCancel = _handleDragCancel;
682733
}
683734

735+
@override
736+
void didChangeDependencies() {
737+
super.didChangeDependencies();
738+
_stretchDragController = _StretchDragControllerProvider.maybeOf(context);
739+
}
740+
684741
@override
685742
void dispose() {
686743
_recognizer.dispose();
687744

688745
// If this is disposed during a drag, call navigator.didStopUserGesture.
689-
if (_downGestureController != null) {
746+
if (_dragGestureController != null) {
690747
WidgetsBinding.instance.addPostFrameCallback((_) {
691-
if (_downGestureController?.navigator.mounted ?? false) {
692-
_downGestureController?.navigator.didStopUserGesture();
748+
if (_dragGestureController?.navigator.mounted ?? false) {
749+
_dragGestureController?.navigator.didStopUserGesture();
693750
}
694-
_downGestureController = null;
751+
_dragGestureController = null;
695752
});
696753
}
697754
super.dispose();
698755
}
699756

700757
void _handleDragStart(DragStartDetails details) {
701758
assert(mounted);
702-
assert(_downGestureController == null);
703-
_downGestureController = widget.onStartPopGesture();
759+
assert(_dragGestureController == null);
760+
_dragGestureController = widget.onStartPopGesture();
704761
}
705762

706763
void _handleDragUpdate(DragUpdateDetails details) {
707764
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);
713770
}
714771

715772
void _handleDragEnd(DragEndDetails details) {
716773
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;
720784
}
721785

722786
void _handleDragCancel() {
723787
assert(mounted);
724788
// This can be called even if start is not called, paired with the "down" event
725789
// 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;
728796
}
729797

730798
void _handlePointerDown(PointerDownEvent event) {
@@ -743,31 +811,52 @@ class _CupertinoDownGestureDetectorState<T> extends State<_CupertinoDownGestureD
743811
}
744812
}
745813

746-
class _CupertinoDownGestureController<T> {
814+
class _CupertinoDragGestureController<T> {
747815
/// Creates a controller for an iOS-style back gesture.
748-
_CupertinoDownGestureController({
816+
_CupertinoDragGestureController({
749817
required this.navigator,
750-
required this.controller,
818+
required this.popDragController,
751819
required this.getIsActive,
752820
required this.getIsCurrent,
753821
}) {
754822
navigator.didStartUserGesture();
755823
}
756824

757-
final AnimationController controller;
825+
final AnimationController popDragController;
758826
final NavigatorState navigator;
759827
final ValueGetter<bool> getIsActive;
760828
final ValueGetter<bool> getIsCurrent;
761829

762830
/// The drag gesture has changed by [delta]. The total range of the drag
763831
/// 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+
}
766843
}
767844

768845
/// The drag gesture has ended with a vertical motion of [velocity] as a
769846
/// 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+
771860
// Fling in the appropriate direction.
772861
//
773862
// This curve has been determined through rigorously eyeballing native iOS
@@ -793,11 +882,11 @@ class _CupertinoDownGestureController<T> {
793882
// If the drag is dropped with low velocity, the sheet will pop if the
794883
// the drag goes a little past the halfway point on the screen. This is
795884
// eyeballed on a simulator running iOS 18.0.
796-
animateForward = controller.value > 0.52;
885+
animateForward = popDragController.value > 0.52;
797886
}
798887

799888
if (animateForward) {
800-
controller.animateTo(
889+
popDragController.animateTo(
801890
1.0,
802891
duration: _kDroppedSheetDragAnimationDuration,
803892
curve: animationCurve,
@@ -809,26 +898,26 @@ class _CupertinoDownGestureController<T> {
809898
rootNavigator.pop();
810899
}
811900

812-
if (controller.isAnimating) {
813-
controller.animateBack(
901+
if (popDragController.isAnimating) {
902+
popDragController.animateBack(
814903
0.0,
815904
duration: _kDroppedSheetDragAnimationDuration,
816905
curve: animationCurve,
817906
);
818907
}
819908
}
820909

821-
if (controller.isAnimating) {
910+
if (popDragController.isAnimating) {
822911
// Keep the userGestureInProgress in true state so we don't change the
823912
// curve of the page transition mid-flight since CupertinoPageTransition
824913
// depends on userGestureInProgress.
825914
// late AnimationStatusListener animationStatusCallback;
826915
void animationStatusCallback(AnimationStatus status) {
827916
navigator.didStopUserGesture();
828-
controller.removeStatusListener(animationStatusCallback);
917+
popDragController.removeStatusListener(animationStatusCallback);
829918
}
830919

831-
controller.addStatusListener(animationStatusCallback);
920+
popDragController.addStatusListener(animationStatusCallback);
832921
} else {
833922
navigator.didStopUserGesture();
834923
}

0 commit comments

Comments
 (0)