Skip to content

Commit bac1af3

Browse files
authored
Reland: "Fix tooltip so only one shows at a time when hovering (flutter#90457)" (flutter#90917)
This reverts commit ab51a02 and fixes the test that broke the first time it landed.
1 parent 0f64038 commit bac1af3

File tree

3 files changed

+200
-36
lines changed

3 files changed

+200
-36
lines changed

packages/flutter/lib/src/material/tooltip.dart

Lines changed: 131 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -194,18 +194,41 @@ class Tooltip extends StatefulWidget {
194194
/// * [Feedback], for providing platform-specific feedback to certain actions.
195195
final bool? enableFeedback;
196196

197-
static final Set<_TooltipState> _openedToolTips = <_TooltipState>{};
197+
static final List<_TooltipState> _openedTooltips = <_TooltipState>[];
198+
199+
// Causes any current tooltips to be concealed. Only called for mouse hover enter
200+
// detections. Won't conceal the supplied tooltip.
201+
static void _concealOtherTooltips(_TooltipState current) {
202+
if (_openedTooltips.isNotEmpty) {
203+
// Avoid concurrent modification.
204+
final List<_TooltipState> openedTooltips = _openedTooltips.toList();
205+
for (final _TooltipState state in openedTooltips) {
206+
if (state == current) {
207+
continue;
208+
}
209+
state._concealTooltip();
210+
}
211+
}
212+
}
213+
214+
// Causes the most recently concealed tooltip to be revealed. Only called for mouse
215+
// hover exit detections.
216+
static void _revealLastTooltip() {
217+
if (_openedTooltips.isNotEmpty) {
218+
_openedTooltips.last._revealTooltip();
219+
}
220+
}
198221

199222
/// Dismiss all of the tooltips that are currently shown on the screen.
200223
///
201224
/// This method returns true if it successfully dismisses the tooltips. It
202225
/// returns false if there is no tooltip shown on the screen.
203226
static bool dismissAllToolTips() {
204-
if (_openedToolTips.isNotEmpty) {
227+
if (_openedTooltips.isNotEmpty) {
205228
// Avoid concurrent modification.
206-
final List<_TooltipState> openedToolTips = List<_TooltipState>.from(_openedToolTips);
207-
for (final _TooltipState state in openedToolTips) {
208-
state._hideTooltip(immediately: true);
229+
final List<_TooltipState> openedTooltips = _openedTooltips.toList();
230+
for (final _TooltipState state in openedTooltips) {
231+
state._dismissTooltip(immediately: true);
209232
}
210233
return true;
211234
}
@@ -255,7 +278,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
255278
late bool excludeFromSemantics;
256279
late AnimationController _controller;
257280
OverlayEntry? _entry;
258-
Timer? _hideTimer;
281+
Timer? _dismissTimer;
259282
Timer? _showTimer;
260283
late Duration showDuration;
261284
late Duration hoverShowDuration;
@@ -264,10 +287,14 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
264287
bool _pressActivated = false;
265288
late TooltipTriggerMode triggerMode;
266289
late bool enableFeedback;
290+
late bool _isConcealed;
291+
late bool _forceRemoval;
267292

268293
@override
269294
void initState() {
270295
super.initState();
296+
_isConcealed = false;
297+
_forceRemoval = false;
271298
_mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
272299
_controller = AnimationController(
273300
duration: _fadeInDuration,
@@ -333,47 +360,96 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
333360
}
334361

335362
void _handleStatusChanged(AnimationStatus status) {
336-
if (status == AnimationStatus.dismissed) {
337-
_hideTooltip(immediately: true);
363+
// If this tip is concealed, don't remove it, even if it is dismissed, so that we can
364+
// reveal it later, unless it has explicitly been hidden with _dismissTooltip.
365+
if (status == AnimationStatus.dismissed && (_forceRemoval || !_isConcealed)) {
366+
_removeEntry();
338367
}
339368
}
340369

341-
void _hideTooltip({ bool immediately = false }) {
370+
void _dismissTooltip({ bool immediately = false }) {
342371
_showTimer?.cancel();
343372
_showTimer = null;
344373
if (immediately) {
345374
_removeEntry();
346375
return;
347376
}
377+
// So it will be removed when it's done reversing, regardless of whether it is
378+
// still concealed or not.
379+
_forceRemoval = true;
348380
if (_pressActivated) {
349-
_hideTimer ??= Timer(showDuration, _controller.reverse);
381+
_dismissTimer ??= Timer(showDuration, _controller.reverse);
350382
} else {
351-
_hideTimer ??= Timer(hoverShowDuration, _controller.reverse);
383+
_dismissTimer ??= Timer(hoverShowDuration, _controller.reverse);
352384
}
353385
_pressActivated = false;
354386
}
355387

356388
void _showTooltip({ bool immediately = false }) {
357-
_hideTimer?.cancel();
358-
_hideTimer = null;
389+
_dismissTimer?.cancel();
390+
_dismissTimer = null;
359391
if (immediately) {
360392
ensureTooltipVisible();
361393
return;
362394
}
363395
_showTimer ??= Timer(waitDuration, ensureTooltipVisible);
364396
}
365397

398+
void _concealTooltip() {
399+
if (_isConcealed || _forceRemoval) {
400+
// Already concealed, or it's being removed.
401+
return;
402+
}
403+
_isConcealed = true;
404+
_dismissTimer?.cancel();
405+
_dismissTimer = null;
406+
_showTimer?.cancel();
407+
_showTimer = null;
408+
if (_entry!= null) {
409+
_entry!.remove();
410+
}
411+
_controller.reverse();
412+
}
413+
414+
void _revealTooltip() {
415+
if (!_isConcealed) {
416+
// Already uncovered.
417+
return;
418+
}
419+
_isConcealed = false;
420+
_dismissTimer?.cancel();
421+
_dismissTimer = null;
422+
_showTimer?.cancel();
423+
_showTimer = null;
424+
if (!_entry!.mounted) {
425+
final OverlayState overlayState = Overlay.of(
426+
context,
427+
debugRequiredFor: widget,
428+
)!;
429+
overlayState.insert(_entry!);
430+
}
431+
SemanticsService.tooltip(widget.message);
432+
_controller.forward();
433+
}
434+
366435
/// Shows the tooltip if it is not already visible.
367436
///
368-
/// Returns `false` when the tooltip was already visible or if the context has
369-
/// become null.
437+
/// Returns `false` when the tooltip was already visible.
370438
bool ensureTooltipVisible() {
371439
_showTimer?.cancel();
372440
_showTimer = null;
441+
_forceRemoval = false;
442+
if (_isConcealed) {
443+
if (_mouseIsConnected) {
444+
Tooltip._concealOtherTooltips(this);
445+
}
446+
_revealTooltip();
447+
return true;
448+
}
373449
if (_entry != null) {
374450
// Stop trying to hide, if we were.
375-
_hideTimer?.cancel();
376-
_hideTimer = null;
451+
_dismissTimer?.cancel();
452+
_dismissTimer = null;
377453
_controller.forward();
378454
return false; // Already visible.
379455
}
@@ -382,6 +458,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
382458
return true;
383459
}
384460

461+
static final Set<_TooltipState> _mouseIn = <_TooltipState>{};
462+
463+
void _handleMouseEnter() {
464+
_showTooltip();
465+
}
466+
467+
void _handleMouseExit({bool immediately = false}) {
468+
// If the tip is currently covered, we can just remove it without waiting.
469+
_dismissTooltip(immediately: _isConcealed || immediately);
470+
}
471+
385472
void _createNewEntry() {
386473
final OverlayState overlayState = Overlay.of(
387474
context,
@@ -404,8 +491,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
404491
height: height,
405492
padding: padding,
406493
margin: margin,
407-
onEnter: _mouseIsConnected ? (PointerEnterEvent event) => _showTooltip() : null,
408-
onExit: _mouseIsConnected ? (PointerExitEvent event) => _hideTooltip() : null,
494+
onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
495+
onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
409496
decoration: decoration,
410497
textStyle: textStyle,
411498
animation: CurvedAnimation(
@@ -418,36 +505,51 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
418505
),
419506
);
420507
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
508+
_isConcealed = false;
421509
overlayState.insert(_entry!);
422510
SemanticsService.tooltip(widget.message);
423-
Tooltip._openedToolTips.add(this);
511+
if (_mouseIsConnected) {
512+
// Hovered tooltips shouldn't show more than one at once. For example, a chip with
513+
// a delete icon shouldn't show both the delete icon tooltip and the chip tooltip
514+
// at the same time.
515+
Tooltip._concealOtherTooltips(this);
516+
}
517+
assert(!Tooltip._openedTooltips.contains(this));
518+
Tooltip._openedTooltips.add(this);
424519
}
425520

426521
void _removeEntry() {
427-
Tooltip._openedToolTips.remove(this);
428-
_hideTimer?.cancel();
429-
_hideTimer = null;
522+
Tooltip._openedTooltips.remove(this);
523+
_mouseIn.remove(this);
524+
_dismissTimer?.cancel();
525+
_dismissTimer = null;
430526
_showTimer?.cancel();
431527
_showTimer = null;
432-
_entry?.remove();
528+
if (!_isConcealed) {
529+
_entry?.remove();
530+
}
531+
_isConcealed = false;
433532
_entry = null;
533+
if (_mouseIsConnected) {
534+
Tooltip._revealLastTooltip();
535+
}
434536
}
435537

436538
void _handlePointerEvent(PointerEvent event) {
437539
if (_entry == null) {
438540
return;
439541
}
440542
if (event is PointerUpEvent || event is PointerCancelEvent) {
441-
_hideTooltip();
543+
_handleMouseExit();
442544
} else if (event is PointerDownEvent) {
443-
_hideTooltip(immediately: true);
545+
_handleMouseExit(immediately: true);
444546
}
445547
}
446548

447549
@override
448550
void deactivate() {
449551
if (_entry != null) {
450-
_hideTooltip(immediately: true);
552+
_dismissTooltip(immediately: true);
451553
}
452554
_showTimer?.cancel();
453555
super.deactivate();
@@ -535,8 +637,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
535637
// Only check for hovering if there is a mouse connected.
536638
if (_mouseIsConnected) {
537639
result = MouseRegion(
538-
onEnter: (PointerEnterEvent event) => _showTooltip(),
539-
onExit: (PointerExitEvent event) => _hideTooltip(),
640+
onEnter: (_) => _handleMouseEnter(),
641+
onExit: (_) => _handleMouseExit(),
540642
child: result,
541643
);
542644
}

packages/flutter/test/material/chip_test.dart

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3429,8 +3429,8 @@ void main() {
34293429
),
34303430
);
34313431

3432-
// Tap at the delete icon of the chip, which is at the right
3433-
// side of the chip
3432+
// Hover over the delete icon of the chip, which is at the right side of the
3433+
// chip
34343434
final Offset centerOfDeleteButton = tester.getCenter(find.byKey(deleteButtonKey));
34353435
final TestGesture hoverGesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
34363436
await hoverGesture.moveTo(centerOfDeleteButton);
@@ -3441,13 +3441,12 @@ void main() {
34413441
// Wait for some more time while pressing and holding the delete button
34423442
await tester.pumpAndSettle();
34433443

3444-
// There should also be a chip tooltip
3445-
expect(findTooltipContainer('Chip Tooltip'), findsOneWidget);
3444+
// There should not be a chip tooltip
3445+
expect(findTooltipContainer('Chip Tooltip'), findsNothing);
34463446
// There should be a delete tooltip
34473447
expect(findTooltipContainer('Delete'), findsOneWidget);
34483448
});
34493449

3450-
34513450
testWidgets('intrinsicHeight implementation meets constraints', (WidgetTester tester) async {
34523451
// Regression test for https://github.com/flutter/flutter/issues/49478.
34533452
await tester.pumpWidget(_wrapForChip(

packages/flutter/test/material/tooltip_test.dart

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -919,8 +919,7 @@ void main() {
919919
const Duration waitDuration = Duration.zero;
920920
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
921921
addTearDown(() async {
922-
if (gesture != null)
923-
return gesture.removePointer();
922+
gesture?.removePointer();
924923
});
925924
await gesture.addPointer();
926925
await gesture.moveTo(const Offset(1.0, 1.0));
@@ -970,6 +969,70 @@ void main() {
970969
expect(find.text(tooltipText), findsNothing);
971970
});
972971

972+
testWidgets('Tooltip should not show more than one tooltip when hovered', (WidgetTester tester) async {
973+
const Duration waitDuration = Duration(milliseconds: 500);
974+
final UniqueKey innerKey = UniqueKey();
975+
final UniqueKey outerKey = UniqueKey();
976+
await tester.pumpWidget(
977+
MaterialApp(
978+
home: Center(
979+
child: Tooltip(
980+
message: 'Outer',
981+
child: Container(
982+
key: outerKey,
983+
width: 100,
984+
height: 100,
985+
alignment: Alignment.centerRight,
986+
child: Tooltip(
987+
message: 'Inner',
988+
child: SizedBox(
989+
key: innerKey,
990+
width: 25,
991+
height: 100,
992+
),
993+
),
994+
),
995+
),
996+
),
997+
),
998+
);
999+
1000+
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
1001+
addTearDown(() async { gesture?.removePointer(); });
1002+
1003+
// Both the inner and outer containers have tooltips associated with them, but only
1004+
// the currently hovered one should appear, even though the pointer is inside both.
1005+
final Finder outer = find.byKey(outerKey);
1006+
final Finder inner = find.byKey(innerKey);
1007+
await gesture.moveTo(Offset.zero);
1008+
await tester.pump();
1009+
await gesture.moveTo(tester.getCenter(outer));
1010+
await tester.pump();
1011+
await gesture.moveTo(tester.getCenter(inner));
1012+
await tester.pump();
1013+
1014+
// Wait for it to appear.
1015+
await tester.pump(waitDuration);
1016+
1017+
expect(find.text('Outer'), findsNothing);
1018+
expect(find.text('Inner'), findsOneWidget);
1019+
await gesture.moveTo(tester.getCenter(outer));
1020+
await tester.pump();
1021+
// Wait for it to switch.
1022+
await tester.pump(waitDuration);
1023+
expect(find.text('Outer'), findsOneWidget);
1024+
expect(find.text('Inner'), findsNothing);
1025+
1026+
await gesture.moveTo(Offset.zero);
1027+
1028+
// Wait for all tooltips to disappear.
1029+
await tester.pumpAndSettle();
1030+
await gesture.removePointer();
1031+
gesture = null;
1032+
expect(find.text('Outer'), findsNothing);
1033+
expect(find.text('Inner'), findsNothing);
1034+
});
1035+
9731036
testWidgets('Tooltip can be dismissed by escape key', (WidgetTester tester) async {
9741037
const Duration waitDuration = Duration.zero;
9751038
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);

0 commit comments

Comments
 (0)