diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm index cc7947421f8..5aaeb546d50 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController.mm @@ -114,6 +114,15 @@ typedef struct MouseState { fml::scoped_nsobject _scrollView; fml::scoped_nsobject _keyboardAnimationView; MouseState _mouseState; + // Timestamp after which a scroll inertia cancel event should be inferred. + NSTimeInterval _scrollInertiaEventStartline; + // When an iOS app is running in emulation on an Apple Silicon Mac, trackpad input goes through + // a translation layer, and events are not received with precise deltas. Due to this, we can't + // rely on checking for a stationary trackpad event. Fortunately, AppKit will send an event of + // type UIEventTypeScroll following a scroll when inertia should stop. This field is needed to + // estimate if such an event represents the natural end of scrolling inertia or a user-initiated + // cancellation. + NSTimeInterval _scrollInertiaEventAppKitDeadline; } @synthesize displayingFlutterUI = _displayingFlutterUI; @@ -1832,9 +1841,33 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) return YES; } +- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer + shouldReceiveEvent:(UIEvent*)event API_AVAILABLE(ios(13.4)) { + if (gestureRecognizer == _continuousScrollingPanGestureRecognizer && + event.type == UIEventTypeScroll) { + // Events with type UIEventTypeScroll are only received when running on macOS under emulation. + flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; + pointer_data.device = reinterpret_cast(_continuousScrollingPanGestureRecognizer); + pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; + pointer_data.signal_kind = flutter::PointerData::SignalKind::kScrollInertiaCancel; + + if (event.timestamp < _scrollInertiaEventAppKitDeadline) { + // Only send the event if it occured before the expected natural end of gesture momentum. + // If received after the deadline, it's not likely the event is from a user-initiated cancel. + auto packet = std::make_unique(1); + packet->SetPointerData(/*index=*/0, pointer_data); + [_engine.get() dispatchPointerDataPacket:std::move(packet)]; + _scrollInertiaEventAppKitDeadline = 0; + } + } + // This method is also called for UITouches, should return YES to process all touches. + return YES; +} + - (void)hoverEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { CGPoint location = [recognizer locationInView:self.view]; CGFloat scale = [UIScreen mainScreen].scale; + CGPoint oldLocation = _mouseState.location; _mouseState.location = {location.x * scale, location.y * scale}; flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; @@ -1859,9 +1892,33 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) break; } - auto packet = std::make_unique(1); - packet->SetPointerData(/*index=*/0, pointer_data); - [_engine.get() dispatchPointerDataPacket:std::move(packet)]; + NSTimeInterval time = [NSProcessInfo processInfo].systemUptime; + BOOL isRunningOnMac = NO; + if (@available(iOS 14.0, *)) { + // This "stationary pointer" heuristic is not reliable when running within macOS. + // We instead receive a scroll cancel event directly from AppKit. + // See gestureRecognizer:shouldReceiveEvent: + isRunningOnMac = [NSProcessInfo processInfo].iOSAppOnMac; + } + if (!isRunningOnMac && CGPointEqualToPoint(oldLocation, _mouseState.location) && + time > _scrollInertiaEventStartline) { + // iPadOS reports trackpad movements events with high (sub-pixel) precision. When an event + // is received with the same position as the previous one, it can only be from a finger + // making or breaking contact with the trackpad surface. + auto packet = std::make_unique(2); + packet->SetPointerData(/*index=*/0, pointer_data); + flutter::PointerData inertia_cancel = pointer_data; + inertia_cancel.device = reinterpret_cast(_continuousScrollingPanGestureRecognizer); + inertia_cancel.kind = flutter::PointerData::DeviceKind::kTrackpad; + inertia_cancel.signal_kind = flutter::PointerData::SignalKind::kScrollInertiaCancel; + packet->SetPointerData(/*index=*/1, inertia_cancel); + [_engine.get() dispatchPointerDataPacket:std::move(packet)]; + _scrollInertiaEventStartline = DBL_MAX; + } else { + auto packet = std::make_unique(1); + packet->SetPointerData(/*index=*/0, pointer_data); + [_engine.get() dispatchPointerDataPacket:std::move(packet)]; + } } - (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { @@ -1914,6 +1971,22 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: + _scrollInertiaEventStartline = + [[NSProcessInfo processInfo] systemUptime] + + 0.1; // Time to lift fingers off trackpad (experimentally determined) + // When running an iOS app on an Apple Silicon Mac, AppKit will send an event + // of type UIEventTypeScroll when trackpad scroll momentum has ended. This event + // is sent whether the momentum ended normally or was cancelled by a trackpad touch. + // Since Flutter scrolling inertia will likely not match the system inertia, we should + // only send a PointerScrollInertiaCancel event for user-initiated cancellations. + // The following (curve-fitted) calculation provides a cutoff point after which any + // UIEventTypeScroll event will likely be from the system instead of the user. + // See https://github.com/flutter/engine/pull/34929. + _scrollInertiaEventAppKitDeadline = + [[NSProcessInfo processInfo] systemUptime] + + (0.1821 * log(fmax([recognizer velocityInView:self.view].x, + [recognizer velocityInView:self.view].y))) - + 0.4825; pointer_data.change = flutter::PointerData::Change::kPanZoomEnd; break; default: diff --git a/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m b/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m index 0422adcb8c1..fc6b06b7e33 100644 --- a/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m +++ b/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/StatusBarTest.m @@ -31,14 +31,18 @@ } XCUIElement* addTextField = - self.application.textFields[@"0,PointerChange.add,device=0,buttons=0"]; + self.application + .textFields[@"0,PointerChange.add,device=0,buttons=0,signalKind=PointerSignalKind.none"]; BOOL exists = [addTextField waitForExistenceWithTimeout:1]; XCTAssertTrue(exists, @""); XCUIElement* downTextField = - self.application.textFields[@"1,PointerChange.down,device=0,buttons=0"]; + self.application + .textFields[@"1,PointerChange.down,device=0,buttons=0,signalKind=PointerSignalKind.none"]; exists = [downTextField waitForExistenceWithTimeout:1]; XCTAssertTrue(exists, @""); - XCUIElement* upTextField = self.application.textFields[@"2,PointerChange.up,device=0,buttons=0"]; + XCUIElement* upTextField = + self.application + .textFields[@"2,PointerChange.up,device=0,buttons=0,signalKind=PointerSignalKind.none"]; exists = [upTextField waitForExistenceWithTimeout:1]; XCTAssertTrue(exists, @""); } diff --git a/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/iPadGestureTests.m b/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/iPadGestureTests.m index 63013508e76..71f5a88796d 100644 --- a/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/iPadGestureTests.m +++ b/engine/src/flutter/testing/scenario_app/ios/Scenarios/ScenariosUITests/iPadGestureTests.m @@ -236,6 +236,12 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N [invocation setArgument:&deltaY atIndex:3]; [invocation invokeWithTarget:flutterView]; + // Hover immediately following the scroll to cause an inertia cancel event. + SEL hover = @selector(hover:); + XCTAssertTrue([flutterView respondsToSelector:hover], + @"If supportsPointerInteraction is true, this should be true too."); + [flutterView performSelector:hover]; + // The hover pointer is observed to be removed by the system after ~3.5 seconds of inactivity. // While this is not a documented behavior, it is the only way to test for the removal of the // hover pointer. Waiting for 5 seconds will ensure that all events are received before @@ -265,21 +271,24 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N [messageSequenceNumberList addObject:@(messageSequenceNumber)]; } // The number of hover events is not consistent, there could be one or many. - int hoverAddedSequenceNumber = - assertOneMessageAndGetSequenceNumber(messages, @"PointerChange.add,device=0,buttons=0"); + int hoverAddedSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, @"PointerChange.add,device=0,buttons=0,signalKind=PointerSignalKind.none"); NSMutableArray* hoverSequenceNumbers = - messages[@"PointerChange.hover,device=0,buttons=0"]; - int hoverRemovedSequenceNumber = - assertOneMessageAndGetSequenceNumber(messages, @"PointerChange.remove,device=0,buttons=0"); - int panZoomAddedSequenceNumber = - assertOneMessageAndGetSequenceNumber(messages, @"PointerChange.add,device=1,buttons=0"); + messages[@"PointerChange.hover,device=0,buttons=0,signalKind=PointerSignalKind.none"]; + int hoverRemovedSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, @"PointerChange.remove,device=0,buttons=0,signalKind=PointerSignalKind.none"); + int panZoomAddedSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, @"PointerChange.add,device=1,buttons=0,signalKind=PointerSignalKind.none"); int panZoomStartSequenceNumber = assertOneMessageAndGetSequenceNumber( - messages, @"PointerChange.panZoomStart,device=1,buttons=0"); + messages, @"PointerChange.panZoomStart,device=1,buttons=0,signalKind=PointerSignalKind.none"); // The number of pan/zoom update events is not consistent, there could be one or many. NSMutableArray* panZoomUpdateSequenceNumbers = - messages[@"PointerChange.panZoomUpdate,device=1,buttons=0"]; + messages[@"PointerChange.panZoomUpdate,device=1,buttons=0,signalKind=PointerSignalKind.none"]; int panZoomEndSequenceNumber = assertOneMessageAndGetSequenceNumber( - messages, @"PointerChange.panZoomEnd,device=1,buttons=0"); + messages, @"PointerChange.panZoomEnd,device=1,buttons=0,signalKind=PointerSignalKind.none"); + int inertiaCancelSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, + @"PointerChange.cancel,device=2,buttons=0,signalKind=PointerSignalKind.scrollInertiaCancel"); XCTAssertGreaterThan(panZoomStartSequenceNumber, panZoomAddedSequenceNumber, @"PanZoomStart occured before pointer was added"); @@ -287,7 +296,9 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N panZoomStartSequenceNumber, @"PanZoomUpdate occured before PanZoomStart"); XCTAssertGreaterThan(panZoomEndSequenceNumber, [[panZoomUpdateSequenceNumbers lastObject] intValue], - @"PanZoomUpdate occured after PanZoomUpdate"); + @"PanZoomUpdate occured after PanZoomEnd"); + XCTAssertGreaterThan(inertiaCancelSequenceNumber, panZoomEndSequenceNumber, + @"ScrollInertiaCancel occured before PanZoomEnd"); XCTAssertGreaterThan([[hoverSequenceNumbers firstObject] intValue], hoverAddedSequenceNumber, @"Hover occured before pointer was added"); diff --git a/engine/src/flutter/testing/scenario_app/lib/src/touches_scenario.dart b/engine/src/flutter/testing/scenario_app/lib/src/touches_scenario.dart index 4b289d2af41..00d11da79fb 100644 --- a/engine/src/flutter/testing/scenario_app/lib/src/touches_scenario.dart +++ b/engine/src/flutter/testing/scenario_app/lib/src/touches_scenario.dart @@ -32,7 +32,7 @@ class TouchesScenario extends Scenario { dispatcher: dispatcher, channel: 'display_data', json: { - 'data': '$_sequenceNo,${datum.change},device=$deviceId,buttons=${datum.buttons}', + 'data': '$_sequenceNo,${datum.change},device=$deviceId,buttons=${datum.buttons},signalKind=${datum.signalKind}', }, ); _sequenceNo++;