Skip to content

Commit aa27e78

Browse files
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
1 parent ccae8cc commit aa27e78

File tree

2 files changed

+190
-3
lines changed

2 files changed

+190
-3
lines changed

packages/flutter/lib/src/material/tooltip.dart

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ class Tooltip extends StatefulWidget {
186186
this.enableFeedback,
187187
this.onTriggered,
188188
this.mouseCursor,
189+
this.ignorePointer,
189190
this.child,
190191
}) : assert(
191192
(message == null) != (richMessage == null),
@@ -363,6 +364,17 @@ class Tooltip extends StatefulWidget {
363364
/// If this property is null, [MouseCursor.defer] will be used.
364365
final MouseCursor? mouseCursor;
365366

367+
/// Whether this tooltip should be invisible to hit testing.
368+
///
369+
/// If no value is passed, pointer events are ignored unless the tooltip has a
370+
/// [richMessage] instead of a [message].
371+
///
372+
/// See also:
373+
///
374+
/// * [IgnorePointer], for more information about how pointer events are
375+
/// handled or ignored.
376+
final bool? ignorePointer;
377+
366378
static final List<TooltipState> _openedTooltips = <TooltipState>[];
367379

368380
/// Dismiss all of the tooltips that are currently shown on the screen,
@@ -846,6 +858,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
846858
verticalOffset:
847859
widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset,
848860
preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow,
861+
ignorePointer: widget.ignorePointer ?? widget.message != null,
849862
);
850863

851864
return SelectionContainer.maybeOf(context) == null
@@ -971,6 +984,7 @@ class _TooltipOverlay extends StatelessWidget {
971984
required this.target,
972985
required this.verticalOffset,
973986
required this.preferBelow,
987+
required this.ignorePointer,
974988
this.onEnter,
975989
this.onExit,
976990
});
@@ -988,6 +1002,7 @@ class _TooltipOverlay extends StatelessWidget {
9881002
final bool preferBelow;
9891003
final PointerEnterEventListener? onEnter;
9901004
final PointerExitEventListener? onExit;
1005+
final bool ignorePointer;
9911006

9921007
@override
9931008
Widget build(BuildContext context) {
@@ -1024,7 +1039,7 @@ class _TooltipOverlay extends StatelessWidget {
10241039
verticalOffset: verticalOffset,
10251040
preferBelow: preferBelow,
10261041
),
1027-
child: result,
1042+
child: IgnorePointer(ignoring: ignorePointer, child: result),
10281043
),
10291044
);
10301045
}

packages/flutter/test/material/tooltip_test.dart

Lines changed: 174 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,7 +1403,8 @@ void main() {
14031403
});
14041404

14051405
testWidgets('Tooltip is dismissed after tap to dismiss immediately', (WidgetTester tester) async {
1406-
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap);
1406+
// This test relies on not ignoring pointer events.
1407+
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, ignorePointer: false);
14071408

