Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 59b4d9b

Browse files
authored
Fixed the ability to scroll to the top on iOS 13 (#16820)
1 parent 3362c5f commit 59b4d9b

File tree

10 files changed

+161
-60
lines changed

10 files changed

+161
-60
lines changed

shell/platform/darwin/ios/framework/Headers/FlutterAppDelegate.h

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,6 @@ FLUTTER_EXPORT
2929

3030
@property(strong, nonatomic) UIWindow* window;
3131

32-
/**
33-
* Handle StatusBar touches.
34-
*
35-
* Call this from your AppDelegate's `touchesBegan:withEvent:` to have Flutter respond to StatusBar
36-
* touches. For example, to enable scroll-to-top behavior. FlutterAppDelegate already calls it so
37-
* you only need to manually call it if you aren't using a FlutterAppDelegate.
38-
*/
39-
+ (void)handleStatusBarTouches:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event;
40-
4132
@end
4233

4334
#endif // FLUTTER_FLUTTERDARTPROJECT_H_

shell/platform/darwin/ios/framework/Headers/FlutterViewController.h

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,6 @@ FLUTTER_EXPORT
7272
nibName:(nullable NSString*)nibName
7373
bundle:(nullable NSBundle*)nibBundle NS_DESIGNATED_INITIALIZER;
7474

75-
- (void)handleStatusBarTouches:(UIEvent*)event;
76-
7775
/**
7876
* Registers a callback that will be invoked when the Flutter view has been rendered.
7977
* The callback will be fired only once.

shell/platform/darwin/ios/framework/Source/FlutterAppDelegate.mm

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,6 @@ + (FlutterViewController*)rootFlutterViewController {
4848
return nil;
4949
}
5050

51-
+ (void)handleStatusBarTouches:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
52-
[self.rootFlutterViewController handleStatusBarTouches:event];
53-
}
54-
55-
- (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {
56-
[super touchesBegan:touches withEvent:event];
57-
[[self class] handleStatusBarTouches:touches withEvent:event];
58-
}
59-
6051
// Do not remove, some clients may be calling these via `super`.
6152
- (void)applicationDidEnterBackground:(UIApplication*)application {
6253
}

shell/platform/darwin/ios/framework/Source/FlutterViewController.mm

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
#import "flutter/shell/platform/darwin/ios/framework/Source/platform_message_response_darwin.h"
2424
#import "flutter/shell/platform/darwin/ios/platform_view_ios.h"
2525

26+
static constexpr int kMicrosecondsPerSecond = 1000 * 1000;
27+
static constexpr CGFloat kScrollViewContentSize = 2.0;
28+
2629
NSNotificationName const FlutterSemanticsUpdateNotification = @"FlutterSemanticsUpdate";
2730

2831
NSNotificationName const FlutterViewControllerWillDealloc = @"FlutterViewControllerWillDealloc";
@@ -86,7 +89,7 @@ - (void)invalidate {
8689
// This is left a FlutterBinaryMessenger privately for now to give people a chance to notice the
8790
// change. Unfortunately unless you have Werror turned on, incompatible pointers as arguments are
8891
// just a warning.
89-
@interface FlutterViewController () <FlutterBinaryMessenger>
92+
@interface FlutterViewController () <FlutterBinaryMessenger, UIScrollViewDelegate>
9093
@property(nonatomic, readwrite, getter=isDisplayingFlutterUI) BOOL displayingFlutterUI;
9194
@end
9295

@@ -123,6 +126,11 @@ @implementation FlutterViewController {
123126
// Coalescer that filters out superfluous keyboard notifications when the app
124127
// is being foregrounded.
125128
fml::scoped_nsobject<FlutterCoalescer> _updateViewportMetrics;
129+
// This scroll view is a workaround to accomodate iOS 13 and higher. There isn't a way to get
130+
// touches on the status bar to trigger scrolling to the top of a scroll view. We place a
131+
// UIScrollView with height zero and a content offset so we can get those events. See also:
132+
// https://github.com/flutter/flutter/issues/35050
133+
fml::scoped_nsobject<UIScrollView> _scrollView;
126134
}
127135

128136
@synthesize displayingFlutterUI = _displayingFlutterUI;
@@ -329,6 +337,40 @@ - (void)loadView {
329337
self.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
330338

331339
[self installSplashScreenViewIfNecessary];
340+
UIScrollView* scrollView = [[UIScrollView alloc] init];
341+
scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth;
342+
// The color shouldn't matter since it is offscreen.
343+
scrollView.backgroundColor = UIColor.whiteColor;
344+
scrollView.delegate = self;
345+
// This is an arbitrary small size.
346+
scrollView.contentSize = CGSizeMake(kScrollViewContentSize, kScrollViewContentSize);
347+
// This is an arbitrary offset that is not CGPointZero.
348+
scrollView.contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize);
349+
[self.view addSubview:scrollView];
350+
_scrollView.reset(scrollView);
351+
}
352+
353+
static void sendFakeTouchEvent(FlutterEngine* engine,
354+
CGPoint location,
355+
flutter::PointerData::Change change) {
356+
const CGFloat scale = [UIScreen mainScreen].scale;
357+
flutter::PointerData pointer_data;
358+
pointer_data.Clear();
359+
pointer_data.physical_x = location.x * scale;
360+
pointer_data.physical_y = location.y * scale;
361+
pointer_data.kind = flutter::PointerData::DeviceKind::kTouch;
362+
pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond;
363+
auto packet = std::make_unique<flutter::PointerDataPacket>(/*count=*/1);
364+
pointer_data.change = change;
365+
packet->SetPointerData(0, pointer_data);
366+
[engine dispatchPointerDataPacket:std::move(packet)];
367+
}
368+
369+
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView*)scrollView {
370+
CGPoint statusBarPoint = CGPointZero;
371+
sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kDown);
372+
sendFakeTouchEvent(_engine.get(), statusBarPoint, flutter::PointerData::Change::kUp);
373+
return NO;
332374
}
333375

