diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm index d88b9bb548b..7a97c7cf5fd 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews.mm @@ -573,6 +573,39 @@ static BOOL _preparedOnce = NO; return NO; } +- (void)searchAndFixWebView:(UIView*)view { + if ([view isKindOfClass:[WKWebView class]]) { + return [self searchAndFixWebViewGestureRecognzier:view]; + } else { + for (UIView* subview in view.subviews) { + [self searchAndFixWebView:subview]; + } + } +} + +- (void)searchAndFixWebViewGestureRecognzier:(UIView*)view { + for (UIGestureRecognizer* recognizer in view.gestureRecognizers) { + // This is to fix a bug on iOS 26 where web view link is not tappable. + // We reset the web view's WKTouchEventsGestureRecognizer in a bad state + // by disabling and re-enabling it. + // See: https://github.com/flutter/flutter/issues/175099. + // See also: https://github.com/flutter/engine/pull/56804 for an explanation of the + // bug on iOS 18.2, which is still valid on iOS 26. + // Warning: This is just a quick fix that patches the bug. For example, + // touches on a drawing website is still not completely blocked. A proper solution + // should rely on overriding the hitTest behavior. + // See: https://github.com/flutter/flutter/issues/179916. + if (recognizer.enabled && + [NSStringFromClass([recognizer class]) hasSuffix:@"TouchEventsGestureRecognizer"]) { + recognizer.enabled = NO; + recognizer.enabled = YES; + } + } + for (UIView* subview in view.subviews) { + [self searchAndFixWebViewGestureRecognzier:subview]; + } +} + - (void)blockGesture { switch (_blockingPolicy) { case FlutterPlatformViewGestureRecognizersBlockingPolicyEager: @@ -588,9 +621,15 @@ static BOOL _preparedOnce = NO; // FlutterPlatformViewGestureRecognizersBlockingPolicyEager, but we should try it if a similar // issue arises for the other policy. if (@available(iOS 26.0, *)) { - // This workaround does not work on iOS 26. - // TODO(hellohuanlin): find a solution for iOS 26, - // https://github.com/flutter/flutter/issues/175099. + // This performs a nested DFS, with the outer one searching for any web view, and the inner + // one searching for a TouchEventsGestureRecognizer inside the web view. Once found, disable + // and immediately reenable it to reset its state. + // TODO(hellohuanlin): remove this flag after it is battle tested. + NSNumber* isWorkaroundDisabled = + [[NSBundle mainBundle] objectForInfoDictionaryKey:@"FLTDisableWebViewGestureReset"]; + if (!isWorkaroundDisabled.boolValue) { + [self searchAndFixWebView:self.embeddedView]; + } } else if (@available(iOS 18.2, *)) { // This workaround is designed for WKWebView only. The 1P web view plugin provides a // WKWebView itself as the platform view. However, some 3P plugins provide wrappers of diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index 20b4e2936c8..5be853d7c72 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -47,6 +47,36 @@ const float kFloatCompareEpsilon = 0.001; @end +// A mock recognizer without "TouchEventsGestureRecognizer" suffix in class name. +// This is to verify a fix to a bug on iOS 26 where web view link is not tappable. +// We reset the web view's WKTouchEventsGestureRecognizer in a bad state +// by disabling and re-enabling it. +// See: https://github.com/flutter/flutter/issues/175099. +@interface MockGestureRecognizer : UIGestureRecognizer +@property(nonatomic, strong) NSMutableArray* toggleHistory; +@end + +@implementation MockGestureRecognizer +- (instancetype)init { + self = [super init]; + if (self) { + _toggleHistory = [NSMutableArray array]; + } + return self; +} +- (void)setEnabled:(BOOL)enabled { + [super setEnabled:enabled]; + [self.toggleHistory addObject:@(enabled)]; +} +@end + +// A mock recognizer with "TouchEventsGestureRecognizer" suffix in class name. +@interface MockTouchEventsGestureRecognizer : MockGestureRecognizer +@end + +@implementation MockTouchEventsGestureRecognizer +@end + @interface FlutterPlatformViewsTestMockFlutterPlatformView : NSObject @property(nonatomic, strong) UIView* view; @property(nonatomic, assign) BOOL viewCreated; @@ -3403,6 +3433,429 @@ fml::RefPtr GetDefaultTaskRunner() { XCTAssertEqual(touchInteceptorView.gestureRecognizers[1], forwardingRecognizer); } +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldDisableAndReEnableTouchEventsGestureRecognizerForSimpleWebView { + if (@available(iOS 26.0, *)) { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + FlutterPlatformViewsController* flutterPlatformViewsController = + [[FlutterPlatformViewsController alloc] init]; + flutterPlatformViewsController.taskRunner = GetDefaultTaskRunner(); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kMetal, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockWebViewFactory* factory = + [[FlutterPlatformViewsTestMockWebViewFactory alloc] init]; + [flutterPlatformViewsController + registerViewFactory:factory + withId:@"MockWebView" + gestureRecognizersBlockingPolicy:FlutterPlatformViewGestureRecognizersBlockingPolicyEager]; + FlutterResult result = ^(id result) { + }; + [flutterPlatformViewsController + onMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockWebView"}] + result:result]; + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + /* + Simple Web View at root, with [*] indicating views containing + MockTouchEventsGestureRecognizer. + + Root (Web View) [*] + ├── Child 1 + └── Child 2 + ├── Child 2.1 + └── Child 2.2 [*] + */ + + UIView* root = gMockPlatformView; + root.gestureRecognizers = nil; + for (UIView* subview in root.subviews) { + [subview removeFromSuperview]; + } + + MockGestureRecognizer* normalRecognizer0 = [[MockGestureRecognizer alloc] init]; + [root addGestureRecognizer:normalRecognizer0]; + + UIView* child1 = [[UIView alloc] init]; + [root addSubview:child1]; + MockGestureRecognizer* normalRecognizer1 = [[MockGestureRecognizer alloc] init]; + [child1 addGestureRecognizer:normalRecognizer1]; + + UIView* child2 = [[UIView alloc] init]; + [root addSubview:child2]; + MockGestureRecognizer* normalRecognizer2 = [[MockGestureRecognizer alloc] init]; + [child2 addGestureRecognizer:normalRecognizer2]; + + UIView* child2_1 = [[UIView alloc] init]; + [child2 addSubview:child2_1]; + MockGestureRecognizer* normalRecognizer2_1 = [[MockGestureRecognizer alloc] init]; + [child2_1 addGestureRecognizer:normalRecognizer2_1]; + + UIView* child2_2 = [[UIView alloc] init]; + [child2 addSubview:child2_2]; + MockGestureRecognizer* normalRecognizer2_2 = [[MockGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:normalRecognizer2_2]; + + // Add the target recognizer at root & child2_2. + MockTouchEventsGestureRecognizer* targetRecognizer0 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [root addGestureRecognizer:targetRecognizer0]; + + MockTouchEventsGestureRecognizer* targetRecognizer2_2 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:targetRecognizer2_2]; + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + NSArray* normalRecognizers = @[ + normalRecognizer0, normalRecognizer1, normalRecognizer2, normalRecognizer2_1, + normalRecognizer2_2 + ]; + + NSArray* targetRecognizers = @[ targetRecognizer0, targetRecognizer2_2 ]; + + NSArray* expectedEmptyHistory = @[]; + NSArray* expectedToggledHistory = @[ @NO, @YES ]; + + for (MockGestureRecognizer* recognizer in normalRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedEmptyHistory); + } + for (MockGestureRecognizer* recognizer in targetRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedToggledHistory); + } + } +} + +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldDisableAndReEnableTouchEventsGestureRecognizerForMultipleWebViewInDifferentBranches { + if (@available(iOS 26.0, *)) { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + FlutterPlatformViewsController* flutterPlatformViewsController = + [[FlutterPlatformViewsController alloc] init]; + flutterPlatformViewsController.taskRunner = GetDefaultTaskRunner(); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kMetal, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockWrapperWebViewFactory* factory = + [[FlutterPlatformViewsTestMockWrapperWebViewFactory alloc] init]; + [flutterPlatformViewsController + registerViewFactory:factory + withId:@"MockWrapperWebView" + gestureRecognizersBlockingPolicy:FlutterPlatformViewGestureRecognizersBlockingPolicyEager]; + FlutterResult result = ^(id result) { + }; + [flutterPlatformViewsController + onMethodCall:[FlutterMethodCall methodCallWithMethodName:@"create" + arguments:@{ + @"id" : @2, + @"viewType" : @"MockWrapperWebView" + }] + result:result]; + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + /* + Platform View with Multiple Web Views in different branches, with [*] indicating views + containing MockTouchEventsGestureRecognizer. + + Root (Platform View) + ├── Child 1 + ├── Child 2 (Web View) + | ├── Child 2.1 + | └── Child 2.2 [*] + └── Child 3 + └── Child 3.1 (Web View) + ├── Child 3.1.1 + └── Child 3.1.2 [*] + */ + + UIView* root = gMockPlatformView; + for (UIView* subview in root.subviews) { + [subview removeFromSuperview]; + } + + MockGestureRecognizer* normalRecognizer0 = [[MockGestureRecognizer alloc] init]; + [root addGestureRecognizer:normalRecognizer0]; + + UIView* child1 = [[UIView alloc] init]; + [root addSubview:child1]; + MockGestureRecognizer* normalRecognizer1 = [[MockGestureRecognizer alloc] init]; + [child1 addGestureRecognizer:normalRecognizer1]; + + UIView* child2 = [[WKWebView alloc] init]; + child2.gestureRecognizers = nil; + for (UIView* subview in child2.subviews) { + [subview removeFromSuperview]; + } + [root addSubview:child2]; + MockGestureRecognizer* normalRecognizer2 = [[MockGestureRecognizer alloc] init]; + [child2 addGestureRecognizer:normalRecognizer2]; + + UIView* child2_1 = [[UIView alloc] init]; + [child2 addSubview:child2_1]; + MockGestureRecognizer* normalRecognizer2_1 = [[MockGestureRecognizer alloc] init]; + [child2_1 addGestureRecognizer:normalRecognizer2_1]; + + UIView* child2_2 = [[UIView alloc] init]; + [child2 addSubview:child2_2]; + MockGestureRecognizer* normalRecognizer2_2 = [[MockGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:normalRecognizer2_2]; + + UIView* child3 = [[UIView alloc] init]; + [root addSubview:child3]; + MockGestureRecognizer* normalRecognizer3 = [[MockGestureRecognizer alloc] init]; + [child3 addGestureRecognizer:normalRecognizer3]; + + UIView* child3_1 = [[WKWebView alloc] init]; + child3_1.gestureRecognizers = nil; + for (UIView* subview in child3_1.subviews) { + [subview removeFromSuperview]; + } + [child3 addSubview:child3_1]; + MockGestureRecognizer* normalRecognizer3_1 = [[MockGestureRecognizer alloc] init]; + [child3_1 addGestureRecognizer:normalRecognizer3_1]; + + UIView* child3_1_1 = [[UIView alloc] init]; + [child3_1 addSubview:child3_1_1]; + MockGestureRecognizer* normalRecognizer3_1_1 = [[MockGestureRecognizer alloc] init]; + [child3_1_1 addGestureRecognizer:normalRecognizer3_1_1]; + + UIView* child3_1_2 = [[UIView alloc] init]; + [child3_1 addSubview:child3_1_2]; + MockGestureRecognizer* normalRecognizer3_1_2 = [[MockGestureRecognizer alloc] init]; + [child3_1_2 addGestureRecognizer:normalRecognizer3_1_2]; + + // Add the target recognizer at child2_2 & child3_1_2 + + MockTouchEventsGestureRecognizer* targetRecognizer2_2 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:targetRecognizer2_2]; + + MockTouchEventsGestureRecognizer* targetRecognizer3_1_2 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [child3_1_2 addGestureRecognizer:targetRecognizer3_1_2]; + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + NSArray* normalRecognizers = @[ + normalRecognizer0, normalRecognizer1, normalRecognizer2, normalRecognizer2_1, + normalRecognizer2_2, normalRecognizer3, normalRecognizer3_1, normalRecognizer3_1_1, + normalRecognizer3_1_2 + ]; + NSArray* targetRecognizers = @[ targetRecognizer2_2, targetRecognizer3_1_2 ]; + + NSArray* expectedEmptyHistory = @[]; + NSArray* expectedToggledHistory = @[ @NO, @YES ]; + + for (MockGestureRecognizer* recognizer in normalRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedEmptyHistory); + } + + for (MockGestureRecognizer* recognizer in targetRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedToggledHistory); + } + } +} + +- (void) + testFlutterPlatformViewBlockGestureUnderEagerPolicyShouldDisableAndReEnableTouchEventsGestureRecognizerForNestedMultipleWebView { + if (@available(iOS 26.0, *)) { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + FlutterPlatformViewsController* flutterPlatformViewsController = + [[FlutterPlatformViewsController alloc] init]; + flutterPlatformViewsController.taskRunner = GetDefaultTaskRunner(); + auto platform_view = std::make_unique( + /*delegate=*/mock_delegate, + /*rendering_api=*/flutter::IOSRenderingAPI::kMetal, + /*platform_views_controller=*/flutterPlatformViewsController, + /*task_runners=*/runners, + /*worker_task_runner=*/nil, + /*is_gpu_disabled_jsync_switch=*/std::make_shared()); + + FlutterPlatformViewsTestMockWebViewFactory* factory = + [[FlutterPlatformViewsTestMockWebViewFactory alloc] init]; + [flutterPlatformViewsController + registerViewFactory:factory + withId:@"MockWebView" + gestureRecognizersBlockingPolicy:FlutterPlatformViewGestureRecognizersBlockingPolicyEager]; + FlutterResult result = ^(id result) { + }; + [flutterPlatformViewsController + onMethodCall:[FlutterMethodCall + methodCallWithMethodName:@"create" + arguments:@{@"id" : @2, @"viewType" : @"MockWebView"}] + result:result]; + + XCTAssertNotNil(gMockPlatformView); + + // Find touch inteceptor view + UIView* touchInteceptorView = gMockPlatformView; + while (touchInteceptorView != nil && + ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) { + touchInteceptorView = touchInteceptorView.superview; + } + XCTAssertNotNil(touchInteceptorView); + + /* + Platform View with nested web views, with [*] indicating views containing + MockTouchEventsGestureRecognizer. + + Root (Web View) + ├── Child 1 + ├── Child 2 + | ├── Child 2.1 + | └── Child 2.2 [*] + └── Child 3 + └── Child 3.1 (Another Web View) + └── Child 3.1.1 + └── Child 3.1.2 + ├── Child 3.1.2.1 + └── Child 3.1.2.2 [*] + */ + + UIView* root = gMockPlatformView; + root.gestureRecognizers = nil; + for (UIView* subview in root.subviews) { + [subview removeFromSuperview]; + } + + MockGestureRecognizer* normalRecognizer0 = [[MockGestureRecognizer alloc] init]; + [root addGestureRecognizer:normalRecognizer0]; + + UIView* child1 = [[UIView alloc] init]; + [root addSubview:child1]; + MockGestureRecognizer* normalRecognizer1 = [[MockGestureRecognizer alloc] init]; + [child1 addGestureRecognizer:normalRecognizer1]; + + UIView* child2 = [[UIView alloc] init]; + [root addSubview:child2]; + MockGestureRecognizer* normalRecognizer2 = [[MockGestureRecognizer alloc] init]; + [child2 addGestureRecognizer:normalRecognizer2]; + + UIView* child2_1 = [[UIView alloc] init]; + [child2 addSubview:child2_1]; + MockGestureRecognizer* normalRecognizer2_1 = [[MockGestureRecognizer alloc] init]; + [child2_1 addGestureRecognizer:normalRecognizer2_1]; + + UIView* child2_2 = [[UIView alloc] init]; + [child2 addSubview:child2_2]; + MockGestureRecognizer* normalRecognizer2_2 = [[MockGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:normalRecognizer2_2]; + + UIView* child3 = [[UIView alloc] init]; + [root addSubview:child3]; + MockGestureRecognizer* normalRecognizer3 = [[MockGestureRecognizer alloc] init]; + [child3 addGestureRecognizer:normalRecognizer3]; + + UIView* child3_1 = [[WKWebView alloc] init]; + child3_1.gestureRecognizers = nil; + for (UIView* subview in child3_1.subviews) { + [subview removeFromSuperview]; + } + [child3 addSubview:child3_1]; + MockGestureRecognizer* normalRecognizer3_1 = [[MockGestureRecognizer alloc] init]; + [child3_1 addGestureRecognizer:normalRecognizer3_1]; + + UIView* child3_1_1 = [[UIView alloc] init]; + [child3_1 addSubview:child3_1_1]; + MockGestureRecognizer* normalRecognizer3_1_1 = [[MockGestureRecognizer alloc] init]; + [child3_1_1 addGestureRecognizer:normalRecognizer3_1_1]; + + UIView* child3_1_2 = [[UIView alloc] init]; + [child3_1 addSubview:child3_1_2]; + MockGestureRecognizer* normalRecognizer3_1_2 = [[MockGestureRecognizer alloc] init]; + [child3_1_2 addGestureRecognizer:normalRecognizer3_1_2]; + + UIView* child3_1_2_1 = [[UIView alloc] init]; + [child3_1_2 addSubview:child3_1_2_1]; + MockGestureRecognizer* normalRecognizer3_1_2_1 = [[MockGestureRecognizer alloc] init]; + [child3_1_2_1 addGestureRecognizer:normalRecognizer3_1_2_1]; + + UIView* child3_1_2_2 = [[UIView alloc] init]; + [child3_1_2 addSubview:child3_1_2_2]; + MockGestureRecognizer* normalRecognizer3_1_2_2 = [[MockGestureRecognizer alloc] init]; + [child3_1_2_2 addGestureRecognizer:normalRecognizer3_1_2_2]; + + // Add the target recognizer at child2_2 & child3_1_2_2 + + MockTouchEventsGestureRecognizer* targetRecognizer2_2 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [child2_2 addGestureRecognizer:targetRecognizer2_2]; + + MockTouchEventsGestureRecognizer* targetRecognizer3_1_2_2 = + [[MockTouchEventsGestureRecognizer alloc] init]; + [child3_1_2_2 addGestureRecognizer:targetRecognizer3_1_2_2]; + + [(FlutterTouchInterceptingView*)touchInteceptorView blockGesture]; + + NSArray* normalRecognizers = @[ + normalRecognizer0, normalRecognizer1, normalRecognizer2, normalRecognizer2_1, + normalRecognizer2_2, normalRecognizer3, normalRecognizer3_1, normalRecognizer3_1_1, + normalRecognizer3_1_2, normalRecognizer3_1_2_1, normalRecognizer3_1_2_2 + ]; + + NSArray* targetRecognizers = @[ targetRecognizer2_2, targetRecognizer3_1_2_2 ]; + + NSArray* expectedEmptyHistory = @[]; + NSArray* expectedToggledHistory = @[ @NO, @YES ]; + + for (MockGestureRecognizer* recognizer in normalRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedEmptyHistory); + } + + for (MockGestureRecognizer* recognizer in targetRecognizers) { + XCTAssertEqualObjects(recognizer.toggleHistory, expectedToggledHistory); + } + } +} + - (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashing { flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate;