iPad trackpad gestures (flutter/engine#31591)

This commit is contained in:
Callum Moffat 2022-06-13 23:48:04 -04:00 committed by GitHub
parent ad0b442030
commit 20d8f266ac
5 changed files with 325 additions and 57 deletions

View File

@ -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.

View File

@ -556,7 +556,7 @@ TEST(PointerDataPacketConverterTest, CanHandleThreeFingerGesture) {
TEST(PointerDataPacketConverterTest, CanConvetScroll) {
PointerDataPacketConverter converter;
auto packet = std::make_unique<PointerDataPacket>(5);
auto packet = std::make_unique<PointerDataPacket>(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<PointerData> 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) {

View File

@ -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<UIScrollView> _scrollView;
fml::scoped_nsobject<UIHoverGestureRecognizer> _hoverGestureRecognizer API_AVAILABLE(ios(13.4));
fml::scoped_nsobject<UIPanGestureRecognizer> _panGestureRecognizer API_AVAILABLE(ios(13.4));
fml::scoped_nsobject<UIView> _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<flutter::PointerDataPacket>(touches.count);
auto packet =
std::make_unique<flutter::PointerDataPacket>(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<int64_t>(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<int64_t>(_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<flutter::PointerDataPacket>(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<flutter::PointerDataPacket>(1);
flutter::PointerData pointer_data = [self generatePointerDataForMouse];
flutter::PointerData pointer_data = [self generatePointerDataAtLastMouseLocation];
pointer_data.device = reinterpret_cast<int64_t>(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<flutter::PointerDataPacket>(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<int64_t>(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<flutter::PointerDataPacket>(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<int64_t>(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<flutter::PointerDataPacket>(1);
packet->SetPointerData(/*index=*/0, pointer_data);
[_engine.get() dispatchPointerDataPacket:std::move(packet)];
}

View File

@ -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<flutter::PointerDataPacket>(0)];

View File

@ -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<NSString*, NSMutableArray<NSNumber*>*>* 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<NSNumber*>* 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<NSString*, NSMutableArray<NSNumber*>*>* 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<NSNumber*>* 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<NSNumber*>* 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<NSNumber*>* 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