Add support for Tooltip hover (#31561)
Adds support for mouse pointer hovering to trigger tooltips, as well as custom timeouts for the tooltip durations, and a custom decoration. It also makes the tooltip be fully opaque when shown, and fade in over 150ms, and fade out over 75ms, and draw a 4.0 corner radius, all to conform with the material spec. Prior to this change, it was using a corner radius of 2.0 when shown, and faded in and out over 200ms.
Fixes #22817
diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart
index 46a20b1..1694c53 100644
--- a/packages/flutter/lib/src/material/tooltip.dart
+++ b/packages/flutter/lib/src/material/tooltip.dart
@@ -12,8 +12,10 @@
import 'theme.dart';
import 'theme_data.dart';
-const Duration _kFadeDuration = Duration(milliseconds: 200);
-const Duration _kShowDuration = Duration(milliseconds: 1500);
+const Duration _kFadeInDuration = Duration(milliseconds: 150);
+const Duration _kFadeOutDuration = Duration(milliseconds: 75);
+const Duration _kDefaultShowDuration = Duration(milliseconds: 1500);
+const Duration _kDefaultWaitDuration = Duration(milliseconds: 0);
/// A material design tooltip.
///
@@ -41,7 +43,7 @@
/// By default, tooltips prefer to appear below the [child] widget when the
/// user long presses on the widget.
///
- /// The [message] argument must not be null.
+ /// All of the arguments except [child] and [decoration] must not be null.
const Tooltip({
Key key,
@required this.message,
@@ -50,19 +52,25 @@
this.verticalOffset = 24.0,
this.preferBelow = true,
this.excludeFromSemantics = false,
+ this.decoration,
+ this.waitDuration = _kDefaultWaitDuration,
+ this.showDuration = _kDefaultShowDuration,
this.child,
- }) : assert(message != null),
- assert(height != null),
- assert(padding != null),
- assert(verticalOffset != null),
- assert(preferBelow != null),
- assert(excludeFromSemantics != null),
- super(key: key);
+ }) : assert(message != null),
+ assert(height != null),
+ assert(padding != null),
+ assert(verticalOffset != null),
+ assert(preferBelow != null),
+ assert(excludeFromSemantics != null),
+ assert(waitDuration != null),
+ assert(showDuration != null),
+ super(key: key);
/// The text to display in the tooltip.
final String message;
- /// The amount of vertical space the tooltip should occupy (inside its padding).
+ /// The amount of vertical space the tooltip should occupy (inside its
+ /// padding).
final double height;
/// The amount of space by which to inset the child.
@@ -70,7 +78,8 @@
/// Defaults to 16.0 logical pixels in each direction.
final EdgeInsetsGeometry padding;
- /// The amount of vertical distance between the widget and the displayed tooltip.
+ /// The amount of vertical distance between the widget and the displayed
+ /// tooltip.
final double verticalOffset;
/// Whether the tooltip defaults to being displayed below the widget.
@@ -89,6 +98,23 @@
/// {@macro flutter.widgets.child}
final Widget child;
+ /// Specifies the decoration of the tooltip window.
+ ///
+ /// If not specified, defaults to a rounded rectangle with a border radius of
+ /// 4.0, and a color derived from the text theme.
+ final Decoration decoration;
+
+ /// The amount of time that a pointer must hover over the widget before it
+ /// will show a tooltip.
+ ///
+ /// Defaults to 0 milliseconds (tooltips show immediately upon hover).
+ final Duration waitDuration;
+
+ /// The amount of time that the tooltip will be shown once it has appeared.
+ ///
+ /// Defaults to 1.5 seconds.
+ final Duration showDuration;
+
@override
_TooltipState createState() => _TooltipState();
@@ -98,38 +124,72 @@
properties.add(StringProperty('message', message, showName: false));
properties.add(DoubleProperty('vertical offset', verticalOffset));
properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
+ properties.add(DiagnosticsProperty<Duration>('waitDuration', waitDuration, defaultValue: _kDefaultWaitDuration));
+ properties.add(DiagnosticsProperty<Duration>('showDuration', showDuration, defaultValue: _kDefaultShowDuration));
}
}
class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
AnimationController _controller;
OverlayEntry _entry;
- Timer _timer;
+ Timer _hideTimer;
+ Timer _showTimer;
@override
void initState() {
super.initState();
- _controller = AnimationController(duration: _kFadeDuration, vsync: this)
+ _controller = AnimationController(duration: _kFadeInDuration, vsync: this)
..addStatusListener(_handleStatusChanged);
}
void _handleStatusChanged(AnimationStatus status) {
- if (status == AnimationStatus.dismissed)
+ if (status == AnimationStatus.dismissed) {
+ _hideTooltip(immediately: true);
+ }
+ }
+
+ void _hideTooltip({bool immediately = false}) {
+ _showTimer?.cancel();
+ _showTimer = null;
+ if (immediately) {
_removeEntry();
+ return;
+ }
+ _hideTimer ??= Timer(widget.showDuration, _controller.reverse);
+ }
+
+ void _showTooltip({bool immediately = false}) {
+ _hideTimer?.cancel();
+ _hideTimer = null;
+ if (immediately) {
+ ensureTooltipVisible();
+ return;
+ }
+ _showTimer ??= Timer(widget.waitDuration, ensureTooltipVisible);
}
/// Shows the tooltip if it is not already visible.
///
/// Returns `false` when the tooltip was already visible.
bool ensureTooltipVisible() {
+ _showTimer?.cancel();
+ _showTimer = null;
if (_entry != null) {
- _timer?.cancel();
- _timer = null;
+ // Stop trying to hide, if we were.
+ _hideTimer?.cancel();
+ _hideTimer = null;
_controller.forward();
return false; // Already visible.
}
+ _createNewEntry();
+ _controller.forward();
+ return true;
+ }
+
+ void _createNewEntry() {
final RenderBox box = context.findRenderObject();
final Offset target = box.localToGlobal(box.size.center(Offset.zero));
+
// We create this widget outside of the overlay entry's builder to prevent
// updated values from happening to leak into the overlay when the overlay
// rebuilds.
@@ -137,9 +197,18 @@
message: widget.message,
height: widget.height,
padding: widget.padding,
+ decoration: widget.decoration,
animation: CurvedAnimation(
parent: _controller,
curve: Curves.fastOutSlowIn,
+ // Add an interval here to make the fade out use a different (shorter)
+ // duration than the fade in. If _kFadeOutDuration is made longer than
+ // _kFadeInDuration, then the equation below will need to change.
+ reverseCurve: Interval(
+ 0.0,
+ _kFadeOutDuration.inMilliseconds / _kFadeInDuration.inMilliseconds,
+ curve: Curves.fastOutSlowIn,
+ ),
),
target: target,
verticalOffset: widget.verticalOffset,
@@ -149,31 +218,30 @@
Overlay.of(context, debugRequiredFor: widget).insert(_entry);
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
SemanticsService.tooltip(widget.message);
- _controller.forward();
- return true;
}
void _removeEntry() {
- assert(_entry != null);
- _timer?.cancel();
- _timer = null;
- _entry.remove();
+ _hideTimer?.cancel();
+ _hideTimer = null;
+ _entry?.remove();
_entry = null;
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
}
void _handlePointerEvent(PointerEvent event) {
assert(_entry != null);
- if (event is PointerUpEvent || event is PointerCancelEvent)
- _timer ??= Timer(_kShowDuration, _controller.reverse);
- else if (event is PointerDownEvent)
- _controller.reverse();
+ if (event is PointerUpEvent || event is PointerCancelEvent) {
+ _hideTooltip();
+ } else if (event is PointerDownEvent) {
+ _hideTooltip(immediately: true);
+ }
}
@override
void deactivate() {
- if (_entry != null)
- _controller.reverse();
+ if (_entry != null) {
+ _hideTooltip(immediately: true);
+ }
super.deactivate();
}
@@ -194,13 +262,17 @@
@override
Widget build(BuildContext context) {
assert(Overlay.of(context, debugRequiredFor: widget) != null);
- return GestureDetector(
- behavior: HitTestBehavior.opaque,
- onLongPress: _handleLongPress,
- excludeFromSemantics: true,
- child: Semantics(
- label: widget.excludeFromSemantics ? null : widget.message,
- child: widget.child,
+ return Listener(
+ onPointerEnter: (PointerEnterEvent event) => _showTooltip(),
+ onPointerExit: (PointerExitEvent event) => _hideTooltip(),
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onLongPress: _handleLongPress,
+ excludeFromSemantics: true,
+ child: Semantics(
+ label: widget.excludeFromSemantics ? null : widget.message,
+ child: widget.child,
+ ),
),
);
}
@@ -216,9 +288,9 @@
@required this.target,
@required this.verticalOffset,
@required this.preferBelow,
- }) : assert(target != null),
- assert(verticalOffset != null),
- assert(preferBelow != null);
+ }) : assert(target != null),
+ assert(verticalOffset != null),
+ assert(preferBelow != null);
/// The offset of the target the tooltip is positioned near in the global
/// coordinate system.
@@ -262,6 +334,7 @@
this.message,
this.height,
this.padding,
+ this.decoration,
this.animation,
this.target,
this.verticalOffset,
@@ -271,6 +344,7 @@
final String message;
final double height;
final EdgeInsetsGeometry padding;
+ final Decoration decoration;
final Animation<double> animation;
final Offset target;
final double verticalOffset;
@@ -294,21 +368,18 @@
),
child: FadeTransition(
opacity: animation,
- child: Opacity(
- opacity: 0.9,
- child: ConstrainedBox(
- constraints: BoxConstraints(minHeight: height),
- child: Container(
- decoration: BoxDecoration(
- color: darkTheme.backgroundColor,
- borderRadius: BorderRadius.circular(2.0),
- ),
- padding: padding,
- child: Center(
- widthFactor: 1.0,
- heightFactor: 1.0,
- child: Text(message, style: darkTheme.textTheme.body1),
- ),
+ child: ConstrainedBox(
+ constraints: BoxConstraints(minHeight: height),
+ child: Container(
+ decoration: decoration ?? BoxDecoration(
+ color: darkTheme.backgroundColor.withOpacity(0.9),
+ borderRadius: BorderRadius.circular(4.0),
+ ),
+ padding: padding,
+ child: Center(
+ widthFactor: 1.0,
+ heightFactor: 1.0,
+ child: Text(message, style: darkTheme.textTheme.body1),
),
),
),
diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart
index 5fe5571..f81921f3 100644
--- a/packages/flutter/test/material/tooltip_test.dart
+++ b/packages/flutter/test/material/tooltip_test.dart
@@ -11,6 +11,7 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
+import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart';
@@ -67,7 +68,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -123,7 +124,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -175,7 +176,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -229,7 +230,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
// we try to put it here but it doesn't fit:
@@ -294,7 +295,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -347,7 +348,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -402,7 +403,7 @@
),
),
);
- (key.currentState as dynamic).ensureTooltipVisible(); // before using "as dynamic" in your code, see note top of file
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen
@@ -422,6 +423,82 @@
expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(324.0));
});
+ testWidgets('Does tooltip end up with the right default size, shape, and color', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Overlay(
+ initialEntries: <OverlayEntry>[
+ OverlayEntry(
+ builder: (BuildContext context) {
+ return Tooltip(
+ key: key,
+ message: tooltipText,
+ child: Container(
+ width: 0.0,
+ height: 0.0,
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
+ await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
+
+ final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent.parent.parent.parent;
+
+ expect(tip.size.height, equals(32.0));
+ expect(tip.size.width, equals(74.0));
+ expect(tip, paints..rrect(
+ rrect: RRect.fromRectAndRadius(tip.paintBounds, const Radius.circular(4.0)),
+ color: const Color(0xe6616161),
+ ));
+ });
+
+ testWidgets('Can tooltip decoration be customized', (WidgetTester tester) async {
+ final GlobalKey key = GlobalKey();
+ const Decoration customDecoration = ShapeDecoration(
+ shape: StadiumBorder(),
+ color: Color(0x80800000),
+ );
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: Overlay(
+ initialEntries: <OverlayEntry>[
+ OverlayEntry(
+ builder: (BuildContext context) {
+ return Tooltip(
+ key: key,
+ decoration: customDecoration,
+ message: tooltipText,
+ child: Container(
+ width: 0.0,
+ height: 0.0,
+ ),
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file.
+ await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
+
+ final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent.parent.parent.parent;
+
+ expect(tip.size.height, equals(32.0));
+ expect(tip.size.width, equals(74.0));
+ expect(tip, paints..path(
+ color: const Color(0x80800000),
+ ));
+ });
+
testWidgets('Tooltip stays around', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
@@ -457,6 +534,50 @@
gesture.up();
});
+ testWidgets('Tooltip shows/hides when hovered', (WidgetTester tester) async {
+ const Duration waitDuration = Duration(milliseconds: 0);
+ const Duration showDuration = Duration(milliseconds: 1500);
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Center(
+ child: Tooltip(
+ message: tooltipText,
+ showDuration: showDuration,
+ waitDuration: waitDuration,
+ child: Container(
+ width: 100.0,
+ height: 100.0,
+ ),
+ ),
+ ),
+ )
+ );
+
+ final Finder tooltip = find.byType(Tooltip);
+ final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.moveTo(Offset.zero);
+ await tester.pump();
+ await gesture.moveTo(tester.getCenter(tooltip));
+ await tester.pump();
+ // Wait for it to appear.
+ await tester.pump(waitDuration);
+ expect(find.text(tooltipText), findsOneWidget);
+
+ // Wait a looong time to make sure that it doesn't go away if the mouse is
+ // still over the widget.
+ await tester.pump(const Duration(days: 1));
+ await tester.pumpAndSettle();
+ expect(find.text(tooltipText), findsOneWidget);
+
+ await gesture.moveTo(Offset.zero);
+ await tester.pump();
+
+ // Wait for it to disappear.
+ await tester.pump(showDuration);
+ await tester.pumpAndSettle();
+ expect(find.text(tooltipText), findsNothing);
+ });
+
testWidgets('Does tooltip contribute semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
@@ -500,7 +621,7 @@
expect(semantics, hasSemantics(expected, ignoreTransform: true, ignoreRect: true));
- // before using "as dynamic" in your code, see note top of file
+ // Before using "as dynamic" in your code, see note at the top of the file.
(key.currentState as dynamic).ensureTooltipVisible(); // this triggers a rebuild of the semantics because the tree changes
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)