Skip to content

Commit 3f7d345

Browse files
Only issue scroll commands to hit-testable PrimaryScrollControllers for iOS status bar tap
1 parent c2dcfad commit 3f7d345

6 files changed

Lines changed: 298 additions & 59 deletions

File tree

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
/// @docImport 'tab_scaffold.dart';
99
library;
1010

11-
import 'package:flutter/foundation.dart';
11+
import 'package:flutter/rendering.dart';
1212
import 'package:flutter/widgets.dart';
1313

1414
import 'colors.dart';
@@ -90,6 +90,8 @@ class CupertinoPageScaffold extends StatefulWidget {
9090
}
9191

9292
class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> with WidgetsBindingObserver {
93+
final GlobalKey _statusBarKey = GlobalKey();
94+
9395
@override
9496
void initState() {
9597
super.initState();
@@ -112,7 +114,14 @@ class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> with Widg
112114
void handleStatusBarTap() {
113115
super.handleStatusBarTap();
114116
final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context);
115-
if (primaryScrollController != null && primaryScrollController.hasClients) {
117+
if (primaryScrollController != null &&
118+
primaryScrollController.hasClients &&
119+
// TODO(LongCatIsLooong): the iOS embedder used to send status bar tap
120+
// evets as fake touches at Offset.zero. The hit-test here makes sure
121+
// the status bar tap is handled by the same Scaffolds as before.
122+
// Switch to a better solution when available:
123+
// https://github.com/flutter/flutter/issues/182403
124+
_HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) {
116125
primaryScrollController.animateTo(
117126
0.0,
118127
// Eyeballed from iOS.
@@ -206,6 +215,15 @@ class _CupertinoPageScaffoldState extends State<CupertinoPageScaffold> with Widg
206215
right: 0.0,
207216
child: MediaQuery.withNoTextScaling(child: widget.navigationBar!),
208217
),
218+
// Add a touch handler the size of the status bar on top of all contents
219+
// to handle scroll to top by status bar taps.
220+
Positioned(
221+
top: 0.0,
222+
left: 0.0,
223+
right: 0.0,
224+
height: existingMediaQuery.padding.top,
225+
child: _HitTestableAtOrigin(_statusBarKey),
226+
),
209227
],
210228
),
211229
),
@@ -260,3 +278,36 @@ abstract class ObstructingPreferredSizeWidget implements PreferredSizeWidget {
260278
/// If false, this widget partially obstructs.
261279
bool shouldFullyObstruct(BuildContext context);
262280
}
281+
282+
final class _HitTestableAtOrigin extends StatelessWidget {
283+
const _HitTestableAtOrigin(this.globalKey);
284+
285+
final GlobalKey globalKey;
286+
287+
/// Whether the render box of the [_HitTestableAtOrigin] widget associated
288+
/// with the given global `key` is hit-testable at [Offset.zero].
289+
///
290+
/// This is used by the `handleStatusBarTap` implementation to avoid sending
291+
/// status bar tap events to scroll views in offscreen subtrees.
292+
static bool hitTestableAtOrigin(GlobalKey key) {
293+
final context = key.currentContext as Element?;
294+
if (context == null) {
295+
assert(false, 'BuildContext associated with $key is not mounted.');
296+
return false;
297+
}
298+
final renderObject = context.renderObject! as RenderMetaData;
299+
final int viewId = View.of(context).viewId;
300+
final result = HitTestResult();
301+
WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId);
302+
return result.path.any((HitTestEntry entry) => entry.target == renderObject);
303+
}
304+
305+
@override
306+
Widget build(BuildContext context) {
307+
return MetaData(
308+
key: globalKey,
309+
behavior: HitTestBehavior.translucent,
310+
child: const SizedBox.expand(),
311+
);
312+
}
313+
}

packages/flutter/lib/src/material/scaffold.dart

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import 'dart:math' as math;
2121
import 'dart:ui';
2222

2323
import 'package:flutter/foundation.dart';
24-
import 'package:flutter/gestures.dart' show DragStartBehavior;
24+
import 'package:flutter/gestures.dart' show DragStartBehavior, HitTestEntry, HitTestResult;
25+
import 'package:flutter/rendering.dart' show RenderMetaData;
2526
import 'package:flutter/widgets.dart';
2627

2728
import 'app_bar.dart';
@@ -72,6 +73,7 @@ enum _ScaffoldSlot {
7273
floatingActionButton,
7374
drawer,
7475
endDrawer,
76+
statusBar,
7577
}
7678

7779
/// Manages [SnackBar]s and [MaterialBanner]s for descendant [Scaffold]s.
@@ -1272,6 +1274,11 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
12721274
}());
12731275
}
12741276

