Skip to content

Commit 0aafd14

Browse files
[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? #177992 #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: #175606 (comment) ### 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.
1 parent 55bea55 commit 0aafd14

18 files changed

Lines changed: 304 additions & 206 deletions

File tree

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ @interface FlutterEngine () <FlutterIndirectScribbleDelegate,
147147
@property(nonatomic, strong) FlutterMethodChannel* navigationChannel;
148148
@property(nonatomic, strong) FlutterMethodChannel* restorationChannel;
149149
@property(nonatomic, strong) FlutterMethodChannel* platformChannel;
150+
// This channel only sends status bar related events to the framework thus has
151+
// no handlers.
152+
@property(nonatomic, strong) FlutterMethodChannel* statusBarChannel;
150153
@property(nonatomic, strong) FlutterMethodChannel* platformViewsChannel;
151154
@property(nonatomic, strong) FlutterMethodChannel* textInputChannel;
152155
@property(nonatomic, strong) FlutterMethodChannel* undoManagerChannel;
@@ -577,6 +580,7 @@ - (void)resetChannels {
577580
self.navigationChannel = nil;
578581
self.restorationChannel = nil;
579582
self.platformChannel = nil;
583+
self.statusBarChannel = nil;
580584
self.platformViewsChannel = nil;
581585
self.textInputChannel = nil;
582586
self.undoManagerChannel = nil;
@@ -641,6 +645,12 @@ - (void)setUpChannels {
641645
binaryMessenger:self.binaryMessenger
642646
codec:[FlutterJSONMethodCodec sharedInstance]];
643647

648+
self.statusBarChannel =
649+
[[FlutterMethodChannel alloc] initWithName:@"flutter/status_bar"
650+
binaryMessenger:self.binaryMessenger
651+
codec:[FlutterJSONMethodCodec sharedInstance]];
652+
[self.statusBarChannel resizeChannelBuffer:0]; // No buffering.
653+
644654
self.platformViewsChannel =
645655
[[FlutterMethodChannel alloc] initWithName:@"flutter/platform_views"
646656
binaryMessenger:self.binaryMessenger
@@ -1483,6 +1493,13 @@ - (void)onLocaleUpdated:(NSNotification*)notification {
14831493
[self.localizationChannel invokeMethod:@"setLocale" arguments:localeData];
14841494
}
14851495

1496+
- (void)onStatusBarTap {
1497+
// Called by FlutterViewController to notify the framework that a tap landed
1498+
// on the status bar, and the most relevant vertical scroll view visible in the
1499+
// app, if applicable, should scroll to top.
1500+
[self.statusBarChannel invokeMethod:@"handleScrollToTop" arguments:nil];
1501+
}
1502+
14861503
- (void)waitForFirstFrameSync:(NSTimeInterval)timeout
14871504
callback:(NS_NOESCAPE void (^_Nonnull)(BOOL didTimeout))callback {
14881505
fml::TimeDelta waitTime = fml::TimeDelta::FromMilliseconds(timeout * 1000);

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine_Internal.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ NS_ASSUME_NONNULL_BEGIN
124124

125125
- (void)sendDeepLinkToFramework:(NSURL*)url completionHandler:(void (^)(BOOL success))completion;
126126

127+
- (void)onStatusBarTap;
127128
@end
128129

129130
@interface FlutterImplicitEngineBridgeImpl : NSObject <FlutterImplicitEngineBridge>

engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -585,29 +585,13 @@ - (void)loadView {
585585
return pointer_data;
586586
}
587587

588-
static void SendFakeTouchEvent(UIScreen* screen,
589-
FlutterEngine* engine,
590-
CGPoint location,
591-
flutter::PointerData::Change change) {
592-
const CGFloat scale = screen.scale;
593-
flutter::PointerData pointer_data = [[engine viewController] generatePointerDataForFake];
594-
pointer_data.physical_x = location.x * scale;
595-
pointer_data.physical_y = location.y * scale;
596-
auto packet = std::make_unique<flutter::PointerDataPacket>(/*count=*/1);
597-
pointer_data.change = change;
598-
packet->SetPointerData(0, pointer_data);
599-
[engine dispatchPointerDataPacket:std::move(packet)];
600-
}
601-
602588
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
603589
if (!self.engine) {
604590
return NO;
605591
}
606-
CGPoint statusBarPoint = CGPointZero;
607-
UIScreen* screen = self.flutterScreenIfViewLoaded;
608-
if (screen) {
609-
SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kDown);
610-
SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kUp);
592+
if (self.isViewLoaded) {
593+
// Status bar taps before the UI is visible should be ignored.
594+
[self.engine onStatusBarTap];
611595
}
612596
return NO;
613597
}

engine/src/flutter/testing/ios_scenario_app/README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,26 @@ See also:
1818

1919
## Adding a New Scenario
2020

21-
Create a new subclass of [Scenario](lib/src/scenario.dart) and add it to the map
22-
in [scenarios.dart](lib/src/scenarios.dart). For an example, see
21+
Like a regular Flutter iOS app, the Scenario app consists of the [iOS embedding
22+
code](ios/Scenarios/Scenarios/AppDelegate.m) and the dart logic that are
23+
`Scenario`s.
24+
25+
To introduce a new subclass of [Scenario](lib/src/scenario.dart), add it to the map
26+
in [scenarios.dart](lib/src/scenarios.dart). For an example,
2327
[animated_color_square.dart](lib/src/animated_color_square.dart), which draws a
2428
continuously animating colored square that bounces off the sides of the
2529
viewport.
2630

27-
Then set the scenario from the iOS app by calling `set_scenario` on platform
28-
channel `driver`.
31+
The Scenarios app loads a `Scenario` when it receives a `set_scenario` method call
32+
on the `driver` platform channel from the objective-c code. However if you're
33+
adding a UI test this is typically not needed as you typically should add a new
34+
launch argument. See
35+
[ScenariosUITests](ios/Scenarios/ScenariosUITests/README.md) for more details.
36+
37+
## Running a specific test
38+
39+
The `run_ios_tests.sh` script runs all tests in the `Scenarios` project. If you're
40+
debugging a specific test, rebuild the `ios_debug_sim_unopt_arm64` engine variant
41+
(assuming testing on a simulator on Apple Silicon chips), and open
42+
`src/out/ios_debug_sim_unopt_arm64/ios_scenario_app/Scenarios.xcworkspace` in xcode.
43+
Use the xcode UI to run the test.

engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@
7777
argument = "--screen-before-flutter"
7878
isEnabled = "NO">
7979
</CommandLineArgument>
80+
<CommandLineArgument
81+
argument = "--tap-status-bar"
82+
isEnabled = "NO">
83+
</CommandLineArgument>
8084
<CommandLineArgument
8185
argument = "--bogus-font-text"
8286
isEnabled = "NO">

engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/AppDelegate.m

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ @interface NoStatusBarViewController : UIViewController
1616

1717
@end
1818

19+
@interface FlutterEngine ()
20+
@property(nonatomic, strong) FlutterMethodChannel* statusBarChannel;
21+
@end
22+
1923
@implementation NoStatusBarViewController
2024
- (BOOL)prefersStatusBarHidden {
2125
return YES;
@@ -210,11 +214,28 @@ - (void)setupFlutterViewControllerTest:(NSString*)scenarioIdentifier {
210214
FlutterPlatformViewGestureRecognizersBlockingPolicyWaitUntilTouchesEnded];
211215

212216
UIViewController* rootViewController = flutterViewController;
213-
// Make Flutter View's origin x/y not 0.
214217
if ([scenarioIdentifier isEqualToString:@"non_full_screen_flutter_view_platform_view"]) {
218+
// Make Flutter View's origin x/y not 0.
215219
rootViewController = [[NoStatusBarViewController alloc] init];
216220
[rootViewController.view addSubview:flutterViewController.view];
217221
flutterViewController.view.frame = CGRectMake(150, 150, 500, 500);
222+
} else if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) {
223+
[engine.binaryMessenger
224+
setMessageHandlerOnChannel:@"flutter/status_bar"
225+
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
226+
NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message
227+
options:0
228+
error:nil];
229+
FlutterBasicMessageChannel* channel = [[FlutterBasicMessageChannel alloc]
230+
initWithName:@"display_data"
231+
binaryMessenger:engine.binaryMessenger
232+
codec:[FlutterJSONMessageCodec sharedInstance]];
233+
[channel sendMessage:@{@"data" : dict}];
234+
UITextField* text =
235+
[[UITextField alloc] initWithFrame:CGRectMake(0, 400, 300, 100)];
236+
text.text = dict[@"method"];
237+
[flutterViewController.view addSubview:text];
238+
}];
218239
}
219240