334376
#pragma mark - Managing launch views
@@ -569,7 +611,6 @@ - (void)flushOngoingTouches {
569611
flutter::PointerData pointer_data;
570612
pointer_data.Clear();
571613

572-
constexpr int kMicrosecondsPerSecond = 1000 * 1000;
573614
// Use current time.
574615
pointer_data.time_stamp = [[NSDate date] timeIntervalSince1970] * kMicrosecondsPerSecond;
575616

@@ -835,6 +876,10 @@ - (void)viewDidLayoutSubviews {
835876
CGSize viewSize = self.view.bounds.size;
836877
CGFloat scale = [UIScreen mainScreen].scale;
837878

879+
// Purposefully place this not visible.
880+
_scrollView.get().frame = CGRectMake(0.0, 0.0, viewSize.width, 0.0);
881+
_scrollView.get().contentOffset = CGPointMake(kScrollViewContentSize, kScrollViewContentSize);
882+
838883
// First time since creation that the dimensions of its view is known.
839884
bool firstViewBoundsUpdate = !_viewportMetrics.physical_width;
840885
_viewportMetrics.device_pixel_ratio = scale;
@@ -1146,42 +1191,6 @@ - (NSString*)contrastMode {
11461191
}
11471192
}
11481193

1149-
#pragma mark - Status Bar touch event handling
1150-
1151-
// Standard iOS status bar height in points.
1152-
constexpr CGFloat kStandardStatusBarHeight = 20.0;
1153-
1154-
- (void)handleStatusBarTouches:(UIEvent*)event {
1155-
CGFloat standardStatusBarHeight = kStandardStatusBarHeight;
1156-
if (@available(iOS 11, *)) {
1157-
standardStatusBarHeight = self.view.safeAreaInsets.top;
1158-
}
1159-
1160-
// If the status bar is double-height, don't handle status bar taps. iOS
1161-
// should open the app associated with the status bar.
1162-
CGRect statusBarFrame = [UIApplication sharedApplication].statusBarFrame;
1163-
if (statusBarFrame.size.height != standardStatusBarHeight) {
1164-
return;
1165-
}
1166-
1167-
// If we detect a touch in the status bar, synthesize a fake touch begin/end.
1168-
for (UITouch* touch in event.allTouches) {
1169-
if (touch.phase == UITouchPhaseBegan && touch.tapCount > 0) {
1170-
CGPoint windowLoc = [touch locationInView:nil];
1171-
CGPoint screenLoc = [touch.window convertPoint:windowLoc toWindow:nil];
1172-
if (CGRectContainsPoint(statusBarFrame, screenLoc)) {
1173-
NSSet* statusbarTouches = [NSSet setWithObject:touch];
1174-
1175-
flutter::PointerData::Change change = flutter::PointerData::Change::kDown;
1176-
[self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change];
1177-
change = flutter::PointerData::Change::kUp;
1178-
[self dispatchTouches:statusbarTouches pointerDataChangeOverride:&change];
1179-
return;
1180-
}
1181-
}
1182-
}
1183-
}
1184-
11851194
#pragma mark - Status bar style
11861195

11871196
- (UIStatusBarStyle)preferredStatusBarStyle {

testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
0A57B3BF2323C74200DD9521 /* FlutterEngine+ScenariosTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */; };
1212
0A57B3C22323D2D700DD9521 /* AppLifecycleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */; };
1313
0D14A3FE239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png in Resources */ = {isa = PBXBuildFile; fileRef = 0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */; };
14+
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0D8470A3240F0B1F0030B565 /* StatusBarTest.m */; };
1415
0DB781EF22E931BE00E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
1516
0DB781F122E933E800E9B371 /* Flutter.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
1617
0DB781FE22EA2C6D00E9B371 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 246B4E4522E3B61000073EBF /* Flutter.framework */; };
@@ -114,6 +115,8 @@
114115
0A57B3C02323C74D00DD9521 /* FlutterEngine+ScenariosTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FlutterEngine+ScenariosTest.h"; sourceTree = "<group>"; };
115116
0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppLifecycleTests.m; sourceTree = "<group>"; };
116117
0D14A3FD239743190013D873 /* golden_platform_view_rotate_iPhone SE_simulator.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "golden_platform_view_rotate_iPhone SE_simulator.png"; sourceTree = "<group>"; };
118+
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = StatusBarTest.h; sourceTree = "<group>"; };
119+
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = StatusBarTest.m; sourceTree = "<group>"; };
117120
0DB781FC22EA2C0300E9B371 /* FlutterViewControllerTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FlutterViewControllerTest.m; sourceTree = "<group>"; };
118121
244EA6CF230DBE8900B2D26E /* golden_platform_view_D21AP.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = golden_platform_view_D21AP.png; sourceTree = "<group>"; };
119122
246B4E4122E3B5F700073EBF /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = App.framework; sourceTree = "<group>"; };
@@ -272,6 +275,8 @@
272275
6816DBA22318358200A51400 /* PlatformViewGoldenTestManager.h */,
273276
6816DBA32318358200A51400 /* PlatformViewGoldenTestManager.m */,
274277
68A5B63323EB71D300BDBCDB /* PlatformViewGestureRecognizerTests.m */,
278+
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */,
279+
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */,
275280
);
276281
path = ScenariosUITests;
277282
sourceTree = "<group>";
@@ -466,6 +471,7 @@
466471
6816DB9E231750ED00A51400 /* GoldenPlatformViewTests.m in Sources */,
467472
6816DBA42318358200A51400 /* PlatformViewGoldenTestManager.m in Sources */,
468473
248D76EF22E388380012F0C1 /* PlatformViewUITests.m in Sources */,
474+
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */,
469475
);
470476
runOnlyForDeploymentPostprocessing = 0;
471477
};

