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.
This commit is contained in:
parent
9db0600e46
commit
885b2f56e1
@ -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);
|
||||
|
Loading…
x
Reference in New Issue
Block a user