Unverified Commit 1b800fd4 authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Remove Tooltip mouse tracker listener & update hovering/MouseRegion logic & animation (#119199)

Fixes https://github.com/flutter/flutter/issues/117627

### Behavior changes:
1. If fade in/fade out animation is already in progress, hiding/showing the tooltip will immediately take effect without waiting for `waitDuration`.
2. A PointerDownEvent that doesn't become a part of a "trigger" gesture dismisses the tooltip, even for hovered ones.
3. The OverlayEntry is now updated only when the previous tooltip was completely dismissed. This can be fixed by OverlayPortal but I'm not sure what the correct behavior is.
parent 9da1d98a
...@@ -20,7 +20,8 @@ void main() { ...@@ -20,7 +20,8 @@ void main() {
await tester.tap(find.byType(FloatingActionButton)); await tester.tap(find.byType(FloatingActionButton));
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tooltipText), findsOneWidget); expect(find.text(tooltipText), findsOneWidget);
// Wait for the tooltip to disappear. // Tap on the tooltip and wait for the tooltip to disappear.
await tester.tap(find.byTooltip(tooltipText));
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
......
...@@ -19,6 +19,70 @@ import 'tooltip_visibility.dart'; ...@@ -19,6 +19,70 @@ import 'tooltip_visibility.dart';
/// Signature for when a tooltip is triggered. /// Signature for when a tooltip is triggered.
typedef TooltipTriggeredCallback = void Function(); typedef TooltipTriggeredCallback = void Function();
/// A special [MouseRegion] that when nested, only the first [_ExclusiveMouseRegion]
/// to be hit in hit-testing order will be added to the BoxHitTestResult (i.e.,
/// child over parent, last sibling over first sibling).
///
/// The [onEnter] method will be called when a mouse pointer enters this
/// [MouseRegion], and there is no other [_ExclusiveMouseRegion]s obstructing
/// this [_ExclusiveMouseRegion] from receiving the events. This includes the
/// case where the mouse cursor stays within the paint bounds of an outer
/// [_ExclusiveMouseRegion], but moves outside of the bounds of the inner
/// [_ExclusiveMouseRegion] that was initially blocking the outer widget.
///
/// Likewise, [onExit] is called when the a mouse pointer moves out of the paint
/// bounds of this widget, or moves into another [_ExclusiveMouseRegion] that
/// overlaps this widget in hit-testing order.
///
/// This widget doesn't affect [MouseRegion]s that aren't [_ExclusiveMouseRegion]s,
/// or other [HitTestTarget]s in the tree.
class _ExclusiveMouseRegion extends MouseRegion {
const _ExclusiveMouseRegion({
super.onEnter,
super.onExit,
super.child,
});
@override
_RenderExclusiveMouseRegion createRenderObject(BuildContext context) {
return _RenderExclusiveMouseRegion(
onEnter: onEnter,
onExit: onExit,
);
}
}
class _RenderExclusiveMouseRegion extends RenderMouseRegion {
_RenderExclusiveMouseRegion({
super.onEnter,
super.onExit,
});
static bool isOutermostMouseRegion = true;
static bool foundInnermostMouseRegion = false;
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
bool isHit = false;
final bool outermost = isOutermostMouseRegion;
isOutermostMouseRegion = false;
if (size.contains(position)) {
isHit = hitTestChildren(result, position: position) || hitTestSelf(position);
if ((isHit || behavior == HitTestBehavior.translucent) && !foundInnermostMouseRegion) {
foundInnermostMouseRegion = true;
result.add(BoxHitTestEntry(this, position));
}
}
if (outermost) {
// The outermost region resets the global states.
isOutermostMouseRegion = true;
foundInnermostMouseRegion = false;
}
return isHit;
}
}
/// A Material Design tooltip. /// A Material Design tooltip.
/// ///
/// Tooltips provide text labels which help explain the function of a button or /// Tooltips provide text labels which help explain the function of a button or
...@@ -230,6 +294,10 @@ class Tooltip extends StatefulWidget { ...@@ -230,6 +294,10 @@ class Tooltip extends StatefulWidget {
/// If this property is null, then [TooltipThemeData.triggerMode] is used. /// If this property is null, then [TooltipThemeData.triggerMode] is used.
/// If [TooltipThemeData.triggerMode] is also null, the default mode is /// If [TooltipThemeData.triggerMode] is also null, the default mode is
/// [TooltipTriggerMode.longPress]. /// [TooltipTriggerMode.longPress].
///
/// This property does not affect mouse devices. Setting [triggerMode] to
/// [TooltipTriggerMode.manual] will not prevent the tooltip from showing when
/// the mouse cursor hovers over it.
final TooltipTriggerMode? triggerMode; final TooltipTriggerMode? triggerMode;
/// Whether the tooltip should provide acoustic and/or haptic feedback. /// Whether the tooltip should provide acoustic and/or haptic feedback.
...@@ -252,30 +320,8 @@ class Tooltip extends StatefulWidget { ...@@ -252,30 +320,8 @@ class Tooltip extends StatefulWidget {
static final List<TooltipState> _openedTooltips = <TooltipState>[]; static final List<TooltipState> _openedTooltips = <TooltipState>[];
// Causes any current tooltips to be concealed. Only called for mouse hover enter /// Dismiss all of the tooltips that are currently shown on the screen,
// detections. Won't conceal the supplied tooltip. /// including those with mouse cursors currently hovering over them.
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 /// 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.
...@@ -284,7 +330,8 @@ class Tooltip extends StatefulWidget { ...@@ -284,7 +330,8 @@ class Tooltip extends StatefulWidget {
// Avoid concurrent modification. // Avoid concurrent modification.
final List<TooltipState> openedTooltips = _openedTooltips.toList(); final List<TooltipState> openedTooltips = _openedTooltips.toList();
for (final TooltipState state in openedTooltips) { for (final TooltipState state in openedTooltips) {
state._dismissTooltip(immediately: true); assert(state.mounted);
state._scheduleDismissTooltip(withDelay: Duration.zero);
} }
return true; return true;
} }
...@@ -350,19 +397,13 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -350,19 +397,13 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late double _verticalOffset; late double _verticalOffset;
late bool _preferBelow; late bool _preferBelow;
late bool _excludeFromSemantics; late bool _excludeFromSemantics;
late AnimationController _controller;
OverlayEntry? _entry; OverlayEntry? _entry;
Timer? _dismissTimer;
Timer? _showTimer;
late Duration _showDuration; late Duration _showDuration;
late Duration _hoverShowDuration; late Duration _hoverShowDuration;
late Duration _waitDuration; late Duration _waitDuration;
late bool _mouseIsConnected;
bool _pressActivated = false;
late TooltipTriggerMode _triggerMode; late TooltipTriggerMode _triggerMode;
late bool _enableFeedback; late bool _enableFeedback;
late bool _isConcealed;
late bool _forceRemoval;
late bool _visible; late bool _visible;
/// The plain text message for this tooltip. /// The plain text message for this tooltip.
...@@ -370,202 +411,332 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -370,202 +411,332 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
/// This value will either come from [widget.message] or [widget.richMessage]. /// This value will either come from [widget.message] or [widget.richMessage].
String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText(); String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText();
@override Timer? _timer;
void initState() { AnimationController? _backingController;
super.initState(); AnimationController get _controller {
_isConcealed = false; return _backingController ??= AnimationController(
_forceRemoval = false;
_mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
_controller = AnimationController(
duration: _fadeInDuration, duration: _fadeInDuration,
reverseDuration: _fadeOutDuration, reverseDuration: _fadeOutDuration,
vsync: this, vsync: this,
) )..addStatusListener(_handleStatusChanged);
..addStatusListener(_handleStatusChanged);
// Listen to see when a mouse is added.
RendererBinding.instance.mouseTracker.addListener(_handleMouseTrackerChange);
// Listen to global pointer events so that we can hide a tooltip immediately
// if some other control is clicked on.
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
} }
@override LongPressGestureRecognizer? _longPressRecognizer;
void didChangeDependencies() { TapGestureRecognizer? _tapRecognizer;
super.didChangeDependencies();
_visible = TooltipVisibility.of(context); // The ids of mouse devices that are keeping the tooltip from being dismissed.
final Set<int> _activeHoveringPointerDevices = <int>{};
AnimationStatus _animationStatus = AnimationStatus.dismissed;
void _handleStatusChanged(AnimationStatus status) {
assert(mounted);
final bool entryNeedsUpdating;
switch (status) {
case AnimationStatus.dismissed:
entryNeedsUpdating = _animationStatus != AnimationStatus.dismissed;
if (entryNeedsUpdating) {
_removeEntry();
}
case AnimationStatus.completed:
case AnimationStatus.forward:
case AnimationStatus.reverse:
entryNeedsUpdating = _animationStatus == AnimationStatus.dismissed;
if (entryNeedsUpdating) {
_createNewEntry();
SemanticsService.tooltip(_tooltipMessage);
}
} }
// https://material.io/components/tooltips#specs if (entryNeedsUpdating) {
double _getDefaultTooltipHeight() { setState(() { /* Rebuild to update the OverlayEntry */ });
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
return 24.0;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return 32.0;
} }
_animationStatus = status;
} }
EdgeInsets _getDefaultPadding() { void _scheduleShowTooltip({ required Duration withDelay, Duration? showDuration }) {
final ThemeData theme = Theme.of(context); assert(mounted);
switch (theme.platform) { void show() {
case TargetPlatform.macOS: assert(mounted);
case TargetPlatform.linux: if (!_visible) {
case TargetPlatform.windows: return;
return const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0);
} }
_controller.forward();
_timer?.cancel();
_timer = showDuration == null ? null : Timer(showDuration, _controller.reverse);
} }
double _getDefaultFontSize() { assert(
final ThemeData theme = Theme.of(context); !(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse,
switch (theme.platform) { 'timer must not be active when the tooltip is fading out',
case TargetPlatform.macOS: );
case TargetPlatform.linux: switch (_controller.status) {
case TargetPlatform.windows: case AnimationStatus.dismissed:
return 12.0; if (withDelay.inMicroseconds > 0) {
case TargetPlatform.android: _timer ??= Timer(withDelay, show);
case TargetPlatform.fuchsia: } else {
case TargetPlatform.iOS: show();
return 14.0; }
// If the tooltip is already fading in or fully visible, skip the
// animation and show the tooltip immediately.
case AnimationStatus.forward:
case AnimationStatus.reverse:
case AnimationStatus.completed:
// Fade in if needed and schedule to hide.
show();
} }
} }
// Forces a rebuild if a mouse has been added or removed. void _scheduleDismissTooltip({ required Duration withDelay }) {
void _handleMouseTrackerChange() { assert(mounted);
if (!mounted) { assert(
return; !(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse,
'timer must not be active when the tooltip is fading out',
);
_timer?.cancel();
_timer = null;
switch (_controller.status) {
case AnimationStatus.reverse:
case AnimationStatus.dismissed:
break;
// Dismiss when the tooltip is fading in: if there's a dismiss delay we'll
// allow the fade in animation to continue until the delay timer fires.
case AnimationStatus.forward:
case AnimationStatus.completed:
if (withDelay.inMicroseconds > 0) {
_timer = Timer(withDelay, _controller.reverse);
} else {
_controller.reverse();
} }
final bool mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
if (mouseIsConnected != _mouseIsConnected) {
setState(() {
_mouseIsConnected = mouseIsConnected;
});
} }
} }
void _handleStatusChanged(AnimationStatus status) { void _handlePointerDown(PointerDownEvent event) {
// If this tip is concealed, don't remove it, even if it is dismissed, so that we can assert(mounted);
// reveal it later, unless it has explicitly been hidden with _dismissTooltip. // PointerDeviceKinds that don't support hovering.
if (status == AnimationStatus.dismissed && (_forceRemoval || !_isConcealed)) { const Set<PointerDeviceKind> triggerModeDeviceKinds = <PointerDeviceKind> {
_removeEntry(); PointerDeviceKind.invertedStylus,
PointerDeviceKind.stylus,
PointerDeviceKind.touch,
PointerDeviceKind.unknown,
// MouseRegion only tracks PointerDeviceKind == mouse.
PointerDeviceKind.trackpad,
};
switch (_triggerMode) {
case TooltipTriggerMode.longPress:
final LongPressGestureRecognizer recognizer = _longPressRecognizer ??= LongPressGestureRecognizer(
debugOwner: this, supportedDevices: triggerModeDeviceKinds,
);
recognizer
..onLongPressCancel = _handleTapToDismiss
..onLongPress = _handleLongPress
..onLongPressUp = _handlePressUp
..addPointer(event);
case TooltipTriggerMode.tap:
final TapGestureRecognizer recognizer = _tapRecognizer ??= TapGestureRecognizer(
debugOwner: this, supportedDevices: triggerModeDeviceKinds
);
recognizer
..onTapCancel = _handleTapToDismiss
..onTap = _handleTap
..addPointer(event);
case TooltipTriggerMode.manual:
break;
}
}
// For PointerDownEvents, this method will be called after _handlePointerDown.
void _handleGlobalPointerEvent(PointerEvent event) {
assert(mounted);
if (_tapRecognizer?.primaryPointer == event.pointer || _longPressRecognizer?.primaryPointer == event.pointer) {
// This is a pointer of interest specified by the trigger mode, since it's
// picked up by the recognizer.
//
// The recognizer will later determine if this is indeed a "trigger"
// gesture and dismiss the tooltip if that's not the case. However there's
// still a chance that the PointerEvent was cancelled before the gesture
// recognizer gets to emit a tap/longPress down, in which case the onCancel
// callback (_handleTapToDismiss) will not be called.
return;
} }
if ((_timer == null && _controller.status == AnimationStatus.dismissed) || event is! PointerDownEvent) {
return;
}
_handleTapToDismiss();
} }
void _dismissTooltip({ bool immediately = false }) { // The primary pointer is not part of a "trigger" gesture so the tooltip
_showTimer?.cancel(); // should be dismissed.
_showTimer = null; void _handleTapToDismiss() {
if (immediately) { _scheduleDismissTooltip(withDelay: Duration.zero);
_removeEntry(); _activeHoveringPointerDevices.clear();
}
void _handleTap() {
if (!_visible) {
return; return;
} }
// So it will be removed when it's done reversing, regardless of whether it is final bool tooltipCreated = _controller.status == AnimationStatus.dismissed;
// still concealed or not. if (tooltipCreated && _enableFeedback) {
_forceRemoval = true; assert(_triggerMode == TooltipTriggerMode.tap);
if (_pressActivated) { Feedback.forTap(context);
_dismissTimer ??= Timer(_showDuration, _controller.reverse);
} else {
_dismissTimer ??= Timer(_hoverShowDuration, _controller.reverse);
} }
_pressActivated = false; widget.onTriggered?.call();
_scheduleShowTooltip(
withDelay: Duration.zero,
// _activeHoveringPointerDevices keep the tooltip visible.
showDuration: _activeHoveringPointerDevices.isEmpty ? _showDuration : null,
);
} }
void _showTooltip({ bool immediately = false }) { // When a "trigger" gesture is recognized and the pointer down even is a part
_dismissTimer?.cancel(); // of it.
_dismissTimer = null; void _handleLongPress() {
if (immediately) { if (!_visible) {
ensureTooltipVisible();
return; return;
} }
_showTimer ??= Timer(_waitDuration, ensureTooltipVisible); final bool tooltipCreated = _visible && _controller.status == AnimationStatus.dismissed;
if (tooltipCreated && _enableFeedback) {
assert(_triggerMode == TooltipTriggerMode.longPress);
Feedback.forLongPress(context);
}
widget.onTriggered?.call();
_scheduleShowTooltip(withDelay: Duration.zero);
} }
void _concealTooltip() { void _handlePressUp() {
if (_isConcealed || _forceRemoval) { if (_activeHoveringPointerDevices.isNotEmpty) {
// Already concealed, or it's being removed.
return; return;
} }
_isConcealed = true; _scheduleDismissTooltip(withDelay: _showDuration);
_dismissTimer?.cancel(); }
_dismissTimer = null;
_showTimer?.cancel(); // # Current Hovering Behavior:
_showTimer = null; // 1. Hovered tooltips don't show more than one at a time, for each mouse
if (_entry != null) { // device. For example, a chip with a delete icon typically shouldn't show
_entry!.remove(); // both the delete icon tooltip and the chip tooltip at the same time.
// 2. Hovered tooltips are dismissed when:
// i. [dismissAllToolTips] is called, even these tooltips are still hovered
// ii. a unrecognized PointerDownEvent occured withint the application
// (even these tooltips are still hovered),
// iii. The last hovering device leaves the tooltip.
void _handleMouseEnter(PointerEnterEvent event) {
// The callback is also used in an OverlayEntry, so there's a chance that
// this widget is already unmounted.
if (!mounted) {
return;
}
// _handleMouseEnter is only called when the mouse starts to hover over this
// tooltip (including the actual tooltip it shows on the overlay), and this
// tooltip is the first to be hit in the widget tree's hit testing order.
// See also _ExclusiveMouseRegion for the exact behavior.
_activeHoveringPointerDevices.add(event.device);
final List<TooltipState> openedTooltips = Tooltip._openedTooltips.toList();
bool otherTooltipsDismissed = false;
for (final TooltipState tooltip in openedTooltips) {
assert(tooltip.mounted);
final Set<int> hoveringDevices = tooltip._activeHoveringPointerDevices;
final bool shouldDismiss = tooltip != this
&& (hoveringDevices.length == 1 && hoveringDevices.single == event.device);
if (shouldDismiss) {
otherTooltipsDismissed = true;
tooltip._scheduleDismissTooltip(withDelay: Duration.zero);
} }
_controller.reverse(); }
_scheduleShowTooltip(withDelay: otherTooltipsDismissed ? Duration.zero : _waitDuration);
} }
void _revealTooltip() { void _handleMouseExit(PointerExitEvent event) {
if (!_isConcealed) { if (!mounted) {
// Already uncovered.
return; return;
} }
_isConcealed = false; assert(_activeHoveringPointerDevices.isNotEmpty);
_dismissTimer?.cancel(); _activeHoveringPointerDevices.remove(event.device);
_dismissTimer = null; if (_activeHoveringPointerDevices.isEmpty) {
_showTimer?.cancel(); _scheduleDismissTooltip(withDelay: _hoverShowDuration);
_showTimer = null;
if (!_entry!.mounted) {
final OverlayState overlayState = Overlay.of(
context,
debugRequiredFor: widget,
);
overlayState.insert(_entry!);
} }
SemanticsService.tooltip(_tooltipMessage);
_controller.forward();
} }
/// Shows the tooltip if it is not already visible. /// Shows the tooltip if it is not already visible.
/// ///
/// After made visible by this method, The tooltip does not automatically
/// dismiss after `waitDuration`, until the user dismisses/re-triggers it, or
/// [Tooltip.dismissAllToolTips] is called.
///
/// Returns `false` when the tooltip shouldn't be shown or when the tooltip /// Returns `false` when the tooltip shouldn't be shown or when the tooltip
/// was already visible. /// was already visible.
bool ensureTooltipVisible() { bool ensureTooltipVisible() {
if (!_visible || !mounted) { if (!_visible) {
return false; return false;
} }
_showTimer?.cancel();
_showTimer = null; _timer?.cancel();
_forceRemoval = false; _timer = null;
if (_isConcealed) { switch (_controller.status) {
if (_mouseIsConnected) { case AnimationStatus.dismissed:
Tooltip._concealOtherTooltips(this); case AnimationStatus.reverse:
} _scheduleShowTooltip(withDelay: Duration.zero);
_revealTooltip();
return true; return true;
case AnimationStatus.forward:
case AnimationStatus.completed:
return false;
} }
if (_entry != null) {
// Stop trying to hide, if we were.
_dismissTimer?.cancel();
_dismissTimer = null;
_controller.forward();
return false; // Already visible.
} }
_createNewEntry();
_controller.forward(); @override
return true; void initState() {
super.initState();
// Listen to global pointer events so that we can hide a tooltip immediately
// if some other control is clicked on. Pointer events are dispatched to
// global routes **after** other routes.
GestureBinding.instance.pointerRouter.addGlobalRoute(_handleGlobalPointerEvent);
} }
static final Set<TooltipState> _mouseIn = <TooltipState>{}; @override
void didChangeDependencies() {
super.didChangeDependencies();
_visible = TooltipVisibility.of(context);
}
// https://material.io/components/tooltips#specs
double _getDefaultTooltipHeight() {
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
return 24.0;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return 32.0;
}
}
void _handleMouseEnter() { EdgeInsets _getDefaultPadding() {
if (mounted) { final ThemeData theme = Theme.of(context);
_showTooltip(); switch (theme.platform) {
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
return const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0);
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0);
} }
} }
void _handleMouseExit({bool immediately = false}) { double _getDefaultFontSize() {
if (mounted) { final ThemeData theme = Theme.of(context);
// If the tip is currently covered, we can just remove it without waiting. switch (theme.platform) {
_dismissTooltip(immediately: _isConcealed || immediately); case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
return 12.0;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return 14.0;
} }
} }
...@@ -591,8 +762,8 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -591,8 +762,8 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
height: _height, height: _height,
padding: _padding, padding: _padding,
margin: _margin, margin: _margin,
onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null, onEnter: _handleMouseEnter,
onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null, onExit: _handleMouseExit,
decoration: _decoration, decoration: _decoration,
textStyle: _textStyle, textStyle: _textStyle,
textAlign: _textAlign, textAlign: _textAlign,
...@@ -605,92 +776,29 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -605,92 +776,29 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
preferBelow: _preferBelow, preferBelow: _preferBelow,
), ),
); );
_entry = OverlayEntry(builder: (BuildContext context) => overlay); final OverlayEntry entry = _entry = OverlayEntry(builder: (BuildContext context) => overlay);
_isConcealed = false; overlayState.insert(entry);
overlayState.insert(_entry!);
SemanticsService.tooltip(_tooltipMessage);
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); Tooltip._openedTooltips.add(this);
} }
void _removeEntry() { void _removeEntry() {
Tooltip._openedTooltips.remove(this); Tooltip._openedTooltips.remove(this);
_mouseIn.remove(this);
_dismissTimer?.cancel();
_dismissTimer = null;
_showTimer?.cancel();
_showTimer = null;
if (!_isConcealed) {
_entry?.remove(); _entry?.remove();
}
_isConcealed = false;
_entry?.dispose(); _entry?.dispose();
_entry = null; _entry = null;
if (_mouseIsConnected) {
Tooltip._revealLastTooltip();
}
}
void _handlePointerEvent(PointerEvent event) {
if (_entry == null) {
return;
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
_handleMouseExit();
} else if (event is PointerDownEvent) {
_handleMouseExit(immediately: true);
}
}
@override
void deactivate() {
if (_entry != null) {
_dismissTooltip(immediately: true);
}
_showTimer?.cancel();
super.deactivate();
} }
@override @override
void dispose() { void dispose() {
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent); GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent);
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
_removeEntry(); _removeEntry();
_controller.dispose(); _longPressRecognizer?.dispose();
_tapRecognizer?.dispose();
_timer?.cancel();
_backingController?.dispose();
super.dispose(); super.dispose();
} }
void _handlePress() {
_pressActivated = true;
final bool tooltipCreated = ensureTooltipVisible();
if (tooltipCreated && _enableFeedback) {
if (_triggerMode == TooltipTriggerMode.longPress) {
Feedback.forLongPress(context);
} else {
Feedback.forTap(context);
}
}
widget.onTriggered?.call();
}
void _handleTap() {
_handlePress();
// When triggerMode is not [TooltipTriggerMode.tap] the tooltip is dismissed
// by _handlePointerEvent, which listens to the global pointer events.
// When triggerMode is [TooltipTriggerMode.tap] and the Tooltip GestureDetector
// competes with other GestureDetectors, the disambiguation process will complete
// after the global pointer event is received. As we can't rely on the global
// pointer events to dismiss the Tooltip, we have to call _handleMouseExit
// to dismiss the tooltip after _showDuration expired.
_handleMouseExit();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// If message is empty then no need to create a tooltip overlay to show // If message is empty then no need to create a tooltip overlay to show
...@@ -740,30 +848,22 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -740,30 +848,22 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback; _enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
Widget result = Semantics( Widget result = Semantics(
tooltip: _excludeFromSemantics tooltip: _excludeFromSemantics ? null : _tooltipMessage,
? null
: _tooltipMessage,
child: widget.child, child: widget.child,
); );
// Only check for gestures if tooltip should be visible. // Only check for gestures if tooltip should be visible.
if (_visible) { if (_visible) {
result = GestureDetector( result = _ExclusiveMouseRegion(
onEnter: _handleMouseEnter,
onExit: _handleMouseExit,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onLongPress: (_triggerMode == TooltipTriggerMode.longPress) ? _handlePress : null,
onTap: (_triggerMode == TooltipTriggerMode.tap) ? _handleTap : null,
excludeFromSemantics: true,
child: result,
);
// Only check for hovering if there is a mouse connected.
if (_mouseIsConnected) {
result = MouseRegion(
onEnter: (_) => _handleMouseEnter(),
onExit: (_) => _handleMouseExit(),
child: result, child: result,
),
); );
} }
}
return result; return result;
} }
...@@ -874,7 +974,7 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -874,7 +974,7 @@ class _TooltipOverlay extends StatelessWidget {
), ),
); );
if (onEnter != null || onExit != null) { if (onEnter != null || onExit != null) {
result = MouseRegion( result = _ExclusiveMouseRegion(
onEnter: onEnter, onEnter: onEnter,
onExit: onExit, onExit: onExit,
child: result, child: result,
......
...@@ -49,14 +49,14 @@ Offset positionDependentBox({ ...@@ -49,14 +49,14 @@ Offset positionDependentBox({
final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - margin; final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - margin;
final bool fitsAbove = target.dy - verticalOffset - childSize.height >= margin; final bool fitsAbove = target.dy - verticalOffset - childSize.height >= margin;
final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow); final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
double y; final double y;
if (tooltipBelow) { if (tooltipBelow) {
y = math.min(target.dy + verticalOffset, size.height - margin); y = math.min(target.dy + verticalOffset, size.height - margin);
} else { } else {
y = math.max(target.dy - verticalOffset - childSize.height, margin); y = math.max(target.dy - verticalOffset - childSize.height, margin);
} }
// HORIZONTAL DIRECTION // HORIZONTAL DIRECTION
double x; final double x;
if (size.width - margin * 2.0 < childSize.width) { if (size.width - margin * 2.0 < childSize.width) {
x = (size.width - childSize.width) / 2.0; x = (size.width - childSize.width) / 2.0;
} else { } else {
......
...@@ -1610,9 +1610,12 @@ void main() { ...@@ -1610,9 +1610,12 @@ void main() {
) )
); );
final Finder tooltip1 = find.byType(Tooltip); await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tapTooltip), findsNothing); expect(find.text(tapTooltip), findsNothing);
expect(find.text(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1); await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget); expect(find.text(tapTooltip), findsOneWidget);
......
...@@ -1250,9 +1250,12 @@ void main() { ...@@ -1250,9 +1250,12 @@ void main() {
) )
); );
final Finder tooltip1 = find.byType(Tooltip); await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tapTooltip), findsNothing); expect(find.text(tapTooltip), findsNothing);
expect(find.text(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1); await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget); expect(find.text(tapTooltip), findsOneWidget);
......
...@@ -1835,9 +1835,12 @@ void main() { ...@@ -1835,9 +1835,12 @@ void main() {
) )
); );
final Finder tooltip1 = find.byType(Tooltip); await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tapTooltip), findsNothing); expect(find.text(tapTooltip), findsNothing);
expect(find.text(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1); await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget); expect(find.text(tapTooltip), findsOneWidget);
......
...@@ -502,10 +502,12 @@ void main() { ...@@ -502,10 +502,12 @@ void main() {
RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText)); RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText));
Offset fabTopRight = tester.getTopRight(fabFinder); Offset fabTopRight = tester.getTopRight(fabFinder);
Offset tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero)); Offset tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero));
expect(tooltipTopRight.dy < fabTopRight.dy, true); expect(tooltipTopRight.dy, lessThan(fabTopRight.dy));
// Simulate Keyboard opening (MediaQuery.viewInsets.bottom = 300)) // Simulate Keyboard opening (MediaQuery.viewInsets.bottom = 300))
await tester.pumpWidget(materialAppWithViewInsets(300)); await tester.pumpWidget(materialAppWithViewInsets(300));
// Wait for the tooltip to dismiss.
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Show FAB tooltip // Show FAB tooltip
...@@ -517,7 +519,7 @@ void main() { ...@@ -517,7 +519,7 @@ void main() {
tip = tester.renderObject(_findTooltipContainer(tooltipText)); tip = tester.renderObject(_findTooltipContainer(tooltipText));
fabTopRight = tester.getTopRight(fabFinder); fabTopRight = tester.getTopRight(fabFinder);
tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero)); tooltipTopRight = tip.localToGlobal(tip.size.topRight(Offset.zero));
expect(tooltipTopRight.dy < fabTopRight.dy, true); expect(tooltipTopRight.dy, lessThan(fabTopRight.dy));
}); });
testWidgetsWithLeakTracking('Custom tooltip margin', (WidgetTester tester) async { testWidgetsWithLeakTracking('Custom tooltip margin', (WidgetTester tester) async {
...@@ -711,6 +713,10 @@ void main() { ...@@ -711,6 +713,10 @@ void main() {
RenderParagraph tooltipRenderParagraph = tester.renderObject<RenderParagraph>(find.text(tooltipText)); RenderParagraph tooltipRenderParagraph = tester.renderObject<RenderParagraph>(find.text(tooltipText));
expect(tooltipRenderParagraph.textDirection, TextDirection.rtl); expect(tooltipRenderParagraph.textDirection, TextDirection.rtl);
await tester.pump(const Duration(seconds: 10));
await tester.pumpAndSettle();
await tester.pump();
await tester.pumpWidget(buildApp(tooltipText, TextDirection.ltr)); await tester.pumpWidget(buildApp(tooltipText, TextDirection.ltr));
await tester.longPress(find.byType(Tooltip)); await tester.longPress(find.byType(Tooltip));
expect(find.text(tooltipText), findsOneWidget); expect(find.text(tooltipText), findsOneWidget);
...@@ -856,6 +862,7 @@ void main() { ...@@ -856,6 +862,7 @@ void main() {
MaterialApp( MaterialApp(
home: Center( home: Center(
child: Tooltip( child: Tooltip(
triggerMode: TooltipTriggerMode.longPress,
message: tooltipText, message: tooltipText,
child: Container( child: Container(
width: 100.0, width: 100.0,
...@@ -879,7 +886,8 @@ void main() { ...@@ -879,7 +886,8 @@ void main() {
// tap (down, up) gesture hides tooltip, since its not // tap (down, up) gesture hides tooltip, since its not
// a long press // a long press
await tester.tap(tooltip); await tester.tap(tooltip);
await tester.pump(const Duration(milliseconds: 10)); await tester.pump();
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
// long press once more // long press once more
...@@ -898,6 +906,32 @@ void main() { ...@@ -898,6 +906,32 @@ void main() {
await gesture.up(); await gesture.up();
}); });
testWidgets('Tooltip dismiss countdown begins on long press release', (WidgetTester tester) async {
// Specs: https://github.com/flutter/flutter/issues/4182
const Duration showDuration = Duration(seconds: 1);
const Duration eternity = Duration(days: 9999);
await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress, showDuration: showDuration);
final Finder tooltip = find.byType(Tooltip);
final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip));
await tester.pump(kLongPressTimeout);
expect(find.text(tooltipText), findsOneWidget);
// Keep holding to prevent the tooltip from dismissing.
await tester.pump(eternity);
expect(find.text(tooltipText), findsOneWidget);
await tester.pump();
expect(find.text(tooltipText), findsOneWidget);
await gesture.up();
await tester.pump();
expect(find.text(tooltipText), findsOneWidget);
await tester.pump(showDuration);
await tester.pump(const Duration(milliseconds: 500));
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip is dismissed after a long press and showDuration expired', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip is dismissed after a long press and showDuration expired', (WidgetTester tester) async {
const Duration showDuration = Duration(seconds: 3); const Duration showDuration = Duration(seconds: 3);
await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress, showDuration: showDuration); await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress, showDuration: showDuration);
...@@ -967,11 +1001,9 @@ void main() { ...@@ -967,11 +1001,9 @@ void main() {
testWidgetsWithLeakTracking('Dispatch the mouse events before tip overlay detached', (WidgetTester tester) async { testWidgetsWithLeakTracking('Dispatch the mouse events before tip overlay detached', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/96890 // Regression test for https://github.com/flutter/flutter/issues/96890
const Duration waitDuration = Duration.zero; const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async { addTearDown(() async {
if (gesture != null) {
return 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));
...@@ -1018,7 +1050,6 @@ void main() { ...@@ -1018,7 +1050,6 @@ void main() {
// Go without crashes. // Go without crashes.
await gesture.removePointer(); await gesture.removePointer();
gesture = null;
}); });
testWidgetsWithLeakTracking('Calling ensureTooltipVisible on an unmounted TooltipState returns false', (WidgetTester tester) async { testWidgetsWithLeakTracking('Calling ensureTooltipVisible on an unmounted TooltipState returns false', (WidgetTester tester) async {
...@@ -1054,11 +1085,9 @@ void main() { ...@@ -1054,11 +1085,9 @@ void main() {
testWidgetsWithLeakTracking('Tooltip shows/hides when hovered', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip shows/hides when hovered', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero; const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async { addTearDown(() async {
if (gesture != null) {
return 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));
...@@ -1101,15 +1130,14 @@ void main() { ...@@ -1101,15 +1130,14 @@ void main() {
// Wait for it to disappear. // Wait for it to disappear.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await gesture.removePointer(); await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
testWidgetsWithLeakTracking('Tooltip text is also hoverable', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip text is also hoverable', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero; const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async { addTearDown(() async {
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));
...@@ -1155,7 +1183,6 @@ void main() { ...@@ -1155,7 +1183,6 @@ void main() {
// Wait for it to disappear. // Wait for it to disappear.
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await gesture.removePointer(); await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
...@@ -1209,7 +1236,7 @@ void main() { ...@@ -1209,7 +1236,7 @@ void main() {
await gesture.moveTo(tester.getCenter(outer)); await gesture.moveTo(tester.getCenter(outer));
await tester.pump(); await tester.pump();
// Wait for it to switch. // Wait for it to switch.
await tester.pump(waitDuration); await tester.pumpAndSettle();
expect(find.text('Outer'), findsOneWidget); expect(find.text('Outer'), findsOneWidget);
expect(find.text('Inner'), findsNothing); expect(find.text('Inner'), findsNothing);
...@@ -1270,17 +1297,6 @@ void main() { ...@@ -1270,17 +1297,6 @@ void main() {
testWidgetsWithLeakTracking('Multiple Tooltips are dismissed by escape key', (WidgetTester tester) async { testWidgetsWithLeakTracking('Multiple Tooltips are 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);
addTearDown(() async {
if (gesture != null) {
return gesture.removePointer();
}
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
await tester.pump();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget( await tester.pumpWidget(
const MaterialApp( const MaterialApp(
home: Center( home: Center(
...@@ -1305,18 +1321,8 @@ void main() { ...@@ -1305,18 +1321,8 @@ void main() {
), ),
); );
final Finder tooltip = find.text('tooltip1'); tester.state<TooltipState>(find.byTooltip('message1')).ensureTooltipVisible();
await gesture.moveTo(Offset.zero); tester.state<TooltipState>(find.byTooltip('message2')).ensureTooltipVisible();
await tester.pump();
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
await tester.pump(waitDuration);
expect(find.text('message1'), findsOneWidget);
final Finder secondTooltip = find.text('tooltip2');
await gesture.moveTo(Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(secondTooltip));
await tester.pump(); await tester.pump();
await tester.pump(waitDuration); await tester.pump(waitDuration);
// Make sure both messages are on the screen. // Make sure both messages are on the screen.
...@@ -1328,11 +1334,6 @@ void main() { ...@@ -1328,11 +1334,6 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('message1'), findsNothing); expect(find.text('message1'), findsNothing);
expect(find.text('message2'), findsNothing); expect(find.text('message2'), findsNothing);
await gesture.moveTo(Offset.zero);
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
}); });
testWidgetsWithLeakTracking('Tooltip does not attempt to show after unmount', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip does not attempt to show after unmount', (WidgetTester tester) async {
...@@ -1881,6 +1882,91 @@ void main() { ...@@ -1881,6 +1882,91 @@ void main() {
expect(onTriggeredCalled, false); expect(onTriggeredCalled, false);
}); });
testWidgets('dismissAllToolTips dismisses hovered tooltips', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Tooltip(
message: tooltipText,
waitDuration: waitDuration,
child: SizedBox(
width: 100.0,
height: 100.0,
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
// Wait for it to appear.
await tester.pump(waitDuration);
expect(find.text(tooltipText), findsOneWidget);
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget);
expect(Tooltip.dismissAllToolTips(), isTrue);
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
});
testWidgets('Hovered tooltips do not dismiss after showDuration', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Tooltip(
message: tooltipText,
waitDuration: waitDuration,
triggerMode: TooltipTriggerMode.longPress,
child: SizedBox(
width: 100.0,
height: 100.0,
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
// Wait for it to appear.
await tester.pump(waitDuration);
expect(find.text(tooltipText), findsOneWidget);
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget);
await tester.longPressAt(tester.getCenter(tooltip));
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
// Still visible.
expect(find.text(tooltipText), findsOneWidget);
// Still visible.
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
// The tooltip is no longer hovered and becomes invisible.
await gesture.moveTo(Offset.zero);
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip should not be shown with empty message (with child)', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip should not be shown with empty message (with child)', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
const MaterialApp( const MaterialApp(
...@@ -1907,6 +1993,84 @@ void main() { ...@@ -1907,6 +1993,84 @@ void main() {
} }
}); });
testWidgets('Tooltip trigger mode ignores mouse events', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Tooltip(
message: tooltipText,
triggerMode: TooltipTriggerMode.longPress,
child: SizedBox.expand(),
),
),
);
final TestGesture mouseGesture = await tester.startGesture(tester.getCenter(find.byTooltip(tooltipText)), kind: PointerDeviceKind.mouse);
await tester.pump(kLongPressTimeout + kPressTimeout);
await mouseGesture.up();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
final TestGesture touchGesture = await tester.startGesture(tester.getCenter(find.byTooltip(tooltipText)));
await tester.pump(kLongPressTimeout + kPressTimeout);
await touchGesture.up();
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget);
});
testWidgets('Tooltip does not block other mouse regions', (WidgetTester tester) async {
bool entered = false;
await tester.pumpWidget(
MaterialApp(
home: MouseRegion(
onEnter: (PointerEnterEvent event) { entered = true; },
child: const Tooltip(
message: tooltipText,
child: SizedBox.expand(),
),
),
),
);
expect(entered, isFalse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(tester.getCenter(find.byType(Tooltip)));
await gesture.removePointer();
expect(entered, isTrue);
});
testWidgets('Does not rebuild on mouse connect/disconnect', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/117627
int buildCount = 0;
await tester.pumpWidget(
MaterialApp(
home: Tooltip(
message: tooltipText,
child: Builder(builder: (BuildContext context) {
buildCount += 1;
return const SizedBox.expand();
}),
),
),
);
expect(buildCount, 1);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await tester.pump();
await gesture.removePointer();
await tester.pump();
expect(buildCount, 1);
});
testWidgetsWithLeakTracking('Tooltip should not ignore users tap on richMessage', (WidgetTester tester) async { testWidgetsWithLeakTracking('Tooltip should not ignore users tap on richMessage', (WidgetTester tester) async {
bool isTapped = false; bool isTapped = false;
......
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