testing/scenario_app/ios/Scenarios/Scenarios/AppDelegate.m

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ - (BOOL)application:(UIApplication*)application
4141
@"--gesture-reject-after-touches-ended" : @"platform_view_gesture_reject_after_touches_ended",
4242
@"--gesture-reject-eager" : @"platform_view_gesture_reject_eager",
4343
@"--gesture-accept" : @"platform_view_gesture_accept",
44+
@"--tap-status-bar" : @"tap_status_bar",
4445
};
4546
__block NSString* platformViewTestName = nil;
4647
[launchArgsMap
@@ -67,15 +68,33 @@ - (void)readyContextForPlatformViewTests:(NSString*)scenarioIdentifier {
6768
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"PlatformViewTest" project:nil];
6869
[engine runWithEntrypoint:nil];
6970

70-
FlutterViewController* flutterViewController =
71-
[[NoStatusBarFlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
71+
FlutterViewController* flutterViewController;
72+
if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) {
73+
flutterViewController = [[FlutterViewController alloc] initWithEngine:engine
74+
nibName:nil
75+
bundle:nil];
76+
} else {
77+
flutterViewController = [[NoStatusBarFlutterViewController alloc] initWithEngine:engine
78+
nibName:nil
79+
bundle:nil];
80+
}
7281
[engine.binaryMessenger
7382
setMessageHandlerOnChannel:@"scenario_status"
7483
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
7584
[engine.binaryMessenger
7685
sendOnChannel:@"set_scenario"
7786
message:[scenarioIdentifier dataUsingEncoding:NSUTF8StringEncoding]];
7887
}];
88+
[engine.binaryMessenger
89+
setMessageHandlerOnChannel:@"touches_scenario"
90+
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
91+
NSDictionary* dict = [NSJSONSerialization JSONObjectWithData:message
92+
options:0
93+
error:nil];
94+
UITextField* text = [[UITextField alloc] initWithFrame:CGRectMake(0, 0, 300, 100)];
95+
text.text = dict[@"change"];
96+
[flutterViewController.view addSubview:text];
97+
}];
7998
TextPlatformViewFactory* textPlatformViewFactory =
8099
[[TextPlatformViewFactory alloc] initWithMessenger:flutterViewController.binaryMessenger];
81100
NSObject<FlutterPluginRegistrar>* registrar =
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright 2020 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 <XCTest/XCTest.h>
6+
7+
NS_ASSUME_NONNULL_BEGIN
8+
9+
@interface StatusBarTest : XCTestCase
10+
@property(nonatomic, strong) XCUIApplication* application;
11+
@end
12+
13+
NS_ASSUME_NONNULL_END
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright 2020 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 "StatusBarTest.h"
6+
7+
@implementation StatusBarTest
8+
9+
- (void)setUp {
10+
[super setUp];
11+
self.continueAfterFailure = NO;
12+
13+
self.application = [[XCUIApplication alloc] init];
14+
self.application.launchArguments = @[ @"--tap-status-bar" ];
15+
[self.application launch];
16+
}
17+
18+
- (void)testTapStatusBar {
19+
if (@available(iOS 13, *)) {
20+
XCUIApplication* systemApp =
21+
[[XCUIApplication alloc] initWithBundleIdentifier:@"com.apple.springboard"];
22+
XCUIElement* statusBar = [systemApp.statusBars firstMatch];
23+
if (statusBar.isHittable) {
24+
[statusBar tap];
25+
} else {
26+
XCUICoordinate* coordinates = [statusBar coordinateWithNormalizedOffset:CGVectorMake(0, 0)];
27+
[coordinates tap];
28+
}
29+
} else {
30+
[[self.application.statusBars firstMatch] tap];
31+
}
32+
33+
XCUIElement* addTextField = self.application.textFields[@"PointerChange.add"];
34+
BOOL exists = [addTextField waitForExistenceWithTimeout:1];
35+
XCTAssertTrue(exists, @"");
36+
XCUIElement* upTextField = self.application.textFields[@"PointerChange.up"];
37+
exists = [upTextField waitForExistenceWithTimeout:1];
38+
XCTAssertTrue(exists, @"");
39+
}
40+
41+
@end

