From aa27e785e059edcf12122ab07d3d807772c70c31 Mon Sep 17 00:00:00 2001 From: Benji Farquhar <26356162+BenjiFarquhar@users.noreply.github.com> Date: Wed, 5 Feb 2025 07:38:53 +1300 Subject: [PATCH] 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]. [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 --- .../flutter/lib/src/material/tooltip.dart | 17 +- .../flutter/test/material/tooltip_test.dart | 176 +++++++++++++++++- 2 files changed, 190 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index c83266ac60..5b60bd66a0 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -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 _openedTooltips = []; /// Dismiss all of the tooltips that are currently shown on the screen, @@ -846,6 +858,7 @@ class TooltipState extends State 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), ), ); } diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 889ebfd71c..83a33b1fe5 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -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(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(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: [ + 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(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: [ + 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(ignorePointerFinder.last); + expect(ignorePointer.ignoring, isTrue); + + await gesture.removePointer(); + }); } Future setWidgetForTooltipMode( @@ -3228,6 +3398,7 @@ Future setWidgetForTooltipMode( Duration? showDuration, bool? enableTapToDismiss, TooltipTriggeredCallback? onTriggered, + bool? ignorePointer, }) async { await tester.pumpWidget( MaterialApp( @@ -3237,6 +3408,7 @@ Future setWidgetForTooltipMode( onTriggered: onTriggered, showDuration: showDuration, enableTapToDismiss: enableTapToDismiss ?? true, + ignorePointer: ignorePointer, child: const SizedBox(width: 100.0, height: 100.0), ), ),