From aeccd6a8bc51b8c93a96e575c003655fdc1fb18e Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 8 May 2019 17:57:42 -0700 Subject: [PATCH] Fix nested listeners so that ancestor listeners can also receive enter/exit/move events. (#32350) This changes Listener to trigger enter/move/exit in all Listeners below the pointer, not just the leaf region (the first region hit). This is because we need to allow listeners to be nested so that, say, a widget that handles changing color on hover, but also is wrapped in a Tooltip (that handles hover) can trigger both actions, not just one. To that end, I added a findAll to Layer, similar to the existing find method that was previously used. It returns an iterator over annotated layers which match the given data type. Since the findAll is implemented as returning an Iterable (and is sync*), I re-implemented the find routines as just returning the first result from findAll, since that should be just as efficient, and would then prevent duplication in the implementation. --- .../lib/src/gestures/mouse_tracking.dart | 58 +++---- .../flutter/lib/src/rendering/binding.dart | 3 +- packages/flutter/lib/src/rendering/layer.dart | 155 ++++++++++++++---- .../lib/src/widgets/widget_inspector.dart | 6 + .../test/gestures/mouse_tracking_test.dart | 7 +- .../test/rendering/annotated_region_test.dart | 152 ++++++++++++++++- .../flutter/test/widgets/listener_test.dart | 84 +++++++++- 7 files changed, 401 insertions(+), 64 deletions(-) diff --git a/packages/flutter/lib/src/gestures/mouse_tracking.dart b/packages/flutter/lib/src/gestures/mouse_tracking.dart index 7f6d30f053..77b35e508a 100644 --- a/packages/flutter/lib/src/gestures/mouse_tracking.dart +++ b/packages/flutter/lib/src/gestures/mouse_tracking.dart @@ -78,7 +78,7 @@ class _TrackedAnnotation { /// /// It is used by the [MouseTracker] to fetch annotations for the mouse /// position. -typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset offset); +typedef MouseDetectorAnnotationFinder = Iterable Function(Offset offset); /// Keeps state about which objects are interested in tracking mouse positions /// and notifies them when a mouse pointer enters, moves, or leaves an annotated @@ -229,42 +229,44 @@ class MouseTracker extends ChangeNotifier { for (int deviceId in _lastMouseEvent.keys) { final PointerEvent lastEvent = _lastMouseEvent[deviceId]; - final MouseTrackerAnnotation hit = annotationFinder(lastEvent.position); + final Iterable hits = annotationFinder(lastEvent.position); - // No annotation was found at this position for this deviceId, so send an + // 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 (hit == null) { + if (hits.isEmpty) { // Send an exit to all tracked animations tracking this deviceId. for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) { exitAnnotation(trackedAnnotation, deviceId); } - return; + continue; } - final _TrackedAnnotation hitAnnotation = _findAnnotation(hit); - 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); - } - - // Tell any tracked annotations that weren't hit that they are no longer - // active. - for (_TrackedAnnotation trackedAnnotation in _trackedAnnotations.values) { - if (hitAnnotation == trackedAnnotation) { - continue; - } - if (trackedAnnotation.activeDevices.contains(deviceId)) { - if (trackedAnnotation.annotation?.onExit != null) { - trackedAnnotation.annotation.onExit(PointerExitEvent.fromMouseEvent(lastEvent)); + 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); + } + + // 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); } - trackedAnnotation.activeDevices.remove(deviceId); } } } diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index 261b5669fb..03448328b2 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -246,8 +246,7 @@ mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, Gesture // Layer hit testing is done using device pixels, so we have to convert // the logical coordinates of the event location back to device pixels // here. - return renderView.layer - .find(offset * window.devicePixelRatio); + return renderView.layer.findAll(offset * window.devicePixelRatio); }); } diff --git a/packages/flutter/lib/src/rendering/layer.dart b/packages/flutter/lib/src/rendering/layer.dart index 9970900eb0..38a942d0fd 100644 --- a/packages/flutter/lib/src/rendering/layer.dart +++ b/packages/flutter/lib/src/rendering/layer.dart @@ -163,7 +163,7 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin { /// /// Returns null if no matching region is found. /// - /// The main way for a value to be assigned here is by pushing an + /// The main way for a value to be found here is by pushing an /// [AnnotatedRegionLayer] into the layer tree. /// /// See also: @@ -171,6 +171,19 @@ abstract class Layer extends AbstractNode with DiagnosticableTreeMixin { /// * [AnnotatedRegionLayer], for placing values in the layer tree. S find(Offset regionOffset); + /// Returns an iterable of [S] values that corresponds to the point described + /// by [regionOffset] on all layers under the point. + /// + /// Returns an empty list if no matching region is found. + /// + /// The main way for a value to be found here is by pushing an + /// [AnnotatedRegionLayer] into the layer tree. + /// + /// See also: + /// + /// * [AnnotatedRegionLayer], for placing values in the layer tree. + Iterable findAll(Offset regionOffset); + /// Override this method to upload this layer to the engine. /// /// Return the engine layer for retained rendering. When there's no @@ -290,6 +303,9 @@ class PictureLayer extends Layer { @override S find(Offset regionOffset) => null; + + @override + Iterable findAll(Offset regionOffset) => []; } /// A composited layer that maps a backend texture to a rectangle. @@ -359,6 +375,9 @@ class TextureLayer extends Layer { @override S find(Offset regionOffset) => null; + + @override + Iterable findAll(Offset regionOffset) => []; } /// A layer that shows an embedded [UIView](https://developer.apple.com/documentation/uikit/uiview) @@ -395,6 +414,9 @@ class PlatformViewLayer extends Layer { @override S find(Offset regionOffset) => null; + + @override + Iterable findAll(Offset regionOffset) => []; } /// A layer that indicates to the compositor that it should display @@ -468,6 +490,9 @@ class PerformanceOverlayLayer extends Layer { @override S find(Offset regionOffset) => null; + + @override + Iterable findAll(Offset regionOffset) => []; } /// A composited layer that has a list of children. @@ -609,15 +634,24 @@ class ContainerLayer extends Layer { @override S find(Offset regionOffset) { - Layer current = lastChild; - while (current != null) { - final Object value = current.find(regionOffset); - if (value != null) { - return value; - } - current = current.previousSibling; + final Iterable all = findAll(regionOffset); + if (all.isEmpty) { + return null; + } + return all.first; + } + + @override + Iterable findAll(Offset regionOffset) sync* { + if (firstChild == null) + return; + Layer child = lastChild; + while (true) { + yield* child.findAll(regionOffset); + if (child == firstChild) + break; + child = child.previousSibling; } - return null; } @override @@ -844,6 +878,11 @@ class OffsetLayer extends ContainerLayer { return super.find(regionOffset - offset); } + @override + Iterable findAll(Offset regionOffset) { + return super.findAll(regionOffset - offset); + } + @override void applyTransform(Layer child, Matrix4 transform) { assert(child != null); @@ -993,6 +1032,13 @@ class ClipRectLayer extends ContainerLayer { return super.find(regionOffset); } + @override + Iterable findAll(Offset regionOffset) sync* { + if (!clipRect.contains(regionOffset)) + return; + yield* super.findAll(regionOffset); + } + @override ui.EngineLayer addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { bool enabled = true; @@ -1065,6 +1111,13 @@ class ClipRRectLayer extends ContainerLayer { return super.find(regionOffset); } + @override + Iterable findAll(Offset regionOffset) sync* { + if (!clipRRect.contains(regionOffset)) + return; + yield* super.findAll(regionOffset); + } + @override ui.EngineLayer addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { bool enabled = true; @@ -1137,6 +1190,13 @@ class ClipPathLayer extends ContainerLayer { return super.find(regionOffset); } + @override + Iterable findAll(Offset regionOffset) sync* { + if (!clipPath.contains(regionOffset)) + return; + yield* super.findAll(regionOffset); + } + @override ui.EngineLayer addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { bool enabled = true; @@ -1205,15 +1265,24 @@ class TransformLayer extends OffsetLayer { @override S find(Offset regionOffset) { + final Iterable all = findAll(regionOffset); + if (all.isEmpty) { + return null; + } + return all.first; + } + + @override + Iterable findAll(Offset regionOffset) sync* { if (_inverseDirty) { _invertedTransform = Matrix4.tryInvert(transform); _inverseDirty = false; } if (_invertedTransform == null) - return null; + return; final Vector4 vector = Vector4(regionOffset.dx, regionOffset.dy, 0.0, 1.0); final Vector4 result = _invertedTransform.transform(vector); - return super.find(Offset(result[0], result[1])); + yield* super.findAll(Offset(result[0], result[1])); } @override @@ -1519,9 +1588,18 @@ class PhysicalModelLayer extends ContainerLayer { @override S find(Offset regionOffset) { - if (!clipPath.contains(regionOffset)) + final Iterable all = findAll(regionOffset); + if (all.isEmpty) { return null; - return super.find(regionOffset); + } + return all.first; + } + + @override + Iterable findAll(Offset regionOffset) sync* { + if (!clipPath.contains(regionOffset)) + return; + yield* super.findAll(regionOffset); } @override @@ -1635,9 +1713,10 @@ class LeaderLayer extends ContainerLayer { Offset _lastOffset; @override - S find(Offset regionOffset) { - return super.find(regionOffset - offset); - } + S find(Offset regionOffset) => super.find(regionOffset - offset); + + @override + Iterable findAll(Offset regionOffset) => super.findAll(regionOffset - offset); @override ui.EngineLayer addToScene(ui.SceneBuilder builder, [ Offset layerOffset = Offset.zero ]) { @@ -1751,11 +1830,7 @@ class FollowerLayer extends ContainerLayer { Matrix4 _invertedTransform; bool _inverseDirty = true; - @override - S find(Offset regionOffset) { - if (link.leader == null) { - return showWhenUnlinked ? super.find(regionOffset - unlinkedOffset) : null; - } + Offset _transformOffset(Offset regionOffset) { if (_inverseDirty) { _invertedTransform = Matrix4.tryInvert(getLastTransform()); _inverseDirty = false; @@ -1764,7 +1839,24 @@ class FollowerLayer extends ContainerLayer { return null; final Vector4 vector = Vector4(regionOffset.dx, regionOffset.dy, 0.0, 1.0); final Vector4 result = _invertedTransform.transform(vector); - return super.find(Offset(result[0] - linkedOffset.dx, result[1] - linkedOffset.dy)); + return Offset(result[0] - linkedOffset.dx, result[1] - linkedOffset.dy); + } + + @override + S find(Offset regionOffset) { + final Iterable all = findAll(regionOffset); + if (all.isEmpty) { + return null; + } + return all.first; + } + + @override + Iterable findAll(Offset regionOffset) { + if (link.leader == null) { + return showWhenUnlinked ? super.findAll(regionOffset - unlinkedOffset) : []; + } + return super.findAll(_transformOffset(regionOffset)); } /// The transform that was used during the last composition phase. @@ -1939,17 +2031,24 @@ class AnnotatedRegionLayer extends ContainerLayer { @override S find(Offset regionOffset) { - final S result = super.find(regionOffset); - if (result != null) - return result; - if (size != null && !(offset & size).contains(regionOffset)) + final Iterable all = findAll(regionOffset); + if (all.isEmpty) { return null; + } + return all.first; + } + + @override + Iterable findAll(Offset regionOffset) sync* { + yield* super.findAll(regionOffset); + if (size != null && !(offset & size).contains(regionOffset)) { + return; + } if (T == S) { final Object untypedResult = value; final S typedResult = untypedResult; - return typedResult; + yield typedResult; } - return super.find(regionOffset); } @override diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 0c03a449b9..032d993965 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -60,6 +60,9 @@ class _ProxyLayer extends Layer { @override S find(Offset regionOffset) => _layer.find(regionOffset); + + @override + Iterable findAll(Offset regionOffset) => []; } /// A [Canvas] that multicasts all method calls to a main canvas and a @@ -2799,6 +2802,9 @@ class _InspectorOverlayLayer extends Layer { @override S find(Offset regionOffset) => null; + + @override + Iterable findAll(Offset regionOffset) => []; } const double _kScreenEdgeMargin = 10.0; diff --git a/packages/flutter/test/gestures/mouse_tracking_test.dart b/packages/flutter/test/gestures/mouse_tracking_test.dart index c2483ac7e2..687ad978a1 100644 --- a/packages/flutter/test/gestures/mouse_tracking_test.dart +++ b/packages/flutter/test/gestures/mouse_tracking_test.dart @@ -65,12 +65,11 @@ void main() { isInHitRegionTwo = false; tracker = MouseTracker( GestureBinding.instance.pointerRouter, - (Offset _) { + (Offset _) sync* { if (isInHitRegionOne) - return annotation; + yield annotation; else if (isInHitRegionTwo) - return partialAnnotation; - return null; + yield partialAnnotation; }, ); }); diff --git a/packages/flutter/test/rendering/annotated_region_test.dart b/packages/flutter/test/rendering/annotated_region_test.dart index 4c71e78e98..204eee69ad 100644 --- a/packages/flutter/test/rendering/annotated_region_test.dart +++ b/packages/flutter/test/rendering/annotated_region_test.dart @@ -7,7 +7,7 @@ import 'package:flutter/widgets.dart'; import '../flutter_test_alternative.dart'; void main() { - group(AnnotatedRegion, () { + group('$AnnotatedRegion find', () { test('finds the first value in a OffsetLayer when sized', () { final ContainerLayer containerLayer = ContainerLayer(); final List layers = [ @@ -136,4 +136,154 @@ void main() { expect(parent.find(const Offset(0.0, 0.0)), 1); }); }); + group('$AnnotatedRegion findAll', () { + test('finds the first value in a OffsetLayer when sized', () { + final ContainerLayer containerLayer = ContainerLayer(); + final List layers = [ + OffsetLayer(offset: Offset.zero), + OffsetLayer(offset: const Offset(0.0, 100.0)), + OffsetLayer(offset: const Offset(0.0, 200.0)), + ]; + int i = 0; + for (OffsetLayer layer in layers) { + layer.append(AnnotatedRegionLayer(i, size: const Size(200.0, 100.0))); + containerLayer.append(layer); + i += 1; + } + + expect(containerLayer.findAll(const Offset(0.0, 1.0)), equals([0])); + expect(containerLayer.findAll(const Offset(0.0, 101.0)),equals([1])); + expect(containerLayer.findAll(const Offset(0.0, 201.0)), equals([2])); + }); + + test('finds a value within the clip in a ClipRectLayer', () { + final ContainerLayer containerLayer = ContainerLayer(); + final List layers = [ + ClipRectLayer(clipRect: const Rect.fromLTRB(0.0, 0.0, 100.0, 100.0)), + ClipRectLayer(clipRect: const Rect.fromLTRB(0.0, 100.0, 100.0, 200.0)), + ClipRectLayer(clipRect: const Rect.fromLTRB(0.0, 200.0, 100.0, 300.0)), + ]; + int i = 0; + for (ClipRectLayer layer in layers) { + layer.append(AnnotatedRegionLayer(i)); + containerLayer.append(layer); + i += 1; + } + + expect(containerLayer.findAll(const Offset(0.0, 1.0)), equals([0])); + expect(containerLayer.findAll(const Offset(0.0, 101.0)), equals([1])); + expect(containerLayer.findAll(const Offset(0.0, 201.0)), equals([2])); + }); + + + test('finds a value within the clip in a ClipRRectLayer', () { + final ContainerLayer containerLayer = ContainerLayer(); + final List layers = [ + ClipRRectLayer(clipRRect: RRect.fromLTRBR(0.0, 0.0, 100.0, 100.0, const Radius.circular(4.0))), + ClipRRectLayer(clipRRect: RRect.fromLTRBR(0.0, 100.0, 100.0, 200.0, const Radius.circular(4.0))), + ClipRRectLayer(clipRRect: RRect.fromLTRBR(0.0, 200.0, 100.0, 300.0, const Radius.circular(4.0))), + ]; + int i = 0; + for (ClipRRectLayer layer in layers) { + layer.append(AnnotatedRegionLayer(i)); + containerLayer.append(layer); + i += 1; + } + + expect(containerLayer.findAll(const Offset(5.0, 5.0)), equals([0])); + expect(containerLayer.findAll(const Offset(5.0, 105.0)), equals([1])); + expect(containerLayer.findAll(const Offset(5.0, 205.0)), equals([2])); + }); + + test('finds a value under a TransformLayer', () { + final Matrix4 transform = Matrix4( + 2.625, 0.0, 0.0, 0.0, + 0.0, 2.625, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, 1.0, + ); + final TransformLayer transformLayer = TransformLayer(transform: transform); + final List layers = [ + OffsetLayer(), + OffsetLayer(offset: const Offset(0.0, 100.0)), + OffsetLayer(offset: const Offset(0.0, 200.0)), + ]; + int i = 0; + for (OffsetLayer layer in layers) { + final AnnotatedRegionLayer annotatedRegionLayer = AnnotatedRegionLayer(i, size: const Size(100.0, 100.0)); + layer.append(annotatedRegionLayer); + transformLayer.append(layer); + i += 1; + } + + expect(transformLayer.findAll(const Offset(0.0, 100.0)), equals([0])); + expect(transformLayer.findAll(const Offset(0.0, 200.0)), equals([0])); + expect(transformLayer.findAll(const Offset(0.0, 270.0)), equals([1])); + expect(transformLayer.findAll(const Offset(0.0, 400.0)), equals([1])); + expect(transformLayer.findAll(const Offset(0.0, 530.0)), equals([2])); + }); + + test('finds multiple nested, overlapping regions', () { + final ContainerLayer parent = ContainerLayer(); + + int index = 0; + final List> layers = >[ + AnnotatedRegionLayer(index++, size: const Size(100.0, 100.0)), + AnnotatedRegionLayer(index++, size: const Size(100.0, 100.0)), + ]; + for (ContainerLayer layer in layers) { + final AnnotatedRegionLayer annotatedRegionLayer = AnnotatedRegionLayer(index++, size: const Size(100.0, 100.0)); + layer.append(annotatedRegionLayer); + parent.append(layer); + } + + expect(parent.findAll(const Offset(0.0, 0.0)), equals([3, 1, 2, 0,])); + }); + + test('looks for child AnnotatedRegions before parents', () { + final AnnotatedRegionLayer parent = AnnotatedRegionLayer(1); + final AnnotatedRegionLayer child1 = AnnotatedRegionLayer(2); + final AnnotatedRegionLayer child2 = AnnotatedRegionLayer(3); + final AnnotatedRegionLayer child3 = AnnotatedRegionLayer(4); + final ContainerLayer layer = ContainerLayer(); + parent.append(child1); + parent.append(child2); + parent.append(child3); + layer.append(parent); + + expect(parent.findAll(Offset.zero), equals([4, 3, 2, 1])); + }); + + test('looks for correct type', () { + final AnnotatedRegionLayer child1 = AnnotatedRegionLayer(1); + final AnnotatedRegionLayer child2 = AnnotatedRegionLayer('hello'); + final ContainerLayer layer = ContainerLayer(); + layer.append(child2); + layer.append(child1); + + expect(layer.findAll(Offset.zero), equals(['hello'])); + }); + + test('does not clip Layer.find on an AnnotatedRegion with an unrelated type', () { + final AnnotatedRegionLayer child = AnnotatedRegionLayer(1); + final AnnotatedRegionLayer parent = AnnotatedRegionLayer('hello', size: const Size(10.0, 10.0)); + final ContainerLayer layer = ContainerLayer(); + parent.append(child); + layer.append(parent); + + expect(layer.findAll(const Offset(100.0, 100.0)), equals([1])); + }); + + test('handles non-invertable transforms', () { + final AnnotatedRegionLayer child = AnnotatedRegionLayer(1); + final TransformLayer parent = TransformLayer(transform: Matrix4.diagonal3Values(0.0, 1.0, 1.0)); + parent.append(child); + + expect(parent.findAll(const Offset(0.0, 0.0)), equals([])); + + parent.transform = Matrix4.diagonal3Values(1.0, 1.0, 1.0); + + expect(parent.findAll(const Offset(0.0, 0.0)), equals([1])); + }); + }); } diff --git a/packages/flutter/test/widgets/listener_test.dart b/packages/flutter/test/widgets/listener_test.dart index e244c4c40f..d563b4e6e2 100644 --- a/packages/flutter/test/widgets/listener_test.dart +++ b/packages/flutter/test/widgets/listener_test.dart @@ -135,7 +135,7 @@ void main() { expect(enter.position, equals(const Offset(400.0, 300.0))); expect(exit, isNull); }); - testWidgets('detects pointer exit', (WidgetTester tester) async { + testWidgets('detects pointer exiting', (WidgetTester tester) async { PointerEnterEvent enter; PointerHoverEvent move; PointerExitEvent exit; @@ -196,6 +196,87 @@ void main() { expect(exit.position, equals(const Offset(400.0, 300.0))); expect(tester.binding.mouseTracker.isAnnotationAttached(renderListener.hoverAnnotation), isFalse); }); + testWidgets('Hover works with nested listeners', (WidgetTester tester) async { + final UniqueKey key1 = UniqueKey(); + final UniqueKey key2 = UniqueKey(); + final List enter1 = []; + final List move1 = []; + final List exit1 = []; + final List enter2 = []; + final List move2 = []; + final List exit2 = []; + void clearLists() { + enter1.clear(); + move1.clear(); + exit1.clear(); + enter2.clear(); + move2.clear(); + exit2.clear(); + } + + await tester.pumpWidget(Container()); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.moveTo(const Offset(400.0, 0.0)); + await tester.pump(); + await tester.pumpWidget( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Listener( + onPointerEnter: (PointerEnterEvent details) => enter1.add(details), + onPointerHover: (PointerHoverEvent details) => move1.add(details), + onPointerExit: (PointerExitEvent details) => exit1.add(details), + key: key1, + child: Container( + width: 200, + height: 200, + padding: const EdgeInsets.all(50.0), + child: Listener( + key: key2, + onPointerEnter: (PointerEnterEvent details) => enter2.add(details), + onPointerHover: (PointerHoverEvent details) => move2.add(details), + onPointerExit: (PointerExitEvent details) => exit2.add(details), + child: Container(), + ), + ), + ), + ], + ), + ); + final RenderPointerListener renderListener1 = tester.renderObject(find.byKey(key1)); + final RenderPointerListener renderListener2 = tester.renderObject(find.byKey(key2)); + Offset center = tester.getCenter(find.byKey(key2)); + await gesture.moveTo(center); + await tester.pump(); + expect(move2, isNotEmpty); + expect(enter2, isNotEmpty); + expect(exit2, isEmpty); + expect(move1, isNotEmpty); + expect(move1.last.position, equals(center)); + 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 + // the parent too. + center = center - const Offset(75.0, 0.0); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(move2, isEmpty); + expect(enter2, isEmpty); + expect(exit2, isNotEmpty); + expect(move1, isNotEmpty); + 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 { final UniqueKey key1 = UniqueKey(); final UniqueKey key2 = UniqueKey(); @@ -341,6 +422,7 @@ void main() { final Offset bottomLeft = tester.getBottomLeft(find.byKey(key)); expect(topRight.dx - topLeft.dx, scaleFactor * localWidth); expect(bottomLeft.dy - topLeft.dy, scaleFactor * localHeight); + print('Rect: ${tester.getRect(find.byKey(key))}'); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); await gesture.moveTo(topLeft - const Offset(1, 1));