Scroll inertia cancel for iPadOS (flutter/engine#34929)

This commit is contained in:
Callum Moffat 2022-09-01 16:27:55 -04:00 committed by GitHub
parent c5482e1b56
commit 4713e320b5
4 changed files with 106 additions and 18 deletions

View File

@ -114,6 +114,15 @@ typedef struct MouseState {
fml::scoped_nsobject<UIScrollView> _scrollView;
fml::scoped_nsobject<UIView> _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<int64_t>(_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<flutter::PointerDataPacket>(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<flutter::PointerDataPacket>(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<flutter::PointerDataPacket>(2);
packet->SetPointerData(/*index=*/0, pointer_data);
flutter::PointerData inertia_cancel = pointer_data;
inertia_cancel.device = reinterpret_cast<int64_t>(_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<flutter::PointerDataPacket>(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:

View File

@ -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, @"");
}

View File

@ -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<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");
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<NSNumber*>* 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");

View File

@ -32,7 +32,7 @@ class TouchesScenario extends Scenario {
dispatcher: dispatcher,
channel: 'display_data',
json: <String, dynamic>{
'data': '$_sequenceNo,${datum.change},device=$deviceId,buttons=${datum.buttons}',
'data': '$_sequenceNo,${datum.change},device=$deviceId,buttons=${datum.buttons},signalKind=${datum.signalKind}',
},
);
_sequenceNo++;