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)