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'; ...@@ -9,6 +9,7 @@ import '../common.dart';
const int _kNumIters = 10000; const int _kNumIters = 10000;
void main() { void main() {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
final Stopwatch watch = Stopwatch(); final Stopwatch watch = Stopwatch();
print('RRect contains benchmark...'); print('RRect contains benchmark...');
watch.start(); watch.start();
......
...@@ -10,6 +10,7 @@ import 'data/velocity_tracker_data.dart'; ...@@ -10,6 +10,7 @@ import 'data/velocity_tracker_data.dart';
const int _kNumIters = 10000; const int _kNumIters = 10000;
void main() { void main() {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
final VelocityTracker tracker = VelocityTracker(); final VelocityTracker tracker = VelocityTracker();
final Stopwatch watch = Stopwatch(); final Stopwatch watch = Stopwatch();
print('Velocity tracker benchmark...'); print('Velocity tracker benchmark...');
......
...@@ -34,7 +34,7 @@ class BenchmarkingBinding extends LiveTestWidgetsFlutterBinding { ...@@ -34,7 +34,7 @@ class BenchmarkingBinding extends LiveTestWidgetsFlutterBinding {
} }
Future<void> main() async { 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; stock_data.StockData.actuallyFetchData = false;
final Stopwatch wallClockWatch = Stopwatch(); final Stopwatch wallClockWatch = Stopwatch();
......
...@@ -14,7 +14,7 @@ import '../common.dart'; ...@@ -14,7 +14,7 @@ import '../common.dart';
const Duration kBenchmarkTime = Duration(seconds: 15); const Duration kBenchmarkTime = Duration(seconds: 15);
Future<void> main() async { 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; stock_data.StockData.actuallyFetchData = false;
// We control the framePolicy below to prevent us from scheduling frames in // We control the framePolicy below to prevent us from scheduling frames in
......
...@@ -15,6 +15,7 @@ import '../common.dart'; ...@@ -15,6 +15,7 @@ import '../common.dart';
const Duration kBenchmarkTime = Duration(seconds: 15); const Duration kBenchmarkTime = Duration(seconds: 15);
Future<void> main() async { Future<void> main() async {
assert(false, "Don't run benchmarks in checked mode! Use 'flutter run --release'.");
stock_data.StockData.actuallyFetchData = false; stock_data.StockData.actuallyFetchData = false;
// We control the framePolicy below to prevent us from scheduling frames in // We control the framePolicy below to prevent us from scheduling frames in
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/foundation.dart' show ChangeNotifier, visibleForTesting;
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'events.dart'; import 'events.dart';
...@@ -84,8 +84,11 @@ typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset o ...@@ -84,8 +84,11 @@ typedef MouseDetectorAnnotationFinder = MouseTrackerAnnotation Function(Offset o
/// and notifies them when a mouse pointer enters, moves, or leaves an annotated /// and notifies them when a mouse pointer enters, moves, or leaves an annotated
/// region that they are interested in. /// 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. /// Owned by the [RendererBinding] class.
class MouseTracker { class MouseTracker extends ChangeNotifier {
/// Creates a mouse tracker to keep track of mouse locations. /// Creates a mouse tracker to keep track of mouse locations.
/// ///
/// All of the parameters must not be null. /// All of the parameters must not be null.
...@@ -129,9 +132,14 @@ class MouseTracker { ...@@ -129,9 +132,14 @@ class MouseTracker {
} }
void _scheduleMousePositionCheck() { void _scheduleMousePositionCheck() {
// 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.addPostFrameCallback((Duration _) => collectMousePositions());
SchedulerBinding.instance.scheduleFrame(); SchedulerBinding.instance.scheduleFrame();
} }
}
// Handler for events coming from the PointerRouter. // Handler for events coming from the PointerRouter.
void _handleEvent(PointerEvent event) { void _handleEvent(PointerEvent event) {
...@@ -139,15 +147,12 @@ class MouseTracker { ...@@ -139,15 +147,12 @@ class MouseTracker {
return; return;
} }
final int deviceId = event.device; final int deviceId = event.device;
if (_trackedAnnotations.isEmpty) { if (event is PointerAddedEvent) {
// If we're not tracking anything, then there is no point in registering a _addMouseEvent(deviceId, event);
// frame callback or scheduling a frame. By definition there are no active
// annotations that need exiting, either.
_lastMouseEvent.remove(deviceId);
return; return;
} }
if (event is PointerRemovedEvent) { if (event is PointerRemovedEvent) {
_lastMouseEvent.remove(deviceId); _removeMouseEvent(deviceId);
// If the mouse was removed, then we need to schedule one more check to // If the mouse was removed, then we need to schedule one more check to
// exit any annotations that were active. // exit any annotations that were active.
_scheduleMousePositionCheck(); _scheduleMousePositionCheck();
...@@ -155,10 +160,10 @@ class MouseTracker { ...@@ -155,10 +160,10 @@ class MouseTracker {
if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) { if (event is PointerMoveEvent || event is PointerHoverEvent || event is PointerDownEvent) {
if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) { if (!_lastMouseEvent.containsKey(deviceId) || _lastMouseEvent[deviceId].position != event.position) {
// Only schedule a frame if we have our first event, or if the // 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(); _scheduleMousePositionCheck();
} }
_lastMouseEvent[deviceId] = event; _addMouseEvent(deviceId, event);
} }
} }
} }
...@@ -260,6 +265,22 @@ class MouseTracker { ...@@ -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. /// 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. /// May be null if no mouse is connected, or hasn't produced an event yet.
......
...@@ -12,9 +12,6 @@ import 'feedback.dart'; ...@@ -12,9 +12,6 @@ import 'feedback.dart';
import 'theme.dart'; import 'theme.dart';
import 'theme_data.dart'; import 'theme_data.dart';
const Duration _kFadeDuration = Duration(milliseconds: 200);
const Duration _kShowDuration = Duration(milliseconds: 1500);
/// A material design tooltip. /// A material design tooltip.
/// ///
/// Tooltips provide text labels that help explain the function of a button or /// Tooltips provide text labels that help explain the function of a button or
...@@ -41,15 +38,18 @@ class Tooltip extends StatefulWidget { ...@@ -41,15 +38,18 @@ class Tooltip extends StatefulWidget {
/// By default, tooltips prefer to appear below the [child] widget when the /// By default, tooltips prefer to appear below the [child] widget when the
/// user long presses on the widget. /// 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({ const Tooltip({
Key key, Key key,
@required this.message, @required this.message,
this.height = 32.0, this.height = _defaultTooltipHeight,
this.padding = const EdgeInsets.symmetric(horizontal: 16.0), this.padding = _defaultPadding,
this.verticalOffset = 24.0, this.verticalOffset = _defaultVerticalOffset,
this.preferBelow = true, this.preferBelow = true,
this.excludeFromSemantics = false, this.excludeFromSemantics = false,
this.decoration,
this.waitDuration = _defaultWaitDuration,
this.showDuration = _defaultShowDuration,
this.child, this.child,
}) : assert(message != null), }) : assert(message != null),
assert(height != null), assert(height != null),
...@@ -57,12 +57,22 @@ class Tooltip extends StatefulWidget { ...@@ -57,12 +57,22 @@ class Tooltip extends StatefulWidget {
assert(verticalOffset != null), assert(verticalOffset != null),
assert(preferBelow != null), assert(preferBelow != null),
assert(excludeFromSemantics != null), assert(excludeFromSemantics != null),
assert(waitDuration != null),
assert(showDuration != null),
super(key: key); 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. /// The text to display in the tooltip.
final String message; 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; final double height;
/// The amount of space by which to inset the child. /// The amount of space by which to inset the child.
...@@ -70,7 +80,7 @@ class Tooltip extends StatefulWidget { ...@@ -70,7 +80,7 @@ class Tooltip extends StatefulWidget {
/// Defaults to 16.0 logical pixels in each direction. /// Defaults to 16.0 logical pixels in each direction.
final EdgeInsetsGeometry padding; 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; final double verticalOffset;
/// Whether the tooltip defaults to being displayed below the widget. /// Whether the tooltip defaults to being displayed below the widget.
...@@ -89,6 +99,24 @@ class Tooltip extends StatefulWidget { ...@@ -89,6 +99,24 @@ class Tooltip extends StatefulWidget {
/// {@macro flutter.widgets.child} /// {@macro flutter.widgets.child}
final Widget 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 @override
_TooltipState createState() => _TooltipState(); _TooltipState createState() => _TooltipState();
...@@ -96,40 +124,110 @@ class Tooltip extends StatefulWidget { ...@@ -96,40 +124,110 @@ class Tooltip extends StatefulWidget {
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(StringProperty('message', message, showName: false)); 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('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 { class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
static const Duration _fadeInDuration = Duration(milliseconds: 150);
static const Duration _fadeOutDuration = Duration(milliseconds: 75);
AnimationController _controller; AnimationController _controller;
OverlayEntry _entry; OverlayEntry _entry;
Timer _timer; Timer _hideTimer;
Timer _showTimer;
bool _mouseIsConnected;
bool _longPressActivated = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController(duration: _kFadeDuration, vsync: this) _mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
_controller = AnimationController(duration: _fadeInDuration, vsync: this)
..addStatusListener(_handleStatusChanged); ..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) { 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(); _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. /// Shows the tooltip if it is not already visible.
/// ///
/// Returns `false` when the tooltip was already visible. /// Returns `false` when the tooltip was already visible.
bool ensureTooltipVisible() { bool ensureTooltipVisible() {
_showTimer?.cancel();
_showTimer = null;
if (_entry != null) { if (_entry != null) {
_timer?.cancel(); // Stop trying to hide, if we were.
_timer = null; _hideTimer?.cancel();
_hideTimer = null;
_controller.forward(); _controller.forward();
return false; // Already visible. return false; // Already visible.
} }
_createNewEntry();
_controller.forward();
return true;
}
void _createNewEntry() {
final RenderBox box = context.findRenderObject(); final RenderBox box = context.findRenderObject();
final Offset target = box.localToGlobal(box.size.center(Offset.zero)); 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 // 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 // updated values from happening to leak into the overlay when the overlay
// rebuilds. // rebuilds.
...@@ -137,9 +235,18 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -137,9 +235,18 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
message: widget.message, message: widget.message,
height: widget.height, height: widget.height,
padding: widget.padding, padding: widget.padding,
decoration: widget.decoration,
animation: CurvedAnimation( animation: CurvedAnimation(
parent: _controller, parent: _controller,
curve: Curves.fastOutSlowIn, 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, target: target,
verticalOffset: widget.verticalOffset, verticalOffset: widget.verticalOffset,
...@@ -147,38 +254,41 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -147,38 +254,41 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
); );
_entry = OverlayEntry(builder: (BuildContext context) => overlay); _entry = OverlayEntry(builder: (BuildContext context) => overlay);
Overlay.of(context, debugRequiredFor: widget).insert(_entry); Overlay.of(context, debugRequiredFor: widget).insert(_entry);
GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
SemanticsService.tooltip(widget.message); SemanticsService.tooltip(widget.message);
_controller.forward();
return true;
} }
void _removeEntry() { void _removeEntry() {
assert(_entry != null); _hideTimer?.cancel();
_timer?.cancel(); _hideTimer = null;
_timer = null; _showTimer?.cancel();
_entry.remove(); _showTimer = null;
_entry?.remove();
_entry = null; _entry = null;
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
} }
void _handlePointerEvent(PointerEvent event) { void _handlePointerEvent(PointerEvent event) {
assert(_entry != null); if (_entry == null) {
if (event is PointerUpEvent || event is PointerCancelEvent) return;
_timer ??= Timer(_kShowDuration, _controller.reverse); }
else if (event is PointerDownEvent) if (event is PointerUpEvent || event is PointerCancelEvent) {
_controller.reverse(); _hideTooltip();
} else if (event is PointerDownEvent) {
_hideTooltip(immediately: true);
}
} }
@override @override
void deactivate() { void deactivate() {
if (_entry != null) if (_entry != null) {
_controller.reverse(); _hideTooltip(immediately: true);
}
super.deactivate(); super.deactivate();
} }
@override @override
void dispose() { void dispose() {
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
if (_entry != null) if (_entry != null)
_removeEntry(); _removeEntry();
_controller.dispose(); _controller.dispose();
...@@ -186,6 +296,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -186,6 +296,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
} }
void _handleLongPress() { void _handleLongPress() {
_longPressActivated = true;
final bool tooltipCreated = ensureTooltipVisible(); final bool tooltipCreated = ensureTooltipVisible();
if (tooltipCreated) if (tooltipCreated)
Feedback.forLongPress(context); Feedback.forLongPress(context);
...@@ -194,7 +305,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -194,7 +305,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(Overlay.of(context, debugRequiredFor: widget) != null); assert(Overlay.of(context, debugRequiredFor: widget) != null);
return GestureDetector( Widget result = GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onLongPress: _handleLongPress, onLongPress: _handleLongPress,
excludeFromSemantics: true, excludeFromSemantics: true,
...@@ -203,6 +314,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -203,6 +314,17 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
child: widget.child, 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 { ...@@ -262,6 +384,7 @@ class _TooltipOverlay extends StatelessWidget {
this.message, this.message,
this.height, this.height,
this.padding, this.padding,
this.decoration,
this.animation, this.animation,
this.target, this.target,
this.verticalOffset, this.verticalOffset,
...@@ -271,6 +394,7 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -271,6 +394,7 @@ class _TooltipOverlay extends StatelessWidget {
final String message; final String message;
final double height; final double height;
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final Decoration decoration;
final Animation<double> animation; final Animation<double> animation;
final Offset target; final Offset target;
final double verticalOffset; final double verticalOffset;
...@@ -279,7 +403,7 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -279,7 +403,7 @@ class _TooltipOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context); final ThemeData theme = Theme.of(context);
final ThemeData darkTheme = ThemeData( final ThemeData tooltipTheme = ThemeData(
brightness: Brightness.dark, brightness: Brightness.dark,
textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme, textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme,
platform: theme.platform, platform: theme.platform,
...@@ -294,21 +418,18 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -294,21 +418,18 @@ class _TooltipOverlay extends StatelessWidget {
), ),
child: FadeTransition( child: FadeTransition(
opacity: animation, opacity: animation,
child: Opacity(
opacity: 0.9,
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(minHeight: height), constraints: BoxConstraints(minHeight: height),
child: Container( child: Container(
decoration: BoxDecoration( decoration: decoration ?? BoxDecoration(
color: darkTheme.backgroundColor, color: tooltipTheme.backgroundColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(2.0), borderRadius: BorderRadius.circular(4.0),
), ),
padding: padding, padding: padding,
child: Center( child: Center(
widthFactor: 1.0, widthFactor: 1.0,
heightFactor: 1.0, heightFactor: 1.0,
child: Text(message, style: darkTheme.textTheme.body1), child: Text(message, style: tooltipTheme.textTheme.body1),
),
), ),
), ),
), ),
......
...@@ -2565,12 +2565,13 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2565,12 +2565,13 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
MouseTrackerAnnotation get hoverAnnotation => _hoverAnnotation; MouseTrackerAnnotation get hoverAnnotation => _hoverAnnotation;
void _updateAnnotations() { void _updateAnnotations() {
assert(_onPointerEnter != _hoverAnnotation.onEnter || _onPointerHover != _hoverAnnotation.onHover || _onPointerExit != _hoverAnnotation.onExit, bool changed = false;
"Shouldn't call _updateAnnotations if nothing has changed.");
if (_hoverAnnotation != null && attached) { if (_hoverAnnotation != null && attached) {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); 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( _hoverAnnotation = MouseTrackerAnnotation(
onEnter: _onPointerEnter, onEnter: _onPointerEnter,
onHover: _onPointerHover, onHover: _onPointerHover,
...@@ -2578,18 +2579,21 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2578,18 +2579,21 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
); );
if (attached) { if (attached) {
RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
changed = true;
} }
} else { } else {
_hoverAnnotation = null; _hoverAnnotation = null;
} }
// Needs to paint in any case, in order to insert/remove the annotation if (changed) {
// layer associated with the updated _hoverAnnotation.
markNeedsPaint(); markNeedsPaint();
} }
}
@override @override
void attach(PipelineOwner owner) { void attach(PipelineOwner owner) {
super.attach(owner); super.attach(owner);
// Add a listener to listen for changes in mouseIsConnected.
RendererBinding.instance.mouseTracker.addListener(_updateAnnotations);
if (_hoverAnnotation != null) { if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.attachAnnotation(_hoverAnnotation);
} }
...@@ -2600,6 +2604,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior { ...@@ -2600,6 +2604,7 @@ class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
if (_hoverAnnotation != null) { if (_hoverAnnotation != null) {
RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation); RendererBinding.instance.mouseTracker.detachAnnotation(_hoverAnnotation);
} }
RendererBinding.instance.mouseTracker.removeListener(_updateAnnotations);
super.detach(); super.detach();
} }
......
...@@ -11,6 +11,7 @@ import 'package:flutter/material.dart'; ...@@ -11,6 +11,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../rendering/mock_canvas.dart';
import '../widgets/semantics_tester.dart'; import '../widgets/semantics_tester.dart';
import 'feedback_tester.dart'; import 'feedback_tester.dart';
...@@ -67,7 +68,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -123,7 +124,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -175,7 +176,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -229,7 +230,7 @@ void main() { ...@@ -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) 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: // we try to put it here but it doesn't fit:
...@@ -294,7 +295,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -347,7 +348,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -402,7 +403,7 @@ void main() { ...@@ -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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
/********************* 800x600 screen /********************* 800x600 screen
...@@ -422,6 +423,82 @@ void main() { ...@@ -422,6 +423,82 @@ void main() {
expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(324.0)); 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 { testWidgets('Tooltip stays around', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -457,6 +534,56 @@ void main() { ...@@ -457,6 +534,56 @@ void main() {
gesture.up(); 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 { testWidgets('Does tooltip contribute semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester); final SemanticsTester semantics = SemanticsTester(tester);
...@@ -500,7 +627,7 @@ void main() { ...@@ -500,7 +627,7 @@ void main() {
expect(semantics, hasSemantics(expected, ignoreTransform: true, ignoreRect: true)); 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 (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) await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
...@@ -718,6 +845,52 @@ void main() { ...@@ -718,6 +845,52 @@ void main() {
semantics.dispose(); semantics.dispose();
SystemChannels.accessibility.setMockMessageHandler(null); 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) { SemanticsNode findDebugSemantics(RenderObject object) {
......
...@@ -539,12 +539,13 @@ abstract class WidgetController { ...@@ -539,12 +539,13 @@ abstract class WidgetController {
/// You can use [startGesture] instead if your gesture begins with a down /// You can use [startGesture] instead if your gesture begins with a down
/// event. /// event.
Future<TestGesture> createGesture({int pointer, PointerDeviceKind kind = PointerDeviceKind.touch}) async { Future<TestGesture> createGesture({int pointer, PointerDeviceKind kind = PointerDeviceKind.touch}) async {
return TestGesture( final TestGesture gesture = TestGesture(
hitTester: hitTestOnBinding, hitTester: hitTestOnBinding,
dispatcher: sendEventToBinding, dispatcher: sendEventToBinding,
kind: kind, kind: kind,
pointer: pointer ?? _getNextPointer(), pointer: pointer ?? _getNextPointer(),
); );
return gesture;
} }
/// Creates a gesture with an initial down gesture at a particular point, and /// Creates a gesture with an initial down gesture at a particular point, and
......
...@@ -22,9 +22,31 @@ class TestPointer { ...@@ -22,9 +22,31 @@ class TestPointer {
/// ///
/// Multiple [TestPointer]s created with the same pointer identifier will /// Multiple [TestPointer]s created with the same pointer identifier will
/// interfere with each other if they are used in parallel. /// interfere with each other if they are used in parallel.
TestPointer([this.pointer = 1, this.kind = PointerDeviceKind.touch]) TestPointer([
: assert(kind != null), this.pointer = 1,
assert(pointer != null); 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. /// The pointer identifier used for events generated by this object.
/// ///
...@@ -63,7 +85,8 @@ class TestPointer { ...@@ -63,7 +85,8 @@ class TestPointer {
assert(isDown); assert(isDown);
_isDown = false; _isDown = false;
break; break;
default: break; default:
break;
} }
return isDown; return isDown;
} }
...@@ -72,7 +95,7 @@ class TestPointer { ...@@ -72,7 +95,7 @@ class TestPointer {
/// ///
/// By default, the time stamp on the event is [Duration.zero]. You can give a /// By default, the time stamp on the event is [Duration.zero]. You can give a
/// specific time stamp by passing the `timeStamp` argument. /// 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); assert(!isDown);
_isDown = true; _isDown = true;
_location = newLocation; _location = newLocation;
...@@ -91,7 +114,7 @@ class TestPointer { ...@@ -91,7 +114,7 @@ class TestPointer {
/// ///
/// [isDown] must be true when this is called, since move events can only /// [isDown] must be true when this is called, since move events can only
/// be generated when the pointer is down. /// be generated when the pointer is down.
PointerMoveEvent move(Offset newLocation, {Duration timeStamp = Duration.zero}) { PointerMoveEvent move(Offset newLocation, { Duration timeStamp = Duration.zero }) {
assert( assert(
isDown, isDown,
'Move events can only be generated when the pointer is down. To ' 'Move events can only be generated when the pointer is down. To '
...@@ -114,7 +137,7 @@ class TestPointer { ...@@ -114,7 +137,7 @@ class TestPointer {
/// specific time stamp by passing the `timeStamp` argument. /// specific time stamp by passing the `timeStamp` argument.
/// ///
/// The object is no longer usable after this method has been called. /// 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); assert(isDown);
_isDown = false; _isDown = false;
return PointerUpEvent( return PointerUpEvent(
...@@ -131,7 +154,7 @@ class TestPointer { ...@@ -131,7 +154,7 @@ class TestPointer {
/// specific time stamp by passing the `timeStamp` argument. /// specific time stamp by passing the `timeStamp` argument.
/// ///
/// The object is no longer usable after this method has been called. /// 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); assert(isDown);
_isDown = false; _isDown = false;
return PointerCancelEvent( return PointerCancelEvent(
...@@ -142,6 +165,43 @@ class TestPointer { ...@@ -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. /// Create a [PointerHoverEvent] to the given location.
/// ///
/// By default, the time stamp on the event is [Duration.zero]. You can give a /// By default, the time stamp on the event is [Duration.zero]. You can give a
...@@ -166,6 +226,7 @@ class TestPointer { ...@@ -166,6 +226,7 @@ class TestPointer {
timeStamp: timeStamp, timeStamp: timeStamp,
kind: kind, kind: kind,
position: newLocation, position: newLocation,
device: _device,
delta: delta, delta: delta,
); );
} }
...@@ -267,12 +328,26 @@ class TestGesture { ...@@ -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. /// 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 /// 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 /// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events. /// 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); return moveTo(_pointer.location + offset, timeStamp: timeStamp);
} }
...@@ -281,7 +356,7 @@ class TestGesture { ...@@ -281,7 +356,7 @@ class TestGesture {
/// If the pointer is down, then a move event is dispatched. If the pointer is /// 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 /// up, then a hover event is dispatched. Touch devices are not able to send
/// hover events. /// hover events.
Future<void> moveTo(Offset location, {Duration timeStamp = Duration.zero}) { Future<void> moveTo(Offset location, { Duration timeStamp = Duration.zero }) {
return TestAsyncUtils.guard<void>(() { return TestAsyncUtils.guard<void>(() {
if (_pointer._isDown) { if (_pointer._isDown) {
assert(_result != null, 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