Support ignoring pointer events on tooltip overlay (#142465) (#161363)

As #142465 states, tooltips often interrupt widget interactivity by not
allowing events to pass through to the Tooltip child, which is
especially poor UX when hovering interact-able widgets on web when the
mouse happens to land on the tooltip.

I've gone with defaulting ignorePointer to true when a simple message is
supplied, since there won't ever be anything interact-able on the
Tooltip, and defaulting to false when richMessage is supplied, so it
doesn't break anyone's code that has interact-able widgets in the
Tooltip.

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-new channel
on [Discord].

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Benji Farquhar 2025-02-05 07:38:53 +13:00 committed by GitHub
parent ccae8cc794
commit aa27e785e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 190 additions and 3 deletions

View File

@ -186,6 +186,7 @@ class Tooltip extends StatefulWidget {
this.enableFeedback,
this.onTriggered,
this.mouseCursor,
this.ignorePointer,
this.child,
}) : assert(
(message == null) != (richMessage == null),
@ -363,6 +364,17 @@ class Tooltip extends StatefulWidget {
/// If this property is null, [MouseCursor.defer] will be used.
final MouseCursor? mouseCursor;
/// Whether this tooltip should be invisible to hit testing.
///
/// If no value is passed, pointer events are ignored unless the tooltip has a
/// [richMessage] instead of a [message].
///
/// See also:
///
/// * [IgnorePointer], for more information about how pointer events are
/// handled or ignored.
final bool? ignorePointer;
static final List<TooltipState> _openedTooltips = <TooltipState>[];
/// Dismiss all of the tooltips that are currently shown on the screen,
@ -846,6 +858,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
verticalOffset:
widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset,
preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow,
ignorePointer: widget.ignorePointer ?? widget.message != null,
);
return SelectionContainer.maybeOf(context) == null
@ -971,6 +984,7 @@ class _TooltipOverlay extends StatelessWidget {
required this.target,
required this.verticalOffset,
required this.preferBelow,
required this.ignorePointer,
this.onEnter,
this.onExit,
});
@ -988,6 +1002,7 @@ class _TooltipOverlay extends StatelessWidget {
final bool preferBelow;
final PointerEnterEventListener? onEnter;
final PointerExitEventListener? onExit;
final bool ignorePointer;
@override
Widget build(BuildContext context) {
@ -1024,7 +1039,7 @@ class _TooltipOverlay extends StatelessWidget {
verticalOffset: verticalOffset,
preferBelow: preferBelow,
),
child: result,
child: IgnorePointer(ignoring: ignorePointer, child: result),
),
);
}

View File

@ -1403,7 +1403,8 @@ void main() {
});
testWidgets('Tooltip is dismissed after tap to dismiss immediately', (WidgetTester tester) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap);
// This test relies on not ignoring pointer events.
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, ignorePointer: false);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
@ -1421,7 +1422,13 @@ void main() {
testWidgets('Tooltip is not dismissed after tap if enableTapToDismiss is false', (
WidgetTester tester,
) async {
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, enableTapToDismiss: false);
// This test relies on not ignoring pointer events.
await setWidgetForTooltipMode(
tester,
TooltipTriggerMode.tap,
enableTapToDismiss: false,
ignorePointer: false,
);
final Finder tooltip = find.byType(Tooltip);
expect(find.text(tooltipText), findsNothing);
@ -1727,6 +1734,8 @@ void main() {
const MaterialApp(
home: Center(
child: Tooltip(
// This test relies on not ignoring pointer events.
ignorePointer: false,
message: tooltipText,
waitDuration: waitDuration,
child: Text('I am tool tip'),
@ -3220,6 +3229,167 @@ void main() {
await tester.pump();
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor);
});
testWidgets('Tooltip overlay ignores pointer by default when passing simple message', (
WidgetTester tester,
) async {
const String tooltipMessage = 'Tooltip message';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Tooltip(
message: tooltipMessage,
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
),
),
),
),
);
final Finder buttonFinder = find.text('Hover me');
expect(buttonFinder, findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(tester.getCenter(buttonFinder));
await tester.pumpAndSettle();
final Finder tooltipFinder = find.text(tooltipMessage);
expect(tooltipFinder, findsOneWidget);
final Finder ignorePointerFinder = find.byType(IgnorePointer);
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
expect(ignorePointer.ignoring, isTrue);
await gesture.removePointer();
});
testWidgets(
"Tooltip overlay with simple message doesn't ignore pointer when passing ignorePointer: false",
(WidgetTester tester) async {
const String tooltipMessage = 'Tooltip message';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Tooltip(
ignorePointer: false,
message: tooltipMessage,
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
),
),
),
),
);
final Finder buttonFinder = find.text('Hover me');
expect(buttonFinder, findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(tester.getCenter(buttonFinder));
await tester.pumpAndSettle();
final Finder tooltipFinder = find.text(tooltipMessage);
expect(tooltipFinder, findsOneWidget);
final Finder ignorePointerFinder = find.byType(IgnorePointer);
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
expect(ignorePointer.ignoring, isFalse);
await gesture.removePointer();
},
);
testWidgets("Tooltip overlay doesn't ignore pointer by default when passing rich message", (
WidgetTester tester,
) async {
const InlineSpan richMessage = TextSpan(
children: <InlineSpan>[
TextSpan(text: 'Rich ', style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'Tooltip'),
],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Tooltip(
richMessage: richMessage,
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
),
),
),
),
);
final Finder buttonFinder = find.text('Hover me');
expect(buttonFinder, findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(tester.getCenter(buttonFinder));
await tester.pumpAndSettle();
final Finder tooltipFinder = find.textContaining('Rich Tooltip');
expect(tooltipFinder, findsOneWidget);
final Finder ignorePointerFinder = find.byType(IgnorePointer);
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
expect(ignorePointer.ignoring, isFalse);
await gesture.removePointer();
});
testWidgets('Tooltip overlay with richMessage ignores pointer when passing ignorePointer: true', (
WidgetTester tester,
) async {
const InlineSpan richMessage = TextSpan(
children: <InlineSpan>[
TextSpan(text: 'Rich ', style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'Tooltip'),
],
);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: Tooltip(
ignorePointer: true,
richMessage: richMessage,
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
),
),
),
),
);
final Finder buttonFinder = find.text('Hover me');
expect(buttonFinder, findsOneWidget);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(tester.getCenter(buttonFinder));
await tester.pumpAndSettle();
final Finder tooltipFinder = find.textContaining('Rich Tooltip');
expect(tooltipFinder, findsOneWidget);
final Finder ignorePointerFinder = find.byType(IgnorePointer);
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
expect(ignorePointer.ignoring, isTrue);
await gesture.removePointer();
});
}
Future<void> setWidgetForTooltipMode(
@ -3228,6 +3398,7 @@ Future<void> setWidgetForTooltipMode(
Duration? showDuration,
bool? enableTapToDismiss,
TooltipTriggeredCallback? onTriggered,
bool? ignorePointer,
}) async {
await tester.pumpWidget(
MaterialApp(
@ -3237,6 +3408,7 @@ Future<void> setWidgetForTooltipMode(
onTriggered: onTriggered,
showDuration: showDuration,
enableTapToDismiss: enableTapToDismiss ?? true,
ignorePointer: ignorePointer,
child: const SizedBox(width: 100.0, height: 100.0),
),
),