From 97dfafbb62335f0b3d856a844fbe2bb74aba3145 Mon Sep 17 00:00:00 2001 From: chunhtai <47866232+chunhtai@users.noreply.github.com> Date: Thu, 3 Jun 2021 17:49:05 -0700 Subject: [PATCH] Make tooltip hoverable and dismissible (#83830) --- packages/flutter/lib/src/material/app.dart | 12 +- .../flutter/lib/src/material/tooltip.dart | 108 ++++++++---- .../flutter/test/material/debug_test.dart | 5 +- .../flutter/test/material/tooltip_test.dart | 165 ++++++++++++++++++ .../test/material/tooltip_theme_test.dart | 4 +- 5 files changed, 252 insertions(+), 42 deletions(-) diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 0e0d7478601..02983a27498 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -6,6 +6,7 @@ import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'arc.dart'; import 'colors.dart'; @@ -16,6 +17,7 @@ import 'page.dart'; import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState; import 'scrollbar.dart'; import 'theme.dart'; +import 'tooltip.dart'; /// [MaterialApp] uses this [TextStyle] as its [DefaultTextStyle] to encourage /// developers to be intentional about their [DefaultTextStyle]. @@ -896,7 +898,15 @@ class _MaterialAppState extends State { @override Widget build(BuildContext context) { Widget result = _buildWidgetApp(context); - + result = Focus( + canRequestFocus: false, + onKey: (FocusNode node, RawKeyEvent event) { + if (event is! RawKeyDownEvent || event.logicalKey != LogicalKeyboardKey.escape) + return KeyEventResult.ignored; + return Tooltip.dismissAllToolTips() ? KeyEventResult.handled : KeyEventResult.ignored; + }, + child: result, + ); assert(() { if (widget.debugShowMaterialGrid) { result = GridPaper( diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 5012047528b..22d06b46e48 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; @@ -56,7 +57,7 @@ import 'tooltip_theme.dart'; /// above the widget. /// `textStyle` has been used to set the font size of the 'message'. /// `showDuration` accepts a Duration to continue showing the message after the long -/// press has been released. +/// press has been released or the mouse pointer exits the child widget. /// `waitDuration` accepts a Duration for which a mouse pointer has to hover over the child /// widget before the tooltip is shown. /// @@ -190,18 +191,34 @@ class Tooltip extends StatefulWidget { /// The length of time that a pointer must hover over a tooltip's widget /// before the tooltip will be shown. /// - /// Once the pointer leaves the widget, the tooltip will immediately - /// disappear. - /// /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover). final Duration? waitDuration; /// The length of time that the tooltip will be shown after a long press - /// is released. + /// is released or mouse pointer exits the widget. /// - /// Defaults to 1.5 seconds. + /// Defaults to 1.5 seconds for long press released or 0.1 seconds for mouse + /// pointer exits the widget. final Duration? showDuration; + static final Set<_TooltipState> _openedToolTips = <_TooltipState>{}; + + /// Dismiss all of the tooltips that are currently shown on the screen. + /// + /// This method returns true if it successfully dismisses the tooltips. It + /// returns false if there is no tooltip shown on the screen. + static bool dismissAllToolTips() { + if (_openedToolTips.isNotEmpty) { + // Avoid concurrent modification. + final List<_TooltipState> openedToolTips = List<_TooltipState>.from(_openedToolTips); + for (final _TooltipState state in openedToolTips) { + state._hideTooltip(immediately: true); + } + return true; + } + return false; + } + @override State createState() => _TooltipState(); @@ -227,6 +244,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { static const Duration _fadeInDuration = Duration(milliseconds: 150); static const Duration _fadeOutDuration = Duration(milliseconds: 75); static const Duration _defaultShowDuration = Duration(milliseconds: 1500); + static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100); static const Duration _defaultWaitDuration = Duration.zero; static const bool _defaultExcludeFromSemantics = false; @@ -243,6 +261,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { Timer? _hideTimer; Timer? _showTimer; late Duration showDuration; + late Duration hoverShowDuration; late Duration waitDuration; late bool _mouseIsConnected; bool _longPressActivated = false; @@ -328,12 +347,9 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { return; } if (_longPressActivated) { - // Tool tips activated by long press should stay around for the showDuration. _hideTimer ??= Timer(showDuration, _controller.reverse); } else { - // Tool tips activated by hover should disappear as soon as the mouse - // leaves the control. - _controller.reverse(); + _hideTimer ??= Timer(hoverShowDuration, _controller.reverse); } _longPressActivated = false; } @@ -389,6 +405,8 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { height: height, padding: padding, margin: margin, + onEnter: _mouseIsConnected ? (PointerEnterEvent event) => _showTooltip() : null, + onExit: _mouseIsConnected ? (PointerExitEvent event) => _hideTooltip() : null, decoration: decoration, textStyle: textStyle, animation: CurvedAnimation( @@ -403,9 +421,11 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { _entry = OverlayEntry(builder: (BuildContext context) => overlay); overlayState.insert(_entry!); SemanticsService.tooltip(widget.message); + Tooltip._openedToolTips.add(this); } void _removeEntry() { + Tooltip._openedToolTips.remove(this); _hideTimer?.cancel(); _hideTimer = null; _showTimer?.cancel(); @@ -438,8 +458,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { void dispose() { GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent); RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange); - if (_entry != null) - _removeEntry(); + _removeEntry(); _controller.dispose(); super.dispose(); } @@ -488,6 +507,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle; waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration; showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration; + hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration; Widget result = GestureDetector( behavior: HitTestBehavior.opaque, @@ -575,6 +595,8 @@ class _TooltipOverlay extends StatelessWidget { required this.target, required this.verticalOffset, required this.preferBelow, + this.onEnter, + this.onExit, }) : super(key: key); final String message; @@ -587,40 +609,50 @@ class _TooltipOverlay extends StatelessWidget { final Offset target; final double verticalOffset; final bool preferBelow; + final PointerEnterEventListener? onEnter; + final PointerExitEventListener? onExit; @override Widget build(BuildContext context) { - return Positioned.fill( - child: IgnorePointer( - child: CustomSingleChildLayout( - delegate: _TooltipPositionDelegate( - target: target, - verticalOffset: verticalOffset, - preferBelow: preferBelow, - ), - child: FadeTransition( - opacity: animation, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: height), - child: DefaultTextStyle( - style: Theme.of(context).textTheme.bodyText2!, - child: Container( - decoration: decoration, - padding: padding, - margin: margin, - child: Center( - widthFactor: 1.0, - heightFactor: 1.0, - child: Text( - message, - style: textStyle, - ), - ), + Widget result = IgnorePointer( + child: FadeTransition( + opacity: animation, + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: DefaultTextStyle( + style: Theme.of(context).textTheme.bodyText2!, + child: Container( + decoration: decoration, + padding: padding, + margin: margin, + child: Center( + widthFactor: 1.0, + heightFactor: 1.0, + child: Text( + message, + style: textStyle, ), ), ), ), ), + ) + ); + if (onEnter != null || onExit != null) { + result = MouseRegion( + onEnter: onEnter, + onExit: onExit, + child: result, + ); + } + return Positioned.fill( + child: CustomSingleChildLayout( + delegate: _TooltipPositionDelegate( + target: target, + verticalOffset: verticalOffset, + preferBelow: preferBelow, + ), + child: result, ), ); } diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index b3d4f25815b..120db60885c 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -206,12 +206,15 @@ void main() { ' UnmanagedRestorationScope\n' ' RootRestorationScope\n' ' WidgetsApp-[GlobalObjectKey _MaterialAppState#00000]\n' + ' Semantics\n' + ' _FocusMarker\n' + ' Focus\n' ' HeroControllerScope\n' ' ScrollConfiguration\n' ' MaterialApp\n' ' [root]\n' ' Typically, the Scaffold widget is introduced by the MaterialApp\n' - ' or WidgetsApp widget at the top of your application widget tree.\n', + ' or WidgetsApp widget at the top of your application widget tree.\n' )); }); diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 31544ebaec1..9d255f1e9ff 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -912,6 +912,171 @@ void main() { expect(find.text(tooltipText), findsNothing); }); + testWidgets('Tooltip text is also hoverable', (WidgetTester tester) async { + const Duration waitDuration = Duration.zero; + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: Text('I am tool tip'), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsOneWidget); + + // Wait a looong time to make sure that it doesn't go away if the mouse is + // still over the widget. + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsOneWidget); + + // Hover to the tool tip text and verify the tooltip doesn't go away. + await gesture.moveTo(tester.getTopLeft(find.text(tooltipText))); + await tester.pump(const Duration(days: 1)); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsOneWidget); + + await gesture.moveTo(Offset.zero); + await tester.pump(); + + // Wait for it to disappear. + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip can be dismissed by escape key', (WidgetTester tester) async { + const Duration waitDuration = Duration.zero; + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: Text('I am tool tip'), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsOneWidget); + + // Try to dismiss the tooltip with the shortcut key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text(tooltipText), findsNothing); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + }); + + testWidgets('Multiple Tooltips are dismissed by escape key', (WidgetTester tester) async { + const Duration waitDuration = Duration.zero; + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Column( + children: const [ + Tooltip( + message: 'message1', + waitDuration: waitDuration, + showDuration: Duration(days: 1), + child: Text('tooltip1'), + ), + Spacer(flex: 2), + Tooltip( + message: 'message2', + waitDuration: waitDuration, + showDuration: Duration(days: 1), + child: Text('tooltip2'), + ) + ], + ), + ), + ), + ); + + final Finder tooltip = find.text('tooltip1'); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(waitDuration); + expect(find.text('message1'), findsOneWidget); + + final Finder secondTooltip = find.text('tooltip2'); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(secondTooltip)); + await tester.pump(); + await tester.pump(waitDuration); + // Make sure both messages are on the screen. + expect(find.text('message1'), findsOneWidget); + expect(find.text('message2'), findsOneWidget); + + // Try to dismiss the tooltip with the shortcut key + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.text('message1'), findsNothing); + expect(find.text('message2'), findsNothing); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + }); + testWidgets('Tooltip does not attempt to show after unmount', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/54096. const Duration waitDuration = Duration(seconds: 1); diff --git a/packages/flutter/test/material/tooltip_theme_test.dart b/packages/flutter/test/material/tooltip_theme_test.dart index f81d8cc834b..1283620118d 100644 --- a/packages/flutter/test/material/tooltip_theme_test.dart +++ b/packages/flutter/test/material/tooltip_theme_test.dart @@ -865,7 +865,7 @@ void main() { await tester.pump(); // Wait for it to disappear. - await tester.pump(Duration.zero); // Should immediately disappear + await tester.pump(customWaitDuration); expect(find.text(tooltipText), findsNothing); }); @@ -909,7 +909,7 @@ void main() { await tester.pump(); // Wait for it to disappear. - await tester.pump(Duration.zero); // Should immediately disappear + await tester.pump(customWaitDuration); // Should disappear after customWaitDuration expect(find.text(tooltipText), findsNothing); });