Skip to content

Commit f4c83de

Browse files
Send statusBarTouch events via dedicated messages (flutter#179643)
Instead of using fake touch events. Before this patch `FlutterViewController` sends two fake touch events (down and up), at `(0, 0)` to the framework to signal that the status bar is tapped on iOS. The scaffold widget and the cupertino page scaffold widget set up gesture detectors to listen for these fake taps, and scroll the "primary" scrollable container to the top in response. This messaging mechanism is sometimes ambiguous, as the framework may interpret that as a pair of regular pointer tap events (for instance in flutter#177992 the modal barrier claims the tap gesture and as a result the modal barrier is dismissed by the fake touch events). This PR changes that to communicate the status bar tap event via a new system channel, and dispatch the events via `WidgetsBindingObserver`s in the framework. See also flutter#177992 (comment) Fixes flutter#177992, fixes flutter#175606 It appears that UIKit also has access to the coordinates of the touch events to determine which scrollable view(s?) to dispatch the scroll to top event to. ```objc * frame #0: 0x00000001032f6520 UIPlayground.debug.dylib`MyScrollViewController.scrollViewShouldScrollToTop(scrollView=0x0000000106014800) at UIScrollView.swift:13:3 frame #2: 0x00000001867c9300 UIKitCore`-[UIScrollView _scrollToTopIfPossible:] + 316 frame #3: 0x00000001867c9604 UIKitCore`-[UIScrollView _scrollToTopFromTouchAtScreenLocation:resultHandler:] + 40 frame flutter#4: 0x0000000186299bbc UIKitCore`__71-[UIWindow _scrollToTopViewsUnderScreenPointIfNecessary:resultHandler:]_block_invoke.358 + 168 frame flutter#5: 0x000000018629981c UIKitCore`-[UIWindow _scrollToTopViewsUnderScreenPointIfNecessary:resultHandler:] + 1212 frame flutter#6: 0x000000018581ed8c UIKitCore`-[UIStatusBarManager _handleScrollToTopAtXPosition:] + 192 frame flutter#7: 0x000000018581eb60 UIKitCore`-[UIStatusBarManager handleTapAction:] + 60 ``` Unfortunately that information is not available to user application. The iOS accessibility bridge currently does create dummy UIScrollViews for each scrollable in the accessibility tree so may be we can take advantage of that in the future. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
1 parent 047c0bd commit f4c83de

File tree

18 files changed

+304
-206
lines changed

18 files changed

+304
-206
lines changed

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
@@ -554,29 +554,13 @@ - (void)loadView {
554554
return pointer_data;
555555
}
556556

557-
static void SendFakeTouchEvent(UIScreen* screen,
558-
FlutterEngine* engine,
559-
CGPoint location,
560-
flutter::PointerData::Change change) {
561-
const CGFloat scale = screen.scale;
562-
flutter::PointerData pointer_data = [[engine viewController] generatePointerDataForFake];
563-
pointer_data.physical_x = location.x * scale;
564-
pointer_data.physical_y = location.y * scale;
565-
auto packet = std::make_unique<flutter::PointerDataPacket>(/*count=*/1);
566-
pointer_data.change = change;
567-
packet->SetPointerData(0, pointer_data);
568-
[engine dispatchPointerDataPacket:std::move(packet)];
569-
}
570-
571557
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
572558
if (!self.engine) {
573559
return NO;
574560
}
575-
CGPoint statusBarPoint = CGPointZero;
576-
UIScreen* screen = self.flutterScreenIfViewLoaded;
577-
if (screen) {
578-
SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kDown);
579-
SendFakeTouchEvent(screen, self.engine, statusBarPoint, flutter::PointerData::Change::kUp);
561+
if (self.isViewLoaded) {
562+
// Status bar taps before the UI is visible should be ignored.
563+
[self.engine onStatusBarTap];
580564
}
581565
return NO;
582566
}

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)