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.
|
/// * [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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|||||||
_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 {
|
|||||||
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 {
|
|||||||
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 {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_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 {
|
|||||||
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 {
|
|||||||
// 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() {
|
|||||||
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() {
|
|||||||
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);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user