14081409
final Finder tooltip = find.byType(Tooltip);
14091410
expect(find.text(tooltipText), findsNothing);
@@ -1421,7 +1422,13 @@ void main() {
14211422
testWidgets('Tooltip is not dismissed after tap if enableTapToDismiss is false', (
14221423
WidgetTester tester,
14231424
) async {
1424-
await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, enableTapToDismiss: false);
1425+
// This test relies on not ignoring pointer events.
1426+
await setWidgetForTooltipMode(
1427+
tester,
1428+
TooltipTriggerMode.tap,
1429+
enableTapToDismiss: false,
1430+
ignorePointer: false,
1431+
);
14251432

14261433
final Finder tooltip = find.byType(Tooltip);
14271434
expect(find.text(tooltipText), findsNothing);
@@ -1727,6 +1734,8 @@ void main() {
17271734
const MaterialApp(
17281735
home: Center(
17291736
child: Tooltip(
1737+
// This test relies on not ignoring pointer events.
1738+
ignorePointer: false,
17301739
message: tooltipText,
17311740
waitDuration: waitDuration,
17321741
child: Text('I am tool tip'),
@@ -3220,6 +3229,167 @@ void main() {
32203229
await tester.pump();
32213230
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), customCursor);
32223231
});
3232+
3233+
testWidgets('Tooltip overlay ignores pointer by default when passing simple message', (
3234+
WidgetTester tester,
3235+
) async {
3236+
const String tooltipMessage = 'Tooltip message';
3237+
3238+
await tester.pumpWidget(
3239+
MaterialApp(
3240+
home: Scaffold(
3241+
body: Center(
3242+
child: Tooltip(
3243+
message: tooltipMessage,
3244+
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
3245+
),
3246+
),
3247+
),
3248+
),
3249+
);
3250+
3251+
final Finder buttonFinder = find.text('Hover me');
3252+
expect(buttonFinder, findsOneWidget);
3253+
3254+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3255+
await gesture.addPointer();
3256+
await gesture.moveTo(tester.getCenter(buttonFinder));
3257+
await tester.pumpAndSettle();
3258+
3259+
final Finder tooltipFinder = find.text(tooltipMessage);
3260+
expect(tooltipFinder, findsOneWidget);
3261+
3262+
final Finder ignorePointerFinder = find.byType(IgnorePointer);
3263+
3264+
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
3265+
expect(ignorePointer.ignoring, isTrue);
3266+
3267+
await gesture.removePointer();
3268+
});
3269+
3270+
testWidgets(
3271+
"Tooltip overlay with simple message doesn't ignore pointer when passing ignorePointer: false",
3272+
(WidgetTester tester) async {
3273+
const String tooltipMessage = 'Tooltip message';
3274+
3275+
await tester.pumpWidget(
3276+
MaterialApp(
3277+
home: Scaffold(
3278+
body: Center(
3279+
child: Tooltip(
3280+
ignorePointer: false,
3281+
message: tooltipMessage,
3282+
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
3283+
),
3284+
),
3285+
),
3286+
),
3287+
);
3288+
3289+
final Finder buttonFinder = find.text('Hover me');
3290+
expect(buttonFinder, findsOneWidget);
3291+
3292+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3293+
await gesture.addPointer();
3294+
await gesture.moveTo(tester.getCenter(buttonFinder));
3295+
await tester.pumpAndSettle();
3296+
3297+
final Finder tooltipFinder = find.text(tooltipMessage);
3298+
expect(tooltipFinder, findsOneWidget);
3299+
3300+
final Finder ignorePointerFinder = find.byType(IgnorePointer);
3301+
3302+
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
3303+
expect(ignorePointer.ignoring, isFalse);
3304+
3305+
await gesture.removePointer();
3306+
},
3307+
);
3308+
3309+
testWidgets("Tooltip overlay doesn't ignore pointer by default when passing rich message", (
3310+
WidgetTester tester,
3311+
) async {
3312+
const InlineSpan richMessage = TextSpan(
3313+
children: <InlineSpan>[
3314+
TextSpan(text: 'Rich ', style: TextStyle(fontWeight: FontWeight.bold)),
3315+
TextSpan(text: 'Tooltip'),
3316+
],
3317+
);
3318+
3319+
await tester.pumpWidget(
3320+
MaterialApp(
3321+
home: Scaffold(
3322+
body: Center(
3323+
child: Tooltip(
3324+
richMessage: richMessage,
3325+
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
3326+
),
3327+
),
3328+
),
3329+
),
3330+
);
3331+
3332+
final Finder buttonFinder = find.text('Hover me');
3333+
expect(buttonFinder, findsOneWidget);
3334+
3335+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3336+
await gesture.addPointer();
3337+
await gesture.moveTo(tester.getCenter(buttonFinder));
3338+
await tester.pumpAndSettle();
3339+
3340+
final Finder tooltipFinder = find.textContaining('Rich Tooltip');
3341+
expect(tooltipFinder, findsOneWidget);
3342+
3343+
final Finder ignorePointerFinder = find.byType(IgnorePointer);
3344+
3345+
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
3346+
expect(ignorePointer.ignoring, isFalse);
3347+
3348+
await gesture.removePointer();
3349+
});
3350+
3351+
testWidgets('Tooltip overlay with richMessage ignores pointer when passing ignorePointer: true', (
3352+
WidgetTester tester,
3353+
) async {
3354+
const InlineSpan richMessage = TextSpan(
3355+
children: <InlineSpan>[
3356+
TextSpan(text: 'Rich ', style: TextStyle(fontWeight: FontWeight.bold)),
3357+
TextSpan(text: 'Tooltip'),
3358+
],
3359+
);
3360+
3361+
await tester.pumpWidget(
3362+
MaterialApp(
3363+
home: Scaffold(
3364+
body: Center(
3365+
child: Tooltip(
3366+
ignorePointer: true,
3367+
richMessage: richMessage,
3368+
child: ElevatedButton(onPressed: () {}, child: const Text('Hover me')),
3369+
),
3370+
),
3371+
),
3372+
),
3373+
);
3374+
3375+
final Finder buttonFinder = find.text('Hover me');
3376+
expect(buttonFinder, findsOneWidget);
3377+
3378+
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
3379+
await gesture.addPointer();
3380+
await gesture.moveTo(tester.getCenter(buttonFinder));
3381+
await tester.pumpAndSettle();
3382+
3383+
final Finder tooltipFinder = find.textContaining('Rich Tooltip');
3384+
expect(tooltipFinder, findsOneWidget);
3385+
3386+
final Finder ignorePointerFinder = find.byType(IgnorePointer);
3387+
3388+
final IgnorePointer ignorePointer = tester.widget<IgnorePointer>(ignorePointerFinder.last);
3389+
expect(ignorePointer.ignoring, isTrue);
3390+
3391+
await gesture.removePointer();
3392+
});
32233393
}
32243394

32253395
Future<void> setWidgetForTooltipMode(
@@ -3228,6 +3398,7 @@ Future<void> setWidgetForTooltipMode(
32283398
Duration? showDuration,
32293399
bool? enableTapToDismiss,
32303400
TooltipTriggeredCallback? onTriggered,
3401+
bool? ignorePointer,
32313402
}) async {
32323403
await tester.pumpWidget(
32333404
MaterialApp(
@@ -3237,6 +3408,7 @@ Future<void> setWidgetForTooltipMode(
32373408
onTriggered: onTriggered,
32383409
showDuration: showDuration,
32393410
enableTapToDismiss: enableTapToDismiss ?? true,
3411+
ignorePointer: ignorePointer,
32403412
child: const SizedBox(width: 100.0, height: 100.0),
32413413
),
32423414
),

0 commit comments

Comments
 (0)