mouse_tracking.dart 12.7 KB
Newer Older
1 2 3 4 5 6
// Copyright 2018 The Chromium 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:ui';

7
import 'package:flutter/foundation.dart' show ChangeNotifier, visibleForTesting;
8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
import 'package:flutter/scheduler.dart';

import 'events.dart';
import 'pointer_router.dart';

/// Signature for listening to [PointerEnterEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerEnterEventListener = void Function(PointerEnterEvent event);

/// Signature for listening to [PointerExitEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerExitEventListener = void Function(PointerExitEvent event);

/// Signature for listening to [PointerHoverEvent] events.
///
/// Used by [MouseTrackerAnnotation], [Listener] and [RenderPointerListener].
typedef PointerHoverEventListener = void Function(PointerHoverEvent event);

/// The annotation object used to annotate layers that are interested in mouse
/// movements.
///
/// This is added to a layer and managed by the [Listener] widget.
class MouseTrackerAnnotation {
  /// Creates an annotation that can be used to find layers interested in mouse
  /// movements.
  const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit});

  /// Triggered when a pointer has entered the bounding box of the annotated
  /// layer.
  final PointerEnterEventListener onEnter;

  /// Triggered when a pointer has moved within the bounding box of the
  /// annotated layer.
  final PointerHoverEventListener onHover;

  /// Triggered when a pointer has exited the bounding box of the annotated
  /// layer.
  final PointerExitEventListener onExit;

  @override
  String toString() {
    final String none = (onEnter == null && onExit == null && onHover == null) ? ' <none>' : '';
    return '[$runtimeType${hashCode.toRadixString(16)}$none'
        '${onEnter == null ? '' : ' onEnter'}'
        '${onHover == null ? '' : ' onHover'}'
        '${onExit == null ? '' : ' onExit'}]';
  }
}

// Used internally by the MouseTracker for accounting for which annotation is
// active on which devices inside of the MouseTracker.
class _TrackedAnnotation {
  _TrackedAnnotation(this.annotation);

  final MouseTrackerAnnotation annotation;

  /// Tracks devices that are currently active for this annotation.
  ///
  /// If the mouse pointer corresponding to the integer device ID is
  /// present in the Set, then it is currently inside of the annotated layer.
  ///
  /// This is used to detect layers that used to have the mouse pointer inside
  /// them, but now no longer do (to facilitate exit notification).
73
  Set<int> activeDevices = <int>{};
74 75 76 77 78 79 80
}

/// Describes a function that finds an annotation given an offset in logical
/// coordinates.
///
/// It is used by the [MouseTracker] to fetch annotations for the mouse
/// position.
81
typedef MouseDetectorAnnotationFinder = Iterable<MouseTrackerAnnotation> Function(Offset offset);
82 83 84 85 86

/// Keeps state about which objects are interested in tracking mouse positions
/// and notifies them when a mouse pointer enters, moves, or leaves an annotated
/// region that they are interested in.
///
87 88 89
/// This class is a [ChangeNotifier] that notifies its listeners if the value of
/// [mouseIsConnected] changes.
///
90
/// Owned by the [RendererBinding] class.
91
class MouseTracker extends ChangeNotifier {
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
  /// Creates a mouse tracker to keep track of mouse locations.
  ///
  /// All of the parameters must not be null.
  MouseTracker(PointerRouter router, this.annotationFinder)
      : assert(router != null),
        assert(annotationFinder != null) {
    router.addGlobalRoute(_handleEvent);
  }

  /// Used to find annotations at a given logical coordinate.
  final MouseDetectorAnnotationFinder annotationFinder;

  // The collection of annotations that are currently being tracked. They may or
  // may not be active, depending on the value of _TrackedAnnotation.active.
  final Map<MouseTrackerAnnotation, _TrackedAnnotation> _trackedAnnotations = <MouseTrackerAnnotation, _TrackedAnnotation>{};

  /// Track an annotation so that if the mouse enters it, we send it events.
  ///
  /// This is typically called when the [AnnotatedRegion] containing this
  /// annotation has been added to the layer tree.
  void attachAnnotation(MouseTrackerAnnotation annotation) {
    _trackedAnnotations[annotation] = _TrackedAnnotation(annotation);
    // Schedule a check so that we test this new annotation to see if the mouse
    // is currently inside its region.
    _scheduleMousePositionCheck();
  }

