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
This commit is contained in:
parent
a21a1f4105
commit
eca9364069
@ -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,6 +52,9 @@ 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),
|
||||
@ -57,12 +62,15 @@ class Tooltip extends StatefulWidget {
|
||||
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<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 @@ class _TooltipState extends State<Tooltip> 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<Tooltip> 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,7 +262,10 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
assert(Overlay.of(context, debugRequiredFor: widget) != null);
|
||||
return GestureDetector(
|
||||
return Listener(
|
||||
onPointerEnter: (PointerEnterEvent event) => _showTooltip(),
|
||||
onPointerExit: (PointerExitEvent event) => _hideTooltip(),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: _handleLongPress,
|
||||
excludeFromSemantics: true,
|
||||
@ -202,6 +273,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
||||
label: widget.excludeFromSemantics ? null : widget.message,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<double> animation;
|
||||
final Offset target;
|
||||
final double verticalOffset;
|
||||
@ -294,14 +368,12 @@ 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),
|
||||
decoration: decoration ?? BoxDecoration(
|
||||
color: darkTheme.backgroundColor.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
padding: padding,
|
||||
child: Center(
|
||||
@ -314,7 +386,6 @@ class _TooltipOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>[
|
||||
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 @@ 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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user