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 {
/// * [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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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<Tooltip> 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,
);
}
......
......@@ -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);
......
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