220241
self.window.rootViewController = rootViewController;

engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
# Adding a New Scenario for a XCUITest
2+
3+
An XCUITest is different from a regular XCTest in that the test subject app runs
4+
in a different process than the test suite, making it trickier for the test code
5+
to communicate with the app. For instance, you won't have access to
6+
the view controller or engine instances from within the test code.
7+
8+
For this reason, the test code typically uses **launch arguments** to configure
9+
the app (for example, use [launchArgsMap](../Scenarios/AppDelegate.m) to inform
10+
the app which `Scenario` to load), and use UIKit UI components to collect test
11+
results (for example, every messsage received on the `display_data` channel adds
12+
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).
13+
14+
Refer to the [Adding a New Scenario](./../../../README.md) section for how to
15+
register a new dart `Scenario`.
16+
117
# Golden UI Tests
218

319
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,
1733
but will contain an attachment with the expected screen shot. If the screen
1834
shot looks good, add it with the correct name to the project and run the test
1935
again - it should pass this time.
36+
37+
## Running a specific Scenario
38+
39+
Add and enable the new launch argument to the `Arguments Passed On Launch`
40+
section of the `Debug - Run` scheme, and build and run the app.

engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ - (void)setUp {
1616
}
1717

1818
- (void)testTapStatusBar {
19+
XCUIElement* textField = self.application.textFields[@"handleScrollToTop"];
20+
BOOL exists = [textField waitForExistenceWithTimeout:1];
21+
XCTAssertFalse(exists, @"");
22+
1923
XCUIApplication* systemApp =
2024
[[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"];
2125
XCUIElement* statusBar = [systemApp.statusBars firstMatch];
@@ -25,21 +29,7 @@ - (void)testTapStatusBar {
2529
XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)];
2630
[coordinates tap];
2731
}
28-
29-
XCUIElement* addTextField =
30-
self.application
31-
.textFields[@"0,PointerChange.add,device=0,buttons=0,signalKind=PointerSignalKind.none"];
32-
BOOL exists = [addTextField waitForExistenceWithTimeout:1];
33-
XCTAssertTrue(exists, @"");
34-
XCUIElement* downTextField =
35-
self.application
36-
.textFields[@"1,PointerChange.down,device=0,buttons=0,signalKind=PointerSignalKind.none"];
37-
exists = [downTextField waitForExistenceWithTimeout:1];
38-
XCTAssertTrue(exists, @"");
39-
XCUIElement* upTextField =
40-
self.application
41-
.textFields[@"2,PointerChange.up,device=0,buttons=0,signalKind=PointerSignalKind.none"];
42-
exists = [upTextField waitForExistenceWithTimeout:1];
32+
exists = [textField waitForExistenceWithTimeout:1];
4333
XCTAssertTrue(exists, @"");
4434
}
4535

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'dart:typed_data' show ByteData;
6+
import 'dart:ui';
7+
8+
import 'scenario.dart';
9+
10+
/// A scenario which intercepts all messages on the given channel, and sends back
11+
/// the same message to the engine on a channel with the same name.
12+
class EchoPlatformChannelScenario extends Scenario {
13+
/// Constructor for `EchoPlatformChannelScenario`.
14+
EchoPlatformChannelScenario(super.view, {required this.channel}) {
15+
channelBuffers.setListener(channel, _onHandlePlatformMessage);
16+
}
17+
18+
/// The name of the channel where all messages should be intercepted.
19+
final String channel;
20+
21+
void _onHandlePlatformMessage(ByteData? data, PlatformMessageResponseCallback _) {
22+
view.platformDispatcher.sendPlatformMessage(channel, data, null);
23+
}
24+
25+
@override
26+
void unmount() {
27+
channelBuffers.clearListener(channel);
28+
super.unmount();
29+
}
30+
}

