From 20d8f266ac968940112dbf9efb0abc231e6333bb Mon Sep 17 00:00:00 2001 From: Callum Moffat Date: Mon, 13 Jun 2022 23:48:04 -0400 Subject: [PATCH] iPad trackpad gestures (flutter/engine#31591) --- .../window/pointer_data_packet_converter.cc | 16 +- ...pointer_data_packet_converter_unittests.cc | 24 +- .../framework/Source/FlutterViewController.mm | 230 ++++++++++++++---- .../Source/FlutterViewControllerTest.mm | 4 +- .../ScenariosUITests/iPadGestureTests.m | 108 +++++++- 5 files changed, 325 insertions(+), 57 deletions(-) diff --git a/engine/src/flutter/lib/ui/window/pointer_data_packet_converter.cc b/engine/src/flutter/lib/ui/window/pointer_data_packet_converter.cc index ba543316a4c..2d54d710114 100644 --- a/engine/src/flutter/lib/ui/window/pointer_data_packet_converter.cc +++ b/engine/src/flutter/lib/ui/window/pointer_data_packet_converter.cc @@ -300,9 +300,21 @@ void PointerDataPacketConverter::ConvertPointerData( case PointerData::SignalKind::kScroll: { // Makes sure we have an existing pointer auto iter = states_.find(pointer_data.device); - FML_DCHECK(iter != states_.end()); + PointerState state; + + if (iter == states_.end()) { + // Synthesizes a add event if the pointer is not previously added. + PointerData synthesized_add_event = pointer_data; + synthesized_add_event.signal_kind = PointerData::SignalKind::kNone; + synthesized_add_event.change = PointerData::Change::kAdd; + synthesized_add_event.synthesized = 1; + synthesized_add_event.buttons = 0; + state = EnsurePointerState(synthesized_add_event); + converted_pointers.push_back(synthesized_add_event); + } else { + state = iter->second; + } - PointerState state = iter->second; if (LocationNeedsUpdate(pointer_data, state)) { if (state.is_down) { // Synthesizes a move event if the pointer is down. diff --git a/engine/src/flutter/lib/ui/window/pointer_data_packet_converter_unittests.cc b/engine/src/flutter/lib/ui/window/pointer_data_packet_converter_unittests.cc index ba17de35899..108310f0cbd 100644 --- a/engine/src/flutter/lib/ui/window/pointer_data_packet_converter_unittests.cc +++ b/engine/src/flutter/lib/ui/window/pointer_data_packet_converter_unittests.cc @@ -556,7 +556,7 @@ TEST(PointerDataPacketConverterTest, CanHandleThreeFingerGesture) { TEST(PointerDataPacketConverterTest, CanConvetScroll) { PointerDataPacketConverter converter; - auto packet = std::make_unique(5); + auto packet = std::make_unique(6); PointerData data; CreateSimulatedMousePointerData(data, PointerData::Change::kAdd, PointerData::SignalKind::kNone, 0, 0.0, 0.0, @@ -578,12 +578,16 @@ TEST(PointerDataPacketConverterTest, CanConvetScroll) { PointerData::SignalKind::kScroll, 1, 49.0, 49.0, 50.0, 0.0, 0); packet->SetPointerData(4, data); + CreateSimulatedMousePointerData(data, PointerData::Change::kHover, + PointerData::SignalKind::kScroll, 2, 10.0, + 20.0, 30.0, 40.0, 0); + packet->SetPointerData(5, data); auto converted_packet = converter.Convert(std::move(packet)); std::vector result; UnpackPointerPacket(result, std::move(converted_packet)); - ASSERT_EQ(result.size(), (size_t)7); + ASSERT_EQ(result.size(), (size_t)9); ASSERT_EQ(result[0].change, PointerData::Change::kAdd); ASSERT_EQ(result[0].signal_kind, PointerData::SignalKind::kNone); ASSERT_EQ(result[0].device, 0); @@ -642,6 +646,22 @@ TEST(PointerDataPacketConverterTest, CanConvetScroll) { ASSERT_EQ(result[6].physical_y, 49.0); ASSERT_EQ(result[6].scroll_delta_x, 50.0); ASSERT_EQ(result[6].scroll_delta_y, 0.0); + + // Converter will synthesize an add for device 2. + ASSERT_EQ(result[7].change, PointerData::Change::kAdd); + ASSERT_EQ(result[7].signal_kind, PointerData::SignalKind::kNone); + ASSERT_EQ(result[7].device, 2); + ASSERT_EQ(result[7].physical_x, 10.0); + ASSERT_EQ(result[7].physical_y, 20.0); + ASSERT_EQ(result[7].synthesized, 1); + + ASSERT_EQ(result[8].change, PointerData::Change::kHover); + ASSERT_EQ(result[8].signal_kind, PointerData::SignalKind::kScroll); + ASSERT_EQ(result[8].device, 2); + ASSERT_EQ(result[8].physical_x, 10.0); + ASSERT_EQ(result[8].physical_y, 20.0); + ASSERT_EQ(result[8].scroll_delta_x, 30.0); + ASSERT_EQ(result[8].scroll_delta_y, 40.0); } TEST(PointerDataPacketConverterTest, CanConvertTrackpadGesture) { 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 a324e881c3d..850ea0cef73 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 @@ -41,7 +41,7 @@ NSNotificationName const FlutterViewControllerHideHomeIndicator = NSNotificationName const FlutterViewControllerShowHomeIndicator = @"FlutterViewControllerShowHomeIndicator"; -// Struct holding the mouse state. +// Struct holding data to help adapt system mouse/trackpad events to embedder events. typedef struct MouseState { // Current coordinate of the mouse cursor in physical device pixels. CGPoint location = CGPointZero; @@ -64,6 +64,25 @@ typedef struct MouseState { @property(nonatomic, assign) double targetViewInsetBottom; @property(nonatomic, retain) CADisplayLink* displayLink; +/* + * Mouse and trackpad gesture recognizers + */ +// Mouse and trackpad hover +@property(nonatomic, retain) + UIHoverGestureRecognizer* hoverGestureRecognizer API_AVAILABLE(ios(13.4)); +// Mouse wheel scrolling +@property(nonatomic, retain) + UIPanGestureRecognizer* discreteScrollingPanGestureRecognizer API_AVAILABLE(ios(13.4)); +// Trackpad and Magic Mouse scrolling +@property(nonatomic, retain) + UIPanGestureRecognizer* continuousScrollingPanGestureRecognizer API_AVAILABLE(ios(13.4)); +// Trackpad pinching +@property(nonatomic, retain) + UIPinchGestureRecognizer* pinchGestureRecognizer API_AVAILABLE(ios(13.4)); +// Trackpad rotating +@property(nonatomic, retain) + UIRotationGestureRecognizer* rotationGestureRecognizer API_AVAILABLE(ios(13.4)); + /** * Creates and registers plugins used by this view controller. */ @@ -106,8 +125,6 @@ typedef enum UIAccessibilityContrast : NSInteger { // UIScrollView with height zero and a content offset so we can get those events. See also: // https://github.com/flutter/flutter/issues/35050 fml::scoped_nsobject _scrollView; - fml::scoped_nsobject _hoverGestureRecognizer API_AVAILABLE(ios(13.4)); - fml::scoped_nsobject _panGestureRecognizer API_AVAILABLE(ios(13.4)); fml::scoped_nsobject _keyboardAnimationView; MouseState _mouseState; } @@ -669,17 +686,37 @@ static void SendFakeTouchEvent(FlutterEngine* engine, } if (@available(iOS 13.4, *)) { - _hoverGestureRecognizer.reset( - [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(hoverEvent:)]); - _hoverGestureRecognizer.get().delegate = self; - [_flutterView.get() addGestureRecognizer:_hoverGestureRecognizer.get()]; + _hoverGestureRecognizer = + [[UIHoverGestureRecognizer alloc] initWithTarget:self action:@selector(hoverEvent:)]; + _hoverGestureRecognizer.delegate = self; + [_flutterView.get() addGestureRecognizer:_hoverGestureRecognizer]; - _panGestureRecognizer.reset( - [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(scrollEvent:)]); - _panGestureRecognizer.get().delegate = self; - _panGestureRecognizer.get().allowedScrollTypesMask = UIScrollTypeMaskAll; - _panGestureRecognizer.get().allowedTouchTypes = @[ @(UITouchTypeIndirectPointer) ]; - [_flutterView.get() addGestureRecognizer:_panGestureRecognizer.get()]; + _discreteScrollingPanGestureRecognizer = + [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(discreteScrollEvent:)]; + _discreteScrollingPanGestureRecognizer.allowedScrollTypesMask = UIScrollTypeMaskDiscrete; + // Disallowing all touch types. If touch events are allowed here, touches to the screen will be + // consumed by the UIGestureRecognizer instead of being passed through to flutter via + // touchesBegan. Trackpad and mouse scrolls are sent by the platform as scroll events rather + // than touch events, so they will still be received. + _discreteScrollingPanGestureRecognizer.allowedTouchTypes = @[]; + _discreteScrollingPanGestureRecognizer.delegate = self; + [_flutterView.get() addGestureRecognizer:_discreteScrollingPanGestureRecognizer]; + _continuousScrollingPanGestureRecognizer = + [[UIPanGestureRecognizer alloc] initWithTarget:self + action:@selector(continuousScrollEvent:)]; + _continuousScrollingPanGestureRecognizer.allowedScrollTypesMask = UIScrollTypeMaskContinuous; + _continuousScrollingPanGestureRecognizer.allowedTouchTypes = @[]; + _continuousScrollingPanGestureRecognizer.delegate = self; + [_flutterView.get() addGestureRecognizer:_continuousScrollingPanGestureRecognizer]; + _pinchGestureRecognizer = + [[UIPinchGestureRecognizer alloc] initWithTarget:self action:@selector(pinchEvent:)]; + _pinchGestureRecognizer.allowedTouchTypes = @[]; + _pinchGestureRecognizer.delegate = self; + [_flutterView.get() addGestureRecognizer:_pinchGestureRecognizer]; + _rotationGestureRecognizer = [[UIRotationGestureRecognizer alloc] init]; + _rotationGestureRecognizer.allowedTouchTypes = @[]; + _rotationGestureRecognizer.delegate = self; + [_flutterView.get() addGestureRecognizer:_rotationGestureRecognizer]; } [super viewDidLoad]; @@ -808,8 +845,16 @@ static void SendFakeTouchEvent(FlutterEngine* engine, [_displayLink release]; _scrollView.get().delegate = nil; - _hoverGestureRecognizer.get().delegate = nil; - _panGestureRecognizer.get().delegate = nil; + _hoverGestureRecognizer.delegate = nil; + [_hoverGestureRecognizer release]; + _discreteScrollingPanGestureRecognizer.delegate = nil; + [_discreteScrollingPanGestureRecognizer release]; + _continuousScrollingPanGestureRecognizer.delegate = nil; + [_continuousScrollingPanGestureRecognizer release]; + _pinchGestureRecognizer.delegate = nil; + [_pinchGestureRecognizer release]; + _rotationGestureRecognizer.delegate = nil; + [_rotationGestureRecognizer release]; [super dealloc]; } @@ -911,8 +956,36 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) return; } + // If the UIApplicationSupportsIndirectInputEvents in Info.plist returns YES, then the platform + // dispatches indirect pointer touches (trackpad clicks) as UITouch with a type of + // UITouchTypeIndirectPointer and different identifiers for each click. They are translated into + // Flutter pointer events with type of kMouse and different device IDs. These devices must be + // terminated with kRemove events when the touches end, otherwise they will keep triggering hover + // events. + // + // If the UIApplicationSupportsIndirectInputEvents in Info.plist returns NO, then the platform + // dispatches indirect pointer touches (trackpad clicks) as UITouch with a type of + // UITouchTypeIndirectPointer and different identifiers for each click. They are translated into + // Flutter pointer events with type of kTouch and different device IDs. Removing these devices is + // neither necessary nor harmful. + // + // Therefore Flutter always removes these devices. The touches_to_remove_count tracks how many + // remove events are needed in this group of touches to properly allocate space for the packet. + // The remove event of a touch is synthesized immediately after its normal event. + // + // See also: + // https://developer.apple.com/documentation/uikit/pointer_interactions?language=objc + // https://developer.apple.com/documentation/bundleresources/information_property_list/uiapplicationsupportsindirectinputevents?language=objc + NSUInteger touches_to_remove_count = 0; + for (UITouch* touch in touches) { + if (touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled) { + touches_to_remove_count++; + } + } + const CGFloat scale = [UIScreen mainScreen].scale; - auto packet = std::make_unique(touches.count); + auto packet = + std::make_unique(touches.count + touches_to_remove_count); size_t pointer_index = 0; @@ -1034,6 +1107,12 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) } packet->SetPointerData(pointer_index++, pointer_data); + + if (touch.phase == UITouchPhaseEnded || touch.phase == UITouchPhaseCancelled) { + flutter::PointerData remove_pointer_data = pointer_data; + remove_pointer_data.change = flutter::PointerData::Change::kRemove; + packet->SetPointerData(pointer_index++, remove_pointer_data); + } } [_engine.get() dispatchPointerDataPacket:std::move(packet)]; @@ -1756,13 +1835,31 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) return self.presentedViewController != nil || self.isPresentingViewControllerAnimating; } -- (flutter::PointerData)generatePointerDataForMouse API_AVAILABLE(ios(13.4)) { +- (flutter::PointerData)generatePointerDataAtLastMouseLocation API_AVAILABLE(ios(13.4)) { flutter::PointerData pointer_data; - pointer_data.Clear(); + pointer_data.time_stamp = [[NSProcessInfo processInfo] systemUptime] * kMicrosecondsPerSecond; + pointer_data.physical_x = _mouseState.location.x; + pointer_data.physical_y = _mouseState.location.y; + return pointer_data; +} +- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer + shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer + API_AVAILABLE(ios(13.4)) { + return YES; +} + +- (void)hoverEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { + CGPoint location = [recognizer locationInView:self.view]; + CGFloat scale = [UIScreen mainScreen].scale; + _mouseState.location = {location.x * scale, location.y * scale}; + + flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; + pointer_data.device = reinterpret_cast(recognizer); pointer_data.kind = flutter::PointerData::DeviceKind::kMouse; - switch (_hoverGestureRecognizer.get().state) { + + switch (_hoverGestureRecognizer.state) { case UIGestureRecognizerStateBegan: pointer_data.change = flutter::PointerData::Change::kAdd; break; @@ -1779,45 +1876,22 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) pointer_data.change = flutter::PointerData::Change::kHover; break; } - pointer_data.time_stamp = [[NSProcessInfo processInfo] systemUptime] * kMicrosecondsPerSecond; - pointer_data.device = reinterpret_cast(_hoverGestureRecognizer.get()); - pointer_data.physical_x = _mouseState.location.x; - pointer_data.physical_y = _mouseState.location.y; - - return pointer_data; -} - -- (BOOL)gestureRecognizer:(UIGestureRecognizer*)gestureRecognizer - shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer*)otherGestureRecognizer - API_AVAILABLE(ios(13.4)) { - return YES; -} - -- (void)hoverEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { auto packet = std::make_unique(1); - CGPoint location = [recognizer locationInView:self.view]; - CGFloat scale = [UIScreen mainScreen].scale; - _mouseState.location = {location.x * scale, location.y * scale}; - - flutter::PointerData pointer_data = [self generatePointerDataForMouse]; - - pointer_data.signal_kind = flutter::PointerData::SignalKind::kNone; packet->SetPointerData(/*index=*/0, pointer_data); - [_engine.get() dispatchPointerDataPacket:std::move(packet)]; } -- (void)scrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { +- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { CGPoint translation = [recognizer translationInView:self.view]; const CGFloat scale = [UIScreen mainScreen].scale; translation.x *= scale; translation.y *= scale; - auto packet = std::make_unique(1); - - flutter::PointerData pointer_data = [self generatePointerDataForMouse]; + flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; + pointer_data.device = reinterpret_cast(recognizer); + pointer_data.kind = flutter::PointerData::DeviceKind::kMouse; pointer_data.signal_kind = flutter::PointerData::SignalKind::kScroll; pointer_data.scroll_delta_x = (translation.x - _mouseState.last_translation.x); pointer_data.scroll_delta_y = -(translation.y - _mouseState.last_translation.y); @@ -1832,8 +1906,72 @@ static flutter::PointerData::DeviceKind DeviceKindFromTouchType(UITouch* touch) _mouseState.last_translation = CGPointZero; } + auto packet = std::make_unique(1); packet->SetPointerData(/*index=*/0, pointer_data); + [_engine.get() dispatchPointerDataPacket:std::move(packet)]; +} +- (void)continuousScrollEvent:(UIPanGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { + CGPoint translation = [recognizer translationInView:self.view]; + const CGFloat scale = [UIScreen mainScreen].scale; + + flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; + pointer_data.device = reinterpret_cast(recognizer); + pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + pointer_data.change = flutter::PointerData::Change::kPanZoomStart; + break; + case UIGestureRecognizerStateChanged: + pointer_data.change = flutter::PointerData::Change::kPanZoomUpdate; + pointer_data.pan_x = translation.x * scale; + pointer_data.pan_y = translation.y * scale; + pointer_data.pan_delta_x = 0; // Delta will be generated in pointer_data_packet_converter.cc. + pointer_data.pan_delta_y = 0; // Delta will be generated in pointer_data_packet_converter.cc. + pointer_data.scale = 1; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + pointer_data.change = flutter::PointerData::Change::kPanZoomEnd; + break; + default: + // continuousScrollEvent: should only ever be triggered with the above phases + NSAssert(false, @"Trackpad pan event occured with unexpected phase 0x%lx", + (long)recognizer.state); + break; + } + + auto packet = std::make_unique(1); + packet->SetPointerData(/*index=*/0, pointer_data); + [_engine.get() dispatchPointerDataPacket:std::move(packet)]; +} + +- (void)pinchEvent:(UIPinchGestureRecognizer*)recognizer API_AVAILABLE(ios(13.4)) { + flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation]; + pointer_data.device = reinterpret_cast(recognizer); + pointer_data.kind = flutter::PointerData::DeviceKind::kTrackpad; + switch (recognizer.state) { + case UIGestureRecognizerStateBegan: + pointer_data.change = flutter::PointerData::Change::kPanZoomStart; + break; + case UIGestureRecognizerStateChanged: + pointer_data.change = flutter::PointerData::Change::kPanZoomUpdate; + pointer_data.scale = recognizer.scale; + pointer_data.rotation = _rotationGestureRecognizer.rotation; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: + pointer_data.change = flutter::PointerData::Change::kPanZoomEnd; + break; + default: + // pinchEvent: should only ever be triggered with the above phases + NSAssert(false, @"Trackpad pinch event occured with unexpected phase 0x%lx", + (long)recognizer.state); + break; + } + + auto packet = std::make_unique(1); + packet->SetPointerData(/*index=*/0, pointer_data); [_engine.get() dispatchPointerDataPacket:std::move(packet)]; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm index 41cab5ba871..79894ef3beb 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterViewControllerTest.mm @@ -132,7 +132,7 @@ typedef enum UIAccessibilityContrast : NSInteger { - (void)performOrientationUpdate:(UIInterfaceOrientationMask)new_preferences; - (void)handlePressEvent:(FlutterUIPressProxy*)press nextAction:(void (^)())next API_AVAILABLE(ios(13.4)); -- (void)scrollEvent:(UIPanGestureRecognizer*)recognizer; +- (void)discreteScrollEvent:(UIPanGestureRecognizer*)recognizer; - (void)updateViewportMetrics; - (void)onUserSettingsChanged:(NSNotification*)notification; - (void)applicationWillTerminate:(NSNotification*)notification; @@ -1156,7 +1156,7 @@ typedef enum UIAccessibilityContrast : NSInteger { id mockPanGestureRecognizer = OCMClassMock([UIPanGestureRecognizer class]); XCTAssertNotNil(mockPanGestureRecognizer); - [vc scrollEvent:mockPanGestureRecognizer]; + [vc discreteScrollEvent:mockPanGestureRecognizer]; [[[self.mockEngine verify] ignoringNonObjectArgs] dispatchPointerDataPacket:std::make_unique(0)]; 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 55ab0cc5963..63013508e76 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 @@ -75,13 +75,16 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N XCTAssertTrue( [app.textFields[@"2,PointerChange.up,device=0,buttons=0"] waitForExistenceWithTimeout:1], @"PointerChange.up event did not occur for a normal tap"); + XCTAssertTrue( + [app.textFields[@"3,PointerChange.remove,device=0,buttons=0"] waitForExistenceWithTimeout:1], + @"PointerChange.remove event did not occur for a normal tap"); SEL rightClick = @selector(rightClick); XCTAssertTrue([flutterView respondsToSelector:rightClick], @"If supportsPointerInteraction is true, this should be true too."); [flutterView performSelector:rightClick]; // On simulated right click, a hover also occurs, so the hover pointer is added XCTAssertTrue( - [app.textFields[@"3,PointerChange.add,device=1,buttons=0"] waitForExistenceWithTimeout:1], + [app.textFields[@"4,PointerChange.add,device=1,buttons=0"] waitForExistenceWithTimeout:1], @"PointerChange.add event did not occur for a right-click's hover pointer"); // The hover pointer is removed after ~3.5 seconds, this ensures that all events are received @@ -89,8 +92,8 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N sleepExpectation.inverted = true; [self waitForExpectations:@[ sleepExpectation ] timeout:5.0]; - // The hover events are interspersed with the right-click events in a varying order - // Ensure the individual orderings are respected without hardcoding the absolute sequence + // The hover events are interspersed with the right-click events in a varying order. + // Ensure the individual orderings are respected without hardcoding the absolute sequence. NSMutableDictionary*>* messages = [[NSMutableDictionary alloc] init]; for (XCUIElement* element in [app.textFields allElementsBoundByIndex]) { @@ -109,7 +112,7 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N } [messageSequenceNumberList addObject:@(messageSequenceNumber)]; } - // The number of hover events is not consistent, there could be one or many + // The number of hover events is not consistent, there could be one or many. NSMutableArray* hoverSequenceNumbers = messages[@"PointerChange.hover,device=1,buttons=0"]; int hoverRemovedSequenceNumber = @@ -137,7 +140,7 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N @"Right-click pointer was pressed before it was added"); XCTAssertGreaterThan(rightClickUpSequenceNumber, rightClickDownSequenceNumber, @"Right-click pointer was released before it was pressed"); - XCTAssertGreaterThan([[hoverSequenceNumbers firstObject] intValue], 3, + XCTAssertGreaterThan([[hoverSequenceNumbers firstObject] intValue], 4, @"Hover occured before hover pointer was added"); XCTAssertGreaterThan(hoverRemovedSequenceNumber, [[hoverSequenceNumbers lastObject] intValue], @"Hover occured after hover pointer was removed"); @@ -196,6 +199,101 @@ static int assertOneMessageAndGetSequenceNumber(NSMutableDictionary* messages, N XCTAssertTrue([app.textFields[removeMessage] waitForExistenceWithTimeout:1], @"PointerChange.remove event did not occur for a hover"); } + +- (void)testPointerScroll { + BOOL supportsPointerInteraction = NO; + SEL supportsPointerInteractionSelector = @selector(supportsPointerInteraction); + if ([XCUIDevice.sharedDevice respondsToSelector:supportsPointerInteractionSelector]) { + supportsPointerInteraction = + performBoolSelector(XCUIDevice.sharedDevice, supportsPointerInteractionSelector); + } + XCTSkipUnless(supportsPointerInteraction, "Device does not support pointer interaction."); + XCUIApplication* app = [[XCUIApplication alloc] init]; + app.launchArguments = @[ @"--pointer-events" ]; + [app launch]; + + NSPredicate* predicateToFindFlutterView = + [NSPredicate predicateWithFormat:@"identifier BEGINSWITH 'flutter_view'"]; + XCUIElement* flutterView = [[app descendantsMatchingType:XCUIElementTypeAny] + elementMatchingPredicate:predicateToFindFlutterView]; + if (![flutterView waitForExistenceWithTimeout:kSecondsToWaitForFlutterView]) { + XCTFail(@"Failed due to not able to find any flutterView with %@ seconds", + @(kSecondsToWaitForFlutterView)); + } + + XCTAssertNotNil(flutterView); + + SEL scroll = @selector(scrollByDeltaX:deltaY:); + XCTAssertTrue([flutterView respondsToSelector:scroll], + @"If supportsPointerInteraction is true, this should be true too."); + // Need to use NSInvocation in order to send primitive arguments to selector. + NSInvocation* invocation = [NSInvocation + invocationWithMethodSignature:[XCUIElement instanceMethodSignatureForSelector:scroll]]; + [invocation setSelector:scroll]; + CGFloat deltaX = 0.0; + CGFloat deltaY = 1000.0; + [invocation setArgument:&deltaX atIndex:2]; + [invocation setArgument:&deltaY atIndex:3]; + [invocation invokeWithTarget:flutterView]; + + // 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 + // processing. + XCTestExpectation* sleepExpectation = [self expectationWithDescription:@"never fires"]; + sleepExpectation.inverted = true; + [self waitForExpectations:@[ sleepExpectation ] timeout:5.0]; + + // There are hover events interspersed with the scroll events in a varying order. + // Ensure the individual orderings are respected without hardcoding the absolute sequence. + NSMutableDictionary*>* messages = + [[NSMutableDictionary alloc] init]; + for (XCUIElement* element in [app.textFields allElementsBoundByIndex]) { + NSString* rawMessage = element.value; + // Parse out the sequence number + NSUInteger commaIndex = [rawMessage rangeOfString:@","].location; + NSInteger messageSequenceNumber = + [rawMessage substringWithRange:NSMakeRange(0, commaIndex)].integerValue; + // Parse out the rest of the message + NSString* message = [rawMessage + substringWithRange:NSMakeRange(commaIndex + 1, rawMessage.length - (commaIndex + 1))]; + NSMutableArray* messageSequenceNumberList = messages[message]; + if (messageSequenceNumberList == nil) { + messageSequenceNumberList = [[NSMutableArray alloc] init]; + messages[message] = messageSequenceNumberList; + } + [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"); + 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"); + int panZoomStartSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, @"PointerChange.panZoomStart,device=1,buttons=0"); + // 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"]; + int panZoomEndSequenceNumber = assertOneMessageAndGetSequenceNumber( + messages, @"PointerChange.panZoomEnd,device=1,buttons=0"); + + XCTAssertGreaterThan(panZoomStartSequenceNumber, panZoomAddedSequenceNumber, + @"PanZoomStart occured before pointer was added"); + XCTAssertGreaterThan([[panZoomUpdateSequenceNumbers firstObject] intValue], + panZoomStartSequenceNumber, @"PanZoomUpdate occured before PanZoomStart"); + XCTAssertGreaterThan(panZoomEndSequenceNumber, + [[panZoomUpdateSequenceNumbers lastObject] intValue], + @"PanZoomUpdate occured after PanZoomUpdate"); + + XCTAssertGreaterThan([[hoverSequenceNumbers firstObject] intValue], hoverAddedSequenceNumber, + @"Hover occured before pointer was added"); + XCTAssertGreaterThan(hoverRemovedSequenceNumber, [[hoverSequenceNumbers lastObject] intValue], + @"Hover occured after pointer was removed"); +} #pragma clang diagnostic pop @end