diff --git a/shell/platform/darwin/ios/framework/Headers/FlutterAppDelegate.h b/shell/platform/darwin/ios/framework/Headers/FlutterAppDelegate.h index 9c534a6fc80e0..8684a22ea7733 100644 --- a/shell/platform/darwin/ios/framework/Headers/FlutterAppDelegate.h +++ b/shell/platform/darwin/ios/framework/Headers/FlutterAppDelegate.h @@ -29,15 +29,6 @@ FLUTTER_EXPORT @property(strong, nonatomic) UIWindow* window; -/** - * Handle StatusBar touches. - * - * Call this from your AppDelegate's `touchesBegan:withEvent:` to have Flutter respond to StatusBar - * touches. For example, to enable scroll-to-top behavior. FlutterAppDelegate already calls it so - * you only need to manually call it if you aren't using a FlutterAppDelegate. - */ -+ (void)handleStatusBarTouches:(NSSet*)touches withEvent:(UIEvent*)event; - @end #endif // FLUTTER_FLUTTERDARTPROJECT_H_ diff --git a/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h b/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h index b0f7df28614e0..9753cdb68bca0 100644 --- a/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h +++ b/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h @@ -72,8 +72,6 @@ FLUTTER_EXPORT nibName:(nullable NSString*)nibName bundle:(nullable NSBundle*)nibBundle NS_DESIGNATED_INITIALIZER; -- (void)handleStatusBarTouches:(UIEvent*)event; - /** * Registers a callback that will be invoked when the Flutter view has been rendered. * The callback will be fired only once. diff --git a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm index b4c750171485f..42a4081cca13c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm @@ -48,15 +48,6 @@ + (FlutterViewController*)rootFlutterViewController { return nil; } -+ (void)handleStatusBarTouches:(NSSet*)touches withEvent:(UIEvent*)event { - [self.rootFlutterViewController handleStatusBarTouches:event]; -} - -- (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { - [super touchesBegan:touches withEvent:event]; - [[self class] handleStatusBarTouches:touches withEvent:event]; -} - // Do not remove, some clients may be calling these via `super`. - (void)applicationDidEnterBackground:(UIApplication*)application { } diff --git a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index 5b04408ec9d2c..470811a5a8882 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -23,6 +23,9 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/platform_message_response_darwin.h" #import "flutter/shell/platform/darwin/ios/platform_view_ios.h" +static constexpr int kMicrosecondsPerSecond = 1000 * 1000; +static constexpr CGFloat kScrollViewContentSize = 2.0; + NSNotificationName const FlutterSemanticsUpdateNotification = @"FlutterSemanticsUpdate"; NSNotificationName const FlutterViewControllerWillDealloc = @"FlutterViewControllerWillDealloc"; @@ -86,7 +89,7 @@ - (void)invalidate { // This is left a FlutterBinaryMessenger privately for now to give people a chance to notice the // change. Unfortunately unless you have Werror turned on, incompatible pointers as arguments are // just a warning. -@interface FlutterViewController () +@interface FlutterViewController () @property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI; @end @@ -123,6 +126,11 @@ @implementation FlutterViewController { // Coalescer that filters out superfluous keyboard notifications when the app // is being foregrounded. fml::scoped_nsobject _updateViewportMetrics; + // This scroll view is a workaround to accomodate iOS 13 and higher. There isn't a way to get + // touches on the status bar to trigger scrolling to the top of a scroll view. We place a + // UIScrollView with height zero and a content offset so we can get those events. See also: + // https://github.com/flutter/flutter/issues/35050 + fml::scoped_nsobject _scrollView; } @synthesize displayingFlutterUI = _displayingFlutterUI; @@ -329,6 +337,40 @@ - (void)loadView { self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; [self installSplashScreenViewIfNecessary]; + UIScrollView* scrollView = [[UIScrollView alloc] init]; + scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth; + // The color shouldn't matter since it is offscreen. + scrollView.backgroundColor = UIColor.whiteColor; + scrollView.delegate = self; + // This is an arbitrary small size. + scrollView.contentSize = CGSizeMake(kScrollViewContentSize, kScrollViewContentSize); + // This is an arbitrary offset that is not CGPointZero. + scrollView.contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize); + [self.view addSubview:scrollView]; + _scrollView.reset(scrollView); +} + +static void sendFakeTouchEvent(FlutterEngine* engine, + CGPoint location, + flutter::PointerData::Change change) { + const CGFloat scale = [UIScreen mainScreen].scale; + flutter::PointerData pointer_data; + pointer_data.Clear(); + pointer_data.physical_x = location.x * scale; + pointer_data.physical_y = location.y * scale; + pointer_data.kind = flutter::PointerData::DeviceKind::kTouch; + pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond; + 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 { + CGPoint statusBarPoint = CGPointZero; + sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kDown); + sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kUp); + return NO; } #pragma mark - Managing launch views @@ -569,7 +611,6 @@ - (void)flushOngoingTouches { flutter::PointerData pointer_data; pointer_data.Clear(); - constexpr int kMicrosecondsPerSecond = 1000 * 1000; // Use current time. pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond; @@ -835,6 +876,10 @@ - (void)viewDidLayoutSubviews { CGSize viewSize = self.view.bounds.size; CGFloat scale = [UIScreen mainScreen].scale; + // Purposefully place this not visible. + _scrollView.get().frame = CGRectMake(0.0, 0.0, viewSize.width, 0.0); + _scrollView.get().contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize); + // First time since creation that the dimensions of its view is known. bool firstViewBoundsUpdate = !_viewportMetrics.physical_width; _viewportMetrics.device_pixel_ratio = scale; @@ -1146,42 +1191,6 @@ - (NSString*)contrastMode { } } -#pragma mark - Status Bar touch event handling - -// Standard iOS status bar height in points. -constexpr CGFloat kStandardStatusBarHeight = 20.0; - -- (void)handleStatusBarTouches:(UIEvent*)event { - CGFloat standardStatusBarHeight = kStandardStatusBarHeight; - if (@available(iOS 11, *)) { - standardStatusBarHeight = self.view.safeAreaInsets.top; - } - - // If the status bar is double-height, don't handle status bar taps. iOS - // should open the app associated with the status bar. - CGRect statusBarFrame = [UIApplication sharedApplication].statusBarFrame; - if (statusBarFrame.size.height != standardStatusBarHeight) { - return; - } - - // If we detect a touch in the status bar, synthesize a fake touch begin/end. - for (UITouch* touch in event.allTouches) { - if (touch.phase == UITouchPhaseBegan && touch.tapCount > 0) { - CGPoint windowLoc = [touch locationInView:nil]; - CGPoint screenLoc = [touch.window convertPoint:windowLoc toWindow:nil]; - if (CGRectContainsPoint(statusBarFrame, screenLoc)) { - NSSet* statusbarTouches = [NSSet setWithObject:touch]; - - flutter::PointerData::Change change = flutter::PointerData::Change::kDown; - [self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change]; - change = flutter::PointerData::Change::kUp; - [self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change]; - return; - } - } - } -} - #pragma mark - Status bar style - (UIStatusBarStyle)preferredStatusBarStyle { diff --git a/testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj b/testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj index 5da90fd32a674..8f95dec91db4f 100644 --- a/testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj +++ b/testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 0A57B3BF2323C74200DD9521 /* FlutterEngine+ScenariosTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */; }; 0A57B3C22323D2D700DD9521 /* AppLifecycleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */; }; 0D14A3FE239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png in Resources */ = {isa = PBXBuildFile; fileRef = 0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */; }; + 0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D8470A3240F0B1F0030B565 /* StatusBarTest.m */; }; 0DB781EF22E931BE00E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0DB781F122E933E800E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0DB781FE22EA2C6D00E9B371 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; }; @@ -114,6 +115,8 @@ 0A57B3C02323C74D00DD9521 /* FlutterEngine+ScenariosTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FlutterEngine+ScenariosTest.h"; sourceTree = ""; }; 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppLifecycleTests.m; sourceTree = ""; }; 0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "golden_platform_view_rotate_iPhone SE_simulator.png"; sourceTree = ""; }; + 0D8470A2240F0B1F0030B565 /* StatusBarTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StatusBarTest.h; sourceTree = ""; }; + 0D8470A3240F0B1F0030B565 /* StatusBarTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StatusBarTest.m; sourceTree = ""; }; 0DB781FC22EA2C0300E9B371 /* FlutterViewControllerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FlutterViewControllerTest.m; sourceTree = ""; }; 244EA6CF230DBE8900B2D26E /* golden_platform_view_D21AP.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = golden_platform_view_D21AP.png; sourceTree = ""; }; 246B4E4122E3B5F700073EBF /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = App.framework; sourceTree = ""; }; @@ -272,6 +275,8 @@ 6816DBA22318358200A51400 /* PlatformViewGoldenTestManager.h */, 6816DBA32318358200A51400 /* PlatformViewGoldenTestManager.m */, 68A5B63323EB71D300BDBCDB /* PlatformViewGestureRecognizerTests.m */, + 0D8470A2240F0B1F0030B565 /* StatusBarTest.h */, + 0D8470A3240F0B1F0030B565 /* StatusBarTest.m */, ); path = ScenariosUITests; sourceTree = ""; @@ -466,6 +471,7 @@ 6816DB9E231750ED00A51400 /* GoldenPlatformViewTests.m in Sources */, 6816DBA42318358200A51400 /* PlatformViewGoldenTestManager.m in Sources */, 248D76EF22E388380012F0C1 /* PlatformViewUITests.m in Sources */, + 0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m b/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m index 8805ad80bd644..348889b19b856 100644 --- a/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m +++ b/testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m @@ -41,6 +41,7 @@ - (BOOL)application:(UIApplication*)application @"--gesture-reject-after-touches-ended" : @"platform_view_gesture_reject_after_touches_ended", @"--gesture-reject-eager" : @"platform_view_gesture_reject_eager", @"--gesture-accept" : @"platform_view_gesture_accept", + @"--tap-status-bar" : @"tap_status_bar", }; __block NSString* platformViewTestName = nil; [launchArgsMap @@ -67,8 +68,16 @@ - (void)readyContextForPlatformViewTests:(NSString*)scenarioIdentifier { FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"PlatformViewTest" project:nil]; [engine runWithEntrypoint:nil]; - FlutterViewController* flutterViewController = - [[NoStatusBarFlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil]; + FlutterViewController* flutterViewController; + if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) { + flutterViewController = [[FlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + } else { + flutterViewController = [[NoStatusBarFlutterViewController alloc] initWithEngine:engine + nibName:nil + bundle:nil]; + } [engine.binaryMessenger setMessageHandlerOnChannel:@"scenario_status" binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) { @@ -76,6 +85,16 @@ - (void)readyContextForPlatformViewTests:(NSString*)scenarioIdentifier { sendOnChannel:@"set_scenario" message:[scenarioIdentifier dataUsingEncoding:NSUTF8StringEncoding]]; }]; + [engine.binaryMessenger + setMessageHandlerOnChannel:@"touches_scenario" + binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) { + NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message + options:0 + error:nil]; + UITextField* text = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 300, 100)]; + text.text = dict[@"change"]; + [flutterViewController.view addSubview:text]; + }]; TextPlatformViewFactory* textPlatformViewFactory = [[TextPlatformViewFactory alloc] initWithMessenger:flutterViewController.binaryMessenger]; NSObject* registrar = diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.h b/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.h new file mode 100644 index 0000000000000..03ff9a1c662cd --- /dev/null +++ b/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.h @@ -0,0 +1,13 @@ +// Copyright 2020 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 + +NS_ASSUME_NONNULL_BEGIN + +@interface StatusBarTest : XCTestCase +@property(nonatomic, strong) XCUIApplication* application; +@end + +NS_ASSUME_NONNULL_END diff --git a/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m b/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m new file mode 100644 index 0000000000000..5192b62e461fc --- /dev/null +++ b/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m @@ -0,0 +1,41 @@ +// Copyright 2020 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 "StatusBarTest.h" + +@implementation StatusBarTest + +- (void)setUp { + [super setUp]; + self.continueAfterFailure = NO; + + self.application = [[XCUIApplication alloc] init]; + self.application.launchArguments = @[ @"--tap-status-bar" ]; + [self.application launch]; +} + +- (void)testTapStatusBar { + if (@available(iOS 13, *)) { + XCUIApplication* systemApp = + [[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"]; + XCUIElement* statusBar = [systemApp.statusBars firstMatch]; + if (statusBar.isHittable) { + [statusBar tap]; + } else { + XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; + [coordinates tap]; + } + } else { + [[self.application.statusBars firstMatch] tap]; + } + + XCUIElement* addTextField = self.application.textFields[@"PointerChange.add"]; + BOOL exists = [addTextField waitForExistenceWithTimeout:1]; + XCTAssertTrue(exists, @""); + XCUIElement* upTextField = self.application.textFields[@"PointerChange.up"]; + exists = [upTextField waitForExistenceWithTimeout:1]; + XCTAssertTrue(exists, @""); +} + +@end diff --git a/testing/scenario_app/lib/main.dart b/testing/scenario_app/lib/main.dart index 8523234f940f7..494d6585aa7fe 100644 --- a/testing/scenario_app/lib/main.dart +++ b/testing/scenario_app/lib/main.dart @@ -14,6 +14,7 @@ import 'src/animated_color_square.dart'; import 'src/platform_view.dart'; import 'src/poppable_screen.dart'; import 'src/scenario.dart'; +import 'src/touches_scenario.dart'; Map _scenarios = { 'animated_color_square': AnimatedColorSquareScenario(window), @@ -30,6 +31,7 @@ Map _scenarios = { 'platform_view_gesture_reject_eager': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false), 'platform_view_gesture_accept': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: true), 'platform_view_gesture_reject_after_touches_ended': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false, rejectUntilTouchesEnded: true), + 'tap_status_bar' : TouchesScenario(window), }; Scenario _currentScenario = _scenarios['animated_color_square']; diff --git a/testing/scenario_app/lib/src/touches_scenario.dart b/testing/scenario_app/lib/src/touches_scenario.dart new file mode 100644 index 0000000000000..d0148ef5a28d9 --- /dev/null +++ b/testing/scenario_app/lib/src/touches_scenario.dart @@ -0,0 +1,31 @@ +// Copyright 2020 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:convert'; +import 'dart:ui'; + +import 'scenario.dart'; + +/// A scenario that sends back messages when touches are received. +class TouchesScenario extends Scenario { + /// Constructor for `TouchesScenario`. + TouchesScenario(Window window) : super(window); + + @override + void onBeginFrame(Duration duration) {} + + @override + void onPointerDataPacket(PointerDataPacket packet) { + window.sendPlatformMessage( + 'touches_scenario', + utf8.encoder + .convert(const JsonCodec().encode({ + 'change': packet.data[0].change.toString(), + })) + .buffer + .asByteData(), + null, + ); + } +}