1277+
if (hasChild(_ScaffoldSlot.statusBar)) {
1278+
layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top));
1279+
positionChild(_ScaffoldSlot.statusBar, Offset.zero);
1280+
}
1281+
12751282
if (hasChild(_ScaffoldSlot.drawer)) {
12761283
layoutChild(_ScaffoldSlot.drawer, BoxConstraints.tight(size));
12771284
positionChild(_ScaffoldSlot.drawer, Offset.zero);
@@ -2205,6 +2212,7 @@ class ScaffoldState extends State<Scaffold>
22052212
final GlobalKey<DrawerControllerState> _endDrawerKey = GlobalKey<DrawerControllerState>();
22062213

22072214
final GlobalKey _bodyKey = GlobalKey();
2215+
late final GlobalKey _statusBarKey = GlobalKey();
22082216

22092217
/// Whether this scaffold has a non-null [Scaffold.appBar].
22102218
bool get hasAppBar => widget.appBar != null;
@@ -2746,7 +2754,14 @@ class ScaffoldState extends State<Scaffold>
27462754
super.handleStatusBarTap();
27472755
assert(widget.primary);
27482756
final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context);
2749-
if (primaryScrollController != null && primaryScrollController.hasClients) {
2757+
if (primaryScrollController != null &&
2758+
primaryScrollController.hasClients &&
2759+
// TODO(LongCatIsLooong): the iOS embedder used to send status bar tap
2760+
// evets as fake touches at Offset.zero. The hit-test here makes sure
2761+
// the status bar tap is handled by the same Scaffolds as before.
2762+
// Switch to a better solution when available:
2763+
// https://github.com/flutter/flutter/issues/182403
2764+
_HitTestableAtOrigin.hitTestableAtOrigin(_statusBarKey)) {
27502765
primaryScrollController.animateTo(
27512766
0.0,
27522767
duration: const Duration(milliseconds: 1000),
@@ -3172,6 +3187,25 @@ class ScaffoldState extends State<Scaffold>
31723187
removeBottomPadding: true,
31733188
);
31743189

3190+
final Widget? statusBar = switch (themeData.platform) {
3191+
TargetPlatform.iOS ||
3192+
TargetPlatform.macOS => widget.primary ? _HitTestableAtOrigin(_statusBarKey) : null,
3193+
TargetPlatform.android ||
3194+
TargetPlatform.fuchsia ||
3195+
TargetPlatform.linux ||
3196+
TargetPlatform.windows => null,
3197+
};
3198+
3199+
_addIfNonNull(
3200+
children,
3201+
statusBar,
3202+
_ScaffoldSlot.statusBar,
3203+
removeLeftPadding: false,
3204+
removeTopPadding: true,
3205+
removeRightPadding: false,
3206+
removeBottomPadding: true,
3207+
);
3208+
31753209
if (_endDrawerOpened.value) {
31763210
_buildDrawer(children, textDirection);
31773211
_buildEndDrawer(children, textDirection);
@@ -3447,3 +3481,41 @@ class _ScaffoldScope extends InheritedWidget {
34473481
return hasDrawer != oldWidget.hasDrawer;
34483482
}
34493483
}
3484+
3485+
final class _HitTestableAtOrigin extends StatelessWidget {
3486+
const _HitTestableAtOrigin(this.globalKey);
3487+
3488+
final GlobalKey globalKey;
3489+
3490+
/// Whether the render box of the [_HitTestableAtOrigin] widget associated
3491+
/// with the given global `key` is hit-testable at [Offset.zero].
3492+
///
3493+
/// This is used by the `handleStatusBarTap` implementation to avoid sending
3494+
/// status bar tap events to scroll views in offscreen subtrees.
3495+
static bool hitTestableAtOrigin(GlobalKey key) {
3496+
final context = key.currentContext as Element?;
3497+
if (context == null) {
3498+
assert(
3499+
false,
3500+
'BuildContext associated with $key is not mounted. '
3501+
'If you see this in a test, this is likely because the test was trying '
3502+
'to simulate status bar tap on a non-iOS platform',
3503+
);
3504+
return false;
3505+
}
3506+
final renderObject = context.renderObject! as RenderMetaData;
3507+
final int viewId = View.of(context).viewId;
3508+
final result = HitTestResult();
3509+
WidgetsBinding.instance.hitTestInView(result, Offset.zero, viewId);
3510+
return result.path.any((HitTestEntry entry) => entry.target == renderObject);
3511+
}
3512+
3513+
@override
3514+
Widget build(BuildContext context) {
3515+
return MetaData(
3516+
key: globalKey,
3517+
behavior: HitTestBehavior.translucent,
3518+
child: const SizedBox.expand(),
3519+
);
3520+
}
3521+
}

packages/flutter/test/cupertino/scaffold_test.dart

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import 'dart:typed_data';
66

77
import 'package:flutter/cupertino.dart';
8-
import 'package:flutter/services.dart' show SystemChannels;
9-
import 'package:flutter/src/services/message_codecs.dart';
108
import 'package:flutter_test/flutter_test.dart';
119

1210
import '../image_data.dart';
@@ -537,29 +535,60 @@ void main() {
537535
addTearDown(scrollController.dispose);
538536
await tester.pumpWidget(
539537
CupertinoApp(
540-
home: Builder(
541-
builder: (BuildContext context) {
542-
return PrimaryScrollController(
543-
controller: scrollController,
544-
child: const CupertinoPageScaffold(
545-
child: SingleChildScrollView(primary: true, child: SizedBox(height: 12345)),
546-
),
547-
);
548-
},
538+
home: MediaQuery(
539+
data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar
540+
child: Builder(
541+
builder: (BuildContext context) {
542+
return PrimaryScrollController(
543+
controller: scrollController,
544+
child: const CupertinoPageScaffold(
545+
child: SingleChildScrollView(primary: true, child: SizedBox(height: 12345)),
546+
),
547+
);
548+
},
549+
),
549550
),
550551
),
551552
);
552-
final ByteData message = const JSONMethodCodec().encodeMethodCall(
553-
const MethodCall('handleScrollToTop'),
553+
554+
tester.simulateStatusBarTap();
555+
await tester.pumpAndSettle();
556+
557+
expect(scrollController.offset, 0.0);
558+
});
559+
560+
testWidgets('status bar tap only scrolls the foregrounded primary controller', (
561+
WidgetTester tester,
562+
) async {
563+
final app = CupertinoApp(
564+
initialRoute: 'a',
565+
onGenerateInitialRoutes: (initialRoute) {
566+
return [
567+
CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()),
568+
CupertinoPageRoute(builder: (context) => _ScaffoldWithPrimaryScrollView()),
569+
];
570+
},
571+
onGenerateRoute: (_) => throw UnimplementedError(),
554572
);
555-
tester.binding.defaultBinaryMessenger.handlePlatformMessage(
556-
SystemChannels.statusBar.name,
557-
message,
558-
(ByteData? data) {},
573+
await tester.pumpWidget(app);
574+
575+
final Iterable<ScrollableState> scrollables = tester.stateList<ScrollableState>(
576+
find.descendant(
577+
of: find.byType(_ScaffoldWithPrimaryScrollView, skipOffstage: false),
578+
matching: find.byType(Scrollable, skipOffstage: false),
579+
skipOffstage: false,
580+
),
559581
);
582+
583+
final [ScrollableState scrollable1, ScrollableState scrollable2] = scrollables.toList();
584+
expect(scrollable1.position.pixels, 1000);
585+
expect(scrollable2.position.pixels, 1000);
586+
587+
tester.simulateStatusBarTap();
560588
await tester.pumpAndSettle();
561589

562-
expect(scrollController.offset, 0.0);
590+
expect(scrollable1.position.pixels, 1000);
591+
expect(scrollable2.position.pixels, 0);
563592
});
564593

565594
testWidgets('CupertinoPageScaffold does not crash at zero area', (WidgetTester tester) async {
@@ -573,3 +602,24 @@ void main() {
573602
expect(tester.getSize(find.byType(CupertinoPageScaffold)), Size.zero);
574603
});
575604
}
605+
606+
class _ScaffoldWithPrimaryScrollView extends StatefulWidget {
607+
@override
608+
State<StatefulWidget> createState() => _ScaffoldWithPrimaryScrollViewState();
609+
}
610+
611+
class _ScaffoldWithPrimaryScrollViewState extends State<_ScaffoldWithPrimaryScrollView> {
612+
final ScrollController controller = ScrollController(initialScrollOffset: 1000);
613+
@override
614+
Widget build(BuildContext context) {
615+
return MediaQuery(
616+
data: const MediaQueryData(padding: EdgeInsets.only(top: 25.0)), // status bar
617+
child: PrimaryScrollController(
618+
controller: controller,
619+
child: const CupertinoPageScaffold(
620+
child: SingleChildScrollView(primary: true, child: SizedBox(height: 2000)),
621+
),
622+
),
623+
);
624+
}
625+
}

0 commit comments

Comments
 (0)