| // 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:async'; |
| 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'; |
| import 'debug.dart'; |
| 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 implements clock used for sampling. |
| class SamplingClock { |
| /// Returns current time. |
| DateTime now() => DateTime.now(); |
| |
| /// Returns a new stopwatch that uses the current time as reported by `this`. |
| Stopwatch stopwatch() => Stopwatch(); |
| } |
| |
| // Class that handles resampling of touch events for multiple pointer |
| // devices. |
| // |
| // The `samplingInterval` is used to determine the approximate next |
| // time for resampling. |
| // SchedulerBinding's `currentSystemFrameTimeStamp` is used to determine |
| // sample time. |
| class _Resampler { |
| _Resampler(this._handlePointerEvent, this._handleSampleTimeChanged, this._samplingInterval); |
| |
| // Resamplers used to filter incoming pointer events. |
| final Map<int, PointerEventResampler> _resamplers = <int, PointerEventResampler>{}; |
| |
| // Flag to track if a frame callback has been scheduled. |
| bool _frameCallbackScheduled = false; |
| |
| // Last frame time for resampling. |
| Duration _frameTime = Duration.zero; |
| |
| // Time since `_frameTime` was updated. |
| Stopwatch _frameTimeAge = Stopwatch(); |
| |
| // 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; |
| |
| // Interval used for sampling. |
| final Duration _samplingInterval; |
| |
| // Timer used to schedule resampling. |
| Timer? _timer; |
| |
| // Add `event` for resampling or dispatch it directly if |
| // not a touch event. |
| void addOrDispatch(PointerEvent event) { |
| final SchedulerBinding? scheduler = SchedulerBinding.instance; |
| assert(scheduler != null); |
| // 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. |
| // |
| // The `samplingOffset` is relative to the current frame time, which |
| // can be in the past when we're not actively resampling. |
| // The `samplingClock` is the clock used to determine frame time age. |
| void sample(Duration samplingOffset, SamplingClock clock) { |
| final SchedulerBinding? scheduler = SchedulerBinding.instance; |
| assert(scheduler != null); |
| |
| // Initialize `_frameTime` if needed. This will be used for periodic |
| // sampling when frame callbacks are not received. |
| if (_frameTime == Duration.zero) { |
| _frameTime = Duration(milliseconds: clock.now().millisecondsSinceEpoch); |
| _frameTimeAge = clock.stopwatch()..start(); |
| } |
| |
| // Schedule periodic resampling if `_timer` is not already active. |
| if (_timer?.isActive != true) { |
| _timer = Timer.periodic(_samplingInterval, (_) => _onSampleTimeChanged()); |
| } |
| |
| // Calculate the effective frame time by taking the number |
| // of sampling intervals since last time `_frameTime` was |
| // updated into account. This allows us to advance sample |
| // time without having to receive frame callbacks. |
| final int samplingIntervalUs = _samplingInterval.inMicroseconds; |
| final int elapsedIntervals = _frameTimeAge.elapsedMicroseconds ~/ samplingIntervalUs; |
| final int elapsedUs = elapsedIntervals * samplingIntervalUs; |
| final Duration frameTime = _frameTime + Duration(microseconds: elapsedUs); |
| |
| // 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; |
| |
| // Determine next sample time by adding the sampling interval |
| // to the current sample time. |
| final Duration nextSampleTime = sampleTime + _samplingInterval; |
| |
| // Iterate over active resamplers and sample pointer events for |
| // current sample time. |
| for (final PointerEventResampler resampler in _resamplers.values) { |
| resampler.sample(sampleTime, nextSampleTime, _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; |
| |
| // Early out if another call to `sample` isn't needed. |
| if (_resamplers.isEmpty) { |
| _timer!.cancel(); |
| return; |
| } |
| |
| // Schedule a frame callback if another call to `sample` is needed. |
| if (!_frameCallbackScheduled) { |
| _frameCallbackScheduled = true; |
| // Add a post frame callback as this avoids producing unnecessary |
| // frames but ensures that sampling phase is adjusted to frame |
| // time when frames are produced. |
| scheduler?.addPostFrameCallback((_) { |
| _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; |
| _frameTimeAge.reset(); |
| // Reset timer to match phase of latest frame callback. |
| _timer?.cancel(); |
| _timer = Timer.periodic(_samplingInterval, (_) => _onSampleTimeChanged()); |
| // Trigger an immediate sample time change. |
| _onSampleTimeChanged(); |
| }); |
| } |
| } |
| |
| // Stop all resampling and dispatched any queued events. |
| void stop() { |
| for (final PointerEventResampler resampler in _resamplers.values) { |
| resampler.stop(_handlePointerEvent); |
| } |
| _resamplers.clear(); |
| _frameTime = Duration.zero; |
| } |
| |
| void _onSampleTimeChanged() { |
| assert(() { |
| if (debugPrintResamplingMargin) { |
| final Duration resamplingMargin = _lastEventTime - _lastSampleTime; |
| debugPrint('$resamplingMargin'); |
| } |
| return true; |
| }()); |
| _handleSampleTimeChanged(); |
| } |
| } |
| |
| // 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); |
| |
| // The sampling interval. |
| // |
| // Sampling interval is used to determine the approximate time for subsequent |
| // sampling. This is used to sample events when frame callbacks are not |
| // being received and decide if early processing of up and removed events |
| // is appropriate. 16667 us for 60hz sampling interval. |
| const Duration _samplingInterval = Duration(microseconds: 16667); |
| |
| /// A binding for the gesture subsystem. |
| /// |
| /// ## Lifecycle of pointer events and the gesture arena |
| /// |
| /// ### [PointerDownEvent] |
| /// |
| /// When a [PointerDownEvent] is received by the [GestureBinding] (from |
| /// [dart:ui.PlatformDispatcher.onPointerDataPacket], as interpreted by the |
| /// [PointerEventConverter]), a [hitTest] is performed to determine which |
| /// [HitTestTarget] nodes are affected. (Other bindings are expected to |
| /// implement [hitTest] to defer to [HitTestable] objects. For example, the |
| /// rendering layer defers to the [RenderView] and the rest of the render object |
| /// hierarchy.) |
| /// |
| /// The affected nodes then are given the event to handle ([dispatchEvent] calls |
| /// [HitTestTarget.handleEvent] for each affected node). If any have relevant |
| /// [GestureRecognizer]s, they provide the event to them using |
| /// [GestureRecognizer.addPointer]. This typically causes the recognizer to |
| /// register with the [PointerRouter] to receive notifications regarding the |
| /// pointer in question. |
| /// |
| /// Once the hit test and dispatching logic is complete, the event is then |
| /// passed to the aforementioned [PointerRouter], which passes it to any objects |
| /// that have registered interest in that event. |
| /// |
| /// Finally, the [gestureArena] is closed for the given pointer |
| /// ([GestureArenaManager.close]), which begins the process of selecting a |
| /// gesture to win that pointer. |
| /// |
| /// ### Other events |
| /// |
| /// A pointer that is [PointerEvent.down] may send further events, such as |
| /// [PointerMoveEvent], [PointerUpEvent], or [PointerCancelEvent]. These are |
| /// sent to the same [HitTestTarget] nodes as were found when the |
| /// [PointerDownEvent] was received (even if they have since been disposed; it is |
| /// the responsibility of those objects to be aware of that possibility). |
| /// |
| /// Then, the events are routed to any still-registered entrants in the |
| /// [PointerRouter]'s table for that pointer. |
| /// |
| /// When a [PointerUpEvent] is received, the [GestureArenaManager.sweep] method |
| /// is invoked to force the gesture arena logic to terminate if necessary. |
| mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget { |
| @override |
| void initInstances() { |
| super.initInstances(); |
| _instance = this; |
| window.onPointerDataPacket = _handlePointerDataPacket; |
| } |
| |
| @override |
| void unlocked() { |
| super.unlocked(); |
| _flushPointerEventQueue(); |
| } |
| |
| /// The singleton instance of this object. |
| static GestureBinding? get instance => _instance; |
| static GestureBinding? _instance; |
| |
| final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>(); |
| |
| void _handlePointerDataPacket(ui.PointerDataPacket packet) { |
| // We convert pointer data to logical pixels so that e.g. the touch slop can be |
| // defined in a device-independent manner. |
| _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio)); |
| if (!locked) |
| _flushPointerEventQueue(); |
| } |
| |
| /// Dispatch a [PointerCancelEvent] for the given pointer soon. |
| /// |
| /// The pointer event will be dispatched before the next pointer event and |
| /// before the end of the microtask but not within this function call. |
| void cancelPointer(int pointer) { |
| if (_pendingPointerEvents.isEmpty && !locked) |
| scheduleMicrotask(_flushPointerEventQueue); |
| _pendingPointerEvents.addFirst(PointerCancelEvent(pointer: pointer)); |
| } |
| |
| void _flushPointerEventQueue() { |
| assert(!locked); |
| |
| while (_pendingPointerEvents.isNotEmpty) |
| handlePointerEvent(_pendingPointerEvents.removeFirst()); |
| } |
| |
| /// A router that routes all pointer events received from the engine. |
| final PointerRouter pointerRouter = PointerRouter(); |
| |
| /// The gesture arenas used for disambiguating the meaning of sequences of |
| /// pointer events. |
| final GestureArenaManager gestureArena = GestureArenaManager(); |
| |
| /// The resolver used for determining which widget handles a |
| /// [PointerSignalEvent]. |
| final PointerSignalResolver pointerSignalResolver = PointerSignalResolver(); |
| |
| /// State for all pointers which are currently down. |
| /// |
| /// The state of hovering pointers is not tracked because that would require |
| /// hit-testing on every frame. |
| final Map<int, HitTestResult> _hitTests = <int, HitTestResult>{}; |
| |
| /// Dispatch an event to the targets found by a hit test on its position. |
| /// |
| /// This method sends the given event to [dispatchEvent] based on event types: |
| /// |
| /// * [PointerDownEvent]s and [PointerSignalEvent]s are dispatched to the |
| /// result of a new [hitTest]. |
| /// * [PointerUpEvent]s and [PointerMoveEvent]s are dispatched to the result of hit test of the |
| /// preceding [PointerDownEvent]s. |
| /// * [PointerHoverEvent]s, [PointerAddedEvent]s, and [PointerRemovedEvent]s |
| /// are dispatched without a hit test result. |
| void handlePointerEvent(PointerEvent event) { |
| assert(!locked); |
| |
| if (resamplingEnabled) { |
| _resampler.addOrDispatch(event); |
| _resampler.sample(samplingOffset, _samplingClock); |
| return; |
| } |
| |
| // Stop resampler if resampling is not enabled. This is a no-op if |
| // resampling was never enabled. |
| _resampler.stop(); |
| _handlePointerEventImmediately(event); |
| } |
| |
| void _handlePointerEventImmediately(PointerEvent event) { |
| HitTestResult? hitTestResult; |
| if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) { |
| assert(!_hitTests.containsKey(event.pointer)); |
| hitTestResult = HitTestResult(); |
| hitTest(hitTestResult, event.position); |
| if (event is PointerDownEvent) { |
| _hitTests[event.pointer] = hitTestResult; |
| } |
| assert(() { |
| if (debugPrintHitTestResults) |
| debugPrint('$event: $hitTestResult'); |
| return true; |
| }()); |
| } else if (event is PointerUpEvent || event is PointerCancelEvent) { |
| hitTestResult = _hitTests.remove(event.pointer); |
| } else if (event.down) { |
| // Because events that occur with the pointer down (like |
| // [PointerMoveEvent]s) should be dispatched to the same place that their |
| // initial PointerDownEvent was, we want to re-use the path we found when |
| // the pointer went down, rather than do hit detection each time we get |
| // such an event. |
| hitTestResult = _hitTests[event.pointer]; |
| } |
| assert(() { |
| if (debugPrintMouseHoverEvents && event is PointerHoverEvent) |
| debugPrint('$event'); |
| return true; |
| }()); |
| if (hitTestResult != null || |
| event is PointerAddedEvent || |
| event is PointerRemovedEvent) { |
| assert(event.position != null); |
| dispatchEvent(event, hitTestResult); |
| } |
| } |
| |
| /// Determine which [HitTestTarget] objects are located at a given position. |
| @override // from HitTestable |
| void hitTest(HitTestResult result, Offset position) { |
| result.add(HitTestEntry(this)); |
| } |
| |
| /// Dispatch an event to [pointerRouter] and the path of a hit test result. |
| /// |
| /// The `event` is routed to [pointerRouter]. If the `hitTestResult` is not |
| /// null, the event is also sent to every [HitTestTarget] in the entries of the |
| /// given [HitTestResult]. Any exceptions from the handlers are caught. |
| /// |
| /// The `hitTestResult` argument may only be null for [PointerAddedEvent]s or |
| /// [PointerRemovedEvent]s. |
| @override // from HitTestDispatcher |
| void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) { |
| assert(!locked); |
| // No hit test information implies that this is a [PointerHoverEvent], |
| // [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially |
| // routed here; other events will be routed through the `handleEvent` below. |
| if (hitTestResult == null) { |
| assert(event is PointerAddedEvent || event is PointerRemovedEvent); |
| try { |
| pointerRouter.route(event); |
| } catch (exception, stack) { |
| FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher( |
| exception: exception, |
| stack: stack, |
| library: 'gesture library', |
| context: ErrorDescription('while dispatching a non-hit-tested pointer event'), |
| event: event, |
| hitTestEntry: null, |
| informationCollector: () sync* { |
| yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty); |
| }, |
| )); |
| } |
| return; |
| } |
| for (final HitTestEntry entry in hitTestResult.path) { |
| try { |
| entry.target.handleEvent(event.transformed(entry.transform), entry); |
| } catch (exception, stack) { |
| FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher( |
| exception: exception, |
| stack: stack, |
| library: 'gesture library', |
| context: ErrorDescription('while dispatching a pointer event'), |
| event: event, |
| hitTestEntry: entry, |
| informationCollector: () sync* { |
| yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty); |
| yield DiagnosticsProperty<HitTestTarget>('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty); |
| }, |
| )); |
| } |
| } |
| } |
| |
| @override // from HitTestTarget |
| void handleEvent(PointerEvent event, HitTestEntry entry) { |
| pointerRouter.route(event); |
| if (event is PointerDownEvent) { |
| gestureArena.close(event.pointer); |
| } else if (event is PointerUpEvent) { |
| gestureArena.sweep(event.pointer); |
| } else if (event is PointerSignalEvent) { |
| pointerSignalResolver.resolve(event); |
| } |
| } |
| |
| /// Reset states of [GestureBinding]. |
| /// |
| /// This clears the hit test records. |
| /// |
| /// This is typically called between tests. |
| @protected |
| void resetGestureBinding() { |
| _hitTests.clear(); |
| } |
| |
| /// Overrides the sampling clock for debugging and testing. |
| /// |
| /// This value is ignored in non-debug builds. |
| @protected |
| SamplingClock? get debugSamplingClock => null; |
| |
| void _handleSampleTimeChanged() { |
| if (!locked) { |
| if (resamplingEnabled) { |
| _resampler.sample(samplingOffset, _samplingClock); |
| } |
| else { |
| _resampler.stop(); |
| } |
| } |
| } |
| |
| SamplingClock get _samplingClock { |
| SamplingClock value = SamplingClock(); |
| assert(() { |
| final SamplingClock? debugValue = debugSamplingClock; |
| if (debugValue != null) |
| value = debugValue; |
| return true; |
| }()); |
| return value; |
| } |
| |
| // Resampler used to filter incoming pointer events when resampling |
| // is enabled. |
| late final _Resampler _resampler = _Resampler( |
| _handlePointerEventImmediately, |
| _handleSampleTimeChanged, |
| _samplingInterval, |
| ); |
| |
| /// 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 |
| /// library's binding's pointer event dispatcher ([GestureBinding.dispatchEvent]). |
| class FlutterErrorDetailsForPointerEventDispatcher extends FlutterErrorDetails { |
| /// Creates a [FlutterErrorDetailsForPointerEventDispatcher] object with the given |
| /// arguments setting the object's properties. |
| /// |
| /// The gesture library calls this constructor when catching an exception |
| /// that will subsequently be reported using [FlutterError.onError]. |
| const FlutterErrorDetailsForPointerEventDispatcher({ |
| required Object exception, |
| StackTrace? stack, |
| String? library, |
| DiagnosticsNode? context, |
| this.event, |
| this.hitTestEntry, |
| InformationCollector? informationCollector, |
| bool silent = false, |
| }) : super( |
| exception: exception, |
| stack: stack, |
| library: library, |
| context: context, |
| informationCollector: informationCollector, |
| silent: silent, |
| ); |
| |
| /// The pointer event that was being routed when the exception was raised. |
| final PointerEvent? event; |
| |
| /// The hit test result entry for the object whose handleEvent method threw |
| /// the exception. May be null if no hit test entry is associated with the |
| /// event (e.g. [PointerHoverEvent]s, [PointerAddedEvent]s, and |
| /// [PointerRemovedEvent]s). |
| /// |
| /// The target object itself is given by the [HitTestEntry.target] property of |
| /// the hitTestEntry object. |
| final HitTestEntry? hitTestEntry; |
| } |