Skip to content

Commit 7f3485e

Browse files
authored
Let CupertinoPageScaffold have tap status bar to scroll to top (flutter#29946)
1 parent 0b68712 commit 7f3485e

File tree

2 files changed

+102
-14
lines changed

2 files changed

+102
-14
lines changed

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

+50-14
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import 'theme.dart';
1616
/// * [CupertinoTabScaffold], a similar widget for tabbed applications.
1717
/// * [CupertinoPageRoute], a modal page route that typically hosts a
1818
/// [CupertinoPageScaffold] with support for iOS-style page transitions.
19-
class CupertinoPageScaffold extends StatelessWidget {
19+
class CupertinoPageScaffold extends StatefulWidget {
2020
/// Creates a layout for pages with a navigation bar at the top.
2121
const CupertinoPageScaffold({
2222
Key key,
@@ -61,32 +61,51 @@ class CupertinoPageScaffold extends StatelessWidget {
6161
/// Defaults to true and cannot be null.
6262
final bool resizeToAvoidBottomInset;
6363

64+
@override
65+
_CupertinoPageScaffoldState createState() => _CupertinoPageScaffoldState();
66+
}
67+
68+
class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> {
69+
final ScrollController _primaryScrollController = ScrollController();
70+
71+
void _handleStatusBarTap() {
72+
// Only act on the scroll controller if it has any attached scroll positions.
73+
if (_primaryScrollController.hasClients) {
74+
_primaryScrollController.animateTo(
75+
0.0,
76+
// Eyeballed from iOS.
77+
duration: const Duration(milliseconds: 500),
78+
curve: Curves.linearToEaseOut,
79+
);
80+
}
81+
}
82+
6483
@override
6584
Widget build(BuildContext context) {
6685
final List<Widget> stacked = <Widget>[];
6786

68-
Widget paddedContent = child;
69-
if (navigationBar != null) {
70-
final MediaQueryData existingMediaQuery = MediaQuery.of(context);
87+
Widget paddedContent = widget.child;
7188

89+
final MediaQueryData existingMediaQuery = MediaQuery.of(context);
90+
if (widget.navigationBar != null) {
7291
// TODO(xster): Use real size after partial layout instead of preferred size.
7392
// https://github.com/flutter/flutter/issues/12912
7493
final double topPadding =
75-
navigationBar.preferredSize.height + existingMediaQuery.padding.top;
94+
widget.navigationBar.preferredSize.height + existingMediaQuery.padding.top;
7695

7796
// Propagate bottom padding and include viewInsets if appropriate
78-
final double bottomPadding = resizeToAvoidBottomInset
97+
final double bottomPadding = widget.resizeToAvoidBottomInset
7998
? existingMediaQuery.viewInsets.bottom
8099
: 0.0;
81100

82-
final EdgeInsets newViewInsets = resizeToAvoidBottomInset
101+
final EdgeInsets newViewInsets = widget.resizeToAvoidBottomInset
83102
// The insets are consumed by the scaffolds and no longer exposed to
84103
// the descendant subtree.
85104
? existingMediaQuery.viewInsets.copyWith(bottom: 0.0)
86105
: existingMediaQuery.viewInsets;
87106

88107
final bool fullObstruction =
89-
navigationBar.fullObstruction ?? CupertinoTheme.of(context).barBackgroundColor.alpha == 0xFF;
108+
widget.navigationBar.fullObstruction ?? CupertinoTheme.of(context).barBackgroundColor.alpha == 0xFF;
90109

91110
// If navigation bar is opaquely obstructing, directly shift the main content
92111
// down. If translucent, let main content draw behind navigation bar but hint the
@@ -101,7 +120,7 @@ class CupertinoPageScaffold extends StatelessWidget {
101120
),
102121
child: Padding(
103122
padding: EdgeInsets.only(top: topPadding, bottom: bottomPadding),
104-
child: child,
123+
child: paddedContent,
105124
),
106125
);
107126
} else {
@@ -114,27 +133,44 @@ class CupertinoPageScaffold extends StatelessWidget {
114133
),
115134
child: Padding(
116135
padding: EdgeInsets.only(bottom: bottomPadding),
117-
child: child,
136+
child: paddedContent,
118137
),
119138
);
120139
}
121140
}
122141

123142
// The main content being at the bottom is added to the stack first.
124-
stacked.add(paddedContent);
143+
stacked.add(PrimaryScrollController(
144+
controller: _primaryScrollController,
145+
child: paddedContent,
146+
));
125147

126-
if (navigationBar != null) {
148+
if (widget.navigationBar != null) {
127149
stacked.add(Positioned(
128150
top: 0.0,
129151
left: 0.0,
130152
right: 0.0,
131-
child: navigationBar,
153+
child: widget.navigationBar,
132154
));
133155
}
134156

157+
// Add a touch handler the size of the status bar on top of all contents
158+
// to handle scroll to top by status bar taps.
159+
stacked.add(Positioned(
160+
top: 0.0,
161+
left: 0.0,
162+
right: 0.0,
163+
height: existingMediaQuery.padding.top,
164+
child: GestureDetector(
165+
excludeFromSemantics: true,
166+
onTap: _handleStatusBarTap,
167+
),
168+
),
169+
);
170+
135171
return DecoratedBox(
136172
decoration: BoxDecoration(
137-
color: backgroundColor ?? CupertinoTheme.of(context).scaffoldBackgroundColor,
173+
color: widget.backgroundColor ?? CupertinoTheme.of(context).scaffoldBackgroundColor,
138174
),
139175
child: Stack(
140176
children: stacked,

packages/flutter/test/cupertino/scaffold_test.dart

+52
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,56 @@ testWidgets('Opaque bar pushes contents down', (WidgetTester tester) async {
343343
final BoxDecoration decoration = decoratedBox.decoration;
344344
expect(decoration.color, const Color(0xFF010203));
345345
});
346+
347+
testWidgets('Lists in CupertinoPageScaffold scroll to the top when status bar tapped', (WidgetTester tester) async {
348+
await tester.pumpWidget(
349+
CupertinoApp(
350+
builder: (BuildContext context, Widget child) {
351+
// Acts as a 20px status bar at the root of the app.
352+
return MediaQuery(
353+
data: MediaQuery.of(context).copyWith(padding: const EdgeInsets.only(top: 20)),
354+
child: child,
355+
);
356+
},
357+
home: CupertinoPageScaffold(
358+
// Default nav bar is translucent.
359+
navigationBar: const CupertinoNavigationBar(
360+
middle: Text('Title'),
361+
),
362+
child: ListView.builder(
363+
itemExtent: 50,
364+
itemBuilder: (BuildContext context, int index) => Text(index.toString()),
365+
),
366+
),
367+
),
368+
);
369+
// Top media query padding 20 + translucent nav bar 44.
370+
expect(tester.getTopLeft(find.text('0')).dy, 64);
371+
expect(tester.getTopLeft(find.text('6')).dy, 364);
372+
373+
await tester.fling(
374+
find.text('5'), // Find some random text on the screen.
375+
const Offset(0, -200),
376+
20,
377+
);
378+
379+
await tester.pumpAndSettle();
380+
381+
expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1));
382+
expect(tester.getTopLeft(find.text('12')).dy, moreOrLessEquals(466.8333333333334, epsilon: 0.1));
383+
384+
// The media query top padding is 20. Tapping at 20 should do nothing.
385+
await tester.tapAt(const Offset(400, 20));
386+
await tester.pumpAndSettle();
387+
expect(tester.getTopLeft(find.text('6')).dy, moreOrLessEquals(166.833, epsilon: 0.1));
388+
expect(tester.getTopLeft(find.text('12')).dy, moreOrLessEquals(466.8333333333334, epsilon: 0.1));
389+
390+
// Tap 1 pixel higher.
391+
await tester.tapAt(const Offset(400, 19));
392+
await tester.pump();
393+
await tester.pump(const Duration(milliseconds: 500));
394+
expect(tester.getTopLeft(find.text('0')).dy, 64);
395+
expect(tester.getTopLeft(find.text('6')).dy, 364);
396+
expect(find.text('12'), findsNothing);
397+
});
346398
}

0 commit comments

Comments
 (0)