From 3e89cd79b515e64684f8468b19106ca6fe7fb7d8 Mon Sep 17 00:00:00 2001 From: hellohuanlin <41930132+hellohuanlin@users.noreply.github.com> Date: Wed, 23 Oct 2024 14:10:57 -0700 Subject: [PATCH] [ios][platform_view]force reset forwarding recognizer state when its stuck (flutter/engine#55958) This PR force reset forwarding recognizer state when it's stuck at failed state, by recreating the recognizer (we are not able to reset the state back to possible - it has to be reset by UIKit). 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. 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 --- .../Source/FlutterPlatformViewsTest.mm | 105 ++++++++++++++++++ .../Source/FlutterPlatformViews_Internal.mm | 37 +++++- 2 files changed, 141 insertions(+), 1 deletion(-) 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 904a3eace25..16f5fa59beb 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 @@ -2782,6 +2782,111 @@ fml::RefPtr GetDefaultTaskRunner() { flutterPlatformViewsController->Reset(); } +- (void)testFlutterPlatformViewTouchesEndedOrTouchesCancelledEventDoesNotFailTheGestureRecognizer { + flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; + + flutter::TaskRunners runners(/*label=*/self.name.UTF8String, + /*platform=*/GetDefaultTaskRunner(), + /*raster=*/GetDefaultTaskRunner(), + /*ui=*/GetDefaultTaskRunner(), + /*io=*/GetDefaultTaskRunner()); + auto flutterPlatformViewsController = std::make_shared(); + flutterPlatformViewsController->SetTaskRunner(GetDefaultTaskRunner()); + auto platform_view = std::make_unique( + /*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()); + + 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 + __block UIGestureRecognizer* forwardGestureRecognizer = nil; + for (UIGestureRecognizer* gestureRecognizer in touchInteceptorView.gestureRecognizers) { + if ([gestureRecognizer isKindOfClass:NSClassFromString(@"ForwardingGestureRecognizer")]) { + forwardGestureRecognizer = gestureRecognizer; + break; + } + } + id flutterViewContoller = OCMClassMock([FlutterViewController class]); + + flutterPlatformViewsController->SetFlutterViewController(flutterViewContoller); + + NSSet* touches1 = [NSSet setWithObject:@1]; + id event1 = OCMClassMock([UIEvent class]); + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStatePossible, + @"Forwarding gesture recognizer must start with possible state."); + [forwardGestureRecognizer touchesBegan:touches1 withEvent:event1]; + [forwardGestureRecognizer touchesEnded:touches1 withEvent:event1]; + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStateFailed, + @"Forwarding gesture recognizer must end with failed state."); + + XCTestExpectation* touchEndedExpectation = + [self expectationWithDescription:@"Wait for gesture recognizer's state change."]; + dispatch_async(dispatch_get_main_queue(), ^{ + // Re-query forward gesture recognizer since it's recreated. + for (UIGestureRecognizer* gestureRecognizer in touchInteceptorView.gestureRecognizers) { + if ([gestureRecognizer isKindOfClass:NSClassFromString(@"ForwardingGestureRecognizer")]) { + forwardGestureRecognizer = gestureRecognizer; + break; + } + } + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStatePossible, + @"Forwarding gesture recognizer must be reset to possible state."); + [touchEndedExpectation fulfill]; + }); + [self waitForExpectationsWithTimeout:30 handler:nil]; + + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStatePossible, + @"Forwarding gesture recognizer must start with possible state."); + [forwardGestureRecognizer touchesBegan:touches1 withEvent:event1]; + [forwardGestureRecognizer touchesCancelled:touches1 withEvent:event1]; + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStateFailed, + @"Forwarding gesture recognizer must end with failed state."); + XCTestExpectation* touchCancelledExpectation = + [self expectationWithDescription:@"Wait for gesture recognizer's state change."]; + dispatch_async(dispatch_get_main_queue(), ^{ + // Re-query forward gesture recognizer since it's recreated. + for (UIGestureRecognizer* gestureRecognizer in touchInteceptorView.gestureRecognizers) { + if ([gestureRecognizer isKindOfClass:NSClassFromString(@"ForwardingGestureRecognizer")]) { + forwardGestureRecognizer = gestureRecognizer; + break; + } + } + XCTAssert(forwardGestureRecognizer.state == UIGestureRecognizerStatePossible, + @"Forwarding gesture recognizer must be reset to possible state."); + [touchCancelledExpectation fulfill]; + }); + [self waitForExpectationsWithTimeout:30 handler:nil]; + + flutterPlatformViewsController->Reset(); +} + - (void)testFlutterPlatformViewControllerSubmitFrameWithoutFlutterViewNotCrashing { flutter::FlutterPlatformViewsTestMockPlatformViewDelegate mock_delegate; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm index 5e76654bed1..d249d44e760 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViews_Internal.mm @@ -526,7 +526,7 @@ static BOOL _preparedOnce = NO; // setting the state to `UIGestureRecognizerStateEnded`. @property(nonatomic) BOOL touchedEndedWithoutBlocking; -@property(nonatomic, readonly) UIGestureRecognizer* forwardingRecognizer; +@property(nonatomic) UIGestureRecognizer* forwardingRecognizer; - (instancetype)initWithTarget:(id)target action:(SEL)action @@ -547,6 +547,7 @@ static BOOL _preparedOnce = NO; - (instancetype)initWithTarget:(id)target platformViewsController: (fml::WeakPtr)platformViewsController; +- (ForwardingGestureRecognizer*)recreateRecognizerWithTarget:(id)target; @end @interface FlutterTouchInterceptingView () @@ -586,6 +587,20 @@ static BOOL _preparedOnce = NO; return self; } +- (void)forceResetForwardingGestureRecognizerState { + // When iPad pencil is involved in a finger touch gesture, the gesture is not reset to "possible" + // state and is stuck on "failed" state, which causes subsequent touches to be blocked. As a + // workaround, we force reset the state by recreating the forwarding gesture recognizer. See: + // https://github.com/flutter/flutter/issues/136244 + ForwardingGestureRecognizer* oldForwardingRecognizer = + (ForwardingGestureRecognizer*)self.delayingRecognizer.forwardingRecognizer; + ForwardingGestureRecognizer* newForwardingRecognizer = + [oldForwardingRecognizer recreateRecognizerWithTarget:self]; + self.delayingRecognizer.forwardingRecognizer = newForwardingRecognizer; + [self removeGestureRecognizer:oldForwardingRecognizer]; + [self addGestureRecognizer:newForwardingRecognizer]; +} + - (void)releaseGesture { self.delayingRecognizer.state = UIGestureRecognizerStateFailed; } @@ -715,6 +730,11 @@ static BOOL _preparedOnce = NO; return self; } +- (ForwardingGestureRecognizer*)recreateRecognizerWithTarget:(id)target { + return [[ForwardingGestureRecognizer alloc] initWithTarget:target + platformViewsController:std::move(_platformViewsController)]; +} + - (void)touchesBegan:(NSSet*)touches withEvent:(UIEvent*)event { FML_DCHECK(_currentTouchPointersCount >= 0); if (_currentTouchPointersCount == 0) { @@ -741,6 +761,7 @@ static BOOL _preparedOnce = NO; if (_currentTouchPointersCount == 0) { self.state = UIGestureRecognizerStateFailed; _flutterViewController.reset(nil); + [self forceResetStateIfNeeded]; } } @@ -755,9 +776,23 @@ static BOOL _preparedOnce = NO; if (_currentTouchPointersCount == 0) { self.state = UIGestureRecognizerStateFailed; _flutterViewController.reset(nil); + [self forceResetStateIfNeeded]; } } +- (void)forceResetStateIfNeeded { + __weak ForwardingGestureRecognizer* weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + ForwardingGestureRecognizer* strongSelf = weakSelf; + if (!strongSelf) { + return; + } + if (strongSelf.state != UIGestureRecognizerStatePossible) { + [(FlutterTouchInterceptingView*)strongSelf.view forceResetForwardingGestureRecognizerState]; + } + }); +} + - (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer: (UIGestureRecognizer*)otherGestureRecognizer {