From 0aafd14efaa95e7adb8bd23bf442bf19b2a669b4 Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:57:25 -0800 Subject: [PATCH] [CP-beta]Send statusBarTouch events via dedicated messages (#181670) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/177992 https://github.com/flutter/flutter/issues/175606 ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping of production apps (the app crashes on launch). This information is for domain experts and release engineers to understand the consequences of saying yes or no to the cherry pick. The issues listed above impact app users. For instance #177992 prevents the user from interacting with hamburger menus on iPadOS 26.1+ for context, this probably has always been an issue but on iPadOS 26.1 the height of the status bar became taller so it gets in the way of the hamburger menu. ### Changelog Description: Explain this cherry pick: * In one line that is accessible to most Flutter developers. * That describes the state prior to the fix. * That includes which platforms are impacted. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples. [flutter/177992] on iPadOS 26.1, tapping on the status bar dismisses the current modal route. ### Workaround: Is there a workaround for this issue? Yes. There is a relatively reliable workaround described here: https://github.com/flutter/flutter/issues/175606#issuecomment-3576240885 ### Risk: What is the risk level of this cherry-pick? - [-] Low ### Test Coverage: Are you confident that your fix is well-tested by automated tests? - [-] Yes ### Validation Steps: What are the steps to validate that this fix works? Following the repro steps in #177992. I've tried the fix myself on master following the steps outlined in the issue and couldn't reproduce the issue. --- .../ios/framework/Source/FlutterEngine.mm | 17 ++++ .../framework/Source/FlutterEngine_Internal.h | 1 + .../framework/Source/FlutterViewController.mm | 22 +---- .../testing/ios_scenario_app/README.md | 23 ++++- .../xcshareddata/xcschemes/Scenarios.xcscheme | 4 + .../ios/Scenarios/Scenarios/AppDelegate.m | 23 ++++- .../ios/Scenarios/ScenariosUITests/README.md | 21 +++++ .../ScenariosUITests/StatusBarTest.m | 20 ++--- .../lib/src/platform_channel_echo.dart | 30 +++++++ .../ios_scenario_app/lib/src/scenarios.dart | 4 +- .../lib/src/cupertino/page_scaffold.dart | 34 ++++--- .../flutter/lib/src/material/scaffold.dart | 67 +++++++------- .../flutter/lib/src/services/binding.dart | 4 +- .../lib/src/services/system_channels.dart | 20 +++++ packages/flutter/lib/src/widgets/binding.dart | 35 ++++++++ .../flutter/test/cupertino/scaffold_test.dart | 90 +++++++------------ .../flutter/test/material/scaffold_test.dart | 89 +++++++----------- .../flutter/test/widgets/binding_test.dart | 6 ++ 18 files changed, 304 insertions(+), 206 deletions(-) create mode 100644 engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 90035fef8b8..41c5a66176b 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -147,6 +147,9 @@ NSString* const kFlutterApplicationRegistrarKey = @"io.flutter.flutter.applicati @property(nonatomic, strong) FlutterMethodChannel* navigationChannel; @property(nonatomic, strong) FlutterMethodChannel* restorationChannel; @property(nonatomic, strong) FlutterMethodChannel* platformChannel; +// This channel only sends status bar related events to the framework thus has +// no handlers. +@property(nonatomic, strong) FlutterMethodChannel* statusBarChannel; @property(nonatomic, strong) FlutterMethodChannel* platformViewsChannel; @property(nonatomic, strong) FlutterMethodChannel* textInputChannel; @property(nonatomic, strong) FlutterMethodChannel* undoManagerChannel; @@ -577,6 +580,7 @@ NSString* const kFlutterApplicationRegistrarKey = @"io.flutter.flutter.applicati self.navigationChannel = nil; self.restorationChannel = nil; self.platformChannel = nil; + self.statusBarChannel = nil; self.platformViewsChannel = nil; self.textInputChannel = nil; self.undoManagerChannel = nil; @@ -641,6 +645,12 @@ NSString* const kFlutterApplicationRegistrarKey = @"io.flutter.flutter.applicati binaryMessenger:self.binaryMessenger codec:[FlutterJSONMethodCodec sharedInstance]]; + self.statusBarChannel = + [[FlutterMethodChannel alloc] initWithName:@"flutter/status_bar" + binaryMessenger:self.binaryMessenger + codec:[FlutterJSONMethodCodec sharedInstance]]; + [self.statusBarChannel resizeChannelBuffer:0]; // No buffering. + self.platformViewsChannel = [[FlutterMethodChannel alloc] initWithName:@"flutter/platform_views" binaryMessenger:self.binaryMessenger @@ -1483,6 +1493,13 @@ static void SetEntryPoint(flutter::Settings* settings, NSString* entrypoint, NSS [self.localizationChannel invokeMethod:@"setLocale" arguments:localeData]; } +- (void)onStatusBarTap { + // Called by FlutterViewController to notify the framework that a tap landed + // on the status bar, and the most relevant vertical scroll view visible in the + // app, if applicable, should scroll to top. + [self.statusBarChannel invokeMethod:@"handleScrollToTop" arguments:nil]; +} + - (void)waitForFirstFrameSync:(NSTimeInterval)timeout callback:(NS_NOESCAPE void (^_Nonnull)(BOOL didTimeout))callback { fml::TimeDelta waitTime = fml::TimeDelta::FromMilliseconds(timeout * 1000); diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h index c7e061d7be6..a1818b2da9b 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h @@ -124,6 +124,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)sendDeepLinkToFramework:(NSURL*)url completionHandler:(void (^)(BOOL success))completion; +- (void)onStatusBarTap; @end @interface FlutterImplicitEngineBridgeImpl : NSObject diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 48480ec61cd..74fea45026d 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -585,29 +585,13 @@ static UIView* GetViewOrPlaceholder(UIView* existing_view) { return pointer_data; } -static void SendFakeTouchEvent(UIScreen* screen, - FlutterEngine* engine, - CGPoint location, - flutter::PointerData::Change change) { - const CGFloat scale = screen.scale; - flutter::PointerData pointer_data = [[engine viewController] generatePointerDataForFake]; - pointer_data.physical_x = location.x * scale; - pointer_data.physical_y = location.y * scale; - auto packet = std::make_unique(/*count=*/1); - pointer_data.change = change; - packet->SetPointerData(0, pointer_data); - [engine dispatchPointerDataPacket:std::move(packet)]; -} - - (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView { if (!self.engine) { return NO; } - CGPoint statusBarPoint = CGPointZero; - UIScreen* screen = self.flutterScreenIfViewLoaded; - if (screen) { - SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kDown); - SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kUp); + if (self.isViewLoaded) { + // Status bar taps before the UI is visible should be ignored. + [self.engine onStatusBarTap]; } return NO; } diff --git a/engine/src/flutter/testing/ios_scenario_app/README.md b/engine/src/flutter/testing/ios_scenario_app/README.md index 4efafa00bee..f12654ea5c9 100644 --- a/engine/src/flutter/testing/ios_scenario_app/README.md +++ b/engine/src/flutter/testing/ios_scenario_app/README.md @@ -18,11 +18,26 @@ See also: ## Adding a New Scenario -Create a new subclass of [Scenario](lib/src/scenario.dart) and add it to the map -in [scenarios.dart](lib/src/scenarios.dart). For an example, see +Like a regular Flutter iOS app, the Scenario app consists of the [iOS embedding +code](ios/Scenarios/Scenarios/AppDelegate.m) and the dart logic that are +`Scenario`s. + +To introduce a new subclass of [Scenario](lib/src/scenario.dart), add it to the map +in [scenarios.dart](lib/src/scenarios.dart). For an example, [animated_color_square.dart](lib/src/animated_color_square.dart), which draws a continuously animating colored square that bounces off the sides of the viewport. -Then set the scenario from the iOS app by calling `set_scenario` on platform -channel `driver`. +The Scenarios app loads a `Scenario` when it receives a `set_scenario` method call +on the `driver` platform channel from the objective-c code. However if you're +adding a UI test this is typically not needed as you typically should add a new +launch argument. See +[ScenariosUITests](ios/Scenarios/ScenariosUITests/README.md) for more details. + +## Running a specific test + +The `run_ios_tests.sh` script runs all tests in the `Scenarios` project. If you're +debugging a specific test, rebuild the `ios_debug_sim_unopt_arm64` engine variant +(assuming testing on a simulator on Apple Silicon chips), and open +`src/out/ios_debug_sim_unopt_arm64/ios_scenario_app/Scenarios.xcworkspace` in xcode. +Use the xcode UI to run the test. diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme index dcd0cd0a356..fa70fe5069d 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme @@ -77,6 +77,10 @@ argument = "--screen-before-flutter" isEnabled = "NO"> + + diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m index 60d7d35f460..41d032c6d8f 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m @@ -16,6 +16,10 @@ @end +@interface FlutterEngine () +@property(nonatomic, strong) FlutterMethodChannel* statusBarChannel; +@end + @implementation NoStatusBarViewController - (BOOL)prefersStatusBarHidden { return YES; @@ -210,11 +214,28 @@ FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded]; UIViewController* rootViewController = flutterViewController; - // Make Flutter View's origin x/y not 0. if ([scenarioIdentifier isEqualToString:@"non_full_screen_flutter_view_platform_view"]) { + // Make Flutter View's origin x/y not 0. rootViewController = [[NoStatusBarViewController alloc] init]; [rootViewController.view addSubview:flutterViewController.view]; flutterViewController.view.frame = CGRectMake(150, 150, 500, 500); + } else if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) { + [engine.binaryMessenger + setMessageHandlerOnChannel:@"flutter/status_bar" + binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) { + NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message + options:0 + error:nil]; + FlutterBasicMessageChannel* channel = [[FlutterBasicMessageChannel alloc] + initWithName:@"display_data" + binaryMessenger:engine.binaryMessenger + codec:[FlutterJSONMessageCodec sharedInstance]]; + [channel sendMessage:@{@"data" : dict}]; + UITextField* text = + [[UITextField alloc] initWithFrame:CGRectMake(0, 400, 300, 100)]; + text.text = dict[@"method"]; + [flutterViewController.view addSubview:text]; + }]; } self.window.rootViewController = rootViewController; diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md index 652fbed14aa..9e6d9a25942 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md @@ -1,3 +1,19 @@ +# Adding a New Scenario for a XCUITest + +An XCUITest is different from a regular XCTest in that the test subject app runs +in a different process than the test suite, making it trickier for the test code +to communicate with the app. For instance, you won't have access to +the view controller or engine instances from within the test code. + +For this reason, the test code typically uses **launch arguments** to configure +the app (for example, use [launchArgsMap](../Scenarios/AppDelegate.m) to inform +the app which `Scenario` to load), and use UIKit UI components to collect test +results (for example, every messsage received on the `display_data` channel adds +a new `UITextField` to the app, which will be visible to the test code. See [touches_scenario.dart](../../../lib/src/touches_scenario.dart) for an example). + +Refer to the [Adding a New Scenario](./../../../README.md) section for how to +register a new dart `Scenario`. + # Golden UI Tests This folder contains golden image tests. It renders UI (for instance, a platform @@ -17,3 +33,8 @@ indicating the file name it expected to find. The test will continue and fail, but will contain an attachment with the expected screen shot. If the screen shot looks good, add it with the correct name to the project and run the test again - it should pass this time. + +## Running a specific Scenario + +Add and enable the new launch argument to the `Arguments Passed On Launch` +section of the `Debug - Run` scheme, and build and run the app. diff --git a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m index aee1a63a239..dd1b3263be2 100644 --- a/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m +++ b/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m @@ -16,6 +16,10 @@ } - (void)testTapStatusBar { + XCUIElement* textField = self.application.textFields[@"handleScrollToTop"]; + BOOL exists = [textField waitForExistenceWithTimeout:1]; + XCTAssertFalse(exists, @""); + XCUIApplication* systemApp = [[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"]; XCUIElement* statusBar = [systemApp.statusBars firstMatch]; @@ -25,21 +29,7 @@ XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; [coordinates tap]; } - - XCUIElement* addTextField = - self.application - .textFields[@"0,PointerChange.add,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - BOOL exists = [addTextField waitForExistenceWithTimeout:1]; - XCTAssertTrue(exists, @""); - XCUIElement* downTextField = - self.application - .textFields[@"1,PointerChange.down,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - exists = [downTextField waitForExistenceWithTimeout:1]; - XCTAssertTrue(exists, @""); - XCUIElement* upTextField = - self.application - .textFields[@"2,PointerChange.up,device=0,buttons=0,signalKind=PointerSignalKind.none"]; - exists = [upTextField waitForExistenceWithTimeout:1]; + exists = [textField waitForExistenceWithTimeout:1]; XCTAssertTrue(exists, @""); } diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart new file mode 100644 index 00000000000..67e8063a1a5 --- /dev/null +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/platform_channel_echo.dart @@ -0,0 +1,30 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:typed_data' show ByteData; +import 'dart:ui'; + +import 'scenario.dart'; + +/// A scenario which intercepts all messages on the given channel, and sends back +/// the same message to the engine on a channel with the same name. +class EchoPlatformChannelScenario extends Scenario { + /// Constructor for `EchoPlatformChannelScenario`. + EchoPlatformChannelScenario(super.view, {required this.channel}) { + channelBuffers.setListener(channel, _onHandlePlatformMessage); + } + + /// The name of the channel where all messages should be intercepted. + final String channel; + + void _onHandlePlatformMessage(ByteData? data, PlatformMessageResponseCallback _) { + view.platformDispatcher.sendPlatformMessage(channel, data, null); + } + + @override + void unmount() { + channelBuffers.clearListener(channel); + super.unmount(); + } +} diff --git a/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart b/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart index 8447a3470af..01ffcf3e1e5 100644 --- a/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart +++ b/engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart @@ -11,6 +11,7 @@ import 'darwin_system_font.dart'; import 'get_bitmap_scenario.dart'; import 'initial_route_reply.dart'; import 'locale_initialization.dart'; +import 'platform_channel_echo.dart'; import 'platform_view.dart'; import 'poppable_screen.dart'; import 'scenario.dart'; @@ -141,7 +142,8 @@ Map _scenarios = { TwoPlatformViewClipPath(view, firstId: _viewId++, secondId: _viewId++), 'two_platform_view_clip_path_multiple_clips': (FlutterView view) => TwoPlatformViewClipPathMultipleClips(view, firstId: _viewId++, secondId: _viewId++), - 'tap_status_bar': (FlutterView view) => TouchesScenario(view), + 'tap_status_bar': (FlutterView view) => + EchoPlatformChannelScenario(view, channel: 'flutter/status_bar'), 'initial_route_reply': (FlutterView view) => InitialRouteReply(view), 'platform_view_with_continuous_texture': (FlutterView view) => PlatformViewWithContinuousTexture(view, id: _viewId++), diff --git a/packages/flutter/lib/src/cupertino/page_scaffold.dart b/packages/flutter/lib/src/cupertino/page_scaffold.dart index 2bb6b4711f2..1965f7e9290 100644 --- a/packages/flutter/lib/src/cupertino/page_scaffold.dart +++ b/packages/flutter/lib/src/cupertino/page_scaffold.dart @@ -89,10 +89,29 @@ class CupertinoPageScaffold extends StatefulWidget { State createState() => _CupertinoPageScaffoldState(); } -class _CupertinoPageScaffoldState extends State { - void _handleStatusBarTap() { +class _CupertinoPageScaffoldState extends State with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); - // Only act on the scroll controller if it has any attached scroll positions. if (primaryScrollController != null && primaryScrollController.hasClients) { primaryScrollController.animateTo( 0.0, @@ -187,15 +206,6 @@ class _CupertinoPageScaffoldState extends State { right: 0.0, child: MediaQuery.withNoTextScaling(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. - Positioned( - top: 0.0, - left: 0.0, - right: 0.0, - height: existingMediaQuery.padding.top, - child: GestureDetector(excludeFromSemantics: true, onTap: _handleStatusBarTap), - ), ], ), ), diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index c157cc5567a..a8ebc639165 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -72,7 +72,6 @@ enum _ScaffoldSlot { floatingActionButton, drawer, endDrawer, - statusBar, } /// Manages [SnackBar]s and [MaterialBanner]s for descendant [Scaffold]s. @@ -1273,11 +1272,6 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate { }()); } - if (hasChild(_ScaffoldSlot.statusBar)) { - layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: minInsets.top)); - positionChild(_ScaffoldSlot.statusBar, Offset.zero); - } - if (hasChild(_ScaffoldSlot.drawer)) { layoutChild(_ScaffoldSlot.drawer, BoxConstraints.tight(size)); positionChild(_ScaffoldSlot.drawer, Offset.zero); @@ -2193,7 +2187,8 @@ class Scaffold extends StatefulWidget { /// /// Can display [BottomSheet]s. Retrieve a [ScaffoldState] from the current /// [BuildContext] using [Scaffold.of]. -class ScaffoldState extends State with TickerProviderStateMixin, RestorationMixin { +class ScaffoldState extends State + with TickerProviderStateMixin, RestorationMixin, WidgetsBindingObserver { @override String? get restorationId => widget.restorationId; @@ -2743,10 +2738,13 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto // iOS FEATURES - status bar tap, back gesture - // On iOS and macOS, if `primary` is true, tapping the status bar scrolls the app's primary scrollable + // On iOS, if `primary` is true, tapping the status bar scrolls the app's primary scrollable // to the top. We implement this by looking up the primary scroll controller and // scrolling it to the top when tapped. - void _handleStatusBarTap() { + @override + void handleStatusBarTap() { + super.handleStatusBarTap(); + assert(widget.primary); final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context); if (primaryScrollController != null && primaryScrollController.hasClients) { primaryScrollController.animateTo( @@ -2787,6 +2785,9 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto ); _bottomSheetScrimAnimationController = AnimationController(vsync: this); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } } @protected @@ -2828,6 +2829,13 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto _updatePersistentBottomSheet(); } } + switch ((oldWidget.primary, widget.primary)) { + case (true, false): + WidgetsBinding.instance.removeObserver(this); + case (false, true): + WidgetsBinding.instance.addObserver(this); + case (true, true) || (false, false): + } } @protected @@ -2849,6 +2857,20 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto super.didChangeDependencies(); } + @override + void deactivate() { + WidgetsBinding.instance.removeObserver(this); + super.deactivate(); + } + + @override + void activate() { + super.activate(); + if (widget.primary) { + WidgetsBinding.instance.addObserver(this); + } + } + @protected @override void dispose() { @@ -3150,33 +3172,6 @@ class ScaffoldState extends State with TickerProviderStateMixin, Resto removeBottomPadding: true, ); - switch (themeData.platform) { - case TargetPlatform.iOS: - case TargetPlatform.macOS: - if (!widget.primary) { - break; - } - _addIfNonNull( - children, - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _handleStatusBarTap, - // iOS accessibility automatically adds scroll-to-top to the clock in the status bar - excludeFromSemantics: true, - ), - _ScaffoldSlot.statusBar, - removeLeftPadding: false, - removeTopPadding: true, - removeRightPadding: false, - removeBottomPadding: true, - ); - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.windows: - break; - } - if (_endDrawerOpened.value) { _buildDrawer(children, textDirection); _buildEndDrawer(children, textDirection); diff --git a/packages/flutter/lib/src/services/binding.dart b/packages/flutter/lib/src/services/binding.dart index b17de4614fc..2b397b88708 100644 --- a/packages/flutter/lib/src/services/binding.dart +++ b/packages/flutter/lib/src/services/binding.dart @@ -436,9 +436,7 @@ mixin ServicesBinding on BindingBase, SchedulerBinding { _systemContextMenuClient!.handleCustomContextMenuAction(callbackId); case 'SystemChrome.systemUIChange': final args = methodCall.arguments as List; - if (_systemUiChangeCallback != null) { - await _systemUiChangeCallback!(args[0] as bool); - } + await _systemUiChangeCallback?.call(args[0] as bool); case 'System.requestAppExit': return {'response': (await handleRequestAppExit()).name}; default: diff --git a/packages/flutter/lib/src/services/system_channels.dart b/packages/flutter/lib/src/services/system_channels.dart index 4f7e94ced8f..83c70be7e1d 100644 --- a/packages/flutter/lib/src/services/system_channels.dart +++ b/packages/flutter/lib/src/services/system_channels.dart @@ -183,6 +183,26 @@ abstract final class SystemChannels { JSONMethodCodec(), ); + /// An unidirectional JSON [MethodChannel] for receiving status bar related + /// events from iOS. + /// + /// The only method this channel receives is `handleScrollToTop` which + /// is called on iOS when the user taps the status bar to scroll a scroll view + /// to the top. + /// + /// Typically you should not subscribe to this channel directly. The events are + /// dispatched to registered [WidgetsBindingObserver]s via the + /// [WidgetsBindingObserver.handleStatusBarTap] callback. + /// + /// See also: + /// + /// * [WidgetsBindingObserver.handleStatusBarTap], the widgets library callback + /// for dispatching the status bar tap event to the widget tree. + static const OptionalMethodChannel statusBar = OptionalMethodChannel( + 'flutter/status_bar', + JSONMethodCodec(), + ); + /// A [MethodChannel] for handling text processing actions. /// /// This channel exposes the text processing feature for supported platforms. diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index fb1e29060d3..670fa972aac 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -152,6 +152,22 @@ abstract mixin class WidgetsBindingObserver { /// predictive back feature. void handleCancelBackGesture() {} + /// Called when the user taps the status bar on iOS, to scroll a scroll + /// view to the top. + /// + /// This event should usually only be handled by at most one scroll view, so + /// implementer(s) of this callback must coordinate to determine the most + /// suitable scroll view for handling this event. + /// + /// This callback is only called on iOS. The default implementation provided by + /// [WidgetsBindingObserver] does nothing. + /// + /// See also: + /// + /// * [Scaffold] and [CupertinoPageScaffold] which use this callback to implement + /// iOS scroll-to-top. + void handleStatusBarTap() {} + /// Called when the host tells the application to push a new route onto the /// navigator. /// @@ -461,6 +477,7 @@ mixin WidgetsBinding platformDispatcher.onLocaleChanged = handleLocaleChanged; SystemChannels.navigation.setMethodCallHandler(_handleNavigationInvocation); SystemChannels.backGesture.setMethodCallHandler(_handleBackGestureInvocation); + SystemChannels.statusBar.setMethodCallHandler(_handleStatusBarActions); assert(() { FlutterErrorDetails.propertiesTransformers.add(debugTransformDebugCreator); return true; @@ -908,6 +925,24 @@ mixin WidgetsBinding } } + Future _handleStatusBarActions(MethodCall call) async { + assert(call.method == 'handleScrollToTop'); + for (final observer in List.of(_observers)) { + try { + observer.handleStatusBarTap(); + } catch (exception, stack) { + final details = FlutterErrorDetails( + exception: exception, + stack: stack, + library: 'widgets library', + context: ErrorDescription('handling status bar action'), + ); + FlutterError.reportError(details); + // No error widget possible here since it wouldn't have a view to render into. + } + } + } + /// Called when the system pops the current route. /// /// This first notifies the binding observers (using diff --git a/packages/flutter/test/cupertino/scaffold_test.dart b/packages/flutter/test/cupertino/scaffold_test.dart index 78eb57da9a0..84d1668f796 100644 --- a/packages/flutter/test/cupertino/scaffold_test.dart +++ b/packages/flutter/test/cupertino/scaffold_test.dart @@ -5,6 +5,8 @@ import 'dart:typed_data'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart' show SystemChannels; +import 'package:flutter/src/services/message_codecs.dart'; import 'package:flutter_test/flutter_test.dart'; import '../image_data.dart'; @@ -424,64 +426,6 @@ void main() { 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); - }); - testWidgets('resizeToAvoidBottomInset is supported even when no navigationBar', ( WidgetTester tester, ) async { @@ -588,6 +532,36 @@ void main() { ); }); + testWidgets('Tap the status bar scrolls to top', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: 1000); + addTearDown(scrollController.dispose); + await tester.pumpWidget( + CupertinoApp( + home: Builder( + builder: (BuildContext context) { + return PrimaryScrollController( + controller: scrollController, + child: const CupertinoPageScaffold( + child: SingleChildScrollView(primary: true, child: SizedBox(height: 12345)), + ), + ); + }, + ), + ), + ); + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ); + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.statusBar.name, + message, + (ByteData? data) {}, + ); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + }); + testWidgets('CupertinoPageScaffold does not crash at zero area', (WidgetTester tester) async { await tester.pumpWidget( const CupertinoApp( diff --git a/packages/flutter/test/material/scaffold_test.dart b/packages/flutter/test/material/scaffold_test.dart index 738fbdcfcef..475da35e523 100644 --- a/packages/flutter/test/material/scaffold_test.dart +++ b/packages/flutter/test/material/scaffold_test.dart @@ -597,23 +597,6 @@ void main() { ); } - testWidgets( - 'Tapping the status bar scrolls to top', - (WidgetTester tester) async { - await tester.pumpWidget(buildStatusBarTestApp(debugDefaultTargetPlatformOverride)); - final ScrollableState scrollable = tester.state(find.byType(Scrollable)); - scrollable.position.jumpTo(500.0); - expect(scrollable.position.pixels, equals(500.0)); - await tester.tapAt(const Offset(100.0, 10.0)); - await tester.pumpAndSettle(); - expect(scrollable.position.pixels, equals(0.0)); - }, - variant: const TargetPlatformVariant({ - TargetPlatform.iOS, - TargetPlatform.macOS, - }), - ); - testWidgets( 'No status bar when primary is false', (WidgetTester tester) async { @@ -692,51 +675,43 @@ void main() { }), ); - testWidgets( - 'Tapping the status bar scrolls to top with ease out curve animation', - (WidgetTester tester) async { - const duration = 1000; - final stops = [0.842, 0.959, 0.993, 1.0]; - const double scrollOffset = 1000; + testWidgets('Tapping the status bar scrolls to top with ease out curve animation', ( + WidgetTester tester, + ) async { + const duration = 1000; + final stops = [0.842, 0.959, 0.993, 1.0]; + const double scrollOffset = 1000; - await tester.pumpWidget(buildStatusBarTestApp(debugDefaultTargetPlatformOverride)); - final ScrollableState scrollable = tester.state(find.byType(Scrollable)); - scrollable.position.jumpTo(scrollOffset); - await tester.tapAt(const Offset(100.0, 10.0)); + await tester.pumpWidget(buildStatusBarTestApp(null)); + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + scrollable.position.jumpTo(scrollOffset); - await tester.pump(Duration.zero); - expect(scrollable.position.pixels, equals(scrollOffset)); + final ByteData message = const JSONMethodCodec().encodeMethodCall( + const MethodCall('handleScrollToTop'), + ); + tester.binding.defaultBinaryMessenger.handlePlatformMessage( + SystemChannels.statusBar.name, + message, + (ByteData? data) {}, + ); - for (var i = 0; i < stops.length; i++) { - await tester.pump(Duration(milliseconds: duration ~/ stops.length)); - // Scroll pixel position is very long double, compare with floored int - // pixel position - expect(scrollable.position.pixels.toInt(), equals((scrollOffset * (1 - stops[i])).toInt())); - } + await tester.pump(Duration.zero); + expect(scrollable.position.pixels, equals(scrollOffset)); - // Finally stops at the top. - expect(scrollable.position.pixels, equals(0.0)); - }, - variant: const TargetPlatformVariant({ - TargetPlatform.iOS, - TargetPlatform.macOS, - }), - ); + for (var i = 0; i < stops.length; i++) { + await tester.pump(Duration(milliseconds: duration ~/ stops.length)); + // Scroll pixel position is very long double, compare with floored int + // pixel position + expect( + scrollable.position.pixels.toInt(), + equals((scrollOffset * (1 - stops[i])).toInt()), + reason: 'stop $i', + ); + } - testWidgets( - 'Tapping the status bar does not scroll to top', - (WidgetTester tester) async { - await tester.pumpWidget(buildStatusBarTestApp(TargetPlatform.android)); - final ScrollableState scrollable = tester.state(find.byType(Scrollable)); - scrollable.position.jumpTo(500.0); - expect(scrollable.position.pixels, equals(500.0)); - await tester.tapAt(const Offset(100.0, 10.0)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(scrollable.position.pixels, equals(500.0)); - }, - variant: const TargetPlatformVariant({TargetPlatform.android}), - ); + // Finally stops at the top. + expect(scrollable.position.pixels, equals(0.0)); + }); testWidgets('Bottom sheet cannot overlap app bar', (WidgetTester tester) async { final Key sheetKey = UniqueKey(); diff --git a/packages/flutter/test/widgets/binding_test.dart b/packages/flutter/test/widgets/binding_test.dart index c19bc2b1b14..1357aa499d9 100644 --- a/packages/flutter/test/widgets/binding_test.dart +++ b/packages/flutter/test/widgets/binding_test.dart @@ -176,6 +176,12 @@ class RentrantObserver implements WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); return Future.value(AppExitResponse.exit); } + + @override + void handleStatusBarTap() { + assert(active); + WidgetsBinding.instance.addObserver(this); + } } void main() {