From 7f3485e388ea3e20bfde90ea4b1ba0bd329653e1 Mon Sep 17 00:00:00 2001 From: xster Date: Wed, 27 Mar 2019 13:52:45 -0700 Subject: [PATCH] Let CupertinoPageScaffold have tap status bar to scroll to top (#29946) --- .../lib/src/cupertino/page_scaffold.dart | 64 +++++++++++++++---- .../flutter/test/cupertino/scaffold_test.dart | 52 +++++++++++++++ 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/page_scaffold.dart b/packages/flutter/lib/src/cupertino/page_scaffold.dart index adc5a7a14cb..a45cb22f994 100644 --- a/packages/flutter/lib/src/cupertino/page_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/page_scaffold.dart @@ -16,7 +16,7 @@ import 'theme.dart'; /// * [CupertinoTabScaffold], a similar widget for tabbed applications. /// * [CupertinoPageRoute], a modal page route that typically hosts a /// [CupertinoPageScaffold] with support for iOS-style page transitions. -class CupertinoPageScaffold extends StatelessWidget { +class CupertinoPageScaffold extends StatefulWidget { /// Creates a layout for pages with a navigation bar at the top. const CupertinoPageScaffold({ Key key, @@ -61,32 +61,51 @@ class CupertinoPageScaffold extends StatelessWidget { /// Defaults to true and cannot be null. final bool resizeToAvoidBottomInset; + @override + _CupertinoPageScaffoldState createState() => _CupertinoPageScaffoldState(); +} + +class _CupertinoPageScaffoldState extends State { + final ScrollController _primaryScrollController = ScrollController(); + + void _handleStatusBarTap() { + // Only act on the scroll controller if it has any attached scroll positions. + if (_primaryScrollController.hasClients) { + _primaryScrollController.animateTo( + 0.0, + // Eyeballed from iOS. + duration: const Duration(milliseconds: 500), + curve: Curves.linearToEaseOut, + ); + } + } + @override Widget build(BuildContext context) { final List stacked = []; - Widget paddedContent = child; - if (navigationBar != null) { - final MediaQueryData existingMediaQuery = MediaQuery.of(context); + Widget paddedContent = widget.child; + final MediaQueryData existingMediaQuery = MediaQuery.of(context); + if (widget.navigationBar != null) { // TODO(xster): Use real size after partial layout instead of preferred size. // https://github.com/flutter/flutter/issues/12912 final double topPadding = - navigationBar.preferredSize.height + existingMediaQuery.padding.top; + widget.navigationBar.preferredSize.height + existingMediaQuery.padding.top; // Propagate bottom padding and include viewInsets if appropriate - final double bottomPadding = resizeToAvoidBottomInset + final double bottomPadding = widget.resizeToAvoidBottomInset ? existingMediaQuery.viewInsets.bottom : 0.0; - final EdgeInsets newViewInsets = resizeToAvoidBottomInset + final EdgeInsets newViewInsets = widget.resizeToAvoidBottomInset // The insets are consumed by the scaffolds and no longer exposed to // the descendant subtree. ? existingMediaQuery.viewInsets.copyWith(bottom: 0.0) : existingMediaQuery.viewInsets; final bool fullObstruction = - navigationBar.fullObstruction ?? CupertinoTheme.of(context).barBackgroundColor.alpha == 0xFF; + widget.navigationBar.fullObstruction ?? CupertinoTheme.of(context).barBackgroundColor.alpha == 0xFF; // If navigation bar is opaquely obstructing, directly shift the main content // down. If translucent, let main content draw behind navigation bar but hint the @@ -101,7 +120,7 @@ class CupertinoPageScaffold extends StatelessWidget { ), child: Padding( padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding), - child: child, + child: paddedContent, ), ); } else { @@ -114,27 +133,44 @@ class CupertinoPageScaffold extends StatelessWidget { ), child: Padding( padding: EdgeInsets.only(bottom: bottomPadding), - child: child, + child: paddedContent, ), ); } } // The main content being at the bottom is added to the stack first. - stacked.add(paddedContent); + stacked.add(PrimaryScrollController( + controller: _primaryScrollController, + child: paddedContent, + )); - if (navigationBar != null) { + if (widget.navigationBar != null) { stacked.add(Positioned( top: 0.0, left: 0.0, right: 0.0, - child: navigationBar, + child: widget.navigationBar, )); } + // Add a touch handler the size of the status bar on top of all contents + // to handle scroll to top by status bar taps. + stacked.add(Positioned( + top: 0.0, + left: 0.0, + right: 0.0, + height: existingMediaQuery.padding.top, + child: GestureDetector( + excludeFromSemantics: true, + onTap: _handleStatusBarTap, + ), + ), + ); + return DecoratedBox( decoration: BoxDecoration( - color: backgroundColor ?? CupertinoTheme.of(context).scaffoldBackgroundColor, + color: widget.backgroundColor ?? CupertinoTheme.of(context).scaffoldBackgroundColor, ), child: Stack( children: stacked, diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index 34155715cda..90bc50a46cd 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -343,4 +343,56 @@ testWidgets('Opaque bar pushes contents down', (WidgetTester tester) async { final BoxDecoration decoration = decoratedBox.decoration; expect(decoration.color, const Color(0xFF010203)); }); + + testWidgets('Lists in CupertinoPageScaffold scroll to the top when status bar tapped', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + builder: (BuildContext context, Widget child) { + // Acts as a 20px status bar at the root of the app. + return MediaQuery( + data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 20)), + child: child, + ); + }, + home: CupertinoPageScaffold( + // Default nav bar is translucent. + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + ), + child: ListView.builder( + itemExtent: 50, + itemBuilder: (BuildContext context, int index) => Text(index.toString()), + ), + ), + ), + ); + // Top media query padding 20 + translucent nav bar 44. + expect(tester.getTopLeft(find.text('0')).dy, 64); + expect(tester.getTopLeft(find.text('6')).dy, 364); + + await tester.fling( + find.text('5'), // Find some random text on the screen. + const Offset(0, -200), + 20, + ); + + await tester.pumpAndSettle(); + + expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1)); + expect(tester.getTopLeft(find.text('12')).dy, moreOrLessEquals(466.8333333333334, epsilon: 0.1)); + + // The media query top padding is 20. Tapping at 20 should do nothing. + await tester.tapAt(const Offset(400, 20)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1)); + expect(tester.getTopLeft(find.text('12')).dy, moreOrLessEquals(466.8333333333334, epsilon: 0.1)); + + // Tap 1 pixel higher. + await tester.tapAt(const Offset(400, 19)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(tester.getTopLeft(find.text('0')).dy, 64); + expect(tester.getTopLeft(find.text('6')).dy, 364); + expect(find.text('12'), findsNothing); + }); }