Skip to content

Commit

Permalink
Mouse onEnter and onExit now support hovering stylus (flutter#149006)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
justinmc authored May 28, 2024
1 parent b1221a9 commit 980b5a1
Show file tree
Hide file tree
Showing 4 changed files with 165 additions and 57 deletions.
2 changes: 1 addition & 1 deletion packages/flutter/lib/src/rendering/mouse_tracker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
46 changes: 46 additions & 0 deletions packages/flutter/test/material/text_button_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
133 changes: 77 additions & 56 deletions packages/flutter/test/rendering/mouse_tracker_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<PointerEvent> events = <PointerEvent>[];
setUpWithOneAnnotation(logEvents: events);
for (final ui.PointerDeviceKind pointerDeviceKind in <ui.PointerDeviceKind>[ui.PointerDeviceKind.mouse, ui.PointerDeviceKind.stylus]) {
test('should detect enter, hover, and exit from Added, Hover, and Removed events for stylus', () {
final List<PointerEvent> events = <PointerEvent>[];
setUpWithOneAnnotation(logEvents: events);

final List<bool> listenerLogs = <bool>[];
_mouseTracker.addListener(() {
listenerLogs.add(_mouseTracker.mouseIsConnected);
});

final List<bool> listenerLogs = <bool>[];
_mouseTracker.addListener(() {
listenerLogs.add(_mouseTracker.mouseIsConnected);
expect(_mouseTracker.mouseIsConnected, isFalse);

// Pointer enters the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(
PointerChange.add,
Offset.zero,
kind: pointerDeviceKind,
),
]));
addTearDown(() => dispatchRemoveDevice());

expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerEnterEvent>(const PointerEnterEvent()),
]));
expect(listenerLogs, <bool>[true]);
events.clear();
listenerLogs.clear();

// Pointer hovers the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(
PointerChange.hover,
const Offset(1.0, 101.0),
kind: pointerDeviceKind,
),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerHoverEvent>(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: <ui.PointerData>[
_pointerData(
PointerChange.remove,
const Offset(1.0, 101.0),
kind: pointerDeviceKind,
),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 101.0))),
]));
expect(listenerLogs, <bool>[false]);
events.clear();
listenerLogs.clear();

// Pointer is added on the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(
PointerChange.add,
const Offset(0.0, 301.0),
kind: pointerDeviceKind,
),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 301.0))),
]));
expect(listenerLogs, <bool>[true]);
events.clear();
listenerLogs.clear();
});

expect(_mouseTracker.mouseIsConnected, isFalse);

// Pointer enters the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, Offset.zero),
]));
addTearDown(() => dispatchRemoveDevice());

expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerEnterEvent>(const PointerEnterEvent()),
]));
expect(listenerLogs, <bool>[true]);
events.clear();
listenerLogs.clear();

// Pointer hovers the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.hover, const Offset(1.0, 101.0)),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerHoverEvent>(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: <ui.PointerData>[
_pointerData(PointerChange.remove, const Offset(1.0, 101.0)),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerExitEvent>(const PointerExitEvent(position: Offset(1.0, 101.0))),
]));
expect(listenerLogs, <bool>[false]);
events.clear();
listenerLogs.clear();

// Pointer is added on the annotation.
RendererBinding.instance.platformDispatcher.onPointerDataPacket!(ui.PointerDataPacket(data: <ui.PointerData>[
_pointerData(PointerChange.add, const Offset(0.0, 301.0)),
]));
expect(events, _equalToEventsOnCriticalFields(<BaseEventMatcher>[
EventMatcher<PointerEnterEvent>(const PointerEnterEvent(position: Offset(0.0, 301.0))),
]));
expect(listenerLogs, <bool>[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', () {
Expand Down Expand Up @@ -623,7 +641,10 @@ class BaseEventMatcher extends Matcher {
bool matches(dynamic untypedItem, Map<dynamic, dynamic> 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)
Expand Down
41 changes: 41 additions & 0 deletions packages/flutter/test/widgets/mouse_region_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 980b5a1

Please sign in to comment.