Unverified Commit 11e0a725 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Re-land: Add support for Tooltip hover (#31699)

This is a re-land of #31561, after fixing performance regressions.

Added change listening to the MouseTracker so that the Listener and tooltip can react to whether or not a mouse is connected at all. Added a change check to make sure Listener only repaints when something changed.

Fixes #22817
parent 3bd1737c
......@@ -9,6 +9,7 @@ import '../common.dart';
const int _kNumIters = 10000;
void main() {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
final Stopwatch watch = Stopwatch();
print('RRect contains benchmark...');
watch.start();
......
......@@ -10,6 +10,7 @@ import 'data/velocity_tracker_data.dart';
const int _kNumIters = 10000;
void main() {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
final VelocityTracker tracker = VelocityTracker();
final Stopwatch watch = Stopwatch();
print('Velocity tracker benchmark...');
......
......@@ -34,7 +34,7 @@ class BenchmarkingBinding extends LiveTestWidgetsFlutterBinding {
}
Future<void> main() async {
assert(false); // don't run this in checked mode! Use --release.
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
stock_data.StockData.actuallyFetchData = false;
final Stopwatch wallClockWatch = Stopwatch();
......
......@@ -14,7 +14,7 @@ import '../common.dart';
const Duration kBenchmarkTime = Duration(seconds: 15);
Future<void> main() async {
assert(false); // don't run this in checked mode! Use --release.
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
stock_data.StockData.actuallyFetchData = false;
// We control the framePolicy below to prevent us from scheduling frames in
......
......@@ -15,6 +15,7 @@ import '../common.dart';
const Duration kBenchmarkTime = Duration(seconds: 15);
Future<void> main() async {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
stock_data.StockData.actuallyFetchData = false;
// We control the framePolicy below to prevent us from scheduling frames in
......
......@@ -4,7 +4,7 @@
import 'dart:ui';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:flutter/foundation.dart' show ChangeNotifier, visibleForTesting;
import 'package:flutter/scheduler.dart';
import 'events.dart';
......@@ -84,8 +84,11 @@ typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset o
/// and notifies them when a mouse pointer enters, moves, or leaves an annotated
/// region that they are interested in.
///
/// This class is a [ChangeNotifier] that notifies its listeners if the value of
/// [mouseIsConnected] changes.
///
/// Owned by the [RendererBinding] class.
class MouseTracker {
class MouseTracker extends ChangeNotifier {
/// Creates a mouse tracker to keep track of mouse locations.
///
/// All of the parameters must not be null.
......@@ -129,8 +132,13 @@ class MouseTracker {
}
void _scheduleMousePositionCheck() {
SchedulerBinding.instance.addPostFrameCallback((Duration _) => collectMousePositions());
SchedulerBinding.instance.scheduleFrame();
// If we're not tracking anything, then there is no point in registering a
// frame callback or scheduling a frame. By definition there are no active
// annotations that need exiting, either.
if (_trackedAnnotations.isNotEmpty) {
SchedulerBinding.instance.addPostFrameCallback((Duration _) => collectMousePositions());
SchedulerBinding.instance.scheduleFrame();
}
}
// Handler for events coming from the PointerRouter.
......@@ -139,15 +147,12 @@ class MouseTracker {
return;
}
final int deviceId = event.device;
if (_trackedAnnotations.isEmpty) {
// If we're not tracking anything, then there is no point in registering a
// frame callback or scheduling a frame. By definition there are no active
// annotations that need exiting, either.
_lastMouseEvent.remove(deviceId);
if (event is PointerAddedEvent) {
_addMouseEvent(deviceId, event);
return;
}
if (event is PointerRemovedEvent) {
_lastMouseEvent.remove(deviceId);
_removeMouseEvent(deviceId);
// If the mouse was removed, then we need to schedule one more check to
// exit any annotations that were active.
_scheduleMousePositionCheck();
......@@ -155,10 +160,10 @@ class MouseTracker {
if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) {
if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) {
// Only schedule a frame if we have our first event, or if the
// location of the mouse has changed.
// location of the mouse has changed, and only if there are tracked annotations.
_scheduleMousePositionCheck();
}
_lastMouseEvent[deviceId] = event;
_addMouseEvent(deviceId, event);
}
}
}
......@@ -260,6 +265,22 @@ class MouseTracker {
}
}
void _addMouseEvent(int deviceId, PointerEvent event) {
final bool wasConnected = mouseIsConnected;
_lastMouseEvent[deviceId] = event;
if (mouseIsConnected != wasConnected) {
notifyListeners();
}
}
void _removeMouseEvent(int deviceId) {
final bool wasConnected = mouseIsConnected;
_lastMouseEvent.remove(deviceId);
if (mouseIsConnected != wasConnected) {
notifyListeners();
}
}
/// The most recent mouse event observed for each mouse device ID observed.
///
/// May be null if no mouse is connected, or hasn't produced an event yet.
......
......@@ -12,9 +12,6 @@ import 'feedback.dart';
import 'theme.dart';
import 'theme_data.dart';
const Duration _kFadeDuration = Duration(milliseconds: 200);
const Duration _kShowDuration = Duration(milliseconds: 1500);
/// A material design tooltip.
///
/// Tooltips provide text labels that help explain the function of a button or
......@@ -41,15 +38,18 @@ 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,
this.height = 32.0,
this.padding = const EdgeInsets.symmetric(horizontal: 16.0),
this.verticalOffset = 24.0,
this.height = _defaultTooltipHeight,
this.padding = _defaultPadding,
this.verticalOffset = _defaultVerticalOffset,
this.preferBelow = true,
this.excludeFromSemantics = false,
this.decoration,
this.waitDuration = _defaultWaitDuration,
this.showDuration = _defaultShowDuration,
this.child,
}) : assert(message != null),
assert(height != null),
......@@ -57,12 +57,22 @@ class Tooltip extends StatefulWidget {
assert(verticalOffset != null),
assert(preferBelow != null),
assert(excludeFromSemantics != null),
assert(waitDuration != null),
assert(showDuration != null),
super(key: key);
static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
static const Duration _defaultWaitDuration = Duration(milliseconds: 0);
static const double _defaultTooltipHeight = 32.0;
static const double _defaultVerticalOffset = 24.0;
static const EdgeInsetsGeometry _defaultPadding = EdgeInsets.symmetric(horizontal: 16.0);
/// The text to display in the tooltip.
final String message;
/// The amount of vertical space the tooltip should occupy (inside its padding).
/// They height of the tooltip's [child].
///
/// If the [child] is null, then this is the intrinsic height.
final double height;
/// The amount of space by which to inset the child.
......@@ -70,7 +80,7 @@ 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 vertical gap between the widget and the displayed tooltip.
final double verticalOffset;
/// Whether the tooltip defaults to being displayed below the widget.
......@@ -89,6 +99,24 @@ class Tooltip extends StatefulWidget {
/// {@macro flutter.widgets.child}
final Widget child;
/// Specifies the tooltip's shape and background color.
///
/// If not specified, defaults to a rounded rectangle with a border radius of
/// 4.0, and a color derived from the [ThemeData.textTheme] if the
/// [ThemeData.brightness] is dark, and [ThemeData.primaryTextTheme] if not.
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();
......@@ -96,40 +124,110 @@ class Tooltip extends StatefulWidget {
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('message', message, showName: false));
properties.add(DoubleProperty('vertical offset', verticalOffset));
properties.add(DoubleProperty('height', height, defaultValue: _defaultTooltipHeight));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: _defaultPadding));
properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: _defaultVerticalOffset));
properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: false));
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: _defaultWaitDuration));
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: _defaultShowDuration));
}
}
class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
static const Duration _fadeInDuration = Duration(milliseconds: 150);
static const Duration _fadeOutDuration = Duration(milliseconds: 75);
AnimationController _controller;
OverlayEntry _entry;
Timer _timer;
Timer _hideTimer;
Timer _showTimer;
bool _mouseIsConnected;
bool _longPressActivated = false;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: _kFadeDuration, vsync: this)
_mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
_controller = AnimationController(duration: _fadeInDuration, vsync: this)
..addStatusListener(_handleStatusChanged);
// Listen to see when a mouse is added.
RendererBinding.instance.mouseTracker.addListener(_handleMouseTrackerChange);
// Listen to global pointer events so that we can hide a tooltip immediately
// if some other control is clicked on.
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
}
// Forces a rebuild if a mouse has been added or removed.
void _handleMouseTrackerChange() {
if (!mounted) {
return;
}
final bool mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
if (mouseIsConnected != _mouseIsConnected) {
setState((){
_mouseIsConnected = mouseIsConnected;
});
}
}
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;
}
if (_longPressActivated) {
// Tool tips activated by long press should stay around for 1.5s.
_hideTimer ??= Timer(widget.showDuration, _controller.reverse);
} else {
// Tool tips activated by hover should disappear as soon as the mouse
// leaves the control.
_controller.reverse();
}
_longPressActivated = false;
}
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));
assert(_fadeOutDuration < _fadeInDuration);
// 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 +235,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,
_fadeOutDuration.inMilliseconds / _fadeInDuration.inMilliseconds,
curve: Curves.fastOutSlowIn,
),
),
target: target,
verticalOffset: widget.verticalOffset,
......@@ -147,38 +254,41 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
);
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
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;
_showTimer?.cancel();
_showTimer = 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 (_entry == null) {
return;
}
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();
}
@override
void dispose() {
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
if (_entry != null)
_removeEntry();
_controller.dispose();
......@@ -186,6 +296,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
}
void _handleLongPress() {
_longPressActivated = true;
final bool tooltipCreated = ensureTooltipVisible();
if (tooltipCreated)
Feedback.forLongPress(context);
......@@ -194,7 +305,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
@override
Widget build(BuildContext context) {
assert(Overlay.of(context, debugRequiredFor: widget) != null);
return GestureDetector(
Widget result = GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: _handleLongPress,
excludeFromSemantics: true,
......@@ -203,6 +314,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
child: widget.child,
),
);
// Only check for hovering if there is a mouse connected.
if (_mouseIsConnected) {
result = Listener(
onPointerEnter: (PointerEnterEvent event) => _showTooltip(),
onPointerExit: (PointerExitEvent event) => _hideTooltip(),
child: result,
);
}
return result;
}
}
......@@ -262,6 +384,7 @@ class _TooltipOverlay extends StatelessWidget {
this.message,
this.height,
this.padding,
this.decoration,
this.animation,
this.target,
this.verticalOffset,
......@@ -271,6 +394,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;
......@@ -279,7 +403,7 @@ class _TooltipOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ThemeData darkTheme = ThemeData(
final ThemeData tooltipTheme = ThemeData(
brightness: Brightness.dark,
textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme,
platform: theme.platform,
......@@ -294,21 +418,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: tooltipTheme.backgroundColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(4.0),
),
padding: padding,
child: Center(
widthFactor: 1.0,
heightFactor: 1.0,
child: Text(message, style: tooltipTheme.textTheme.body1),
),
),
),
......
......@@ -2565,12 +2565,13 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
MouseTrackerAnnotation get hoverAnnotation => _hoverAnnotation;
void _updateAnnotations() {
assert(_onPointerEnter != _hoverAnnotation.onEnter || _onPointerHover != _hoverAnnotation.onHover || _onPointerExit != _hoverAnnotation.onExit,
"Shouldn't call _updateAnnotations if nothing has changed.");
bool changed = false;
if (_hoverAnnotation != null && attached) {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
changed = true;
}
if (_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null) {
if (RendererBinding.instance.mouseTracker.mouseIsConnected &&
(_onPointerEnter != null || _onPointerHover != null || _onPointerExit != null)) {
_hoverAnnotation = MouseTrackerAnnotation(
onEnter: _onPointerEnter,
onHover: _onPointerHover,
......@@ -2578,18 +2579,21 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
);
if (attached) {
RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
changed = true;
}
} else {
_hoverAnnotation = null;
}
// Needs to paint in any case, in order to insert/remove the annotation
// layer associated with the updated _hoverAnnotation.
markNeedsPaint();
if (changed) {
markNeedsPaint();
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
// Add a listener to listen for changes in mouseIsConnected.
RendererBinding.instance.mouseTracker.addListener(_updateAnnotations);
if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
}
......@@ -2600,6 +2604,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
}
RendererBinding.instance.mouseTracker.removeListener(_updateAnnotations);
super.detach();
}
......
......@@ -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,56 @@ 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);
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: Center(
child: Tooltip(
message: tooltipText,
showDuration: showDuration,
waitDuration: waitDuration,
child: Container(
width: 100.0,
height: 100.0,
),
),
),
)
);
final Finder tooltip = find.byType(Tooltip);
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();
await gesture.removePointer();
expect(find.text(tooltipText), findsNothing);
});
testWidgets('Does tooltip contribute semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
......@@ -500,7 +627,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)
......@@ -718,6 +845,52 @@ void main() {
semantics.dispose();
SystemChannels.accessibility.setMockMessageHandler(null);
});
testWidgets('default Tooltip debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Tooltip(message: 'message',).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString()).toList();
expect(description, <String>[
'"message"',
'position: below',
]);
});
testWidgets('Tooltip implements debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
// Not checking controller, inputFormatters, focusNode
const Tooltip(
key: ValueKey<String>('foo'),
message: 'message',
decoration: BoxDecoration(),
waitDuration: Duration(seconds: 1),
showDuration: Duration(seconds: 2),
padding: EdgeInsets.zero,
height: 100.0,
excludeFromSemantics: true,
preferBelow: false,
verticalOffset: 50.0,
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString()).toList();
expect(description, <String>[
'"message"',
'height: 100.0',
'padding: EdgeInsets.zero',
'vertical offset: 50.0',
'position: above',
'semantics: excluded',
'wait duration: 0:00:01.000000',
'show duration: 0:00:02.000000',
]);
});
}
SemanticsNode findDebugSemantics(RenderObject object) {
......
......@@ -539,12 +539,13 @@ abstract class WidgetController {
/// You can use [startGesture] instead if your gesture begins with a down
/// event.
Future<TestGesture> createGesture({int pointer, PointerDeviceKind kind = PointerDeviceKind.touch}) async {
return TestGesture(
final TestGesture gesture = TestGesture(
hitTester: hitTestOnBinding,
dispatcher: sendEventToBinding,
kind: kind,
pointer: pointer ?? _getNextPointer(),
);
return gesture;
}
/// Creates a gesture with an initial down gesture at a particular point, and
......
......@@ -22,9 +22,31 @@ class TestPointer {
///
/// Multiple [TestPointer]s created with the same pointer identifier will
/// interfere with each other if they are used in parallel.
TestPointer([this.pointer = 1, this.kind = PointerDeviceKind.touch])
: assert(kind != null),
assert(pointer != null);
TestPointer([
this.pointer = 1,
this.kind = PointerDeviceKind.touch,
this._device,
]) : assert(kind != null),
assert(pointer != null) {
switch (kind) {
case PointerDeviceKind.mouse:
_device ??= 1;
break;
case PointerDeviceKind.stylus:
case PointerDeviceKind.invertedStylus:
case PointerDeviceKind.touch:
case PointerDeviceKind.unknown:
_device ??= 0;
break;
}
}
/// The device identifier used for events generated by this object.
///
/// Set when the object is constructed. Defaults to 1 if the [kind] is
/// [PointerDeviceKind.mouse], and 0 otherwise.
int get device => _device;
int _device;
/// The pointer identifier used for events generated by this object.
///
......@@ -63,7 +85,8 @@ class TestPointer {
assert(isDown);
_isDown = false;
break;
default: break;
default:
break;
}
return isDown;
}
......@@ -72,7 +95,7 @@ class TestPointer {
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
PointerDownEvent down(Offset newLocation, {Duration timeStamp = Duration.zero}) {
PointerDownEvent down(Offset newLocation, { Duration timeStamp = Duration.zero }) {
assert(!isDown);
_isDown = true;
_location = newLocation;
......@@ -91,7 +114,7 @@ class TestPointer {
///
/// [isDown] must be true when this is called, since move events can only
/// be generated when the pointer is down.
PointerMoveEvent move(Offset newLocation, {Duration timeStamp = Duration.zero}) {
PointerMoveEvent move(Offset newLocation, { Duration timeStamp = Duration.zero }) {
assert(
isDown,
'Move events can only be generated when the pointer is down. To '
......@@ -114,7 +137,7 @@ class TestPointer {
/// specific time stamp by passing the `timeStamp` argument.
///
/// The object is no longer usable after this method has been called.
PointerUpEvent up({Duration timeStamp = Duration.zero}) {
PointerUpEvent up({ Duration timeStamp = Duration.zero }) {
assert(isDown);
_isDown = false;
return PointerUpEvent(
......@@ -131,7 +154,7 @@ class TestPointer {
/// specific time stamp by passing the `timeStamp` argument.
///
/// The object is no longer usable after this method has been called.
PointerCancelEvent cancel({Duration timeStamp = Duration.zero}) {
PointerCancelEvent cancel({ Duration timeStamp = Duration.zero }) {
assert(isDown);
_isDown = false;
return PointerCancelEvent(
......@@ -142,6 +165,43 @@ class TestPointer {
);
}
/// Create a [PointerAddedEvent] with the [PointerDeviceKind] the pointer was
/// created with.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
///
/// [isDown] must be false, since hover events can't be sent when the pointer
/// is up.
PointerAddedEvent addPointer({
Duration timeStamp = Duration.zero,
}) {
assert(timeStamp != null);
return PointerAddedEvent(
timeStamp: timeStamp,
kind: kind,
device: _device,
);
}
/// Create a [PointerRemovedEvent] with the kind the pointer was created with.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument.
///
/// [isDown] must be false, since hover events can't be sent when the pointer
/// is up.
PointerRemovedEvent removePointer({
Duration timeStamp = Duration.zero,
}) {
assert(timeStamp != null);
return PointerRemovedEvent(
timeStamp: timeStamp,
kind: kind,
device: _device,
);
}
/// Create a [PointerHoverEvent] to the given location.
///
/// By default, the time stamp on the event is [Duration.zero]. You can give a
......@@ -160,12 +220,13 @@ class TestPointer {
'Hover events can only be generated when the pointer is up. To '
'simulate movement when the pointer is down, use move() instead.');
assert(kind != PointerDeviceKind.touch, "Touch pointers can't generate hover events");
final Offset delta = location != null ? newLocation - location : Offset.zero;
final Offset delta = location != null ? newLocation - location : Offset.zero;
_location = newLocation;
return PointerHoverEvent(
timeStamp: timeStamp,
kind: kind,
position: newLocation,
device: _device,
delta: delta,
);
}
......@@ -267,12 +328,26 @@ class TestGesture {
});
}
/// In a test, send a pointer add event for this pointer.
Future<void> addPointer({ Duration timeStamp = Duration.zero }) {
return TestAsyncUtils.guard<void>(() {
return _dispatcher(_pointer.addPointer(timeStamp: timeStamp), null);
});
}
/// In a test, send a pointer remove event for this pointer.
Future<void> removePointer({ Duration timeStamp = Duration.zero }) {
return TestAsyncUtils.guard<void>(() {
return _dispatcher(_pointer.removePointer(timeStamp: timeStamp), null);
});
}
/// Send a move event moving the pointer by the given offset.
///
/// If the pointer is down, then a move event is dispatched. If the pointer is
/// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events.
Future<void> moveBy(Offset offset, {Duration timeStamp = Duration.zero}) {
Future<void> moveBy(Offset offset, { Duration timeStamp = Duration.zero }) {
return moveTo(_pointer.location + offset, timeStamp: timeStamp);
}
......@@ -281,7 +356,7 @@ class TestGesture {
/// If the pointer is down, then a move event is dispatched. If the pointer is
/// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events.
Future<void> moveTo(Offset location, {Duration timeStamp = Duration.zero}) {
Future<void> moveTo(Offset location, { Duration timeStamp = Duration.zero }) {
return TestAsyncUtils.guard<void>(() {
if (_pointer._isDown) {
assert(_result != null,
......
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