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);
+  });
 }