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() {
await tester.tap(find.byType(FloatingActionButton));
await tester.pump(const Duration(milliseconds: 10));
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));
expect(find.text(tooltipText), findsNothing);
});
......
......@@ -19,6 +19,70 @@ import 'tooltip_visibility.dart';
/// Signature for when a tooltip is triggered.
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.
///
/// Tooltips provide text labels which help explain the function of a button or
......@@ -230,6 +294,10 @@ class Tooltip extends StatefulWidget {
/// If this property is null, then [TooltipThemeData.triggerMode] is used.
/// If [TooltipThemeData.triggerMode] is also null, the default mode is
/// [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;
/// Whether the tooltip should provide acoustic and/or haptic feedback.
......@@ -252,30 +320,8 @@ class Tooltip extends StatefulWidget {
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,
/// including those with mouse cursors currently hovering over them.
///
/// This method returns true if it successfully dismisses the tooltips. It
/// returns false if there is no tooltip shown on the screen.
......@@ -284,7 +330,8 @@ class Tooltip extends StatefulWidget {
// Avoid concurrent modification.
final List<TooltipState> openedTooltips = _openedTooltips.toList();
for (final TooltipState state in openedTooltips) {
state._dismissTooltip(immediately: true);
assert(state.mounted);
state._scheduleDismissTooltip(withDelay: Duration.zero);
}
return true;
}
......@@ -350,19 +397,13 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late double _verticalOffset;
late bool _preferBelow;
late bool _excludeFromSemantics;
late AnimationController _controller;
OverlayEntry? _entry;
Timer? _dismissTimer;
Timer? _showTimer;
late Duration _showDuration;
late Duration _hoverShowDuration;
late Duration _waitDuration;
late bool _mouseIsConnected;
bool _pressActivated = false;
late TooltipTriggerMode _triggerMode;
late bool _enableFeedback;
late bool _isConcealed;
late bool _forceRemoval;
late bool _visible;
/// The plain text message for this tooltip.
......@@ -370,202 +411,332 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
/// This value will either come from [widget.message] or [widget.richMessage].
String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText();
@override
void initState() {
super.initState();
_isConcealed = false;
_forceRemoval = false;
_mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
_controller = AnimationController(
Timer? _timer;
AnimationController? _backingController;
AnimationController get _controller {
return _backingController ??= AnimationController(
duration: _fadeInDuration,
reverseDuration: _fadeOutDuration,
vsync: this,
)
..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);
)..addStatusListener(_handleStatusChanged);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_visible = TooltipVisibility.of(context);
LongPressGestureRecognizer? _longPressRecognizer;
TapGestureRecognizer? _tapRecognizer;
// 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);
}
}
if (entryNeedsUpdating) {
setState(() { /* Rebuild to update the OverlayEntry */ });
}
_animationStatus = status;
}
// 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 _scheduleShowTooltip({ required Duration withDelay, Duration? showDuration }) {
assert(mounted);
void show() {
assert(mounted);
if (!_visible) {
return;
}
_controller.forward();
_timer?.cancel();
_timer = showDuration == null ? null : Timer(showDuration, _controller.reverse);
}
assert(
!(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse,
'timer must not be active when the tooltip is fading out',
);
switch (_controller.status) {
case AnimationStatus.dismissed:
if (withDelay.inMicroseconds > 0) {
_timer ??= Timer(withDelay, show);
} else {
show();
}
// 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();
}
}
EdgeInsets _getDefaultPadding() {
final ThemeData theme = Theme.of(context);
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 _scheduleDismissTooltip({ required Duration withDelay }) {
assert(mounted);
assert(
!(_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();
}
}
}
double _getDefaultFontSize() {
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.macOS:
case TargetPlatform.linux:
case TargetPlatform.windows:
return 12.0;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.iOS:
return 14.0;
void _handlePointerDown(PointerDownEvent event) {
assert(mounted);
// PointerDeviceKinds that don't support hovering.
const Set<PointerDeviceKind> triggerModeDeviceKinds = <PointerDeviceKind> {
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;
}
}
// Forces a rebuild if a mouse has been added or removed.
void _handleMouseTrackerChange() {
if (!mounted) {
// 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;
}
final bool mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
if (mouseIsConnected != _mouseIsConnected) {
setState(() {
_mouseIsConnected = mouseIsConnected;
});
if ((_timer == null && _controller.status == AnimationStatus.dismissed) || event is! PointerDownEvent) {
return;
}
_handleTapToDismiss();
}
void _handleStatusChanged(AnimationStatus status) {
// 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();
// The primary pointer is not part of a "trigger" gesture so the tooltip
// should be dismissed.
void _handleTapToDismiss() {
_scheduleDismissTooltip(withDelay: Duration.zero);
_activeHoveringPointerDevices.clear();
}
void _handleTap() {
if (!_visible) {
return;
}
final bool tooltipCreated = _controller.status == AnimationStatus.dismissed;
if (tooltipCreated && _enableFeedback) {
assert(_triggerMode == TooltipTriggerMode.tap);
Feedback.forTap(context);
}
widget.onTriggered?.call();
_scheduleShowTooltip(
withDelay: Duration.zero,
// _activeHoveringPointerDevices keep the tooltip visible.
showDuration: _activeHoveringPointerDevices.isEmpty ? _showDuration : null,
);
}
void _dismissTooltip({ bool immediately = false }) {
_showTimer?.cancel();
_showTimer = null;
if (immediately) {
_removeEntry();
// When a "trigger" gesture is recognized and the pointer down even is a part
// of it.
void _handleLongPress() {
if (!_visible) {
return;
}
// So it will be removed when it's done reversing, regardless of whether it is
// still concealed or not.
_forceRemoval = true;
if (_pressActivated) {
_dismissTimer ??= Timer(_showDuration, _controller.reverse);
} else {
_dismissTimer ??= Timer(_hoverShowDuration, _controller.reverse);
final bool tooltipCreated = _visible && _controller.status == AnimationStatus.dismissed;
if (tooltipCreated && _enableFeedback) {
assert(_triggerMode == TooltipTriggerMode.longPress);
Feedback.forLongPress(context);
}
_pressActivated = false;
widget.onTriggered?.call();
_scheduleShowTooltip(withDelay: Duration.zero);
}
void _showTooltip({ bool immediately = false }) {
_dismissTimer?.cancel();
_dismissTimer = null;
if (immediately) {
ensureTooltipVisible();
void _handlePressUp() {
if (_activeHoveringPointerDevices.isNotEmpty) {
return;
}
_showTimer ??= Timer(_waitDuration, ensureTooltipVisible);
_scheduleDismissTooltip(withDelay: _showDuration);
}
void _concealTooltip() {
if (_isConcealed || _forceRemoval) {
// Already concealed, or it's being removed.
// # Current Hovering Behavior:
// 1. Hovered tooltips don't show more than one at a time, for each mouse
// device. For example, a chip with a delete icon typically shouldn't show
// 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;
}
_isConcealed = true;
_dismissTimer?.cancel();
_dismissTimer = null;
_showTimer?.cancel();
_showTimer = null;
if (_entry != null) {
_entry!.remove();
// _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() {
if (!_isConcealed) {
// Already uncovered.
void _handleMouseExit(PointerExitEvent event) {
if (!mounted) {
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!);
assert(_activeHoveringPointerDevices.isNotEmpty);
_activeHoveringPointerDevices.remove(event.device);
if (_activeHoveringPointerDevices.isEmpty) {
_scheduleDismissTooltip(withDelay: _hoverShowDuration);
}
SemanticsService.tooltip(_tooltipMessage);
_controller.forward();
}
/// 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
/// was already visible.
bool ensureTooltipVisible() {
if (!_visible || !mounted) {
if (!_visible) {
return false;
}
_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.
_dismissTimer?.cancel();
_dismissTimer = null;
_controller.forward();
return false; // Already visible.
_timer?.cancel();
_timer = null;
switch (_controller.status) {
case AnimationStatus.dismissed:
case AnimationStatus.reverse:
_scheduleShowTooltip(withDelay: Duration.zero);
return true;
case AnimationStatus.forward:
case AnimationStatus.completed:
return false;
}
_createNewEntry();
_controller.forward();
return true;
}
static final Set<TooltipState> _mouseIn = <TooltipState>{};
@override
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);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_visible = TooltipVisibility.of(context);
}
void _handleMouseEnter() {
if (mounted) {
_showTooltip();
// 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 _handleMouseExit({bool immediately = false}) {
if (mounted) {
// If the tip is currently covered, we can just remove it without waiting.
_dismissTooltip(immediately: _isConcealed || immediately);
EdgeInsets _getDefaultPadding() {
final ThemeData theme = Theme.of(context);
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);
}
}
double _getDefaultFontSize() {
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
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 {
height: _height,
padding: _padding,
margin: _margin,
onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
onEnter: _handleMouseEnter,
onExit: _handleMouseExit,
decoration: _decoration,
textStyle: _textStyle,
textAlign: _textAlign,
......@@ -605,92 +776,29 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
preferBelow: _preferBelow,
),
);
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
_isConcealed = false;
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));
final OverlayEntry entry = _entry = OverlayEntry(builder: (BuildContext context) => overlay);
overlayState.insert(entry);
Tooltip._openedTooltips.add(this);
}
void _removeEntry() {
Tooltip._openedTooltips.remove(this);
_mouseIn.remove(this);
_dismissTimer?.cancel();
_dismissTimer = null;
_showTimer?.cancel();
_showTimer = null;
if (!_isConcealed) {
_entry?.remove();
}
_isConcealed = false;
_entry?.remove();
_entry?.dispose();
_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
void dispose() {
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent);
_removeEntry();
_controller.dispose();
_longPressRecognizer?.dispose();
_tapRecognizer?.dispose();
_timer?.cancel();
_backingController?.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
Widget build(BuildContext context) {
// If message is empty then no need to create a tooltip overlay to show
......@@ -740,29 +848,21 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
Widget result = Semantics(
tooltip: _excludeFromSemantics
? null
: _tooltipMessage,
tooltip: _excludeFromSemantics ? null : _tooltipMessage,
child: widget.child,
);
// Only check for gestures if tooltip should be visible.
if (_visible) {
result = GestureDetector(
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(),
result = _ExclusiveMouseRegion(
onEnter: _handleMouseEnter,
onExit: _handleMouseExit,
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.opaque,
child: result,
);
}
),
);
}
return result;
......@@ -874,7 +974,7 @@ class _TooltipOverlay extends StatelessWidget {
),
);
if (onEnter != null || onExit != null) {
result = MouseRegion(
result = _ExclusiveMouseRegion(
onEnter: onEnter,
onExit: onExit,
child: result,
......
......@@ -49,14 +49,14 @@ Offset positionDependentBox({
final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - margin;
final bool fitsAbove = target.dy - verticalOffset - childSize.height >= margin;
final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
double y;
final double y;
if (tooltipBelow) {
y = math.min(target.dy + verticalOffset, size.height - margin);
} else {
y = math.max(target.dy - verticalOffset - childSize.height, margin);
}
// HORIZONTAL DIRECTION
double x;
final double x;
if (size.width - margin * 2.0 < childSize.width) {
x = (size.width - childSize.width) / 2.0;
} else {
......
......@@ -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(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget);
......
......@@ -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(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget);
......
......@@ -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(longPressTooltip), findsNothing);
final Finder tooltip1 = find.byType(Tooltip);
await tester.tap(tooltip1);
await tester.pump(const Duration(milliseconds: 10));
expect(find.text(tapTooltip), findsOneWidget);
......
......@@ -502,10 +502,12 @@ void main() {
RenderBox tip = tester.renderObject(_findTooltipContainer(tooltipText));
Offset fabTopRight = tester.getTopRight(fabFinder);
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))
await tester.pumpWidget(materialAppWithViewInsets(300));
// Wait for the tooltip to dismiss.
await tester.pump(const Duration(days: 1));
await tester.pumpAndSettle();
// Show FAB tooltip
......@@ -517,7 +519,7 @@ void main() {
tip = tester.renderObject(_findTooltipContainer(tooltipText));
fabTopRight = tester.getTopRight(fabFinder);
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 {
......@@ -711,6 +713,10 @@ void main() {
RenderParagraph tooltipRenderParagraph = tester.renderObject<RenderParagraph>(find.text(tooltipText));
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.longPress(find.byType(Tooltip));
expect(find.text(tooltipText), findsOneWidget);
......@@ -856,6 +862,7 @@ void main() {
MaterialApp(
home: Center(
child: Tooltip(
triggerMode: TooltipTriggerMode.longPress,
message: tooltipText,
child: Container(
width: 100.0,
......@@ -879,7 +886,8 @@ void main() {
// tap (down, up) gesture hides tooltip, since its not
// a long press
await tester.tap(tooltip);
await tester.pump(const Duration(milliseconds: 10));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
// long press once more
......@@ -898,6 +906,32 @@ void main() {
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 {
const Duration showDuration = Duration(seconds: 3);
await setWidgetForTooltipMode(tester, TooltipTriggerMode.longPress, showDuration: showDuration);
......@@ -967,11 +1001,9 @@ void main() {
testWidgetsWithLeakTracking('Dispatch the mouse events before tip overlay detached', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/96890
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
if (gesture != null) {
return gesture.removePointer();
}
return gesture.removePointer();
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
......@@ -1018,7 +1050,6 @@ void main() {
// Go without crashes.
await gesture.removePointer();
gesture = null;
});
testWidgetsWithLeakTracking('Calling ensureTooltipVisible on an unmounted TooltipState returns false', (WidgetTester tester) async {
......@@ -1054,11 +1085,9 @@ void main() {
testWidgetsWithLeakTracking('Tooltip shows/hides when hovered', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
if (gesture != null) {
return gesture.removePointer();
}
return gesture.removePointer();
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
......@@ -1101,15 +1130,14 @@ void main() {
// Wait for it to disappear.
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip text is also hoverable', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
gesture?.removePointer();
return gesture.removePointer();
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
......@@ -1155,7 +1183,6 @@ void main() {
// Wait for it to disappear.
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing);
});
......@@ -1209,7 +1236,7 @@ void main() {
await gesture.moveTo(tester.getCenter(outer));
await tester.pump();
// Wait for it to switch.
await tester.pump(waitDuration);
await tester.pumpAndSettle();
expect(find.text('Outer'), findsOneWidget);
expect(find.text('Inner'), findsNothing);
......@@ -1270,17 +1297,6 @@ void main() {
testWidgetsWithLeakTracking('Multiple Tooltips are dismissed by escape key', (WidgetTester tester) async {
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(
const MaterialApp(
home: Center(
......@@ -1305,18 +1321,8 @@ void main() {
),
);
final Finder tooltip = find.text('tooltip1');
await gesture.moveTo(Offset.zero);
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));
tester.state<TooltipState>(find.byTooltip('message1')).ensureTooltipVisible();
tester.state<TooltipState>(find.byTooltip('message2')).ensureTooltipVisible();
await tester.pump();
await tester.pump(waitDuration);
// Make sure both messages are on the screen.
......@@ -1328,11 +1334,6 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('message1'), 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 {
......@@ -1881,6 +1882,91 @@ void main() {
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 {
await tester.pumpWidget(
const MaterialApp(
......@@ -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 {
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