diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 46a20b1b6e..1694c53b4d 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -12,8 +12,10 @@ import 'feedback.dart'; 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 @@ class Tooltip extends StatefulWidget { /// 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 @@ class Tooltip extends StatefulWidget { 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 @@ class Tooltip extends StatefulWidget { /// 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 @@ class Tooltip extends StatefulWidget { /// {@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 @@ class Tooltip extends StatefulWidget { 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('waitDuration', waitDuration, defaultValue: _kDefaultWaitDuration)); + properties.add(DiagnosticsProperty('showDuration', showDuration, defaultValue: _kDefaultShowDuration)); } } class _TooltipState extends State 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 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { 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 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { 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 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { @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 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate { @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 @@ class _TooltipOverlay extends StatelessWidget { this.message, this.height, this.padding, + this.decoration, this.animation, this.target, this.verticalOffset, @@ -271,6 +344,7 @@ class _TooltipOverlay extends StatelessWidget { final String message; final double height; final EdgeInsetsGeometry padding; + final Decoration decoration; final Animation animation; final Offset target; final double verticalOffset; @@ -294,21 +368,18 @@ class _TooltipOverlay extends StatelessWidget { ), 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 5fe557196e..f81921f307 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; 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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { ), ), ); - (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 @@ void main() { 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( + 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( + 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 @@ void main() { 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 @@ void main() { 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)