Let CupertinoPageScaffold have tap status bar to scroll to top (#29946)
diff --git a/packages/flutter/lib/src/cupertino/page_scaffold.dart b/packages/flutter/lib/src/cupertino/page_scaffold.dart
index adc5a7a..a45cb22 100644
--- a/packages/flutter/lib/src/cupertino/page_scaffold.dart
+++ b/packages/flutter/lib/src/cupertino/page_scaffold.dart
@@ -16,7 +16,7 @@
/// * [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,
@@ -62,31 +62,50 @@
final bool resizeToAvoidBottomInset;
@override
+ _CupertinoPageScaffoldState createState() => _CupertinoPageScaffoldState();
+}
+
+class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> {
+ 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<Widget> stacked = <Widget>[];
- 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 @@
),
child: Padding(
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
- child: child,
+ child: paddedContent,
),
);
} else {
@@ -114,27 +133,44 @@
),
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 3415571..90bc50a 100644
--- a/packages/flutter/test/cupertino/scaffold_test.dart
+++ b/packages/flutter/test/cupertino/scaffold_test.dart
@@ -343,4 +343,56 @@
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);
+ });
}