testing/scenario_app/lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'src/animated_color_square.dart';
1414
import 'src/platform_view.dart';
1515
import 'src/poppable_screen.dart';
1616
import 'src/scenario.dart';
17+
import 'src/touches_scenario.dart';
1718

1819
Map<String, Scenario> _scenarios = <String, Scenario>{
1920
'animated_color_square': AnimatedColorSquareScenario(window),
@@ -30,6 +31,7 @@ Map<String, Scenario> _scenarios = <String, Scenario>{
3031
'platform_view_gesture_reject_eager': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false),
3132
'platform_view_gesture_accept': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: true),
3233
'platform_view_gesture_reject_after_touches_ended': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false, rejectUntilTouchesEnded: true),
34+
'tap_status_bar' : TouchesScenario(window),
3335
};
3436

3537
Scenario _currentScenario = _scenarios['animated_color_square'];
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Copyright 2020 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:convert';
6+
import 'dart:ui';
7+
8+
import 'scenario.dart';
9+
10+
/// A scenario that sends back messages when touches are received.
11+
class TouchesScenario extends Scenario {
12+
/// Constructor for `TouchesScenario`.
13+
TouchesScenario(Window window) : super(window);
14+
15+
@override
16+
void onBeginFrame(Duration duration) {}
17+
18+
@override
19+
void onPointerDataPacket(PointerDataPacket packet) {
20+
window.sendPlatformMessage(
21+
'touches_scenario',
22+
utf8.encoder
23+
.convert(const JsonCodec().encode(<String, dynamic>{
24+
'change': packet.data[0].change.toString(),
25+
}))
26+
.buffer
27+
.asByteData(),
28+
null,
29+
);
30+
}
31+
}

0 commit comments

Comments
 (0)