diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 13cc4a5bbb..9f8dcd3923 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -194,18 +194,41 @@ class Tooltip extends StatefulWidget { /// * [Feedback], for providing platform-specific feedback to certain actions. final bool? enableFeedback; - static final Set<_TooltipState> _openedToolTips = <_TooltipState>{}; + static final List<_TooltipState> _openedTooltips = <_TooltipState>[]; + + // Causes any current tooltips to be concealed. Only called for mouse hover enter + // detections. Won't conceal the supplied tooltip. + static void _concealOtherTooltips(_TooltipState current) { + if (_openedTooltips.isNotEmpty) { + // Avoid concurrent modification. + final List<_TooltipState> openedTooltips = _openedTooltips.toList(); + for (final _TooltipState state in openedTooltips) { + if (state == current) { + continue; + } + state._concealTooltip(); + } + } + } + + // Causes the most recently concealed tooltip to be revealed. Only called for mouse + // hover exit detections. + static void _revealLastTooltip() { + if (_openedTooltips.isNotEmpty) { + _openedTooltips.last._revealTooltip(); + } + } /// 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) { + if (_openedTooltips.isNotEmpty) { // Avoid concurrent modification. - final List<_TooltipState> openedToolTips = List<_TooltipState>.from(_openedToolTips); - for (final _TooltipState state in openedToolTips) { - state._hideTooltip(immediately: true); + final List<_TooltipState> openedTooltips = _openedTooltips.toList(); + for (final _TooltipState state in openedTooltips) { + state._dismissTooltip(immediately: true); } return true; } @@ -255,7 +278,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { late bool excludeFromSemantics; late AnimationController _controller; OverlayEntry? _entry; - Timer? _hideTimer; + Timer? _dismissTimer; Timer? _showTimer; late Duration showDuration; late Duration hoverShowDuration; @@ -264,10 +287,14 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { bool _pressActivated = false; late TooltipTriggerMode triggerMode; late bool enableFeedback; + late bool _isConcealed; + late bool _forceRemoval; @override void initState() { super.initState(); + _isConcealed = false; + _forceRemoval = false; _mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected; _controller = AnimationController( duration: _fadeInDuration, @@ -333,29 +360,34 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { } void _handleStatusChanged(AnimationStatus status) { - if (status == AnimationStatus.dismissed) { - _hideTooltip(immediately: true); + // If this tip is concealed, don't remove it, even if it is dismissed, so that we can + // reveal it later, unless it has explicitly been hidden with _dismissTooltip. + if (status == AnimationStatus.dismissed && (_forceRemoval || !_isConcealed)) { + _removeEntry(); } } - void _hideTooltip({ bool immediately = false }) { + void _dismissTooltip({ bool immediately = false }) { _showTimer?.cancel(); _showTimer = null; if (immediately) { _removeEntry(); return; } + // So it will be removed when it's done reversing, regardless of whether it is + // still concealed or not. + _forceRemoval = true; if (_pressActivated) { - _hideTimer ??= Timer(showDuration, _controller.reverse); + _dismissTimer ??= Timer(showDuration, _controller.reverse); } else { - _hideTimer ??= Timer(hoverShowDuration, _controller.reverse); + _dismissTimer ??= Timer(hoverShowDuration, _controller.reverse); } _pressActivated = false; } void _showTooltip({ bool immediately = false }) { - _hideTimer?.cancel(); - _hideTimer = null; + _dismissTimer?.cancel(); + _dismissTimer = null; if (immediately) { ensureTooltipVisible(); return; @@ -363,17 +395,61 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { _showTimer ??= Timer(waitDuration, ensureTooltipVisible); } + void _concealTooltip() { + if (_isConcealed || _forceRemoval) { + // Already concealed, or it's being removed. + return; + } + _isConcealed = true; + _dismissTimer?.cancel(); + _dismissTimer = null; + _showTimer?.cancel(); + _showTimer = null; + if (_entry!= null) { + _entry!.remove(); + } + _controller.reverse(); + } + + void _revealTooltip() { + if (!_isConcealed) { + // Already uncovered. + return; + } + _isConcealed = false; + _dismissTimer?.cancel(); + _dismissTimer = null; + _showTimer?.cancel(); + _showTimer = null; + if (!_entry!.mounted) { + final OverlayState overlayState = Overlay.of( + context, + debugRequiredFor: widget, + )!; + overlayState.insert(_entry!); + } + SemanticsService.tooltip(widget.message); + _controller.forward(); + } + /// Shows the tooltip if it is not already visible. /// - /// Returns `false` when the tooltip was already visible or if the context has - /// become null. + /// Returns `false` when the tooltip was already visible. bool ensureTooltipVisible() { _showTimer?.cancel(); _showTimer = null; + _forceRemoval = false; + if (_isConcealed) { + if (_mouseIsConnected) { + Tooltip._concealOtherTooltips(this); + } + _revealTooltip(); + return true; + } if (_entry != null) { // Stop trying to hide, if we were. - _hideTimer?.cancel(); - _hideTimer = null; + _dismissTimer?.cancel(); + _dismissTimer = null; _controller.forward(); return false; // Already visible. } @@ -382,6 +458,17 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { return true; } + static final Set<_TooltipState> _mouseIn = <_TooltipState>{}; + + void _handleMouseEnter() { + _showTooltip(); + } + + void _handleMouseExit({bool immediately = false}) { + // If the tip is currently covered, we can just remove it without waiting. + _dismissTooltip(immediately: _isConcealed || immediately); + } + void _createNewEntry() { final OverlayState overlayState = Overlay.of( context, @@ -404,8 +491,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, + onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null, + onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null, decoration: decoration, textStyle: textStyle, animation: CurvedAnimation( @@ -418,19 +505,34 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { ), ); _entry = OverlayEntry(builder: (BuildContext context) => overlay); + _isConcealed = false; overlayState.insert(_entry!); SemanticsService.tooltip(widget.message); - Tooltip._openedToolTips.add(this); + if (_mouseIsConnected) { + // Hovered tooltips shouldn't show more than one at once. For example, a chip with + // a delete icon shouldn't show both the delete icon tooltip and the chip tooltip + // at the same time. + Tooltip._concealOtherTooltips(this); + } + assert(!Tooltip._openedTooltips.contains(this)); + Tooltip._openedTooltips.add(this); } void _removeEntry() { - Tooltip._openedToolTips.remove(this); - _hideTimer?.cancel(); - _hideTimer = null; + Tooltip._openedTooltips.remove(this); + _mouseIn.remove(this); + _dismissTimer?.cancel(); + _dismissTimer = null; _showTimer?.cancel(); _showTimer = null; - _entry?.remove(); + if (!_isConcealed) { + _entry?.remove(); + } + _isConcealed = false; _entry = null; + if (_mouseIsConnected) { + Tooltip._revealLastTooltip(); + } } void _handlePointerEvent(PointerEvent event) { @@ -438,16 +540,16 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { return; } if (event is PointerUpEvent || event is PointerCancelEvent) { - _hideTooltip(); + _handleMouseExit(); } else if (event is PointerDownEvent) { - _hideTooltip(immediately: true); + _handleMouseExit(immediately: true); } } @override void deactivate() { if (_entry != null) { - _hideTooltip(immediately: true); + _dismissTooltip(immediately: true); } _showTimer?.cancel(); super.deactivate(); @@ -535,8 +637,8 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { // Only check for hovering if there is a mouse connected. if (_mouseIsConnected) { result = MouseRegion( - onEnter: (PointerEnterEvent event) => _showTooltip(), - onExit: (PointerExitEvent event) => _hideTooltip(), + onEnter: (_) => _handleMouseEnter(), + onExit: (_) => _handleMouseExit(), child: result, ); } diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 8e7e113168..35a02a693b 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -919,8 +919,7 @@ void main() { const Duration waitDuration = Duration.zero; TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); addTearDown(() async { - if (gesture != null) - return gesture.removePointer(); + gesture?.removePointer(); }); await gesture.addPointer(); await gesture.moveTo(const Offset(1.0, 1.0)); @@ -970,6 +969,70 @@ void main() { expect(find.text(tooltipText), findsNothing); }); + testWidgets('Tooltip should not show more than one tooltip when hovered', (WidgetTester tester) async { + const Duration waitDuration = Duration(milliseconds: 500); + final UniqueKey innerKey = UniqueKey(); + final UniqueKey outerKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: Tooltip( + message: 'Outer', + child: Container( + key: outerKey, + width: 100, + height: 100, + alignment: Alignment.centerRight, + child: Tooltip( + message: 'Inner', + child: SizedBox( + key: innerKey, + width: 25, + height: 100, + ), + ), + ), + ), + ), + ), + ); + + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { gesture?.removePointer(); }); + + // Both the inner and outer containers have tooltips associated with them, but only + // the currently hovered one should appear, even though the pointer is inside both. + final Finder outer = find.byKey(outerKey); + final Finder inner = find.byKey(innerKey); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(outer)); + await tester.pump(); + await gesture.moveTo(tester.getCenter(inner)); + await tester.pump(); + + // Wait for it to appear. + await tester.pump(waitDuration); + + expect(find.text('Outer'), findsNothing); + expect(find.text('Inner'), findsOneWidget); + await gesture.moveTo(tester.getCenter(outer)); + await tester.pump(); + // Wait for it to switch. + await tester.pump(waitDuration); + expect(find.text('Outer'), findsOneWidget); + expect(find.text('Inner'), findsNothing); + + await gesture.moveTo(Offset.zero); + + // Wait for all tooltips to disappear. + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text('Outer'), findsNothing); + expect(find.text('Inner'), 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);