[ios][platform_view] Fix Platform view gesture recognizer with iPad pencil getting stuck (#55724)
## Summary
I came across our the "gesture recognizer delegate" implementation and it is quite odd (see below). After fixing it, the problem is resolved. However, it's hard to reason about how it's related to iPad pencil, since it's internal logic that we don't know (see my research below).
## Gesture recognizer delegate
### Existing odd implementation
- shouldBeRequiredToFailByGestureRecognizer:
`otherGestureRecognizer != self` is always YES because the delegate set to self, hence `gestureRecognizer` must be self, hence `otherGestureRecognizer` must not be self.
- shouldRequireFailureOfGestureRecognizer:
`otherGestureRecognizer == self` is always NO, for the same reason described above.
### new implementation:
After digging into various PRs, the idea seems to be that we want to have a precedence of "Forwarding recognizer > Delaying recognizer > Other recognizers in platform view".
- shouldBeRequiredToFailByGestureRecognizer:
`return otherGestureRecognizer != _forwardingRecognizer` means Delaying recognizer needs to be higher precedence than all non-Forwarding recognizer. (aka "Delaying recognizer > Other recognizers in platform view")
- shouldRequireFailureOfGestureRecognizer:
`return otherGestureRecognizer == _forwardingRecognizer` means Delaying recognizer needs to have lower precedence than forwarding recognizer. (aka "Forwarding recognizer > Delaying recognizer").
## Some research
This is a tricky one since pencil and finger triggers exactly the same callbacks. It turns out that when pencil is involved after finger interaction, the platform view's "forwarding" gesture recognizer is stuck at failed state. This seems to be an iOS bug, because according to [the API doc](https://developer.apple.com/documentation/uikit/uigesturerecognizerstate/uigesturerecognizerstatefailed?language=objc), it should be reset back to "possible" state:
> No action message is sent and the gesture recognizer is reset to [UIGestureRecognizerStatePossible](https://developer.apple.com/documentation/uikit/uigesturerecognizerstate/uigesturerecognizerstatepossible?language=objc).
However, when iPad pencil is involved, the state is not reset. I tried to KVO the state property, and wasn't able to capture the change. This means the state change very likely happened internally within the recognizer via the backing ivar of the state property.
*List which issues are fixed by this PR. You must list at least one issue.*
Fixes https://github.com/flutter/flutter/issues/136244
*If you had to change anything in the [flutter/tests] repo, include a link to the migration guide as per the [breaking change policy].*
[C++, Objective-C, Java style guides]: https://github.com/flutter/engine/blob/main/CONTRIBUTING.md#style
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm
index 904a3ea..0013657 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm
@@ -2782,6 +2782,79 @@
flutterPlatformViewsController->Reset();
}
+- (void)testFlutterPlatformViewForwardingAndDelayingRecognizerFailureCondition {
+ flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate;
+
+ flutter::TaskRunners runners(/*label=*/self.name.UTF8String,
+ /*platform=*/GetDefaultTaskRunner(),
+ /*raster=*/GetDefaultTaskRunner(),
+ /*ui=*/GetDefaultTaskRunner(),
+ /*io=*/GetDefaultTaskRunner());
+ auto flutterPlatformViewsController = std::make_shared<flutter::PlatformViewsController>();
+ flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner());
+ auto platform_view = std::make_unique<flutter::PlatformViewIOS>(
+ /*delegate=*/mock_delegate,
+ /*rendering_api=*/mock_delegate.settings_.enable_impeller
+ ? flutter::IOSRenderingAPI::kMetal
+ : flutter::IOSRenderingAPI::kSoftware,
+ /*platform_views_controller=*/flutterPlatformViewsController,
+ /*task_runners=*/runners,
+ /*worker_task_runner=*/nil,
+ /*is_gpu_disabled_jsync_switch=*/std::make_shared<fml::SyncSwitch>());
+
+ FlutterPlatformViewsTestMockFlutterPlatformFactory* factory =
+ [[FlutterPlatformViewsTestMockFlutterPlatformFactory alloc] init];
+ flutterPlatformViewsController->RegisterViewFactory(
+ factory, @"MockFlutterPlatformView",
+ FlutterPlatformViewGestureRecognizersBlockingPolicyEager);
+ FlutterResult result = ^(id result) {
+ };
+ flutterPlatformViewsController->OnMethodCall(
+ [FlutterMethodCall
+ methodCallWithMethodName:@"create"
+ arguments:@{@"id" : @2, @"viewType" : @"MockFlutterPlatformView"}],
+ result);
+
+ XCTAssertNotNil(gMockPlatformView);
+
+ // Find touch inteceptor view
+ UIView* touchInteceptorView = gMockPlatformView;
+ while (touchInteceptorView != nil &&
+ ![touchInteceptorView isKindOfClass:[FlutterTouchInterceptingView class]]) {
+ touchInteceptorView = touchInteceptorView.superview;
+ }
+ XCTAssertNotNil(touchInteceptorView);
+
+ // Find ForwardGestureRecognizer
+ UIGestureRecognizer* forwardingGestureRecognizer = nil;
+ UIGestureRecognizer* delayingGestureRecognizer = nil;
+ for (UIGestureRecognizer* gestureRecognizer in touchInteceptorView.gestureRecognizers) {
+ if ([gestureRecognizer isKindOfClass:NSClassFromString(@"ForwardingGestureRecognizer")]) {
+ forwardingGestureRecognizer = gestureRecognizer;
+ }
+ if ([gestureRecognizer isKindOfClass:NSClassFromString(@"FlutterDelayingGestureRecognizer")]) {
+ delayingGestureRecognizer = gestureRecognizer;
+ }
+ }
+ UIGestureRecognizer* otherGestureRecognizer = OCMClassMock([UIGestureRecognizer class]);
+
+ id flutterViewContoller = OCMClassMock([FlutterViewController class]);
+ flutterPlatformViewsController->SetFlutterViewController(flutterViewContoller);
+
+ XCTAssertFalse([delayingGestureRecognizer.delegate
+ gestureRecognizer:delayingGestureRecognizer
+ shouldBeRequiredToFailByGestureRecognizer:forwardingGestureRecognizer]);
+ XCTAssertTrue([delayingGestureRecognizer.delegate gestureRecognizer:delayingGestureRecognizer
+ shouldBeRequiredToFailByGestureRecognizer:otherGestureRecognizer]);
+
+ XCTAssertTrue([delayingGestureRecognizer.delegate gestureRecognizer:delayingGestureRecognizer
+ shouldRequireFailureOfGestureRecognizer:forwardingGestureRecognizer]);
+ XCTAssertFalse([delayingGestureRecognizer.delegate gestureRecognizer:delayingGestureRecognizer
+ shouldRequireFailureOfGestureRecognizer:otherGestureRecognizer]);
+
+ flutterPlatformViewsController->Reset();
+}
+
- (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashing {
flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate;
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm
index 5e76654..90c4d42 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm
@@ -654,14 +654,14 @@
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer {
- // The forwarding gesture recognizer should always get all touch events, so it should not be
- // required to fail by any other gesture recognizer.
- return otherGestureRecognizer != _forwardingRecognizer && otherGestureRecognizer != self;
+ // The forwarding gesture recognizer should always get all touch events, so it should not
+ // require other gesture recognizer to fail.
+ return otherGestureRecognizer != _forwardingRecognizer;
}
- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer
shouldRequireFailureOfGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer {
- return otherGestureRecognizer == self;
+ return otherGestureRecognizer == _forwardingRecognizer;
}
- (void)touchesBegan:(NSSet<UITouch*>*)touches withEvent:(UIEvent*)event {