Unverified Commit 3c422dd7 authored by Jasper van Riet's avatar Jasper van Riet Committed by GitHub

Introduce `exitDuration` to Tooltip for mouse pointer devices (#138321)

This PR introduces a new property `exitDuration` to Tooltip, the counterpart to `waitDuration`. The need for this is shown by #136586. This changes the behaviour of `showDuration` on mouse pointer devices. This is because the use cases for the current behaviour on touch screen devices vs mouse pointer devices is fundamentally different.

<details>
<summary>Demo: tooltip with showDuration set</summary>

Tooltip disappears after 100 ms when moving away the mouse. Tooltip will not disappear when hovered.

https://github.com/flutter/flutter/assets/5138348/81d36dc9-78e0-4723-a84b-2552843ee181

</details>

Currently, when `showDuration` is set, this adjusts the time it takes for the tooltip to hide _after_ a mouse pointer has left the tooltip. This is not the same use case as its effect on touch screen devices, where it dictates how long the tooltip stays on screen after a long press. That is needed because the tooltip takes up screen space and there is not an intuitive way to hide it, whereas when using a mouse users expect to simply have to hover somewhere else. Having the tooltip stay around will look broken.

Thus, this PR splits the two use cases. `showDuration` no longer affects mouse pointer devices at all*. There is a precedent for such mouse pointer-only behaviour in `waitDuration`. Instead, I have split up the two use cases and created the new property `exitDuration`, which will still allow for tweaking the time it takes for the tooltip to hide after the user has moved their mouse pointer somewhere else.

*Note: Should `showDuration` affect [this line](https://github.com/flutter/flutter/blob/e33d4b86270e3c012ba13d68d6e90f2eabc4912b/packages/flutter/lib/src/material/tooltip.dart#L610)?

Fixes #136586.

Note: I noticed that when I made the change, no tests were broken. Hopefully, the tests added here help that in the future. I also noticed that in the _existing_ tests, the `waitDuration` tests contain assertions that implicate that it is the role of `waitDuration` to change this behaviour, but that's not currently (nor in the new behaviour) true, so I have fixed those tests.
parent 6d4d0123
......@@ -15,11 +15,9 @@ void main() {
const example.TooltipExampleApp(),
);
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();
}
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
......@@ -38,14 +36,9 @@ void main() {
// Move the mouse away and wait for the tooltip to disappear.
await gesture.moveTo(const Offset(1.0, 1.0));
await tester.pump();
// Wait a second and the tooltip should still be visible.
await tester.pump(const Duration(seconds: 1));
expect(find.text(tooltipText), findsOneWidget);
// Wait another second and the tooltip should be gone.
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing);
});
}
......@@ -185,6 +185,7 @@ class Tooltip extends StatefulWidget {
this.textAlign,
this.waitDuration,
this.showDuration,
this.exitDuration,
this.enableTapToDismiss = true,
this.triggerMode,
this.enableFeedback,
......@@ -301,13 +302,28 @@ class Tooltip extends StatefulWidget {
/// The length of time that the tooltip will be shown after a long press is
/// released (if triggerMode is [TooltipTriggerMode.longPress]) or a tap is
/// released (if triggerMode is [TooltipTriggerMode.tap]) or mouse pointer
/// exits the widget.
/// released (if triggerMode is [TooltipTriggerMode.tap]). This property
/// does not affect mouse pointer devices.
///
/// Defaults to 1.5 seconds for long press and tap released or 0.1 seconds
/// for mouse pointer exits the widget.
/// Defaults to 1.5 seconds for long press and tap released
///
/// See also:
///
/// * [exitDuration], which allows configuring the time untill a pointer
/// dissapears when hovering.
final Duration? showDuration;
/// The length of time that a pointer must have stopped hovering over a
/// tooltip's widget before the tooltip will be hidden.
///
/// Defaults to 100 milliseconds.
///
/// See also:
///
/// * [showDuration], which allows configuring the length of time that a
/// tooltip will be visible after touch events are released.
final Duration? exitDuration;
/// Whether the tooltip can be dismissed by tap.
///
/// The default value is true.
......@@ -388,6 +404,7 @@ class Tooltip extends StatefulWidget {
properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true));
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('exit duration', exitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true));
properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
......@@ -405,7 +422,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
static const Duration _fadeInDuration = Duration(milliseconds: 150);
static const Duration _fadeOutDuration = Duration(milliseconds: 75);
static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
static const Duration _defaultHoverExitDuration = Duration(milliseconds: 100);
static const Duration _defaultWaitDuration = Duration.zero;
static const bool _defaultExcludeFromSemantics = false;
static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress;
......@@ -419,7 +436,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late TooltipThemeData _tooltipTheme;
Duration get _showDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultShowDuration;
Duration get _hoverShowDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultHoverShowDuration;
Duration get _hoverExitDuration => widget.exitDuration ?? _tooltipTheme.exitDuration ?? _defaultHoverExitDuration;
Duration get _waitDuration => widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration;
TooltipTriggerMode get _triggerMode => widget.triggerMode ?? _tooltipTheme.triggerMode ?? _defaultTriggerMode;
bool get _enableFeedback => widget.enableFeedback ?? _tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
......@@ -669,7 +686,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
}
_activeHoveringPointerDevices.remove(event.device);
if (_activeHoveringPointerDevices.isEmpty) {
_scheduleDismissTooltip(withDelay: _hoverShowDuration);
_scheduleDismissTooltip(withDelay: _hoverExitDuration);
}
}
......
......@@ -41,6 +41,7 @@ class TooltipThemeData with Diagnosticable {
this.textAlign,
this.waitDuration,
this.showDuration,
this.exitDuration,
this.triggerMode,
this.enableFeedback,
});
......@@ -96,6 +97,10 @@ class TooltipThemeData with Diagnosticable {
/// The length of time that the tooltip will be shown once it has appeared.
final Duration? showDuration;
/// The length of time that a pointer must have stopped hovering over a
/// tooltip's widget before the tooltip will be hidden.
final Duration? exitDuration;
/// The [TooltipTriggerMode] that will show the tooltip.
final TooltipTriggerMode? triggerMode;
......@@ -126,6 +131,7 @@ class TooltipThemeData with Diagnosticable {
TextAlign? textAlign,
Duration? waitDuration,
Duration? showDuration,
Duration? exitDuration,
TooltipTriggerMode? triggerMode,
bool? enableFeedback,
}) {
......@@ -181,6 +187,7 @@ class TooltipThemeData with Diagnosticable {
textAlign,
waitDuration,
showDuration,
exitDuration,
triggerMode,
enableFeedback,
);
......@@ -205,6 +212,7 @@ class TooltipThemeData with Diagnosticable {
&& other.textAlign == textAlign
&& other.waitDuration == waitDuration
&& other.showDuration == showDuration
&& other.exitDuration == exitDuration
&& other.triggerMode == triggerMode
&& other.enableFeedback == enableFeedback;
}
......@@ -223,6 +231,7 @@ class TooltipThemeData with Diagnosticable {
properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
properties.add(DiagnosticsProperty<Duration>('exit duration', exitDuration, defaultValue: null));
properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true));
}
......
......@@ -2431,6 +2431,104 @@ void main() {
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Hovered tooltips with showDuration set do dismiss when hovering elswhere', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
const Duration showDuration = Duration(seconds: 1);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Tooltip(
message: tooltipText,
waitDuration: waitDuration,
showDuration: showDuration,
triggerMode: TooltipTriggerMode.longPress,
child: SizedBox(
width: 100.0,
height: 100.0,
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byTooltip(tooltipText)));
await tester.pump(const Duration(seconds: 1));
expect(find.text(tooltipText), findsOneWidget, reason: 'Tooltip should be visible when hovered.');
await gesture.moveTo(Offset.zero);
// Set a duration equal to the default exit
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing,
reason: 'Tooltip should not wait for showDuration before it hides itself.');
});
testWidgetsWithLeakTracking('Hovered tooltips hide after stopping the hover', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: SizedBox.square(
dimension: 10.0,
child: Tooltip(
message: tooltipText,
triggerMode: TooltipTriggerMode.longPress,
child: SizedBox.expand(),
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byTooltip(tooltipText)));
await tester.pump(const Duration(seconds: 1));
expect(find.text(tooltipText), findsOneWidget, reason: 'Tooltip should be visible when hovered.');
await gesture.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing, reason: 'Tooltip should be hidden when no longer hovered.');
});
testWidgetsWithLeakTracking('Hovered tooltips hide after stopping the hover and exitDuration expires', (WidgetTester tester) async {
const Duration exitDuration = Duration(seconds: 1);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: SizedBox.square(
dimension: 10.0,
child: Tooltip(
message: tooltipText,
exitDuration: exitDuration,
triggerMode: TooltipTriggerMode.longPress,
child: SizedBox.expand(),
),
),
),
),
);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(gesture.removePointer);
await gesture.moveTo(tester.getCenter(find.byTooltip(tooltipText)));
await tester.pump(const Duration(seconds: 1));
expect(find.text(tooltipText), findsOneWidget, reason: 'Tooltip should be visible when hovered.');
await gesture.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 100));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget,
reason: 'Tooltip should wait untill exitDuration expires before being hidden');
await tester.pump(const Duration(seconds: 1));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing, reason: 'Tooltip should be hidden when no longer hovered.');
});
testWidgetsWithLeakTracking('Tooltip should not be shown with empty message (with child)', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
......
......@@ -37,6 +37,7 @@ void main() {
expect(theme.textAlign, null);
expect(theme.waitDuration, null);
expect(theme.showDuration, null);
expect(theme.exitDuration, null);
expect(theme.triggerMode, null);
expect(theme.enableFeedback, null);
});
......@@ -57,6 +58,7 @@ void main() {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Duration wait = Duration(milliseconds: 100);
const Duration show = Duration(milliseconds: 200);
const Duration exit = Duration(milliseconds: 100);
const TooltipTriggerMode triggerMode = TooltipTriggerMode.longPress;
const bool enableFeedback = true;
const TooltipThemeData(
......@@ -70,6 +72,7 @@ void main() {
textAlign: TextAlign.center,
waitDuration: wait,
showDuration: show,
exitDuration: exit,
triggerMode: triggerMode,
enableFeedback: enableFeedback,
).debugFillProperties(builder);
......@@ -90,6 +93,7 @@ void main() {
'textAlign: TextAlign.center',
'wait duration: $wait',
'show duration: $show',
'exit duration: $exit',
'triggerMode: $triggerMode',
'enableFeedback: true',
]);
......@@ -967,7 +971,7 @@ void main() {
await tester.pump();
// Wait for it to disappear.
await tester.pump(customWaitDuration);
await tester.pump(const Duration(milliseconds: 100)); // Should disappear after default exitDuration
expect(find.text(tooltipText), findsNothing);
});
......@@ -1010,7 +1014,7 @@ void main() {
await tester.pump();
// Wait for it to disappear.
await tester.pump(customWaitDuration); // Should disappear after customWaitDuration
await tester.pump(const Duration(milliseconds: 100)); // Should disappear after default exitDuration
expect(find.text(tooltipText), findsNothing);
});
......@@ -1084,6 +1088,92 @@ void main() {
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip exitDuration - ThemeData.tooltipTheme', (WidgetTester tester) async {
const Duration customExitDuration = Duration(milliseconds: 500);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
await tester.pump();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget(
MaterialApp(
home: Theme(
data: ThemeData(
tooltipTheme: const TooltipThemeData(
exitDuration: customExitDuration,
),
),
child: const Center(
child: Tooltip(
message: tooltipText,
child: SizedBox(
width: 100.0,
height: 100.0,
),
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
expect(find.text(tooltipText), findsOneWidget);
await gesture.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget);
// Wait for it to disappear.
await tester.pump(customExitDuration);
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip exitDuration - TooltipTheme', (WidgetTester tester) async {
const Duration customExitDuration = Duration(milliseconds: 500);
final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
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: TooltipTheme(
data: TooltipThemeData(exitDuration: customExitDuration),
child: Center(
child: Tooltip(
message: tooltipText,
child: SizedBox(
width: 100.0,
height: 100.0,
),
),
),
),
),
);
final Finder tooltip = find.byType(Tooltip);
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
expect(find.text(tooltipText), findsOneWidget);
await gesture.moveTo(Offset.zero);
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsOneWidget);
// Wait for it to disappear.
await tester.pump(customExitDuration);
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
});
testWidgetsWithLeakTracking('Tooltip triggerMode - ThemeData.triggerMode', (WidgetTester tester) async {
const TooltipTriggerMode triggerMode = TooltipTriggerMode.tap;
await tester.pumpWidget(
......
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