From 02612bfe325496d99bbf72d4002ca1a1b09238da Mon Sep 17 00:00:00 2001 From: David Reveman Date: Mon, 24 Aug 2020 15:11:05 -0400 Subject: [PATCH] Pointer event resampler (#41118) (#60558) --- packages/flutter/lib/gestures.dart | 1 + .../flutter/lib/src/gestures/binding.dart | 169 +++++ packages/flutter/lib/src/gestures/debug.dart | 17 +- packages/flutter/lib/src/gestures/events.dart | 564 ++++++++++++++++ .../flutter/lib/src/gestures/resampler.dart | 305 +++++++++ .../flutter/test/gestures/events_test.dart | 280 ++++++++ .../test/gestures/gesture_binding_test.dart | 121 +++- .../flutter/test/gestures/resampler_test.dart | 605 ++++++++++++++++++ 8 files changed, 2060 insertions(+), 2 deletions(-) create mode 100644 packages/flutter/lib/src/gestures/resampler.dart create mode 100644 packages/flutter/test/gestures/resampler_test.dart diff --git a/packages/flutter/lib/gestures.dart b/packages/flutter/lib/gestures.dart index 3d1cdc68edf..7bc4302bfae 100644 --- a/packages/flutter/lib/gestures.dart +++ b/packages/flutter/lib/gestures.dart @@ -26,6 +26,7 @@ export 'src/gestures/multitap.dart'; export 'src/gestures/pointer_router.dart'; export 'src/gestures/pointer_signal_resolver.dart'; export 'src/gestures/recognizer.dart'; +export 'src/gestures/resampler.dart'; export 'src/gestures/scale.dart'; export 'src/gestures/tap.dart'; export 'src/gestures/team.dart'; diff --git a/packages/flutter/lib/src/gestures/binding.dart b/packages/flutter/lib/src/gestures/binding.dart index 696acb95799..d3ef4cc3e58 100644 --- a/packages/flutter/lib/src/gestures/binding.dart +++ b/packages/flutter/lib/src/gestures/binding.dart @@ -8,6 +8,7 @@ import 'dart:collection'; import 'dart:ui' as ui show PointerDataPacket; import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; import 'arena.dart'; import 'converter.dart'; @@ -16,6 +17,131 @@ import 'events.dart'; import 'hit_test.dart'; import 'pointer_router.dart'; import 'pointer_signal_resolver.dart'; +import 'resampler.dart'; + +typedef HandleSampleTimeChangedCallback = void Function(); + +// Class that handles resampling of touch events for multiple pointer +// devices. +// +// SchedulerBinding's `currentSystemFrameTimeStamp` is used to determine +// sample time. +class _Resampler { + _Resampler(this._handlePointerEvent, this._handleSampleTimeChanged); + + // Resamplers used to filter incoming pointer events. + final Map _resamplers = {}; + + // Flag to track if a frame callback has been scheduled. + bool _frameCallbackScheduled = false; + + // Current frame time for resampling. + Duration _frameTime = Duration.zero; + + // Last sample time and time stamp of last event. + // + // Only used for debugPrint of resampling margin. + Duration _lastSampleTime = Duration.zero; + Duration _lastEventTime = Duration.zero; + + // Callback used to handle pointer events. + final HandleEventCallback _handlePointerEvent; + + // Callback used to handle sample time changes. + final HandleSampleTimeChangedCallback _handleSampleTimeChanged; + + // Enqueue `events` for resampling or dispatch them directly if + // not a touch event. + void addOrDispatchAll(Queue events) { + final SchedulerBinding? scheduler = SchedulerBinding.instance; + assert(scheduler != null); + + while (events.isNotEmpty) { + final PointerEvent event = events.removeFirst(); + + // Add touch event to resampler or dispatch pointer event directly. + if (event.kind == PointerDeviceKind.touch) { + // Save last event time for debugPrint of resampling margin. + _lastEventTime = event.timeStamp; + + final PointerEventResampler resampler = _resamplers.putIfAbsent( + event.device, + () => PointerEventResampler(), + ); + resampler.addEvent(event); + } else { + _handlePointerEvent(event); + } + } + } + + // Sample and dispatch events. + // + // `samplingOffset` is relative to the current frame time, which + // can be in the past when we're not actively resampling. + // `currentSystemFrameTimeStamp` is used to determine the current + // frame time. + void sample(Duration samplingOffset) { + final SchedulerBinding? scheduler = SchedulerBinding.instance; + assert(scheduler != null); + + // Determine sample time by adding the offset to the current + // frame time. This is expected to be in the past and not + // result in any dispatched events unless we're actively + // resampling events. + final Duration sampleTime = _frameTime + samplingOffset; + + // Iterate over active resamplers and sample pointer events for + // current sample time. + for (final PointerEventResampler resampler in _resamplers.values) { + resampler.sample(sampleTime, _handlePointerEvent); + } + + // Remove inactive resamplers. + _resamplers.removeWhere((int key, PointerEventResampler resampler) { + return !resampler.hasPendingEvents && !resampler.isDown; + }); + + // Save last sample time for debugPrint of resampling margin. + _lastSampleTime = sampleTime; + + // Schedule a frame callback if another call to `sample` is needed. + if (!_frameCallbackScheduled && _resamplers.isNotEmpty) { + _frameCallbackScheduled = true; + scheduler?.scheduleFrameCallback((_) { + _frameCallbackScheduled = false; + // We use `currentSystemFrameTimeStamp` here as it's critical that + // sample time is in the same clock as the event time stamps, and + // never adjusted or scaled like `currentFrameTimeStamp`. + _frameTime = scheduler.currentSystemFrameTimeStamp; + assert(() { + if (debugPrintResamplingMargin) { + final Duration resamplingMargin = _lastEventTime - _lastSampleTime; + debugPrint('$resamplingMargin'); + } + return true; + }()); + _handleSampleTimeChanged(); + }); + } + } + + // Stop all resampling and dispatched any queued events. + void stop() { + for (final PointerEventResampler resampler in _resamplers.values) { + resampler.stop(_handlePointerEvent); + } + _resamplers.clear(); + } +} + +// The default sampling offset. +// +// Sampling offset is relative to presentation time. If we produce frames +// 16.667 ms before presentation and input rate is ~60hz, worst case latency +// is 33.334 ms. This however assumes zero latency from the input driver. +// 4.666 ms margin is added for this. +const Duration _defaultSamplingOffset = Duration(milliseconds: -38); /// A binding for the gesture subsystem. /// @@ -99,6 +225,17 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H void _flushPointerEventQueue() { assert(!locked); + + if (resamplingEnabled) { + _resampler.addOrDispatchAll(_pendingPointerEvents); + _resampler.sample(samplingOffset); + return; + } + + // Stop resampler if resampling is not enabled. This is a no-op if + // resampling was never enabled. + _resampler.stop(); + while (_pendingPointerEvents.isNotEmpty) _handlePointerEvent(_pendingPointerEvents.removeFirst()); } @@ -226,6 +363,38 @@ mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, H pointerSignalResolver.resolve(event); } } + + void _handleSampleTimeChanged() { + if (!locked) { + _flushPointerEventQueue(); + } + } + + // Resampler used to filter incoming pointer events when resampling + // is enabled. + late final _Resampler _resampler = _Resampler( + _handlePointerEvent, + _handleSampleTimeChanged, + ); + + /// Enable pointer event resampling for touch devices by setting + /// this to true. + /// + /// Resampling results in smoother touch event processing at the + /// cost of some added latency. Devices with low frequency sensors + /// or when the frequency is not a multiple of the display frequency + /// (e.g., 120Hz input and 90Hz display) benefit from this. + /// + /// This is typically set during application initialization but + /// can be adjusted dynamically in case the application only + /// wants resampling for some period of time. + bool resamplingEnabled = false; + + /// Offset relative to current frame time that should be used for + /// resampling. The [samplingOffset] is expected to be negative. + /// Non-negative [samplingOffset] is allowed but will effectively + /// disable resampling. + Duration samplingOffset = _defaultSamplingOffset; } /// Variant of [FlutterErrorDetails] with extra fields for the gesture diff --git a/packages/flutter/lib/src/gestures/debug.dart b/packages/flutter/lib/src/gestures/debug.dart index 83eb1310da4..3a3b6dc15fc 100644 --- a/packages/flutter/lib/src/gestures/debug.dart +++ b/packages/flutter/lib/src/gestures/debug.dart @@ -50,6 +50,20 @@ bool debugPrintGestureArenaDiagnostics = false; /// arenas. bool debugPrintRecognizerCallbacksTrace = false; +/// Whether to print the resampling margin to the console. +/// +/// When this is set, in debug mode, any time resampling is triggered by the +/// [GestureBinding] the resampling margin is dumped to the console. The +/// resampling margin is the delta between the time of the last received +/// touch event and the current sample time. Positive value indicates that +/// resampling is effective and the resampling offset can potentially be +/// reduced for improved latency. Negative value indicates that resampling +/// is failing and resampling offset needs to be increased for smooth +/// touch event processing. +/// +/// This has no effect in release builds. +bool debugPrintResamplingMargin = false; + /// Returns true if none of the gestures library debug variables have been changed. /// /// This function is used by the test framework to ensure that debug variables @@ -61,7 +75,8 @@ bool debugAssertAllGesturesVarsUnset(String reason) { assert(() { if (debugPrintHitTestResults || debugPrintGestureArenaDiagnostics || - debugPrintRecognizerCallbacksTrace) + debugPrintRecognizerCallbacksTrace || + debugPrintResamplingMargin) throw FlutterError(reason); return true; }()); diff --git a/packages/flutter/lib/src/gestures/events.dart b/packages/flutter/lib/src/gestures/events.dart index c36828ba807..e66add885d9 100644 --- a/packages/flutter/lib/src/gestures/events.dart +++ b/packages/flutter/lib/src/gestures/events.dart @@ -517,6 +517,36 @@ abstract class PointerEvent with Diagnosticable { /// overridden by the provided `transform`. PointerEvent transformed(Matrix4? transform); + /// Creates a copy of event with the specified properties replaced. + PointerEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }); + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -682,6 +712,56 @@ class PointerAddedEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerAddedEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerAddedEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + transform: transform ?? this.transform, + original: original as PointerAddedEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The device is no longer tracking the pointer. @@ -747,6 +827,53 @@ class PointerRemovedEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerRemovedEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerRemovedEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distanceMax: distanceMax ?? this.distanceMax, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + transform: transform ?? this.transform, + original: original as PointerRemovedEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has moved with respect to the device while the pointer is not @@ -857,6 +984,63 @@ class PointerHoverEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerHoverEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerHoverEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + delta: delta ?? this.delta, + localDelta: localDelta ?? this.localDelta, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + synthesized: synthesized ?? this.synthesized, + transform: transform ?? this.transform, + original: original as PointerHoverEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has moved with respect to the device while the pointer is or is @@ -1008,6 +1192,63 @@ class PointerEnterEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerEnterEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerEnterEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + delta: delta ?? this.delta, + localDelta: localDelta ?? this.localDelta, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + synthesized: synthesized ?? this.synthesized, + transform: transform ?? this.transform, + original: original as PointerEnterEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has moved with respect to the device while the pointer is or is @@ -1159,6 +1400,63 @@ class PointerExitEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerExitEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerExitEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + delta: delta ?? this.delta, + localDelta: localDelta ?? this.localDelta, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + synthesized: synthesized ?? this.synthesized, + transform: transform ?? this.transform, + original: original as PointerExitEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has made contact with the device. @@ -1251,6 +1549,61 @@ class PointerDownEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerDownEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerDownEvent( + timeStamp: timeStamp ?? this.timeStamp, + pointer: pointer ?? this.pointer, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressure: pressure ?? this.pressure, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + transform: transform ?? this.transform, + original: original as PointerDownEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has moved with respect to the device while the pointer is in @@ -1365,6 +1718,64 @@ class PointerMoveEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerMoveEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerMoveEvent( + timeStamp: timeStamp ?? this.timeStamp, + pointer: pointer ?? this.pointer, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + delta: delta ?? this.delta, + localDelta: localDelta ?? this.localDelta, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressure: pressure ?? this.pressure, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + synthesized: synthesized ?? this.synthesized, + transform: transform ?? this.transform, + original: original as PointerMoveEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// The pointer has stopped making contact with the device. @@ -1461,6 +1872,62 @@ class PointerUpEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerUpEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerUpEvent( + timeStamp: timeStamp ?? this.timeStamp, + pointer: pointer ?? this.pointer, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressure: pressure ?? this.pressure, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + transform: transform ?? this.transform, + original: original as PointerUpEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } /// An event that corresponds to a discrete pointer signal. @@ -1559,6 +2026,48 @@ class PointerScrollEvent extends PointerSignalEvent { ); } + @override + PointerScrollEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerScrollEvent( + timeStamp: timeStamp ?? this.timeStamp, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + scrollDelta: scrollDelta, + transform: transform ?? this.transform, + original: original as PointerScrollEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } + @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); @@ -1656,4 +2165,59 @@ class PointerCancelEvent extends PointerEvent { embedderId: embedderId, ); } + + @override + PointerCancelEvent copyWith({ + Duration? timeStamp, + int? pointer, + PointerDeviceKind? kind, + int? device, + Offset? position, + Offset? localPosition, + Offset? delta, + Offset? localDelta, + int? buttons, + bool? obscured, + double? pressure, + double? pressureMin, + double? pressureMax, + double? distance, + double? distanceMax, + double? size, + double? radiusMajor, + double? radiusMinor, + double? radiusMin, + double? radiusMax, + double? orientation, + double? tilt, + bool? synthesized, + Matrix4? transform, + PointerEvent? original, + int? embedderId, + }) { + return PointerCancelEvent( + timeStamp: timeStamp ?? this.timeStamp, + pointer: pointer ?? this.pointer, + kind: kind ?? this.kind, + device: device ?? this.device, + position: position ?? this.position, + localPosition: localPosition ?? this.localPosition, + buttons: buttons ?? this.buttons, + obscured: obscured ?? this.obscured, + pressureMin: pressureMin ?? this.pressureMin, + pressureMax: pressureMax ?? this.pressureMax, + distance: distance ?? this.distance, + distanceMax: distanceMax ?? this.distanceMax, + size: size ?? this.size, + radiusMajor: radiusMajor ?? this.radiusMajor, + radiusMinor: radiusMinor ?? this.radiusMinor, + radiusMin: radiusMin ?? this.radiusMin, + radiusMax: radiusMax ?? this.radiusMax, + orientation: orientation ?? this.orientation, + tilt: tilt ?? this.tilt, + transform: transform ?? this.transform, + original: original as PointerCancelEvent? ?? this, + embedderId: embedderId ?? this.embedderId, + ); + } } diff --git a/packages/flutter/lib/src/gestures/resampler.dart b/packages/flutter/lib/src/gestures/resampler.dart new file mode 100644 index 00000000000..208a4d99a66 --- /dev/null +++ b/packages/flutter/lib/src/gestures/resampler.dart @@ -0,0 +1,305 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:collection'; + +import 'events.dart'; + +typedef HandleEventCallback = void Function(PointerEvent event); + +/// Class for pointer event resampling. +/// +/// An instance of this class can be used to resample one sequence +/// of pointer events. Multiple instances are expected to be used for +/// multi-touch support. The sampling frequency and the sampling +/// offset is determined by the caller. +/// +/// This can be used to get smooth touch event processing at the cost +/// of adding some latency. Devices with low frequency sensors or when +/// the frequency is not a multiple of the display frequency +/// (e.g., 120Hz input and 90Hz display) benefit from this. +/// +/// The following pointer event types are supported: +/// [PointerAddedEvent], [PointerHoverEvent], [PointerDownEvent], +/// [PointerMoveEvent], [PointerCancelEvent], [PointerUpEvent], +/// [PointerRemovedEvent]. +/// +/// Resampling is currently limited to event position and delta. All +/// pointer event types except [PointerAddedEvent] will be resampled. +/// [PointerHoverEvent] and [PointerMoveEvent] will only be generated +/// when the position has changed. +class PointerEventResampler { + // Events queued for processing. + final Queue _queuedEvents = Queue(); + + // Pointer state required for resampling. + PointerEvent? _last; + PointerEvent? _next; + Offset _position = Offset.zero; + bool _isTracked = false; + bool _isDown = false; + int _pointerIdentifier = 0; + + PointerEvent _toHoverEvent( + PointerEvent event, + Offset position, + Offset delta, + Duration timeStamp, + ) { + return PointerHoverEvent( + timeStamp: timeStamp, + kind: event.kind, + device: event.device, + position: position, + delta: delta, + buttons: event.buttons, + obscured: event.obscured, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distance: event.distance, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + synthesized: event.synthesized, + embedderId: event.embedderId, + ); + } + + PointerEvent _toMoveEvent( + PointerEvent event, + Offset position, + Offset delta, + int pointerIdentifier, + Duration timeStamp, + ) { + return PointerMoveEvent( + timeStamp: timeStamp, + pointer: pointerIdentifier, + kind: event.kind, + device: event.device, + position: position, + delta: delta, + buttons: event.buttons, + obscured: event.obscured, + pressure: event.pressure, + pressureMin: event.pressureMin, + pressureMax: event.pressureMax, + distanceMax: event.distanceMax, + size: event.size, + radiusMajor: event.radiusMajor, + radiusMinor: event.radiusMinor, + radiusMin: event.radiusMin, + radiusMax: event.radiusMax, + orientation: event.orientation, + tilt: event.tilt, + platformData: event.platformData, + synthesized: event.synthesized, + embedderId: event.embedderId, + ); + } + + Offset _positionAt(Duration sampleTime) { + // Use `next` position by default. + double x = _next?.position.dx ?? 0.0; + double y = _next?.position.dy ?? 0.0; + + final Duration nextTimeStamp = _next?.timeStamp ?? Duration.zero; + final Duration lastTimeStamp = _last?.timeStamp ?? Duration.zero; + + // Resample if `next` time stamp is past `sampleTime`. + if (nextTimeStamp > sampleTime && nextTimeStamp > lastTimeStamp) { + final double interval = (nextTimeStamp - lastTimeStamp).inMicroseconds.toDouble(); + final double scalar = (sampleTime - lastTimeStamp).inMicroseconds.toDouble() / interval; + final double lastX = _last?.position.dx ?? 0.0; + final double lastY = _last?.position.dy ?? 0.0; + x = lastX + (x - lastX) * scalar; + y = lastY + (y - lastY) * scalar; + } + + return Offset(x, y); + } + + void _processPointerEvents(Duration sampleTime) { + final Iterator it = _queuedEvents.iterator; + while (it.moveNext()) { + final PointerEvent event = it.current; + + // Update both `last` and `next` pointer event if time stamp is older + // or equal to `sampleTime`. + if (event.timeStamp <= sampleTime || _last == null) { + _last = event; + _next = event; + continue; + } + + // Update only `next` pointer event if time stamp is more recent than + // `sampleTime` and next event is not already more recent. + final Duration nextTimeStamp = _next?.timeStamp ?? Duration.zero; + if (nextTimeStamp < sampleTime) { + _next = event; + break; + } + } + } + + void _dequeueAndSampleNonHoverOrMovePointerEventsUntil( + Duration sampleTime, + HandleEventCallback callback, + ) { + while (_queuedEvents.isNotEmpty) { + final PointerEvent event = _queuedEvents.first; + + // Potentially stop dispatching events if more recent than `sampleTime`. + if (event.timeStamp > sampleTime) { + // Stop if event is not up or removed. Otherwise, continue to + // allow early processing of up and remove events as this improves + // resampling of these events, which is important for fling + // animations. + if (event is! PointerUpEvent && event is! PointerRemovedEvent) { + break; + } + + // When this line is reached, the following two invariants hold: + // (1) `event.timeStamp > sampleTime` + // (2) `_next` has the smallest time stamp that's no less than + // `sampleTime` + // + // Therefore, event must satisfy `event.timeStamp >= _next.timeStamp`. + // + // Those events with the minimum `event.timeStamp == _next.timeStamp` + // time stamp are processed early for smoother fling. For events with + // `event.timeStamp > _next.timeStamp`, the following lines break the + // while loop to stop the early processing. + // + // Specifically, when `sampleTime < _next.timeStamp`, there must be + // at least one event with `_next.timeStamp == event.timeStamp` + // and that event is `_next` itself, and it will be processed early. + // + // When `sampleTime == _next.timeStamp`, all events with + // `event.timeStamp > sampleTime` must also have + // `event.timeStamp > _next.timeStamp` so no events will be processed + // early. + // + // When the input frequency is no greater than the sampling + // frequency, this early processing should guarantee that `up` and + // `remove` events are always re-sampled. + final Duration nextTimeStamp = _next?.timeStamp ?? Duration.zero; + assert(event.timeStamp >= nextTimeStamp); + if (event.timeStamp > nextTimeStamp) { + break; + } + } + + final bool wasTracked = _isTracked; + final bool wasDown = _isDown; + + // Update pointer state. + _isTracked = event is! PointerRemovedEvent; + _isDown = event.down; + + // Position at `sampleTime`. + final Offset position = _positionAt(sampleTime); + + // Initialize position if we are starting to track this pointer. + if (_isTracked && !wasTracked) { + _position = position; + } + + // Current pointer identifier. + final int pointerIdentifier = event.pointer; + + // Initialize pointer identifier for `move` events. + // Identifier is expected to be the same while `down`. + assert(!wasDown || _pointerIdentifier == pointerIdentifier); + _pointerIdentifier = pointerIdentifier; + + // Skip `move` and `hover` events as they are automatically + // generated when the position has changed. + if (event is! PointerMoveEvent && event is! PointerHoverEvent) { + callback(event.copyWith( + position: position, + delta: position - _position, + pointer: pointerIdentifier, + timeStamp: sampleTime, + )); + _position = position; + } + + _queuedEvents.removeFirst(); + } + } + + void _samplePointerPosition( + Duration sampleTime, + HandleEventCallback callback, + ) { + // Position at `sampleTime`. + final Offset position = _positionAt(sampleTime); + + // Add `move` or `hover` events if position has changed. + final PointerEvent? next = _next; + if (position != _position && next != null) { + final Offset delta = position - _position; + final PointerEvent event = _isDown + ? _toMoveEvent(next, position, delta, _pointerIdentifier, sampleTime) + : _toHoverEvent(next, position, delta, sampleTime); + callback(event); + _position = position; + } + } + + /// Enqueue pointer `event` for resampling. + void addEvent(PointerEvent event) { + _queuedEvents.add(event); + } + + /// Dispatch resampled pointer events for the specified `sampleTime` + /// by calling [callback]. + /// + /// This may dispatch multiple events if position is not the only + /// state that has changed since last sample. + /// + /// Calling [callback] must not add or sample events. + void sample(Duration sampleTime, HandleEventCallback callback) { + _processPointerEvents(sampleTime); + + // Dequeue and sample pointer events until `sampleTime`. + _dequeueAndSampleNonHoverOrMovePointerEventsUntil(sampleTime, callback); + + // Dispatch resampled pointer location event if tracked. + if (_isTracked) { + _samplePointerPosition(sampleTime, callback); + } + } + + /// Stop resampling. + /// + /// This will dispatch pending events by calling [callback] and reset + /// internal state. + void stop(HandleEventCallback callback) { + while (_queuedEvents.isNotEmpty) { + callback(_queuedEvents.removeFirst()); + } + _pointerIdentifier = 0; + _isDown = false; + _isTracked = false; + _position = Offset.zero; + _next = null; + _last = null; + } + + /// Returns `true` if a call to [sample] can dispatch more events. + bool get hasPendingEvents => _queuedEvents.isNotEmpty; + + /// Returns `true` if pointer is currently tracked. + bool get isTracked => _isTracked; + + /// Returns `true` if pointer is currently down. + bool get isDown => _isDown; +} diff --git a/packages/flutter/test/gestures/events_test.dart b/packages/flutter/test/gestures/events_test.dart index 10ea864b371..5c42445c649 100644 --- a/packages/flutter/test/gestures/events_test.dart +++ b/packages/flutter/test/gestures/events_test.dart @@ -498,6 +498,286 @@ void main() { localPosition: localPosition, ); }); + + group('copyWith', () { + const PointerEvent added = PointerAddedEvent( + timeStamp: Duration(days: 1), + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distance: 11, + distanceMax: 110, + radiusMin: 1.1, + radiusMax: 22, + orientation: 1.1, + tilt: 1.1, + ); + const PointerEvent hover = PointerHoverEvent( + timeStamp: Duration(days: 1), + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + buttons: 7, + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distance: 11, + distanceMax: 110, + size: 11, + radiusMajor: 11, + radiusMinor: 9, + radiusMin: 1.1, + radiusMax: 22, + orientation: 1.1, + tilt: 1.1, + synthesized: true, + ); + const PointerEvent down = PointerDownEvent( + timeStamp: Duration(days: 1), + pointer: 1, + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + buttons: 7, + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distanceMax: 110, + size: 11, + radiusMajor: 11, + radiusMinor: 9, + radiusMin: 1.1, + radiusMax: 22, + orientation: 1.1, + tilt: 1.1, + ); + const PointerEvent move = PointerMoveEvent( + timeStamp: Duration(days: 1), + pointer: 1, + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + delta: Offset(1.0, 2.0), + buttons: 7, + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distanceMax: 110, + size: 11, + radiusMajor: 11, + radiusMinor: 9, + radiusMin: 1.1, + radiusMax: 22, + orientation: 1.1, + tilt: 1.1, + synthesized: true, + ); + const PointerEvent up = PointerUpEvent( + timeStamp: Duration(days: 1), + pointer: 1, + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + buttons: 7, + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distanceMax: 110, + size: 11, + radiusMajor: 11, + radiusMinor: 9, + radiusMin: 1.1, + radiusMax: 22, + orientation: 1.1, + tilt: 1.1, + ); + const PointerEvent removed = PointerRemovedEvent( + timeStamp: Duration(days: 1), + kind: PointerDeviceKind.unknown, + device: 10, + position: Offset(101.0, 202.0), + obscured: true, + pressureMax: 2.1, + pressureMin: 1.1, + distanceMax: 110, + radiusMin: 1.1, + radiusMax: 22, + ); + + const Offset position = Offset.zero; + const Duration timeStamp = Duration(days: 2); + + test('PointerAddedEvent.copyWith()', () { + final PointerEvent event = added.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerAddedEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, empty.pointer); + expect(event.kind, added.kind); + expect(event.device, added.device); + expect(event.position, position); + expect(event.delta, empty.delta); + expect(event.buttons, added.buttons); + expect(event.down, empty.down); + expect(event.obscured, added.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, added.pressureMin); + expect(event.pressureMax, added.pressureMax); + expect(event.distance, added.distance); + expect(event.distanceMax, added.distanceMax); + expect(event.distanceMax, added.distanceMax); + expect(event.size, empty.size); + expect(event.radiusMajor, empty.radiusMajor); + expect(event.radiusMinor, empty.radiusMinor); + expect(event.radiusMin, added.radiusMin); + expect(event.radiusMax, added.radiusMax); + expect(event.orientation, added.orientation); + expect(event.tilt, added.tilt); + expect(event.synthesized, empty.synthesized); + }); + + test('PointerHoverEvent.copyWith()', () { + final PointerEvent event = hover.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerHoverEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, empty.pointer); + expect(event.kind, hover.kind); + expect(event.device, hover.device); + expect(event.position, position); + expect(event.delta, empty.delta); + expect(event.buttons, hover.buttons); + expect(event.down, empty.down); + expect(event.obscured, hover.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, hover.pressureMin); + expect(event.pressureMax, hover.pressureMax); + expect(event.distance, hover.distance); + expect(event.distanceMax, hover.distanceMax); + expect(event.distanceMax, hover.distanceMax); + expect(event.size, hover.size); + expect(event.radiusMajor, hover.radiusMajor); + expect(event.radiusMinor, hover.radiusMinor); + expect(event.radiusMin, hover.radiusMin); + expect(event.radiusMax, hover.radiusMax); + expect(event.orientation, hover.orientation); + expect(event.tilt, hover.tilt); + expect(event.synthesized, hover.synthesized); + }); + + test('PointerDownEvent.copyWith()', () { + final PointerEvent event = down.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerDownEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, down.pointer); + expect(event.kind, down.kind); + expect(event.device, down.device); + expect(event.position, position); + expect(event.delta, empty.delta); + expect(event.buttons, down.buttons); + expect(event.down, empty.down); + expect(event.obscured, down.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, down.pressureMin); + expect(event.pressureMax, down.pressureMax); + expect(event.distance, down.distance); + expect(event.distanceMax, down.distanceMax); + expect(event.distanceMax, down.distanceMax); + expect(event.size, down.size); + expect(event.radiusMajor, down.radiusMajor); + expect(event.radiusMinor, down.radiusMinor); + expect(event.radiusMin, down.radiusMin); + expect(event.radiusMax, down.radiusMax); + expect(event.orientation, down.orientation); + expect(event.tilt, down.tilt); + expect(event.synthesized, empty.synthesized); + }); + + test('PointerMoveEvent.copyWith()', () { + final PointerEvent event = move.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerMoveEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, move.pointer); + expect(event.kind, move.kind); + expect(event.device, move.device); + expect(event.position, position); + expect(event.delta, move.delta); + expect(event.buttons, move.buttons); + expect(event.down, move.down); + expect(event.obscured, move.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, move.pressureMin); + expect(event.pressureMax, move.pressureMax); + expect(event.distance, move.distance); + expect(event.distanceMax, move.distanceMax); + expect(event.distanceMax, move.distanceMax); + expect(event.size, move.size); + expect(event.radiusMajor, move.radiusMajor); + expect(event.radiusMinor, move.radiusMinor); + expect(event.radiusMin, move.radiusMin); + expect(event.radiusMax, move.radiusMax); + expect(event.orientation, move.orientation); + expect(event.tilt, move.tilt); + expect(event.synthesized, move.synthesized); + }); + + test('PointerUpEvent.copyWith()', () { + final PointerEvent event = up.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerUpEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, up.pointer); + expect(event.kind, up.kind); + expect(event.device, up.device); + expect(event.position, position); + expect(event.delta, up.delta); + expect(event.buttons, up.buttons); + expect(event.down, empty.down); + expect(event.obscured, up.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, up.pressureMin); + expect(event.pressureMax, up.pressureMax); + expect(event.distance, up.distance); + expect(event.distanceMax, up.distanceMax); + expect(event.distanceMax, up.distanceMax); + expect(event.size, up.size); + expect(event.radiusMajor, up.radiusMajor); + expect(event.radiusMinor, up.radiusMinor); + expect(event.radiusMin, up.radiusMin); + expect(event.radiusMax, up.radiusMax); + expect(event.orientation, up.orientation); + expect(event.tilt, up.tilt); + expect(event.synthesized, empty.synthesized); + }); + + test('PointerRemovedEvent.copyWith()', () { + final PointerEvent event = removed.copyWith(position: position, timeStamp: timeStamp); + const PointerEvent empty = PointerRemovedEvent(); + expect(event.timeStamp, timeStamp); + expect(event.pointer, empty.pointer); + expect(event.kind, removed.kind); + expect(event.device, removed.device); + expect(event.position, position); + expect(event.delta, empty.delta); + expect(event.buttons, removed.buttons); + expect(event.down, empty.down); + expect(event.obscured, removed.obscured); + expect(event.pressure, empty.pressure); + expect(event.pressureMin, removed.pressureMin); + expect(event.pressureMax, removed.pressureMax); + expect(event.distance, empty.distance); + expect(event.distanceMax, removed.distanceMax); + expect(event.distanceMax, removed.distanceMax); + expect(event.size, empty.size); + expect(event.radiusMajor, empty.radiusMajor); + expect(event.radiusMinor, empty.radiusMinor); + expect(event.radiusMin, removed.radiusMin); + expect(event.radiusMax, removed.radiusMax); + expect(event.orientation, empty.orientation); + expect(event.tilt, empty.tilt); + expect(event.synthesized, empty.synthesized); + }); + }); } void _expectTransformedEvent({ diff --git a/packages/flutter/test/gestures/gesture_binding_test.dart b/packages/flutter/test/gestures/gesture_binding_test.dart index e30080c54dd..af384d0fd45 100644 --- a/packages/flutter/test/gestures/gesture_binding_test.dart +++ b/packages/flutter/test/gestures/gesture_binding_test.dart @@ -8,13 +8,16 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/scheduler.dart'; import '../flutter_test_alternative.dart'; typedef HandleEventCallback = void Function(PointerEvent event); -class TestGestureFlutterBinding extends BindingBase with GestureBinding { +class TestGestureFlutterBinding extends BindingBase with GestureBinding, SchedulerBinding { HandleEventCallback callback; + FrameCallback frameCallback; + Duration frameTime; @override void handleEvent(PointerEvent event, HitTestEntry entry) { @@ -22,6 +25,18 @@ class TestGestureFlutterBinding extends BindingBase with GestureBinding { if (callback != null) callback(event); } + + @override + Duration get currentSystemFrameTimeStamp { + assert(frameTime != null); + return frameTime; + } + + @override + int scheduleFrameCallback(FrameCallback callback, {bool rescheduling = false}) { + frameCallback = callback; + return 0; + } } TestGestureFlutterBinding _binding = TestGestureFlutterBinding(); @@ -29,6 +44,7 @@ TestGestureFlutterBinding _binding = TestGestureFlutterBinding(); void ensureTestGestureBinding() { _binding ??= TestGestureFlutterBinding(); assert(GestureBinding.instance != null); + assert(SchedulerBinding.instance != null); } void main() { @@ -299,4 +315,107 @@ void main() { expect(events[4].buttons, equals(0)); } }); + + test('Pointer event resampling', () { + const ui.PointerDataPacket packet = ui.PointerDataPacket( + data: [ + ui.PointerData( + change: ui.PointerChange.add, + physicalX: 0.0, + timeStamp: Duration(milliseconds: 0), + ), + ui.PointerData( + change: ui.PointerChange.down, + physicalX: 0.0, + timeStamp: Duration(milliseconds: 1), + ), + ui.PointerData( + change: ui.PointerChange.move, + physicalX: 10.0, + timeStamp: Duration(milliseconds: 2), + ), + ui.PointerData( + change: ui.PointerChange.move, + physicalX: 20.0, + timeStamp: Duration(milliseconds: 3), + ), + ui.PointerData( + change: ui.PointerChange.move, + physicalX: 30.0, + timeStamp: Duration(milliseconds: 4), + ), + ui.PointerData( + change: ui.PointerChange.up, + physicalX: 40.0, + timeStamp: Duration(milliseconds: 5), + ), + ui.PointerData( + change: ui.PointerChange.remove, + physicalX: 40.0, + timeStamp: Duration(milliseconds: 6), + ), + ], + ); + + final List pointerRouterEvents = []; + GestureBinding.instance.pointerRouter.addGlobalRoute(pointerRouterEvents.add); + + final List events = []; + _binding.callback = events.add; + + GestureBinding.instance.resamplingEnabled = true; + GestureBinding.instance.samplingOffset = const Duration(microseconds: -5500); + + ui.window.onPointerDataPacket(packet); + + // No pointer events should have been dispatched yet. + expect(events.length, 0); + + // Frame callback should have been requested. + FrameCallback callback = _binding.frameCallback; + _binding.frameCallback = null; + expect(callback, isNotNull); + + _binding.frameTime = const Duration(milliseconds: 7); + callback(Duration.zero); + + // One pointer event should have been dispatched. + expect(events.length, 1); + expect(events[0].runtimeType, equals(PointerDownEvent)); + expect(events[0].timeStamp, _binding.frameTime + GestureBinding.instance.samplingOffset); + expect(events[0].position, Offset(5.0 / ui.window.devicePixelRatio, 0.0)); + + // Second frame callback should have been requested. + callback = _binding.frameCallback; + _binding.frameCallback = null; + expect(callback, isNotNull); + + _binding.frameTime = const Duration(milliseconds: 9); + callback(Duration.zero); + + // Second pointer event should have been dispatched. + expect(events.length, 2); + expect(events[1].timeStamp, _binding.frameTime + GestureBinding.instance.samplingOffset); + expect(events[1].runtimeType, equals(PointerMoveEvent)); + expect(events[1].position, Offset(25.0 / ui.window.devicePixelRatio, 0.0)); + expect(events[1].delta, Offset(20.0 / ui.window.devicePixelRatio, 0.0)); + + // Third frame callback should have been requested. + callback = _binding.frameCallback; + _binding.frameCallback = null; + expect(callback, isNotNull); + + _binding.frameTime = const Duration(milliseconds: 11); + callback(Duration.zero); + + // Third pointer event should have been dispatched. + expect(events.length, 3); + expect(events[2].timeStamp, _binding.frameTime + GestureBinding.instance.samplingOffset); + expect(events[2].runtimeType, equals(PointerUpEvent)); + expect(events[2].position, Offset(40.0 / ui.window.devicePixelRatio, 0.0)); + + // No frame callback should have been requested. + callback = _binding.frameCallback; + expect(callback, isNull); + }); } diff --git a/packages/flutter/test/gestures/resampler_test.dart b/packages/flutter/test/gestures/resampler_test.dart new file mode 100644 index 00000000000..25e6700d56c --- /dev/null +++ b/packages/flutter/test/gestures/resampler_test.dart @@ -0,0 +1,605 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.8 + +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; + +import '../flutter_test_alternative.dart'; + +void main() { + PointerEvent _createSimulatedPointerAddedEvent( + int timeStampUs, + double x, + double y, + ) { + return PointerAddedEvent( + timeStamp: Duration(microseconds: timeStampUs), + position: Offset(x, y), + ); + } + + PointerEvent _createSimulatedPointerRemovedEvent( + int timeStampUs, + double x, + double y, + ) { + return PointerRemovedEvent( + timeStamp: Duration(microseconds: timeStampUs), + position: Offset(x, y), + ); + } + + PointerEvent _createSimulatedPointerDownEvent( + int timeStampUs, + double x, + double y, + ) { + return PointerDownEvent( + timeStamp: Duration(microseconds: timeStampUs), + position: Offset(x, y), + ); + } + + PointerEvent _createSimulatedPointerMoveEvent( + int timeStampUs, + double x, + double y, + double deltaX, + double deltaY, + ) { + return PointerMoveEvent( + timeStamp: Duration(microseconds: timeStampUs), + position: Offset(x, y), + delta: Offset(deltaX, deltaY), + ); + } + + PointerEvent _createSimulatedPointerUpEvent( + int timeStampUs, + double x, + double y, + ) { + return PointerUpEvent( + timeStamp: Duration(microseconds: timeStampUs), + position: Offset(x, y), + ); + } + + test('basic', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 50.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(2000, 10.0, 40.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(3000, 20.0, 30.0, 10.0, -10.0); + final PointerEvent event3 = _createSimulatedPointerMoveEvent(4000, 30.0, 20.0, 10.0, -10.0); + final PointerEvent event4 = _createSimulatedPointerUpEvent(5000, 40.0, 10.0); + final PointerEvent event5 = _createSimulatedPointerRemovedEvent(6000, 50.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3) + ..addEvent(event4) + ..addEvent(event5); + + final List result = []; + + resampler.sample(const Duration(microseconds: 500), result.add); + + // No pointer event should have been returned yet. + expect(result.isEmpty, true); + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // Add pointer event should have been returned. + expect(result.length, 1); + expect(result[0].timeStamp, const Duration(microseconds: 1500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 5.0); + expect(result[0].position.dy, 45.0); + + resampler.sample(const Duration(microseconds: 2500), result.add); + + // Down pointer event should have been returned. + expect(result.length, 2); + expect(result[1].timeStamp, const Duration(microseconds: 2500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 15.0); + expect(result[1].position.dy, 35.0); + + resampler.sample(const Duration(microseconds: 3500), result.add); + + // Move pointer event should have been returned. + expect(result.length, 3); + expect(result[2].timeStamp, const Duration(microseconds: 3500)); + expect(result[2] is PointerMoveEvent, true); + expect(result[2].position.dx, 25.0); + expect(result[2].position.dy, 25.0); + expect(result[2].delta.dx, 10.0); + expect(result[2].delta.dy, -10.0); + + resampler.sample(const Duration(microseconds: 4500), result.add); + + // Up pointer event should have been returned. + expect(result.length, 4); + expect(result[3].timeStamp, const Duration(microseconds: 4500)); + expect(result[3] is PointerUpEvent, true); + expect(result[3].position.dx, 35.0); + expect(result[3].position.dy, 15.0); + + resampler.sample(const Duration(microseconds: 5500), result.add); + + // Remove pointer event should have been returned. + expect(result.length, 5); + expect(result[4].timeStamp, const Duration(microseconds: 5500)); + expect(result[4] is PointerRemovedEvent, true); + expect(result[4].position.dx, 45.0); + expect(result[4].position.dy, 5.0); + + resampler.sample(const Duration(microseconds: 6500), result.add); + + // No pointer event should have been returned. + expect(result.length, 5); + }); + + test('stream', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 50.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(2000, 10.0, 40.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(3000, 20.0, 30.0, 10.0, -10.0); + final PointerEvent event3 = _createSimulatedPointerMoveEvent(4000, 30.0, 20.0, 10.0, -10.0); + final PointerEvent event4 = _createSimulatedPointerUpEvent(5000, 40.0, 10.0); + final PointerEvent event5 = _createSimulatedPointerRemovedEvent(6000, 50.0, 0.0); + + resampler.addEvent(event0); + + // + // Initial sample time a 0.5 ms. + // + + final List result = []; + + resampler.sample(const Duration(microseconds: 500), result.add); + + // No pointer event should have been returned yet. + expect(result.isEmpty, true); + + resampler.addEvent(event1); + + resampler.sample(const Duration(microseconds: 500), result.add); + + // No pointer event should have been returned yet. + expect(result.isEmpty, true); + + // + // Advance sample time to 1.5 ms. + // + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // Add pointer event should have been returned. + expect(result.length, 1); + expect(result[0].timeStamp, const Duration(microseconds: 1500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 5.0); + expect(result[0].position.dy, 45.0); + + resampler.addEvent(event2); + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // No more pointer events should have been returned. + expect(result.length, 1); + + // + // Advance sample time to 2.5 ms. + // + + resampler.sample(const Duration(microseconds: 2500), result.add); + + // Down pointer event should have been returned. + expect(result.length, 2); + expect(result[1].timeStamp, const Duration(microseconds: 2500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 15.0); + expect(result[1].position.dy, 35.0); + + resampler.addEvent(event3); + + resampler.sample(const Duration(microseconds: 2500), result.add); + + // No more pointer events should have been returned. + expect(result.length, 2); + + // + // Advance sample time to 3.5 ms. + // + + resampler.sample(const Duration(microseconds: 3500), result.add); + + // Move pointer event should have been returned. + expect(result.length, 3); + expect(result[2].timeStamp, const Duration(microseconds: 3500)); + expect(result[2] is PointerMoveEvent, true); + expect(result[2].position.dx, 25.0); + expect(result[2].position.dy, 25.0); + expect(result[2].delta.dx, 10.0); + expect(result[2].delta.dy, -10.0); + + resampler.addEvent(event4); + + resampler.sample(const Duration(microseconds: 3500), result.add); + + // No more pointer events should have been returned. + expect(result.length, 3); + + // + // Advance sample time to 4.5 ms. + // + + resampler.sample(const Duration(microseconds: 4500), result.add); + + // Up pointer event should have been returned. + expect(result.length, 4); + expect(result[3].timeStamp, const Duration(microseconds: 4500)); + expect(result[3] is PointerUpEvent, true); + expect(result[3].position.dx, 35.0); + expect(result[3].position.dy, 15.0); + + resampler.addEvent(event5); + + resampler.sample(const Duration(microseconds: 4500), result.add); + + // No more pointer events should have been returned. + expect(result.length, 4); + + // + // Advance sample time to 5.5 ms. + // + + resampler.sample(const Duration(microseconds: 5500), result.add); + + // Remove pointer event should have been returned. + expect(result.length, 5); + expect(result[4].timeStamp, const Duration(microseconds: 5500)); + expect(result[4] is PointerRemovedEvent, true); + expect(result[4].position.dx, 45.0); + expect(result[4].position.dy, 5.0); + + // + // Advance sample time to 6.5 ms. + // + + resampler.sample(const Duration(microseconds: 6500), result.add); + + // No pointer events should have been returned. + expect(result.length, 5); + }); + + test('quick tap', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(1000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerUpEvent(1000, 0.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerRemovedEvent(1000, 0.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3); + + final List result = []; + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // All pointer events should have been returned. + expect(result.length, 4); + expect(result[0].timeStamp, const Duration(microseconds: 1500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 0.0); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 1500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 0.0); + expect(result[1].position.dy, 0.0); + expect(result[2].timeStamp, const Duration(microseconds: 1500)); + expect(result[2] is PointerUpEvent, true); + expect(result[2].position.dx, 0.0); + expect(result[2].position.dy, 0.0); + expect(result[3].timeStamp, const Duration(microseconds: 1500)); + expect(result[3] is PointerRemovedEvent, true); + expect(result[3].position.dx, 0.0); + expect(result[3].position.dy, 0.0); + }); + + test('advance slowly', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(1000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(2000, 10.0, 0.0, 10.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerUpEvent(3000, 20.0, 0.0); + final PointerEvent event4 = _createSimulatedPointerRemovedEvent(3000, 20.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3) + ..addEvent(event4); + + final List result = []; + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // Added and down pointer events should have been returned. + expect(result.length, 2); + expect(result[0].timeStamp, const Duration(microseconds: 1500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 5.0); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 1500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 5.0); + expect(result[1].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // No pointer events should have been returned. + expect(result.length, 2); + + resampler.sample(const Duration(microseconds: 1750), result.add); + + // Move pointer event should have been returned. + expect(result.length, 3); + expect(result[2].timeStamp, const Duration(microseconds: 1750)); + expect(result[2] is PointerMoveEvent, true); + expect(result[2].position.dx, 7.5); + expect(result[2].position.dy, 0.0); + expect(result[2].delta.dx, 2.5); + expect(result[2].delta.dy, 0.0); + + resampler.sample(const Duration(microseconds: 2000), result.add); + + // Another move pointer event should have been returned. + expect(result.length, 4); + expect(result[3].timeStamp, const Duration(microseconds: 2000)); + expect(result[3] is PointerMoveEvent, true); + expect(result[3].position.dx, 10.0); + expect(result[3].position.dy, 0.0); + expect(result[3].delta.dx, 2.5); + expect(result[3].delta.dy, 0.0); + + resampler.sample(const Duration(microseconds: 2500), result.add); + + // Last two pointer events should have been returned. + expect(result.length, 6); + expect(result[4].timeStamp, const Duration(microseconds: 2500)); + expect(result[4] is PointerUpEvent, true); + expect(result[4].position.dx, 15.0); + expect(result[4].position.dy, 0.0); + expect(result[5].timeStamp, const Duration(microseconds: 2500)); + expect(result[5] is PointerRemovedEvent, true); + expect(result[5].position.dx, 15.0); + expect(result[5].position.dy, 0.0); + }); + + test('advance fast', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(1000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(2000, 5.0, 0.0, 5.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerMoveEvent(3000, 20.0, 0.0, 15.0, 0.0); + final PointerEvent event4 = _createSimulatedPointerUpEvent(4000, 30.0, 0.0); + final PointerEvent event5 = _createSimulatedPointerRemovedEvent(4000, 30.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3) + ..addEvent(event4) + ..addEvent(event5); + + final List result = []; + + resampler.sample(const Duration(microseconds: 2500), result.add); + + // Add and down pointer events should have been returned. + expect(result.length, 2); + expect(result[0].timeStamp, const Duration(microseconds: 2500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 12.5); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 2500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 12.5); + expect(result[1].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 5500), result.add); + + // Up and removed pointer events should have been returned. + expect(result.length, 4); + expect(result[2].timeStamp, const Duration(microseconds: 5500)); + expect(result[2] is PointerUpEvent, true); + expect(result[2].position.dx, 30.0); + expect(result[2].position.dy, 0.0); + expect(result[3].timeStamp, const Duration(microseconds: 5500)); + expect(result[3] is PointerRemovedEvent, true); + expect(result[3].position.dx, 30.0); + expect(result[3].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 6500), result.add); + + // No pointer events should have been returned. + expect(result.length, 4); + }); + + test('skip', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(1000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(2000, 10.0, 0.0, 10.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerUpEvent(3000, 10.0, 0.0); + final PointerEvent event4 = _createSimulatedPointerDownEvent(4000, 20.0, 0.0); + final PointerEvent event5 = _createSimulatedPointerUpEvent(5000, 30.0, 0.0); + final PointerEvent event6 = _createSimulatedPointerRemovedEvent(5000, 30.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3) + ..addEvent(event4) + ..addEvent(event5) + ..addEvent(event6); + + final List result = []; + + resampler.sample(const Duration(microseconds: 1500), result.add); + + // Added and down pointer events should have been returned. + expect(result.length, 2); + expect(result[0].timeStamp, const Duration(microseconds: 1500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 5.0); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 1500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 5.0); + expect(result[1].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 4500), result.add); + + // All remaining pointer events should have been returned. + expect(result.length, 6); + expect(result[2].timeStamp, const Duration(microseconds: 4500)); + expect(result[2] is PointerUpEvent, true); + expect(result[2].position.dx, 25.0); + expect(result[2].position.dy, 0.0); + expect(result[3].timeStamp, const Duration(microseconds: 4500)); + expect(result[3] is PointerDownEvent, true); + expect(result[3].position.dx, 25.0); + expect(result[3].position.dy, 0.0); + expect(result[4].timeStamp, const Duration(microseconds: 4500)); + expect(result[4] is PointerUpEvent, true); + expect(result[4].position.dx, 25.0); + expect(result[4].position.dy, 0.0); + expect(result[5].timeStamp, const Duration(microseconds: 4500)); + expect(result[5] is PointerRemovedEvent, true); + expect(result[5].position.dx, 25.0); + expect(result[5].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 5500), result.add); + + // No pointer events should have been returned. + expect(result.length, 6); + }); + + test('skip all', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(1000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerUpEvent(4000, 30.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerRemovedEvent(4000, 30.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3); + + final List result = []; + + resampler.sample(const Duration(microseconds: 500), result.add); + + // No pointer events should have been returned. + expect(result.isEmpty, true); + + resampler.sample(const Duration(microseconds: 5500), result.add); + + // All remaining pointer events should have been returned. + expect(result.length, 4); + expect(result[0].timeStamp, const Duration(microseconds: 5500)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 30.0); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 5500)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 30.0); + expect(result[1].position.dy, 0.0); + expect(result[2].timeStamp, const Duration(microseconds: 5500)); + expect(result[2] is PointerUpEvent, true); + expect(result[2].position.dx, 30.0); + expect(result[2].position.dy, 0.0); + expect(result[3].timeStamp, const Duration(microseconds: 5500)); + expect(result[3] is PointerRemovedEvent, true); + expect(result[3].position.dx, 30.0); + expect(result[3].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 6500), result.add); + + // No pointer events should have been returned. + expect(result.length, 4); + }); + + test('stop', () { + final PointerEventResampler resampler = PointerEventResampler(); + final PointerEvent event0 = _createSimulatedPointerAddedEvent(1000, 0.0, 0.0); + final PointerEvent event1 = _createSimulatedPointerDownEvent(2000, 0.0, 0.0); + final PointerEvent event2 = _createSimulatedPointerMoveEvent(3000, 10.0, 0.0, 10.0, 0.0); + final PointerEvent event3 = _createSimulatedPointerUpEvent(4000, 20.0, 0.0); + final PointerEvent event4 = _createSimulatedPointerRemovedEvent(5000, 20.0, 0.0); + + resampler + ..addEvent(event0) + ..addEvent(event1) + ..addEvent(event2) + ..addEvent(event3) + ..addEvent(event4); + + final List result = []; + + resampler.sample(const Duration(microseconds: 500), result.add); + + // No pointer events should have been returned. + expect(result.isEmpty, true); + + resampler.stop(result.add); + + // All pointer events should have been returned with orignal + // time stamps and positions. + expect(result.length, 5); + expect(result[0].timeStamp, const Duration(microseconds: 1000)); + expect(result[0] is PointerAddedEvent, true); + expect(result[0].position.dx, 0.0); + expect(result[0].position.dy, 0.0); + expect(result[1].timeStamp, const Duration(microseconds: 2000)); + expect(result[1] is PointerDownEvent, true); + expect(result[1].position.dx, 0.0); + expect(result[1].position.dy, 0.0); + expect(result[2].timeStamp, const Duration(microseconds: 3000)); + expect(result[2] is PointerMoveEvent, true); + expect(result[2].position.dx, 10.0); + expect(result[2].position.dy, 0.0); + expect(result[2].delta.dx, 10.0); + expect(result[2].delta.dy, 0.0); + expect(result[3].timeStamp, const Duration(microseconds: 4000)); + expect(result[3] is PointerUpEvent, true); + expect(result[3].position.dx, 20.0); + expect(result[3].position.dy, 0.0); + expect(result[4].timeStamp, const Duration(microseconds: 5000)); + expect(result[4] is PointerRemovedEvent, true); + expect(result[4].position.dx, 20.0); + expect(result[4].position.dy, 0.0); + + resampler.sample(const Duration(microseconds: 10000), result.add); + + // No pointer events should have been returned. + expect(result.length, 5); + }); +}