diff --git a/packages/flutter/lib/src/gestures/mouse_tracking.dart b/packages/flutter/lib/src/gestures/mouse_tracking.dart index 871e3ad1fb..381812e4a3 100644 --- a/packages/flutter/lib/src/gestures/mouse_tracking.dart +++ b/packages/flutter/lib/src/gestures/mouse_tracking.dart @@ -30,7 +30,7 @@ typedef PointerHoverEventListener = void Function(PointerHoverEvent event); /// movements. /// /// This is added to a layer and managed by the [MouseRegion] widget. -class MouseTrackerAnnotation { +class MouseTrackerAnnotation extends Diagnosticable { /// Creates an annotation that can be used to find layers interested in mouse /// movements. const MouseTrackerAnnotation({this.onEnter, this.onHover, this.onExit}); @@ -39,24 +39,13 @@ class MouseTrackerAnnotation { /// entered the annotated region. /// /// This callback is triggered when the pointer has started to be contained - /// by the annotationed region for any reason. - /// - /// More specifically, the callback is triggered by the following cases: - /// - /// * A new annotated region has appeared under a pointer. - /// * An existing annotated region has moved to under a pointer. - /// * A new pointer has been added to somewhere within an annotated region. - /// * An existing pointer has moved into an annotated region. - /// - /// This callback is not always matched by an [onExit]. If the render object - /// that owns the annotation is disposed while being hovered by a pointer, - /// the [onExit] callback of that annotation will never called, despite - /// the earlier call of [onEnter]. For more details, see [onExit]. + /// by the annotationed region for any reason, which means it always matches a + /// later [onExit]. /// /// See also: /// - /// * [MouseRegion.onEnter], which uses this callback. /// * [onExit], which is triggered when a mouse pointer exits the region. + /// * [MouseRegion.onEnter], which uses this callback. final PointerEnterEventListener onEnter; /// Triggered when a pointer has moved within the annotated region without @@ -69,7 +58,7 @@ class MouseTrackerAnnotation { /// * A pointer has moved onto, or moved within an annotation without buttons /// pressed. /// - /// This callback is not triggered when + /// This callback is not triggered when: /// /// * An annotation that is containing the pointer has moved, and still /// contains the pointer. @@ -78,59 +67,30 @@ class MouseTrackerAnnotation { /// Triggered when a mouse pointer, with or without buttons pressed, has /// exited the annotated region when the annotated region still exists. /// - /// This callback is triggered when the pointer has stopped to be contained - /// by the region, except when it's caused by the removal of the render object - /// that owns the annotation. More specifically, the callback is triggered by - /// the following cases: - /// - /// * An annotated region that used to contain a pointer has moved away. - /// * A pointer that used to be within an annotated region has been removed. - /// * A pointer that used to be within an annotated region has moved away. - /// - /// And is __not__ triggered by the following case, - /// - /// * An annotated region that used to contain a pointer has disappeared. - /// - /// The last case is the only case when [onExit] does not match an earlier + /// This callback is triggered when the pointer has stopped being contained + /// by the region for any reason, which means it always matches an earlier /// [onEnter]. - /// {@template flutter.mouseTracker.onExit} - /// This design is because the last case is very likely to be - /// handled improperly and cause exceptions (such as calling `setState` of the - /// disposed widget). There are a few ways to mitigate this limit: - /// - /// * If the state of hovering is contained within a widget that - /// unconditionally attaches the annotation (as long as a mouse is - /// connected), then this will not be a concern, since when the annotation - /// is disposed the state is no longer used. - /// * If you're accessible to the condition that controls whether the - /// annotation is attached, then you can call the callback when that - /// condition goes from true to false. - /// * In the cases where the solutions above won't work, you can always - /// override [State.dispose] or [RenderObject.detach]. - /// {@endtemplate} - /// - /// Technically, whether [onExit] will be called is controlled by - /// [MouseTracker.attachAnnotation] and [MouseTracker.detachAnnotation]. /// /// See also: /// - /// * [MouseRegion.onExit], which uses this callback. /// * [onEnter], which is triggered when a mouse pointer enters the region. + /// * [RenderMouseRegion.onExit], which uses this callback. + /// * [MouseRegion.onExit], which uses this callback, but is not triggered in + /// certain cases and does not always match its earier [MouseRegion.onEnter]. final PointerExitEventListener onExit; @override - String toString() { - final List callbacks = []; - if (onEnter != null) - callbacks.add('enter'); - if (onHover != null) - callbacks.add('hover'); - if (onExit != null) - callbacks.add('exit'); - final String describeCallbacks = callbacks.isEmpty - ? '' - : callbacks.join(' '); - return '${describeIdentity(this)}(callbacks: $describeCallbacks)'; + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(FlagsSummary( + 'callbacks', + { + 'enter': onEnter, + 'hover': onHover, + 'exit': onExit, + }, + ifEmpty: '', + )); } } @@ -194,11 +154,9 @@ class _MouseState { /// /// ### Details /// -/// The state of [MouseTracker] consists of 3 parts: +/// The state of [MouseTracker] consists of two parts: /// /// * The mouse devices that are connected. -/// * The annotations that are attached, i.e. whose owner render object is -/// painted on the screen. /// * In which annotations each device is contained. /// /// The states remain stable most of the time, and are only changed at the @@ -247,10 +205,6 @@ class MouseTracker extends ChangeNotifier { // mouse events from. final PointerRouter _router; - // The collection of annotations that are currently being tracked. It is - // operated on by [attachAnnotation] and [detachAnnotation]. - final Set _trackedAnnotations = {}; - // Tracks the state of connected mouse devices. // // It is the source of truth for the list of connected mouse devices. @@ -298,7 +252,6 @@ class MouseTracker extends ChangeNotifier { nextAnnotations: mouseState.annotations, previousEvent: previousEvent, unhandledEvent: event, - trackedAnnotations: _trackedAnnotations, ); }, ); @@ -306,12 +259,12 @@ class MouseTracker extends ChangeNotifier { // Find the annotations that is hovered by the device of the `state`. // - // If the device is not connected or there are no annotations attached, empty - // is returned without calling `annotationFinder`. + // If the device is not connected, an empty set is returned without calling + // `annotationFinder`. LinkedHashSet _findAnnotations(_MouseState state) { final Offset globalPosition = state.latestEvent.position; final int device = state.device; - return (_mouseStates.containsKey(device) && _trackedAnnotations.isNotEmpty) + return (_mouseStates.containsKey(device)) ? LinkedHashSet.from(annotationFinder(globalPosition)) : {} as LinkedHashSet; } @@ -332,7 +285,6 @@ class MouseTracker extends ChangeNotifier { nextAnnotations: mouseState.annotations, previousEvent: mouseState.latestEvent, unhandledEvent: null, - trackedAnnotations: _trackedAnnotations, ); } ); @@ -428,16 +380,15 @@ class MouseTracker extends ChangeNotifier { // null, which means the update is triggered by a new event. // The `unhandledEvent` can be null, which means the update is not triggered // by an event. + // However, one of `previousEvent` or `unhandledEvent` must not be null. static void _dispatchDeviceCallbacks({ @required LinkedHashSet lastAnnotations, @required LinkedHashSet nextAnnotations, @required PointerEvent previousEvent, @required PointerEvent unhandledEvent, - @required Set trackedAnnotations, }) { assert(lastAnnotations != null); assert(nextAnnotations != null); - assert(trackedAnnotations != null); final PointerEvent latestEvent = unhandledEvent ?? previousEvent; assert(latestEvent != null); // Order is important for mouse event callbacks. The `findAnnotations` @@ -446,45 +397,41 @@ class MouseTracker extends ChangeNotifier { // The algorithm here is explained in // https://github.com/flutter/flutter/issues/41420 - // Send exit events in visual order. - final Iterable exitingAnnotations = - lastAnnotations.difference(nextAnnotations); + // Send exit events to annotations that are in last but not in next, in + // visual order. + final Iterable exitingAnnotations = lastAnnotations.where( + (MouseTrackerAnnotation value) => !nextAnnotations.contains(value), + ); for (final MouseTrackerAnnotation annotation in exitingAnnotations) { - final bool attached = trackedAnnotations.contains(annotation); - // Exit is not sent if annotation is no longer attached, because this - // trigger may cause exceptions and has safer alternatives. See - // [MouseRegion.onExit] for details. - if (annotation.onExit != null && attached) { + if (annotation.onExit != null) { annotation.onExit(PointerExitEvent.fromMouseEvent(latestEvent)); } } - // Send enter events in reverse visual order. + // Send enter events to annotations that are not in last but in next, in + // reverse visual order. final Iterable enteringAnnotations = nextAnnotations.difference(lastAnnotations).toList().reversed; for (final MouseTrackerAnnotation annotation in enteringAnnotations) { - assert(trackedAnnotations.contains(annotation)); if (annotation.onEnter != null) { annotation.onEnter(PointerEnterEvent.fromMouseEvent(latestEvent)); } } - // Send hover events in reverse visual order. - // For now the order between the hover events is designed this way for no - // solid reasons but to keep it aligned with enter events for simplicity. + // Send hover events to annotations that are in next, in reverse visual + // order. The reverse visual order is chosen only because of the simplicity + // by keeping the hover events aligned with enter events. if (unhandledEvent is PointerHoverEvent) { - final Iterable hoveringAnnotations = - nextAnnotations.toList().reversed; final Offset lastHoverPosition = previousEvent is PointerHoverEvent ? previousEvent.position : null; + final bool pointerHasMoved = lastHoverPosition == null || lastHoverPosition != unhandledEvent.position; + // If the hover event follows a non-hover event, or has moved since the + // last hover, then trigger the hover callback on all annotations. + // Otherwise, trigger the hover callback only on annotations that it + // newly enters. + final Iterable hoveringAnnotations = pointerHasMoved ? nextAnnotations.toList().reversed : enteringAnnotations; for (final MouseTrackerAnnotation annotation in hoveringAnnotations) { - // Deduplicate: Trigger hover if it's a newly hovered annotation - // or the position has changed - assert(trackedAnnotations.contains(annotation)); - if (!lastAnnotations.contains(annotation) - || lastHoverPosition != unhandledEvent.position) { - if (annotation.onHover != null) { - annotation.onHover(unhandledEvent); - } + if (annotation.onHover != null) { + annotation.onHover(unhandledEvent); } } } @@ -519,63 +466,4 @@ class MouseTracker extends ChangeNotifier { /// Whether or not a mouse is connected and has produced events. bool get mouseIsConnected => _mouseStates.isNotEmpty; - - /// 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) { - return _trackedAnnotations.contains(annotation); - } - - /// Notify [MouseTracker] that a new [MouseTrackerAnnotation] has started to - /// take effect. - /// - /// This method is typically called by the [RenderObject] that owns an - /// annotation, as soon as the render object is added to the render tree. - /// - /// {@template flutter.mouseTracker.attachAnnotation} - /// Render objects that call this method might want to schedule a frame as - /// well, typically by calling [RenderObject.markNeedsPaint], because this - /// method does not cause any immediate effect, since the state it changes is - /// used during a post-frame callback or when handling certain pointer events. - /// - /// ### About annotation attachment - /// - /// It is the responsibility of the render object that owns the annotation to - /// maintain the attachment of the annotation. Whether an annotation is - /// attached should be kept in sync with whether its owner object is mounted, - /// which is used in the following ways: - /// - /// * If a pointer enters an annotation, it is asserted that the annotation - /// is attached. - /// * If a pointer stops being contained by an annotation, - /// the exit event is triggered only if the annotation is still attached. - /// This is to prevent exceptions caused calling setState of a disposed - /// widget. See [MouseTrackerAnnotation.onExit] for more details. - /// * The [MouseTracker] also uses the attachment to track the number of - /// attached annotations, and will skip mouse position checks if there is no - /// annotations attached. - /// {@endtemplate} - /// * Attaching an annotation that has been attached will assert. - void attachAnnotation(MouseTrackerAnnotation annotation) { - assert(!_duringDeviceUpdate); - assert(!_trackedAnnotations.contains(annotation)); - _trackedAnnotations.add(annotation); - } - - /// Notify [MouseTracker] that a mouse tracker annotation that was previously - /// attached has stopped taking effect. - /// - /// This method is typically called by the [RenderObject] that owns an - /// annotation, as soon as the render object is removed from the render tree. - /// {@macro flutter.mouseTracker.attachAnnotation} - /// * Detaching an annotation that has not been attached will assert. - void detachAnnotation(MouseTrackerAnnotation annotation) { - assert(!_duringDeviceUpdate); - assert(_trackedAnnotations.contains(annotation)); - _trackedAnnotations.remove(annotation); - } } diff --git a/packages/flutter/lib/src/rendering/platform_view.dart b/packages/flutter/lib/src/rendering/platform_view.dart index 7d2825aedc..dc8c32fd61 100644 --- a/packages/flutter/lib/src/rendering/platform_view.dart +++ b/packages/flutter/lib/src/rendering/platform_view.dart @@ -10,7 +10,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; -import 'binding.dart'; import 'box.dart'; import 'layer.dart'; import 'object.dart'; @@ -838,13 +837,11 @@ mixin _PlatformViewGestureMixin on RenderBox { if (_handlePointerEvent != null) _handlePointerEvent(event); }); - RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); } @override void detach() { _gestureRecognizer.reset(); - RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); _hoverAnnotation = null; super.detach(); } diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index afa7b563aa..0bb97268b8 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -2659,6 +2659,9 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { /// [RenderMouseRegion]. class RenderMouseRegion extends RenderProxyBox { /// Creates a render object that forwards pointer events to callbacks. + /// + /// All parameters are optional. By default this method creates an opaque + /// mouse region with no callbacks. RenderMouseRegion({ PointerEnterEventListener onEnter, PointerHoverEventListener onHover, @@ -2698,17 +2701,23 @@ class RenderMouseRegion extends RenderProxyBox { set opaque(bool value) { if (_opaque != value) { _opaque = value; - _updateAnnotations(); + _markPropertyUpdated(mustRepaint: true); } } - /// Called when a mouse pointer enters the region (with or without buttons - /// pressed). + /// Called when a mouse pointer starts being contained by the region (with or + /// without buttons pressed) for any reason. + /// + /// This callback is always matched by a later [onExit]. + /// + /// See also: + /// + /// * [MouseRegion.onEnter], which uses this callback. PointerEnterEventListener get onEnter => _onEnter; set onEnter(PointerEnterEventListener value) { if (_onEnter != value) { _onEnter = value; - _updateAnnotations(); + _markPropertyUpdated(mustRepaint: false); } } PointerEnterEventListener _onEnter; @@ -2723,7 +2732,7 @@ class RenderMouseRegion extends RenderProxyBox { set onHover(PointerHoverEventListener value) { if (_onHover != value) { _onHover = value; - _updateAnnotations(); + _markPropertyUpdated(mustRepaint: false); } } PointerHoverEventListener _onHover; @@ -2732,13 +2741,20 @@ class RenderMouseRegion extends RenderProxyBox { _onHover(event); } - /// Called when a pointer leaves the region (with or without buttons pressed) - /// and the annotation is still attached. + /// Called when a pointer is no longer contained by the region (with or + /// without buttons pressed) for any reason. + /// + /// This callback is always matched by an earlier [onEnter]. + /// + /// See also: + /// + /// * [MouseRegion.onExit], which uses this callback, but is not triggered in + /// certain cases and does not always match its earier [MouseRegion.onEnter]. PointerExitEventListener get onExit => _onExit; set onExit(PointerExitEventListener value) { if (_onExit != value) { _onExit = value; - _updateAnnotations(); + _markPropertyUpdated(mustRepaint: false); } } PointerExitEventListener _onExit; @@ -2757,64 +2773,52 @@ class RenderMouseRegion extends RenderProxyBox { @visibleForTesting MouseTrackerAnnotation get hoverAnnotation => _hoverAnnotation; - void _updateAnnotations() { - final bool annotationWasActive = _annotationIsActive; - final bool annotationWillBeActive = ( + // Call this method when a property has changed and might affect the + // `_annotationIsActive` bit. + // + // If `mustRepaint` is false, this method does NOT call `markNeedsPaint` + // unless the `_annotationIsActive` bit is changed. If there is a property + // that needs updating while `_annotationIsActive` stays true, make + // `mustRepaint` true. + // + // This method must not be called during `paint`. + void _markPropertyUpdated({@required bool mustRepaint}) { + assert(owner == null || !owner.debugDoingPaint); + final bool newAnnotationIsActive = ( _onEnter != null || _onHover != null || _onExit != null || opaque - ) && - RendererBinding.instance.mouseTracker.mouseIsConnected; - if (annotationWasActive != annotationWillBeActive) { + ) && RendererBinding.instance.mouseTracker.mouseIsConnected; + _setAnnotationIsActive(newAnnotationIsActive); + if (mustRepaint) + markNeedsPaint(); + } + + void _setAnnotationIsActive(bool value) { + final bool annotationWasActive = _annotationIsActive; + _annotationIsActive = value; + if (annotationWasActive != value) { markNeedsPaint(); markNeedsCompositingBitsUpdate(); - if (annotationWillBeActive) { - RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); - } else { - RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); - } - _annotationIsActive = annotationWillBeActive; } } + void _handleUpdatedMouseIsConnected() { + _markPropertyUpdated(mustRepaint: false); + } + @override void attach(PipelineOwner owner) { super.attach(owner); // Add a listener to listen for changes in mouseIsConnected. - RendererBinding.instance.mouseTracker.addListener(_updateAnnotations); - _updateAnnotations(); - } - - /// Attaches the annotation for this render object, if any. - /// - /// This is called by the [MouseRegion]'s [Element] to tell this - /// [RenderMouseRegion] that it has transitioned from "inactive" - /// state to "active". We call it here so that - /// [MouseTrackerAnnotation.onEnter] isn't called during the build step for - /// the widget that provided the callback, and [State.setState] can safely be - /// called within that callback. - void postActivate() { - if (_annotationIsActive) - RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); - } - - /// Detaches the annotation for this render object, if any. - /// - /// This is called by the [MouseRegion]'s [Element] to tell this - /// [RenderMouseRegion] that it will shortly be transitioned from "active" - /// state to "inactive". We call it here so that - /// [MouseTrackerAnnotation.onExit] isn't called during the build step for the - /// widget that provided the callback, and [State.setState] can safely be - /// called within that callback. - void preDeactivate() { - if (_annotationIsActive) - RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); + RendererBinding.instance.mouseTracker.addListener(_handleUpdatedMouseIsConnected); + _markPropertyUpdated(mustRepaint: false); } @override void detach() { - RendererBinding.instance.mouseTracker.removeListener(_updateAnnotations); + RendererBinding.instance.mouseTracker.removeListener(_handleUpdatedMouseIsConnected); super.detach(); } diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 4ea531b311..8c14b69933 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -5854,7 +5854,7 @@ class _PointerListener extends SingleChildRenderObjectWidget { /// /// * [Listener], a similar widget that tracks pointer events when the pointer /// have buttons pressed. -class MouseRegion extends SingleChildRenderObjectWidget { +class MouseRegion extends StatefulWidget { /// Creates a widget that forwards mouse events to callbacks. const MouseRegion({ Key key, @@ -5862,16 +5862,15 @@ class MouseRegion extends SingleChildRenderObjectWidget { this.onExit, this.onHover, this.opaque = true, - Widget child, + this.child, }) : assert(opaque != null), - super(key: key, child: child); + super(key: key); - /// Called when a mouse pointer, with or without buttons pressed, has - /// entered this widget. + /// Called when a mouse pointer has entered this widget. /// - /// This callback is triggered when the pointer has started to be contained - /// by the region of this widget. More specifically, the callback is triggered - /// by the following cases: + /// This callback is triggered when the pointer, with or without buttons + /// pressed, has started to be contained by the region of this widget. More + /// specifically, the callback is triggered by the following cases: /// /// * This widget has appeared under a pointer. /// * This widget has moved to under a pointer. @@ -5880,8 +5879,13 @@ class MouseRegion extends SingleChildRenderObjectWidget { /// /// This callback is not always matched by an [onExit]. If the [MouseRegion] /// is unmounted while being hovered by a pointer, the [onExit] of the widget - /// callback will never called, despite the earlier call of [onEnter]. For - /// more details, see [onExit]. + /// callback will never called. For more details, see [onExit]. + /// + /// {@template flutter.mouseRegion.triggerTime} + /// The time that this callback is triggered is always between frames: either + /// during the post-frame callbacks, or during the callback of a pointer + /// event. + /// {@endtemplate} /// /// See also: /// @@ -5890,47 +5894,182 @@ class MouseRegion extends SingleChildRenderObjectWidget { /// internally implemented. final PointerEnterEventListener onEnter; - /// Called when a mouse pointer changes position without buttons pressed, and - /// the new position is within the region defined by this widget. + /// Called when a mouse pointer moves within this widget without buttons + /// pressed. /// - /// This callback is triggered when: + /// This callback is not triggered when the [MouseRegion] has moved + /// while being hovered by the mouse pointer. /// - /// * An annotation that did not contain the pointer has moved to under a - /// pointer that has no buttons pressed. - /// * A pointer has moved onto, or moved within an annotation without buttons - /// pressed. - /// - /// This callback is not triggered when - /// - /// * An annotation that is containing the pointer has moved, and still - /// contains the pointer. + /// {@macro flutter.mouseRegion.triggerTime} final PointerHoverEventListener onHover; - /// Called when a mouse pointer, with or without buttons pressed, has exited - /// this widget when the widget is still mounted. + /// Called when a mouse pointer has exited this widget when the widget is + /// still mounted. /// - /// This callback is triggered when the pointer has stopped to be contained - /// by the region of this widget, except when it's caused by the removal of - /// this widget. More specifically, the callback is triggered by - /// the following cases: + /// This callback is triggered when the pointer, with or without buttons + /// pressed, has stopped being contained by the region of this widget, except + /// when the exit is caused by the disappearance of this widget. More + /// specifically, this callback is triggered by the following cases: /// - /// * This widget, which used to contain a pointer, has moved away. - /// * A pointer that used to be within this widget has been removed. - /// * A pointer that used to be within this widget has moved away. + /// * A pointer that is hovering this widget has moved away. + /// * A pointer that is hovering this widget has been removed. + /// * This widget, which is being hovered by a pointer, has moved away. /// - /// And is __not__ triggered by the following case, + /// And is __not__ triggered by the following case: /// - /// * This widget, which used to contain a pointer, has disappeared. + /// * This widget, which is being hovered by a pointer, has disappeared. /// - /// The last case is the only case when [onExit] does not match an earlier - /// [onEnter]. - /// {@macro flutter.mouseTracker.onExit} + /// This means that a [MouseRegion.onExit] might not be matched by a + /// [MouseRegion.onEnter]. + /// + /// This restriction aims to prevent a common misuse: if [setState] is called + /// during [MouseRegion.onExit] without checking whether the widget is still + /// mounted, an exception will occur. This is because the callback is + /// triggered during the post-frame phase, at which point the widget has been + /// unmounted. Since [setState] is exclusive to widgets, the restriction is + /// specific to [MouseRegion], and does not apply to its lower-level + /// counterparts, [RenderMouseRegion] and [MouseTrackerAnnotation]. + /// + /// There are a few ways to mitigate this restriction: + /// + /// * If the hover state is completely contained within a widget that + /// unconditionally creates this [MouseRegion], then this will not be a + /// concern, since after the [MouseRegion] is unmounted the state is no + /// longer used. + /// * Otherwise, the outer widget very likely has access to the variable that + /// controls whether this [MouseRegion] is present. If so, call [onExit] at + /// the event that turns the condition from true to false. + /// * In cases where the solutions above won't work, you can always + /// override [State.dispose] and call [onExit], or create your own widget + /// using [RenderMouseRegion]. + /// + /// {@tool sample --template=stateful_widget_scaffold_center} + /// The following example shows a blue rectangular that turns yellow when + /// hovered. Since the hover state is completely contained within a widget + /// that unconditionally creates the `MouseRegion`, you can ignore the + /// aforementioned restriction. + /// + /// ```dart + /// bool hovered = false; + /// + /// @override + /// Widget build(BuildContext context) { + /// return Container( + /// height: 100, + /// width: 100, + /// decoration: BoxDecoration(color: hovered ? Colors.yellow : Colors.blue), + /// child: MouseRegion( + /// onEnter: (_) { + /// setState(() { hovered = true; }); + /// }, + /// onExit: (_) { + /// setState(() { hovered = false; }); + /// }, + /// ), + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// {@tool sample --template=stateful_widget_scaffold_center} + /// The following example shows a widget that hides its content one second + /// after behing hovered, and also exposes the enter and exit callbacks. + /// Because the widget conditionally creates the `MouseRegion`, and leaks the + /// hover state, it needs to take the restriction into consideration. In this + /// case, since it has access to the event that triggers the disappearance of + /// the `MouseRegion`, it simply trigger the exit callback during that event + /// as well. + /// + /// ```dart preamble + /// // A region that hides its content one second after being hovered. + /// class MyTimedButton extends StatefulWidget { + /// MyTimedButton({ Key key, this.onEnterButton, this.onExitButton }) + /// : super(key: key); + /// + /// final VoidCallback onEnterButton; + /// final VoidCallback onExitButton; + /// + /// @override + /// _MyTimedButton createState() => _MyTimedButton(); + /// } + /// + /// class _MyTimedButton extends State { + /// bool regionIsHidden = false; + /// bool hovered = false; + /// + /// void startCountdown() async { + /// await Future.delayed(const Duration(seconds: 1)); + /// hideButton(); + /// } + /// + /// void hideButton() { + /// setState(() { regionIsHidden = true; }); + /// // This statement is necessary. + /// if (hovered) + /// widget.onExitButton(); + /// } + /// + /// @override + /// Widget build(BuildContext context) { + /// return Container( + /// width: 100, + /// height: 100, + /// child: MouseRegion( + /// child: regionIsHidden ? null : MouseRegion( + /// onEnter: (_) { + /// widget.onEnterButton(); + /// setState(() { hovered = true; }); + /// startCountdown(); + /// }, + /// onExit: (_) { + /// setState(() { hovered = false; }); + /// widget.onExitButton(); + /// }, + /// child: Container(color: Colors.red), + /// ), + /// ), + /// ); + /// } + /// } + /// ``` + /// + /// ```dart + /// Key key = UniqueKey(); + /// bool hovering = false; + /// + /// @override + /// Widget build(BuildContext context) { + /// return Column( + /// children: [ + /// RaisedButton( + /// onPressed: () { + /// setState(() { key = UniqueKey(); }); + /// }, + /// child: Text('Refresh'), + /// ), + /// hovering ? Text('Hovering') : Text('Not hovering'), + /// MyTimedButton( + /// key: key, + /// onEnterButton: () { + /// setState(() { hovering = true; }); + /// }, + /// onExitButton: () { + /// setState(() { hovering = false; }); + /// }, + /// ), + /// ], + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// {@macro flutter.mouseRegion.triggerTime} /// /// See also: /// /// * [onEnter], which is triggered when a mouse pointer enters the region. - /// * [MouseTrackerAnnotation.onExit], which is how this callback is - /// internally implemented. + /// * [RenderMouseRegion] and [MouseTrackerAnnotation.onExit], which are how + /// this callback is internally implemented, but without the restriction. final PointerExitEventListener onExit; /// Whether this widget should prevent other [MouseRegion]s visually behind it @@ -5949,27 +6088,13 @@ class MouseRegion extends SingleChildRenderObjectWidget { /// This defaults to true. final bool opaque; - @override - _MouseRegionElement createElement() => _MouseRegionElement(this); + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.child} + final Widget child; @override - RenderMouseRegion createRenderObject(BuildContext context) { - return RenderMouseRegion( - onEnter: onEnter, - onHover: onHover, - onExit: onExit, - opaque: opaque, - ); - } - - @override - void updateRenderObject(BuildContext context, RenderMouseRegion renderObject) { - renderObject - ..onEnter = onEnter - ..onHover = onHover - ..onExit = onExit - ..opaque = opaque; - } + _MouseRegionState createState() => _MouseRegionState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { @@ -5986,21 +6111,46 @@ class MouseRegion extends SingleChildRenderObjectWidget { } } -class _MouseRegionElement extends SingleChildRenderObjectElement { - _MouseRegionElement(SingleChildRenderObjectWidget widget) : super(widget); +class _MouseRegionState extends State { + void handleExit(PointerExitEvent event) { + if (widget.onExit != null && mounted) + widget.onExit(event); + } - @override - void activate() { - super.activate(); - final RenderMouseRegion renderMouseRegion = renderObject as RenderMouseRegion; - renderMouseRegion.postActivate(); + PointerExitEventListener getHandleExit() { + return widget.onExit == null ? null : handleExit; } @override - void deactivate() { - final RenderMouseRegion renderMouseRegion = renderObject as RenderMouseRegion; - renderMouseRegion.preDeactivate(); - super.deactivate(); + Widget build(BuildContext context) { + return _RawMouseRegion(this); + } +} + +class _RawMouseRegion extends SingleChildRenderObjectWidget { + _RawMouseRegion(this.owner) : super(child: owner.widget.child); + + final _MouseRegionState owner; + + @override + RenderMouseRegion createRenderObject(BuildContext context) { + final MouseRegion widget = owner.widget; + return RenderMouseRegion( + onEnter: widget.onEnter, + onHover: widget.onHover, + onExit: owner.getHandleExit(), + opaque: widget.opaque, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderMouseRegion renderObject) { + final MouseRegion widget = owner.widget; + renderObject + ..onEnter = widget.onEnter + ..onHover = widget.onHover + ..onExit = owner.getHandleExit() + ..opaque = widget.opaque; } } diff --git a/packages/flutter/test/gestures/mouse_tracking_test.dart b/packages/flutter/test/gestures/mouse_tracking_test.dart index b823d3b528..95753d1d8d 100644 --- a/packages/flutter/test/gestures/mouse_tracking_test.dart +++ b/packages/flutter/test/gestures/mouse_tracking_test.dart @@ -85,7 +85,6 @@ void main() { yield annotation; }, ); - _mouseTracker.attachAnnotation(annotation); return annotation; } @@ -102,7 +101,7 @@ void main() { ); expect( annotation1.toString(), - equals('MouseTrackerAnnotation#${shortHash(annotation1)}(callbacks: enter hover exit)'), + equals('MouseTrackerAnnotation#${shortHash(annotation1)}(callbacks: [enter, hover, exit])'), ); const MouseTrackerAnnotation annotation2 = MouseTrackerAnnotation(); @@ -249,73 +248,6 @@ void main() { events.clear(); }); - test('should not flip out when attaching and detaching during callbacks', () { - // It is a common pattern that a callback that listens to the changes of - // [MouseTracker.mouseIsConnected] triggers annotation attaching and - // detaching. This test ensures that no exceptions are thrown for this - // pattern. - bool isInHitRegion = false; - final List events = []; - final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( - onEnter: (PointerEnterEvent event) => events.add(event), - onHover: (PointerHoverEvent event) => events.add(event), - onExit: (PointerExitEvent event) => events.add(event), - ); - _setUpMouseAnnotationFinder((Offset position) sync* { - if (isInHitRegion) { - yield annotation; - } - }); - - void mockMarkNeedsPaint() { - _binding.scheduleMouseTrackerPostFrameCheck(); - } - - final VoidCallback firstListener = () { - if (!_mouseTracker.mouseIsConnected) { - _mouseTracker.detachAnnotation(annotation); - isInHitRegion = false; - } else { - _mouseTracker.attachAnnotation(annotation); - isInHitRegion = true; - } - mockMarkNeedsPaint(); - }; - _mouseTracker.addListener(firstListener); - - // The pointer is added onto the annotation, triggering attaching callback. - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.add, const Offset(1.0, 0.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_mouseTracker.mouseIsConnected, isTrue); - - _binding.flushPostFrameCallbacks(Duration.zero); - expect(events, _equalToEventsOnCriticalFields([ - const PointerEnterEvent(position: Offset(1.0, 0.0)), - ])); - expect(_mouseTracker.mouseIsConnected, isTrue); - events.clear(); - - // The pointer is removed while on the annotation, triggering dettaching callback. - _mouseTracker.removeListener(firstListener); - _mouseTracker.addListener(() { - if (!_mouseTracker.mouseIsConnected) { - _mouseTracker.detachAnnotation(annotation); - isInHitRegion = false; - } - }); - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.remove, const Offset(1.0, 0.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - const PointerExitEvent(position: Offset(1.0, 0.0)), - ])); - expect(_mouseTracker.mouseIsConnected, isFalse); - events.clear(); - }); - test('should not handle non-hover events', () { final List events = []; _setUpWithOneAnnotation(logEvents: events); @@ -346,7 +278,7 @@ void main() { events.clear(); }); - test('should correctly handle when the annotation is attached or detached on the pointer', () { + test('should correctly handle when the annotation appears or disappears on the pointer', () { bool isInHitRegion; final List events = []; final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( @@ -371,13 +303,8 @@ void main() { expect(_mouseTracker.mouseIsConnected, isTrue); events.clear(); - // Attaching an annotation should trigger Enter event. + // Adding an annotation should trigger Enter event. isInHitRegion = true; - _mouseTracker.attachAnnotation(annotation); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_binding.postFrameCallbacks, hasLength(0)); - _binding.scheduleMouseTrackerPostFrameCheck(); expect(_binding.postFrameCallbacks, hasLength(1)); @@ -387,18 +314,14 @@ void main() { ])); events.clear(); - // Detaching an annotation should not trigger events. + // Removing an annotation should trigger events. isInHitRegion = false; - _mouseTracker.detachAnnotation(annotation); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_binding.postFrameCallbacks, hasLength(0)); - _binding.scheduleMouseTrackerPostFrameCheck(); expect(_binding.postFrameCallbacks, hasLength(1)); _binding.flushPostFrameCallbacks(Duration.zero); expect(events, _equalToEventsOnCriticalFields([ + const PointerExitEvent(position: Offset(0.0, 100.0)), ])); expect(_binding.postFrameCallbacks, hasLength(0)); }); @@ -417,8 +340,6 @@ void main() { } }); - // Start with an annotation attached. - _mouseTracker.attachAnnotation(annotation); isInHitRegion = false; // Connect a mouse. @@ -468,8 +389,6 @@ void main() { } }); - // Start with an annotation attached. - _mouseTracker.attachAnnotation(annotation); isInHitRegion = false; // Connect a mouse in the region. Should trigger Enter. @@ -508,8 +427,6 @@ void main() { } }); - // Start with annotation and mouse attached. - _mouseTracker.attachAnnotation(annotation); isInHitRegion = false; ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ _pointerData(PointerChange.add, const Offset(200.0, 100.0)), @@ -541,75 +458,17 @@ void main() { ])); }); - test('should correctly handle when annotation is attached or detached while not containing the pointer', () { - final List events = []; - final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( - onEnter: (PointerEnterEvent event) => events.add(event), - onHover: (PointerHoverEvent event) => events.add(event), - onExit: (PointerExitEvent event) => events.add(event), - ); - _setUpMouseAnnotationFinder((Offset position) sync* { - // This annotation is never in the region. - }); - - // Connect a mouse when there is no annotation. - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.add, const Offset(0.0, 100.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_mouseTracker.mouseIsConnected, isTrue); - events.clear(); - - // Attaching an annotation should not trigger events. - _mouseTracker.attachAnnotation(annotation); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_binding.postFrameCallbacks, hasLength(0)); - - _binding.scheduleMouseTrackerPostFrameCheck(); - expect(_binding.postFrameCallbacks, hasLength(1)); - - _binding.flushPostFrameCallbacks(Duration.zero); - expect(events, _equalToEventsOnCriticalFields([ - ])); - events.clear(); - - // Detaching an annotation should not trigger events. - _mouseTracker.detachAnnotation(annotation); - expect(events, _equalToEventsOnCriticalFields([ - ])); - expect(_binding.postFrameCallbacks, hasLength(0)); - - _binding.scheduleMouseTrackerPostFrameCheck(); - expect(_binding.postFrameCallbacks, hasLength(1)); - - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.remove, const Offset(0.0, 100.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - ])); - }); - test('should not schedule postframe callbacks when no mouse is connected', () { - const MouseTrackerAnnotation annotation = MouseTrackerAnnotation(); _setUpMouseAnnotationFinder((Offset position) sync* { }); - // This device only supports touching + // Connect a touch device, which should not be recognized by MouseTracker ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ _pointerData(PointerChange.add, const Offset(0.0, 100.0), kind: PointerDeviceKind.touch), ])); expect(_mouseTracker.mouseIsConnected, isFalse); - // Attaching an annotation just in case - _mouseTracker.attachAnnotation(annotation); expect(_binding.postFrameCallbacks, hasLength(0)); - - _binding.scheduleMouseTrackerPostFrameCheck(); - expect(_binding.postFrameCallbacks, hasLength(0)); - - _mouseTracker.detachAnnotation(annotation); }); test('should not flip out if not all mouse events are listened to', () { @@ -628,60 +487,15 @@ void main() { yield annotation2; }); - final ui.PointerDataPacket packet = ui.PointerDataPacket(data: [ - _pointerData(PointerChange.add, const Offset(0.0, 101.0)), - _pointerData(PointerChange.hover, const Offset(1.0, 101.0)), - ]); - isInHitRegionOne = false; isInHitRegionTwo = true; - _mouseTracker.attachAnnotation(annotation2); - ui.window.onPointerDataPacket(packet); - _mouseTracker.detachAnnotation(annotation2); - isInHitRegionTwo = false; - - // Passes if no errors are thrown. - }); - - test('should not call annotationFinder when no annotations are attached', () { - final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( - onEnter: (PointerEnterEvent event) {}, - ); - int finderCalled = 0; - _setUpMouseAnnotationFinder((Offset position) sync* { - finderCalled++; - // This annotation is never in the region. - }); - - // When no annotations are attached, hovering should not call finder. ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ _pointerData(PointerChange.add, const Offset(0.0, 101.0)), + _pointerData(PointerChange.hover, const Offset(1.0, 101.0)), ])); - expect(finderCalled, 0); - // Attaching should not call finder. - _mouseTracker.attachAnnotation(annotation); - _binding.flushPostFrameCallbacks(Duration.zero); - expect(finderCalled, 0); - - // When annotations are attached, hovering should call finder. - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.hover, const Offset(0.0, 201.0)), - ])); - expect(finderCalled, 1); - finderCalled = 0; - - // Detaching an annotation should not call finder. - _mouseTracker.detachAnnotation(annotation); - _binding.flushPostFrameCallbacks(Duration.zero); - expect(finderCalled, 0); - - // When all annotations are detached, hovering should not call finder. - ui.window.onPointerDataPacket(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.hover, const Offset(0.0, 201.0)), - ])); - expect(finderCalled, 0); + // Passes if no errors are thrown. }); test('should trigger callbacks between parents and children in correct order', () { @@ -713,8 +527,6 @@ void main() { yield annotationA; } }); - _mouseTracker.attachAnnotation(annotationA); - _mouseTracker.attachAnnotation(annotationB); // Starts out of A. isInB = false; @@ -768,8 +580,6 @@ void main() { yield annotationB; } }); - _mouseTracker.attachAnnotation(annotationA); - _mouseTracker.attachAnnotation(annotationB); // Starts within A. isInA = true; @@ -868,13 +678,13 @@ class _EventCriticalFieldsMatcher extends Matcher { return mismatchDescription .add('is ') .addDescriptionOf(item.runtimeType) - .add(' and doesn\'t match ') + .add(" and doesn't match ") .addDescriptionOf(_expected.runtimeType); } return mismatchDescription .add('has ') .addDescriptionOf(matchState['actual']) - .add(' at field `${matchState['field']}`, which doesn\'t match the expected ') + .add(" at field `${matchState['field']}`, which doesn't match the expected ") .addDescriptionOf(matchState['expected']); } } @@ -940,7 +750,7 @@ class _EventListCriticalFieldsMatcher extends Matcher { mismatchDescription .add('has\n ') .addDescriptionOf(matchState['actual']) - .add('\nat index ${matchState['index']}, which doesn\'t match\n ') + .add("\nat index ${matchState['index']}, which doesn't match\n ") .addDescriptionOf(matchState['expected']) .add('\nsince it '); final Description subDescription = StringDescription(); diff --git a/packages/flutter/test/rendering/proxy_box_test.dart b/packages/flutter/test/rendering/proxy_box_test.dart index 94a078ca01..5ddfb52eaa 100644 --- a/packages/flutter/test/rendering/proxy_box_test.dart +++ b/packages/flutter/test/rendering/proxy_box_test.dart @@ -467,6 +467,20 @@ void main() { // transform -> clip _testFittedBoxWithClipRectLayer(); }); + + test('RenderMouseRegion can change properties when detached', () { + renderer.initMouseTracker(MouseTracker( + renderer.pointerRouter, + (_) => [], + )); + final RenderMouseRegion object = RenderMouseRegion(); + object + ..opaque = false + ..onEnter = (_) {} + ..onExit = (_) {} + ..onHover = (_) {}; + // Passes if no error is thrown + }); } class _TestRectClipper extends CustomClipper { diff --git a/packages/flutter/test/widgets/listener_deprecated_test.dart b/packages/flutter/test/widgets/listener_deprecated_test.dart index 9a2444d3bf..1f190f6c7e 100644 --- a/packages/flutter/test/widgets/listener_deprecated_test.dart +++ b/packages/flutter/test/widgets/listener_deprecated_test.dart @@ -162,7 +162,6 @@ void main() { ), ), ); - final RenderMouseRegion renderListener = tester.renderObject(find.byType(MouseRegion)); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: const Offset(400.0, 300.0)); addTearDown(gesture.removePointer); @@ -178,7 +177,6 @@ void main() { ), )); expect(exit, isNull); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse); }); testWidgets('Hover works with nested listeners', (WidgetTester tester) async { final UniqueKey key1 = UniqueKey(); @@ -229,9 +227,6 @@ void main() { ], ), ); - final List listeners = tester.renderObjectList(find.byType(MouseRegion)).toList(); - final RenderMouseRegion renderListener1 = listeners[0] as RenderMouseRegion; - final RenderMouseRegion renderListener2 = listeners[1] as RenderMouseRegion; Offset center = tester.getCenter(find.byKey(key2)); await gesture.moveTo(center); await tester.pump(); @@ -243,8 +238,6 @@ void main() { expect(enter1, isNotEmpty); expect(enter1.last.position, equals(center)); expect(exit1, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); // Now make sure that exiting the child only triggers the child exit, not @@ -259,8 +252,6 @@ void main() { expect(move1.last.position, equals(center)); expect(enter1, isEmpty); expect(exit1, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); }); testWidgets('Hover transfers between two listeners', (WidgetTester tester) async { @@ -314,9 +305,6 @@ void main() { ], ), ); - final List listeners = tester.renderObjectList(find.byType(MouseRegion)).toList(); - final RenderMouseRegion renderListener1 = listeners[0] as RenderMouseRegion; - final RenderMouseRegion renderListener2 = listeners[1] as RenderMouseRegion; final Offset center1 = tester.getCenter(find.byKey(key1)); final Offset center2 = tester.getCenter(find.byKey(key2)); await gesture.moveTo(center1); @@ -329,8 +317,6 @@ void main() { expect(move2, isEmpty); expect(enter2, isEmpty); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await gesture.moveTo(center2); await tester.pump(); @@ -343,8 +329,6 @@ void main() { expect(enter2, isNotEmpty); expect(enter2.last.position, equals(center2)); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await gesture.moveTo(const Offset(400.0, 450.0)); await tester.pump(); @@ -355,8 +339,6 @@ void main() { expect(enter2, isEmpty); expect(exit2, isNotEmpty); expect(exit2.last.position, equals(const Offset(400.0, 450.0))); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await tester.pumpWidget(Container()); expect(move1, isEmpty); @@ -365,8 +347,6 @@ void main() { expect(move2, isEmpty); expect(enter2, isEmpty); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isFalse); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isFalse); }); testWidgets('needsCompositing set when parent class needsCompositing is set', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/mouse_region_test.dart b/packages/flutter/test/widgets/mouse_region_test.dart index fae36a9e88..25b3fc921d 100644 --- a/packages/flutter/test/widgets/mouse_region_test.dart +++ b/packages/flutter/test/widgets/mouse_region_test.dart @@ -261,7 +261,6 @@ void main() { onExit: (PointerExitEvent details) => exit = details, ), )); - final RenderMouseRegion renderListener = tester.renderObject(find.byType(MouseRegion)); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.addPointer(location: Offset.zero); addTearDown(gesture.removePointer); @@ -279,7 +278,6 @@ void main() { expect(enter, isNull); expect(move, isNull); expect(exit, isNull); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse); }); testWidgets('triggers pointer enter when widget moves in', (WidgetTester tester) async { @@ -415,8 +413,6 @@ void main() { ], ), ); - final RenderMouseRegion renderListener1 = tester.renderObject(find.byKey(key1)); - final RenderMouseRegion renderListener2 = tester.renderObject(find.byKey(key2)); Offset center = tester.getCenter(find.byKey(key2)); await gesture.moveTo(center); await tester.pump(); @@ -428,8 +424,6 @@ void main() { expect(enter1, isNotEmpty); expect(enter1.last.position, equals(center)); expect(exit1, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); // Now make sure that exiting the child only triggers the child exit, not @@ -444,8 +438,6 @@ void main() { expect(move1.last.position, equals(center)); expect(enter1, isEmpty); expect(exit1, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); }); @@ -500,8 +492,6 @@ void main() { ], ), ); - final RenderMouseRegion renderListener1 = tester.renderObject(find.byKey(key1)); - final RenderMouseRegion renderListener2 = tester.renderObject(find.byKey(key2)); final Offset center1 = tester.getCenter(find.byKey(key1)); final Offset center2 = tester.getCenter(find.byKey(key2)); await gesture.moveTo(center1); @@ -514,8 +504,6 @@ void main() { expect(move2, isEmpty); expect(enter2, isEmpty); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await gesture.moveTo(center2); await tester.pump(); @@ -528,8 +516,6 @@ void main() { expect(enter2, isNotEmpty); expect(enter2.last.position, equals(center2)); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await gesture.moveTo(const Offset(400.0, 450.0)); await tester.pump(); @@ -540,8 +526,6 @@ void main() { expect(enter2, isEmpty); expect(exit2, isNotEmpty); expect(exit2.last.position, equals(const Offset(400.0, 450.0))); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isTrue); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isTrue); clearLists(); await tester.pumpWidget(Container()); expect(move1, isEmpty); @@ -550,8 +534,6 @@ void main() { expect(move2, isEmpty); expect(enter2, isEmpty); expect(exit2, isEmpty); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener1.hoverAnnotation), isFalse); - expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener2.hoverAnnotation), isFalse); }); testWidgets('MouseRegion uses updated callbacks', (WidgetTester tester) async { @@ -713,8 +695,8 @@ void main() { child: const MouseRegion(opaque: false), ), ); - final RenderMouseRegion listener = tester.renderObject(find.byType(MouseRegion)); - expect(listener.needsCompositing, isFalse); + final RenderMouseRegion mouseRegion = tester.renderObject(find.byType(MouseRegion)); + expect(mouseRegion.needsCompositing, isFalse); // No TransformLayer for `Transform.scale` is added because composting is // not required and therefore the transform is executed on the canvas // directly. (One TransformLayer is always present for the root @@ -731,7 +713,7 @@ void main() { ), ), ); - expect(listener.needsCompositing, isTrue); + expect(mouseRegion.needsCompositing, isTrue); // Compositing is required, therefore a dedicated TransformLayer for // `Transform.scale` is added. expect(tester.layers.whereType(), hasLength(2)); @@ -742,7 +724,7 @@ void main() { child: const MouseRegion(opaque: false), ), ); - expect(listener.needsCompositing, isFalse); + expect(mouseRegion.needsCompositing, isFalse); // TransformLayer for `Transform.scale` is removed again as transform is // executed directly on the canvas. expect(tester.layers.whereType(), hasLength(1)); @@ -756,7 +738,7 @@ void main() { ), ), ); - expect(listener.needsCompositing, isTrue); + expect(mouseRegion.needsCompositing, isTrue); // Compositing is required, therefore a dedicated TransformLayer for // `Transform.scale` is added. expect(tester.layers.whereType(), hasLength(2)); @@ -767,20 +749,20 @@ void main() { addTearDown(gesture.removePointer); await gesture.addPointer(location: Offset.zero); - int numEntries = 0; + int numEntrances = 0; int numExits = 0; await tester.pumpWidget( Center( child: HoverFeedback( - onEnter: () => numEntries++, - onExit: () => numExits++, + onEnter: () { numEntrances += 1; }, + onExit: () { numExits += 1; }, )), ); await gesture.moveTo(tester.getCenter(find.byType(Text))); await tester.pumpAndSettle(); - expect(numEntries, equals(1)); + expect(numEntrances, equals(1)); expect(numExits, equals(0)); expect(find.text('HOVERING'), findsOneWidget); @@ -788,18 +770,18 @@ void main() { Container(), ); await tester.pump(); - expect(numEntries, equals(1)); + expect(numEntrances, equals(1)); expect(numExits, equals(0)); await tester.pumpWidget( Center( child: HoverFeedback( - onEnter: () => numEntries++, - onExit: () => numExits++, + onEnter: () { numEntrances += 1; }, + onExit: () { numExits += 1; }, )), ); await tester.pump(); - expect(numEntries, equals(2)); + expect(numEntrances, equals(2)); expect(numExits, equals(0)); }); @@ -809,41 +791,43 @@ void main() { await gesture.addPointer(); addTearDown(gesture.removePointer); - int numEntries = 0; + int numEntrances = 0; int numExits = 0; await tester.pumpWidget( Center( child: HoverFeedback( key: feedbackKey, - onEnter: () => numEntries++, - onExit: () => numExits++, + onEnter: () { numEntrances += 1; }, + onExit: () { numExits += 1; }, )), ); await gesture.moveTo(tester.getCenter(find.byType(Text))); await tester.pumpAndSettle(); - expect(numEntries, equals(1)); + expect(numEntrances, equals(1)); expect(numExits, equals(0)); expect(find.text('HOVERING'), findsOneWidget); await tester.pumpWidget( Center( - child: Container( - child: HoverFeedback( - key: feedbackKey, - onEnter: () => numEntries++, - onExit: () => numExits++, - ))), + child: Container( + child: HoverFeedback( + key: feedbackKey, + onEnter: () { numEntrances += 1; }, + onExit: () { numExits += 1; }, + ), + ), + ), ); await tester.pump(); - expect(numEntries, equals(1)); + expect(numEntrances, equals(1)); expect(numExits, equals(0)); await tester.pumpWidget( Container(), ); await tester.pump(); - expect(numEntries, equals(1)); + expect(numEntrances, equals(1)); expect(numExits, equals(0)); }); @@ -920,8 +904,8 @@ void main() { textDirection: TextDirection.ltr, child: MouseRegion( onEnter: (PointerEnterEvent e) {}, - child: _PaintDelegateWidget( - onPaint: _VoidDelegate(() => paintCount++), + child: CustomPaint( + painter: _DelegatedPainter(onPaint: () { paintCount += 1; }), child: const Text('123'), ), ), @@ -943,8 +927,8 @@ void main() { textDirection: TextDirection.ltr, child: MouseRegion( onEnter: (PointerEnterEvent e) {}, - child: _PaintDelegateWidget( - onPaint: _VoidDelegate(() => paintCount++), + child: CustomPaint( + painter: _DelegatedPainter(onPaint: () { paintCount += 1; }), child: const Text('123'), ), ), @@ -1299,12 +1283,12 @@ void main() { Align( alignment: Alignment.topLeft, child: MouseRegion( - onEnter: (_) { bottomRegionIsHovered = true; }, - onHover: (_) { bottomRegionIsHovered = true; }, - onExit: (_) { bottomRegionIsHovered = true; }, - child: Container( - width: 10, - height: 10, + onEnter: (_) { bottomRegionIsHovered = true; }, + onHover: (_) { bottomRegionIsHovered = true; }, + onExit: (_) { bottomRegionIsHovered = true; }, + child: Container( + width: 10, + height: 10, ), ), ), @@ -1325,7 +1309,118 @@ void main() { expect(bottomRegionIsHovered, isFalse); }); - testWidgets('RenderMouseRegion\'s debugFillProperties when default', (WidgetTester tester) async { + testWidgets("Changing MouseRegion's callbacks is effective and doesn't repaint", (WidgetTester tester) async { + final List logs = []; + const Key key = ValueKey(1); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(20, 20)); + addTearDown(gesture.removePointer); + + await tester.pumpWidget(_Scaffold( + topLeft: Container( + height: 10, + width: 10, + child: MouseRegion( + onEnter: (_) { logs.add('enter1'); }, + onHover: (_) { logs.add('hover1'); }, + onExit: (_) { logs.add('exit1'); }, + child: CustomPaint( + painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key), + ), + ), + ), + )); + expect(logs, ['paint']); + logs.clear(); + + await gesture.moveTo(const Offset(5, 5)); + expect(logs, ['enter1', 'hover1']); + logs.clear(); + + await tester.pumpWidget(_Scaffold( + topLeft: Container( + height: 10, + width: 10, + child: MouseRegion( + onEnter: (_) { logs.add('enter2'); }, + onHover: (_) { logs.add('hover2'); }, + onExit: (_) { logs.add('exit2'); }, + child: CustomPaint( + painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key), + ), + ), + ), + )); + expect(logs, isEmpty); + + await gesture.moveTo(const Offset(6, 6)); + expect(logs, ['hover2']); + logs.clear(); + + // Compare: It repaints if the MouseRegion is unactivated. + await tester.pumpWidget(_Scaffold( + topLeft: Container( + height: 10, + width: 10, + child: MouseRegion( + opaque: false, + child: CustomPaint( + painter: _DelegatedPainter(onPaint: () { logs.add('paint'); }, key: key), + ), + ), + ), + )); + expect(logs, ['paint']); + }); + + testWidgets('Changing MouseRegion.opaque is effective and repaints', (WidgetTester tester) async { + final List logs = []; + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: const Offset(5, 5)); + addTearDown(gesture.removePointer); + + final PointerHoverEventListener onHover = (_) {}; + final VoidCallback onPaintChild = () { logs.add('paint'); }; + + await tester.pumpWidget(_Scaffold( + topLeft: Container( + height: 10, + width: 10, + child: MouseRegion( + opaque: true, + // Dummy callback so that MouseRegion stays affective after opaque + // turns false. + onHover: onHover, + child: CustomPaint(painter: _DelegatedPainter(onPaint: onPaintChild)), + ), + ), + background: MouseRegion(onEnter: (_) { logs.add('hover-enter'); }) + )); + expect(logs, ['paint']); + logs.clear(); + + expect(logs, isEmpty); + logs.clear(); + + await tester.pumpWidget(_Scaffold( + topLeft: Container( + height: 10, + width: 10, + child: MouseRegion( + opaque: false, + onHover: onHover, + child: CustomPaint(painter: _DelegatedPainter(onPaint: onPaintChild)), + ), + ), + background: MouseRegion(onEnter: (_) { logs.add('hover-enter'); }) + )); + + expect(logs, ['paint', 'hover-enter']); + }); + + testWidgets("RenderMouseRegion's debugFillProperties when default", (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); RenderMouseRegion().debugFillProperties(builder); @@ -1339,7 +1434,7 @@ void main() { ]); }); - testWidgets('RenderMouseRegion\'s debugFillProperties when full', (WidgetTester tester) async { + testWidgets("RenderMouseRegion's debugFillProperties when full", (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); RenderMouseRegion( onEnter: (PointerEnterEvent event) {}, @@ -1377,64 +1472,46 @@ void main() { await gesture.moveBy(const Offset(10.0, 10.0)); expect(tester.binding.hasScheduledFrame, isFalse); }); +} - testWidgets("MouseTracker's attachAnnotation doesn't schedule any frames", (WidgetTester tester) async { - // This test is here because MouseTracker can't use testWidgets. - final MouseTrackerAnnotation annotation = MouseTrackerAnnotation( - onEnter: (PointerEnterEvent event) {}, - onHover: (PointerHoverEvent event) {}, - onExit: (PointerExitEvent event) {}, +// Render widget `topLeft` at the top-left corner, stacking on top of the widget +// `background`. +class _Scaffold extends StatelessWidget { + const _Scaffold({this.topLeft, this.background}); + + final Widget topLeft; + final Widget background; + + @override + Widget build(BuildContext context) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + if (background != null) background, + Align( + alignment: Alignment.topLeft, + child: topLeft, + ), + ], + ), ); - RendererBinding.instance.mouseTracker.attachAnnotation(annotation); - expect(tester.binding.hasScheduledFrame, isFalse); - expect(RendererBinding.instance.mouseTracker.isAnnotationAttached(annotation), isTrue); - RendererBinding.instance.mouseTracker.detachAnnotation(annotation); - }); -} - -// This widget allows you to send a callback that is called during `onPaint`. -@immutable -class _PaintDelegateWidget extends SingleChildRenderObjectWidget { - const _PaintDelegateWidget({ - Key key, - Widget child, - this.onPaint, - }) : super(key: key, child: child); - - final _VoidDelegate onPaint; - - @override - RenderObject createRenderObject(BuildContext context) { - return _PaintCallbackObject(onPaint: onPaint?.callback); - } - - @override - void updateRenderObject(BuildContext context, _PaintCallbackObject renderObject) { - renderObject..onPaint = onPaint?.callback; } } -class _VoidDelegate { - _VoidDelegate(this.callback); - - void Function() callback; -} - -class _PaintCallbackObject extends RenderProxyBox { - _PaintCallbackObject({ - RenderBox child, - this.onPaint, - }) : super(child); - - void Function() onPaint; +class _DelegatedPainter extends CustomPainter { + _DelegatedPainter({this.key, this.onPaint}); + final Key key; + final VoidCallback onPaint; @override - void paint(PaintingContext context, Offset offset) { - if (onPaint != null) { - onPaint(); - } - super.paint(context, offset); + void paint(Canvas canvas, Size size) { + onPaint(); } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => + !(oldDelegate is _DelegatedPainter && key == oldDelegate.key); } class _HoverClientWithClosures extends StatefulWidget {