  /// Stops tracking an annotation, indicating that it has been removed from the
  /// layer tree.
  ///
  /// If the associated layer is not removed, and receives a hit, then
  /// [collectMousePositions] will assert the next time it is called.
  void detachAnnotation(MouseTrackerAnnotation annotation) {
    final _TrackedAnnotation trackedAnnotation = _findAnnotation(annotation);
    for (int deviceId in trackedAnnotation.activeDevices) {
127
      if (annotation.onExit != null) {
128 129 130
        final PointerEvent event = _lastMouseEvent[deviceId] ?? _pendingRemovals[deviceId];
        assert(event != null);
        annotation.onExit(PointerExitEvent.fromMouseEvent(event));
131
      }
132
    }
133
    _trackedAnnotations.remove(annotation);
134 135
  }

136
  bool _postFrameCheckScheduled = false;
137
  void _scheduleMousePositionCheck() {
138 139 140
    // If we're not tracking anything, then there is no point in registering a
    // frame callback or scheduling a frame. By definition there are no active
    // annotations that need exiting, either.
141 142 143 144 145 146
    if (_trackedAnnotations.isNotEmpty && !_postFrameCheckScheduled) {
      _postFrameCheckScheduled = true;
      SchedulerBinding.instance.addPostFrameCallback((Duration _) {
        _postFrameCheckScheduled = false;
        collectMousePositions();
      });
147 148
      SchedulerBinding.instance.scheduleFrame();
    }
149 150 151 152 153 154 155 156
  }

  // Handler for events coming from the PointerRouter.
  void _handleEvent(PointerEvent event) {
    if (event.kind != PointerDeviceKind.mouse) {
      return;
    }
    final int deviceId = event.device;
157
    if (event is PointerAddedEvent) {
158 159
      // If we are adding the device again, then we're not removing it anymore.
      _pendingRemovals.remove(deviceId);
160
      _addMouseEvent(deviceId, event);
161 162 163
      return;
    }
    if (event is PointerRemovedEvent) {
164
      _removeMouseEvent(deviceId, event);
165 166 167 168 169 170 171
      // If the mouse was removed, then we need to schedule one more check to
      // exit any annotations that were active.
      _scheduleMousePositionCheck();
    } else {
      if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) {
        if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) {
          // Only schedule a frame if we have our first event, or if the
172
          // location of the mouse has changed, and only if there are tracked annotations.
173 174
          _scheduleMousePositionCheck();
        }
175
        _addMouseEvent(deviceId, event);
176 177 178 179 180 181 182 183 184 185 186 187 188
      }
    }
  }

  _TrackedAnnotation _findAnnotation(MouseTrackerAnnotation annotation) {
    final _TrackedAnnotation trackedAnnotation = _trackedAnnotations[annotation];
    assert(
        trackedAnnotation != null,
        'Unable to find annotation $annotation in tracked annotations. '
        'Check that attachAnnotation has been called for all annotated layers.');
    return trackedAnnotation;
  }

189 190 191 192 193 194 195
  /// Checks if the given [MouseTrackerAnnotation] is attached to this
  /// [MouseTracker].
  ///
  /// This function is only public to allow for proper testing of the
  /// MouseTracker. Do not call in other contexts.
  @visibleForTesting
  bool isAnnotationAttached(MouseTrackerAnnotation annotation) {
196
    return _trackedAnnotations.containsKey(annotation);
197 198
  }

