Unverified Commit 885b2f56 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Fix tooltip so only one shows at a time when hovering (#90457)

In the process of fixing #90044, I realized that it's also possible for hovered tooltips to show more than one at a time if the widgets are nested, so this PR is a fix that prevents more than one tooltip from showing at a time with hovered tooltips.
parent 9db0600e
...@@ -194,18 +194,41 @@ class Tooltip extends StatefulWidget { ...@@ -194,18 +194,41 @@ class Tooltip extends StatefulWidget {
/// * [Feedback], for providing platform-specific feedback to certain actions. /// * [Feedback], for providing platform-specific feedback to certain actions.
final bool? enableFeedback; 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. /// Dismiss all of the tooltips that are currently shown on the screen.
/// ///
/// This method returns true if it successfully dismisses the tooltips. It /// This method returns true if it successfully dismisses the tooltips. It
/// returns false if there is no tooltip shown on the screen. /// returns false if there is no tooltip shown on the screen.
static bool dismissAllToolTips() { static bool dismissAllToolTips() {
if (_openedToolTips.isNotEmpty) { if (_openedTooltips.isNotEmpty) {
// Avoid concurrent modification. // Avoid concurrent modification.
final List<_TooltipState> openedToolTips = List<_TooltipState>.from(_openedToolTips); final List<_TooltipState> openedTooltips = _openedTooltips.toList();
for (final _TooltipState state in openedToolTips) { for (final _TooltipState state in openedTooltips) {
state._hideTooltip(immediately: true); state._dismissTooltip(immediately: true);
} }
return true; return true;
} }
...@@ -255,7 +278,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -255,7 +278,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late bool excludeFromSemantics; late bool excludeFromSemantics;
late AnimationController _controller; late AnimationController _controller;
OverlayEntry? _entry; OverlayEntry? _entry;
Timer? _hideTimer; Timer? _dismissTimer;
Timer? _showTimer; Timer? _showTimer;
late Duration showDuration; late Duration showDuration;
late Duration hoverShowDuration; late Duration hoverShowDuration;
...@@ -264,10 +287,14 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -264,10 +287,14 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
bool _pressActivated = false; bool _pressActivated = false;
late TooltipTriggerMode triggerMode; late TooltipTriggerMode triggerMode;
late bool enableFeedback; late bool enableFeedback;
late bool _isConcealed;
late bool _forceRemoval;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isConcealed = false;
_forceRemoval = false;
_mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected; _mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
_controller = AnimationController( _controller = AnimationController(
duration: _fadeInDuration, duration: _fadeInDuration,
...@@ -333,29 +360,34 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -333,29 +360,34 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
} }
void _handleStatusChanged(AnimationStatus status) { void _handleStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.dismissed) { // If this tip is concealed, don't remove it, even if it is dismissed, so that we can
_hideTooltip(immediately: true); // 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?.cancel();
_showTimer = null; _showTimer = null;
if (immediately) { if (immediately) {
_removeEntry(); _removeEntry();
return; return;
} }
// So it will be removed when it's done reversing, regardless of whether it is
// still concealed or not.
_forceRemoval = true;
if (_pressActivated) { if (_pressActivated) {
_hideTimer ??= Timer(showDuration, _controller.reverse); _dismissTimer ??= Timer(showDuration, _controller.reverse);
} else { } else {
_hideTimer ??= Timer(hoverShowDuration, _controller.reverse); _dismissTimer ??= Timer(hoverShowDuration, _controller.reverse);
} }
_pressActivated = false; _pressActivated = false;
} }
void _showTooltip({ bool immediately = false }) { void _showTooltip({ bool immediately = false }) {
_hideTimer?.cancel(); _dismissTimer?.cancel();
_hideTimer = null; _dismissTimer = null;
if (immediately) { if (immediately) {
ensureTooltipVisible(); ensureTooltipVisible();
return; return;
...@@ -363,17 +395,61 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -363,17 +395,61 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_showTimer ??= Timer(waitDuration, ensureTooltipVisible); _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. /// Shows the tooltip if it is not already visible.
/// ///
/// Returns `false` when the tooltip was already visible or if the context has /// Returns `false` when the tooltip was already visible.
/// become null.
bool ensureTooltipVisible() { bool ensureTooltipVisible() {
_showTimer?.cancel(); _showTimer?.cancel();
_showTimer = null; _showTimer = null;
_forceRemoval = false;
if (_isConcealed) {
if (_mouseIsConnected) {
Tooltip._concealOtherTooltips(this);
}
_revealTooltip();
return true;
}
if (_entry != null) { if (_entry != null) {
// Stop trying to hide, if we were. // Stop trying to hide, if we were.
_hideTimer?.cancel(); _dismissTimer?.cancel();
_hideTimer = null; _dismissTimer = null;
_controller.forward(); _controller.forward();
return false; // Already visible. return false; // Already visible.
} }
...@@ -382,6 +458,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -382,6 +458,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
return true; 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() { void _createNewEntry() {
final OverlayState overlayState = Overlay.of( final OverlayState overlayState = Overlay.of(
context, context,
...@@ -404,8 +491,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -404,8 +491,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
height: height, height: height,
padding: padding, padding: padding,
margin: margin, margin: margin,
onEnter: _mouseIsConnected ? (PointerEnterEvent event) => _showTooltip() : null, onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
onExit: _mouseIsConnected ? (PointerExitEvent event) => _hideTooltip() : null, onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
decoration: decoration, decoration: decoration,
textStyle: textStyle, textStyle: textStyle,
animation: CurvedAnimation( animation: CurvedAnimation(
...@@ -418,19 +505,34 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -418,19 +505,34 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
), ),
); );
_entry = OverlayEntry(builder: (BuildContext context) => overlay); _entry = OverlayEntry(builder: (BuildContext context) => overlay);
_isConcealed = false;
overlayState.insert(_entry!); overlayState.insert(_entry!);
SemanticsService.tooltip(widget.message); 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() { void _removeEntry() {
Tooltip._openedToolTips.remove(this); Tooltip._openedTooltips.remove(this);
_hideTimer?.cancel(); _mouseIn.remove(this);
_hideTimer = null; _dismissTimer?.cancel();
_dismissTimer = null;
_showTimer?.cancel(); _showTimer?.cancel();
_showTimer = null; _showTimer = null;
_entry?.remove(); if (!_isConcealed) {
_entry?.remove();
}
_isConcealed = false;
_entry = null; _entry = null;
if (_mouseIsConnected) {
Tooltip._revealLastTooltip();
}
} }
void _handlePointerEvent(PointerEvent event) { void _handlePointerEvent(PointerEvent event) {
...@@ -438,16 +540,16 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -438,16 +540,16 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
return; return;
} }
if (event is PointerUpEvent || event is PointerCancelEvent) { if (event is PointerUpEvent || event is PointerCancelEvent) {
_hideTooltip(); _handleMouseExit();
} else if (event is PointerDownEvent) { } else if (event is PointerDownEvent) {
_hideTooltip(immediately: true); _handleMouseExit(immediately: true);
} }
} }
@override @override
void deactivate() { void deactivate() {
if (_entry != null) { if (_entry != null) {
_hideTooltip(immediately: true); _dismissTooltip(immediately: true);
} }
_showTimer?.cancel(); _showTimer?.cancel();
super.deactivate(); super.deactivate();
...@@ -535,8 +637,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -535,8 +637,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
// Only check for hovering if there is a mouse connected. // Only check for hovering if there is a mouse connected.
if (_mouseIsConnected) { if (_mouseIsConnected) {
result = MouseRegion( result = MouseRegion(
onEnter: (PointerEnterEvent event) => _showTooltip(), onEnter: (_) => _handleMouseEnter(),
onExit: (PointerExitEvent event) => _hideTooltip(), onExit: (_) => _handleMouseExit(),
child: result, child: result,
); );
} }
......
...@@ -919,8 +919,7 @@ void main() { ...@@ -919,8 +919,7 @@ void main() {
const Duration waitDuration = Duration.zero; const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async { addTearDown(() async {
if (gesture != null) gesture?.removePointer();
return gesture.removePointer();
}); });
await gesture.addPointer(); await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0)); await gesture.moveTo(const Offset(1.0, 1.0));
...@@ -970,6 +969,70 @@ void main() { ...@@ -970,6 +969,70 @@ void main() {
expect(find.text(tooltipText), findsNothing); 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 { testWidgets('Tooltip can be dismissed by escape key', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero; const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment