From 980b5a1976883ea99301b6c6205204841ff565e8 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Tue, 28 May 2024 10:39:30 -0700 Subject: [PATCH] Mouse onEnter and onExit now support hovering stylus (#149006) Hovering with a stylus will now behave similarly to hovering with a mouse. For example, hovering a button will show the button's hovered styling. --- .../lib/src/rendering/mouse_tracker.dart | 2 +- .../test/material/text_button_test.dart | 46 ++++++ .../test/rendering/mouse_tracker_test.dart | 133 ++++++++++-------- .../test/widgets/mouse_region_test.dart | 41 ++++++ 4 files changed, 165 insertions(+), 57 deletions(-) diff --git a/packages/flutter/lib/src/rendering/mouse_tracker.dart b/packages/flutter/lib/src/rendering/mouse_tracker.dart index e79683ecd31d7..8264cb9bfe953 100644 --- a/packages/flutter/lib/src/rendering/mouse_tracker.dart +++ b/packages/flutter/lib/src/rendering/mouse_tracker.dart @@ -300,7 +300,7 @@ class MouseTracker extends ChangeNotifier { /// The [updateWithEvent] is one of the two ways of updating mouse /// states, the other one being [updateAllDevices]. void updateWithEvent(PointerEvent event, HitTestResult? hitTestResult) { - if (event.kind != PointerDeviceKind.mouse) { + if (event.kind != PointerDeviceKind.mouse && event.kind != PointerDeviceKind.stylus) { return; } if (event is PointerSignalEvent) { diff --git a/packages/flutter/test/material/text_button_test.dart b/packages/flutter/test/material/text_button_test.dart index 3f8a5b5e4b749..e15a7a7558ca3 100644 --- a/packages/flutter/test/material/text_button_test.dart +++ b/packages/flutter/test/material/text_button_test.dart @@ -2300,6 +2300,52 @@ void main() { // The icon is aligned to the left of the button. expect(buttonTopLeft.dx, iconTopLeft.dx - 16.0); // 16.0 - padding between icon and button edge. }); + + testWidgets('treats a hovering stylus like a mouse', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final ThemeData theme = ThemeData(useMaterial3: true); + bool hasBeenHovered = false; + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold( + body: Center( + child: Builder( + builder: (BuildContext context) { + return TextButton( + onPressed: () {}, + onHover: (bool entered) { + hasBeenHovered = true; + }, + focusNode: focusNode, + child: const Text('TextButton'), + ); + }, + ), + ), + ), + ), + ); + + RenderObject overlayColor() { + return tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + } + + final Offset center = tester.getCenter(find.byType(TextButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.stylus, + ); + await gesture.addPointer(); + await tester.pumpAndSettle(); + + expect(hasBeenHovered, isFalse); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(overlayColor(), paints..rect(color: theme.colorScheme.primary.withOpacity(0.08))); + expect(hasBeenHovered, isTrue); + }); } TextStyle? _iconStyle(WidgetTester tester, IconData icon) { diff --git a/packages/flutter/test/rendering/mouse_tracker_test.dart b/packages/flutter/test/rendering/mouse_tracker_test.dart index d624410c21b2a..4d0fa0537492e 100644 --- a/packages/flutter/test/rendering/mouse_tracker_test.dart +++ b/packages/flutter/test/rendering/mouse_tracker_test.dart @@ -72,63 +72,81 @@ void main() { final Matrix4 translate10by20 = Matrix4.translationValues(10, 20, 0); - test('should detect enter, hover, and exit from Added, Hover, and Removed events', () { - final List events = []; - setUpWithOneAnnotation(logEvents: events); + for (final ui.PointerDeviceKind pointerDeviceKind in [ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.stylus]) { + test('should detect enter, hover, and exit from Added, Hover, and Removed events for stylus', () { + final List events = []; + setUpWithOneAnnotation(logEvents: events); + + final List listenerLogs = []; + _mouseTracker.addListener(() { + listenerLogs.add(_mouseTracker.mouseIsConnected); + }); - final List listenerLogs = []; - _mouseTracker.addListener(() { - listenerLogs.add(_mouseTracker.mouseIsConnected); + expect(_mouseTracker.mouseIsConnected, isFalse); + + // Pointer enters the annotation. + RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ + _pointerData( + PointerChange.add, + Offset.zero, + kind: pointerDeviceKind, + ), + ])); + addTearDown(() => dispatchRemoveDevice()); + + expect(events, _equalToEventsOnCriticalFields([ + EventMatcher(const PointerEnterEvent()), + ])); + expect(listenerLogs, [true]); + events.clear(); + listenerLogs.clear(); + + // Pointer hovers the annotation. + RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ + _pointerData( + PointerChange.hover, + const Offset(1.0, 101.0), + kind: pointerDeviceKind, + ), + ])); + expect(events, _equalToEventsOnCriticalFields([ + EventMatcher(const PointerHoverEvent(position: Offset(1.0, 101.0))), + ])); + expect(_mouseTracker.mouseIsConnected, isTrue); + expect(listenerLogs, isEmpty); + events.clear(); + + // Pointer is removed while on the annotation. + RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ + _pointerData( + PointerChange.remove, + const Offset(1.0, 101.0), + kind: pointerDeviceKind, + ), + ])); + expect(events, _equalToEventsOnCriticalFields([ + EventMatcher(const PointerExitEvent(position: Offset(1.0, 101.0))), + ])); + expect(listenerLogs, [false]); + events.clear(); + listenerLogs.clear(); + + // Pointer is added on the annotation. + RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ + _pointerData( + PointerChange.add, + const Offset(0.0, 301.0), + kind: pointerDeviceKind, + ), + ])); + expect(events, _equalToEventsOnCriticalFields([ + EventMatcher(const PointerEnterEvent(position: Offset(0.0, 301.0))), + ])); + expect(listenerLogs, [true]); + events.clear(); + listenerLogs.clear(); }); - - expect(_mouseTracker.mouseIsConnected, isFalse); - - // Pointer enters the annotation. - RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.add, Offset.zero), - ])); - addTearDown(() => dispatchRemoveDevice()); - - expect(events, _equalToEventsOnCriticalFields([ - EventMatcher(const PointerEnterEvent()), - ])); - expect(listenerLogs, [true]); - events.clear(); - listenerLogs.clear(); - - // Pointer hovers the annotation. - RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.hover, const Offset(1.0, 101.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - EventMatcher(const PointerHoverEvent(position: Offset(1.0, 101.0))), - ])); - expect(_mouseTracker.mouseIsConnected, isTrue); - expect(listenerLogs, isEmpty); - events.clear(); - - // Pointer is removed while on the annotation. - RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.remove, const Offset(1.0, 101.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - EventMatcher(const PointerExitEvent(position: Offset(1.0, 101.0))), - ])); - expect(listenerLogs, [false]); - events.clear(); - listenerLogs.clear(); - - // Pointer is added on the annotation. - RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: [ - _pointerData(PointerChange.add, const Offset(0.0, 301.0)), - ])); - expect(events, _equalToEventsOnCriticalFields([ - EventMatcher(const PointerEnterEvent(position: Offset(0.0, 301.0))), - ])); - expect(listenerLogs, [true]); - events.clear(); - listenerLogs.clear(); - }); + } // Regression test for https://github.com/flutter/flutter/issues/90838 test('should not crash if the first event is a Removed event', () { @@ -623,7 +641,10 @@ class BaseEventMatcher extends Matcher { bool matches(dynamic untypedItem, Map matchState) { final PointerEvent actual = untypedItem as PointerEvent; if (!( - _matchesField(matchState, 'kind', actual.kind, PointerDeviceKind.mouse) && + ( + _matchesField(matchState, 'kind', actual.kind, PointerDeviceKind.mouse) || + _matchesField(matchState, 'kind', actual.kind, PointerDeviceKind.stylus) + ) && _matchesField(matchState, 'position', actual.position, expected.position) && _matchesField(matchState, 'device', actual.device, expected.device) && _matchesField(matchState, 'localPosition', actual.localPosition, expected.localPosition) diff --git a/packages/flutter/test/widgets/mouse_region_test.dart b/packages/flutter/test/widgets/mouse_region_test.dart index e4549e23e82f7..a84da27402d02 100644 --- a/packages/flutter/test/widgets/mouse_region_test.dart +++ b/packages/flutter/test/widgets/mouse_region_test.dart @@ -1868,6 +1868,47 @@ void main() { await gesture.cancel(); expect(tester.takeException(), isNull); }); + + testWidgets('stylus input works', (WidgetTester tester) async { + bool onEnter = false; + bool onExit = false; + bool onHover = false; + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: MouseRegion( + onEnter: (_) => onEnter = true, + onExit: (_) => onExit = true, + onHover: (_) => onHover = true, + child: const SizedBox( + width: 10.0, + height: 10.0, + ), + ), + ), + )); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.stylus); + await gesture.addPointer(location: const Offset(20.0, 20.0)); + await tester.pump(); + + expect(onEnter, false); + expect(onHover, false); + expect(onExit, false); + + await gesture.moveTo(const Offset(5.0, 5.0)); + await tester.pump(); + + expect(onEnter, true); + expect(onHover, true); + expect(onExit, false); + + await gesture.moveTo(const Offset(20.0, 20.0)); + await tester.pump(); + + expect(onEnter, true); + expect(onHover, true); + expect(onExit, true); + }); } // Render widget `topLeft` at the top-left corner, stacking on top of the widget