199 200 201 202 203 204 205 206 207 208 209 210 211
  /// Tells interested objects that a mouse has entered, exited, or moved, given
  /// a callback to fetch the [MouseTrackerAnnotation] associated with a global
  /// offset.
  ///
  /// This is called from a post-frame callback when the layer tree has been
  /// updated, right after rendering the frame.
  ///
  /// This function is only public to allow for proper testing of the
  /// MouseTracker. Do not call in other contexts.
  @visibleForTesting
  void collectMousePositions() {
    void exitAnnotation(_TrackedAnnotation trackedAnnotation, int deviceId) {
      if (trackedAnnotation.annotation?.onExit != null && trackedAnnotation.activeDevices.contains(deviceId)) {
212 213 214
        final PointerEvent event = _lastMouseEvent[deviceId] ?? _pendingRemovals[deviceId];
        assert(event != null);
        trackedAnnotation.annotation.onExit(PointerExitEvent.fromMouseEvent(event));
215 216 217 218 219 220 221 222 223 224 225 226 227
        trackedAnnotation.activeDevices.remove(deviceId);
      }
    }

    void exitAllDevices(_TrackedAnnotation trackedAnnotation) {
      if (trackedAnnotation.activeDevices.isNotEmpty) {
        final Set<int> deviceIds = trackedAnnotation.activeDevices.toSet();
        for (int deviceId in deviceIds) {
          exitAnnotation(trackedAnnotation, deviceId);
        }
      }
    }

228 229 230 231 232 233 234
    try {
      // This indicates that all mouse pointers were removed, or none have been
      // connected yet. If no mouse is connected, then we want to make sure that
      // all active annotations are exited.
      if (!mouseIsConnected) {
        _trackedAnnotations.values.forEach(exitAllDevices);
        return;
235 236
      }

237 238 239 240 241 242 243 244 245 246
      for (int deviceId in _lastMouseEvent.keys) {
        final PointerEvent lastEvent = _lastMouseEvent[deviceId];
        final Iterable<MouseTrackerAnnotation> hits = annotationFinder(lastEvent.position);

        // No annotations were found at this position for this deviceId, so send an
        // exit to all active tracked annotations, since none of them were hit.
        if (hits.isEmpty) {
          // Send an exit to all tracked animations tracking this deviceId.
          for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
            exitAnnotation(trackedAnnotation, deviceId);
247
          }
248
          continue;
249
        }
250

251 252 253 254 255 256 257 258 259 260 261 262
        final Set<_TrackedAnnotation> hitAnnotations = hits.map<_TrackedAnnotation>((MouseTrackerAnnotation hit) => _findAnnotation(hit)).toSet();
        for (_TrackedAnnotation hitAnnotation in hitAnnotations) {
          if (!hitAnnotation.activeDevices.contains(deviceId)) {
            // A tracked annotation that just became active and needs to have an enter
            // event sent to it.
            hitAnnotation.activeDevices.add(deviceId);
            if (hitAnnotation.annotation?.onEnter != null) {
              hitAnnotation.annotation.onEnter(PointerEnterEvent.fromMouseEvent(lastEvent));
            }
          }
          if (hitAnnotation.annotation?.onHover != null && lastEvent is PointerHoverEvent) {
            hitAnnotation.annotation.onHover(lastEvent);
263
          }
264 265 266 267 268 269 270 271 272 273 274 275

          // Tell any tracked annotations that weren't hit that they are no longer
          // active.
          for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) {
            if (hitAnnotations.contains(trackedAnnotation)) {
              continue;
            }
            if (trackedAnnotation.activeDevices.contains(deviceId)) {
              if (trackedAnnotation.annotation?.onExit != null) {
                trackedAnnotation.annotation.onExit(PointerExitEvent.fromMouseEvent(lastEvent));
              }
              trackedAnnotation.activeDevices.remove(deviceId);
276
            }
277 278 279
          }
        }
      }
280 281
    } finally {
      _pendingRemovals.clear();
282 283 284
    }
  }

285 286
  void _addMouseEvent(int deviceId, PointerEvent event) {
    final bool wasConnected = mouseIsConnected;
287 288 289 290
    if (event is PointerAddedEvent) {
      // If we are adding the device again, then we're not removing it anymore.
      _pendingRemovals.remove(deviceId);
    }
291 292 293 294 295 296
    _lastMouseEvent[deviceId] = event;
    if (mouseIsConnected != wasConnected) {
      notifyListeners();
    }
  }

297
  void _removeMouseEvent(int deviceId, PointerEvent event) {
298
    final bool wasConnected = mouseIsConnected;
299 300
    assert(event is PointerRemovedEvent);
    _pendingRemovals[deviceId] = event;
301 302 303 304 305 306
    _lastMouseEvent.remove(deviceId);
    if (mouseIsConnected != wasConnected) {
      notifyListeners();
    }
  }

307 308 309 310
  // A list of device IDs that should be removed and notified when scheduling a
  // mouse position check.
  final Map<int, PointerRemovedEvent> _pendingRemovals = <int, PointerRemovedEvent>{};

311 312 313 314 315 316 317 318 319
  /// The most recent mouse event observed for each mouse device ID observed.
  ///
  /// May be null if no mouse is connected, or hasn't produced an event yet.
  /// Will not be updated unless there is at least one tracked annotation.
  final Map<int, PointerEvent> _lastMouseEvent = <int, PointerEvent>{};

  /// Whether or not a mouse is connected and has produced events.
  bool get mouseIsConnected => _lastMouseEvent.isNotEmpty;
}