engine/src/flutter/testing/ios_scenario_app/lib/src/scenarios.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'darwin_system_font.dart';
1111
import 'get_bitmap_scenario.dart';
1212
import 'initial_route_reply.dart';
1313
import 'locale_initialization.dart';
14+
import 'platform_channel_echo.dart';
1415
import 'platform_view.dart';
1516
import 'poppable_screen.dart';
1617
import 'scenario.dart';
@@ -141,7 +142,8 @@ Map<String, _ScenarioFactory> _scenarios = <String, _ScenarioFactory>{
141142
TwoPlatformViewClipPath(view, firstId: _viewId++, secondId: _viewId++),
142143
'two_platform_view_clip_path_multiple_clips': (FlutterView view) =>
143144
TwoPlatformViewClipPathMultipleClips(view, firstId: _viewId++, secondId: _viewId++),
144-
'tap_status_bar': (FlutterView view) => TouchesScenario(view),
145+
'tap_status_bar': (FlutterView view) =>
146+
EchoPlatformChannelScenario(view, channel: 'flutter/status_bar'),
145147
'initial_route_reply': (FlutterView view) => InitialRouteReply(view),
146148
'platform_view_with_continuous_texture': (FlutterView view) =>
147149
PlatformViewWithContinuousTexture(view, id: _viewId++),

0 commit comments

Comments
 (0)