Make tooltip hoverable and dismissible (#83830)

This commit is contained in:
chunhtai 2021-06-03 17:49:05 -07:00 committed by GitHub
parent 92992550e6
commit 97dfafbb62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 252 additions and 42 deletions

View file

@ -6,6 +6,7 @@ import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'arc.dart'; import 'arc.dart';
import 'colors.dart'; import 'colors.dart';
@ -16,6 +17,7 @@ import 'page.dart';
import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState; import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState;
import 'scrollbar.dart'; import 'scrollbar.dart';
import 'theme.dart'; import 'theme.dart';
import 'tooltip.dart';
/// [MaterialApp] uses this [TextStyle] as its [DefaultTextStyle] to encourage /// [MaterialApp] uses this [TextStyle] as its [DefaultTextStyle] to encourage
/// developers to be intentional about their [DefaultTextStyle]. /// developers to be intentional about their [DefaultTextStyle].
@ -896,7 +898,15 @@ class _MaterialAppState extends State<MaterialApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget result = _buildWidgetApp(context); Widget result = _buildWidgetApp(context);
result = Focus(
canRequestFocus: false,
onKey: (FocusNode node, RawKeyEvent event) {
if (event is! RawKeyDownEvent || event.logicalKey != LogicalKeyboardKey.escape)
return KeyEventResult.ignored;
return Tooltip.dismissAllToolTips() ? KeyEventResult.handled : KeyEventResult.ignored;
},
child: result,
);
assert(() { assert(() {
if (widget.debugShowMaterialGrid) { if (widget.debugShowMaterialGrid) {
result = GridPaper( result = GridPaper(

View file

@ -6,6 +6,7 @@ import 'dart:async';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'colors.dart'; import 'colors.dart';
@ -56,7 +57,7 @@ import 'tooltip_theme.dart';
/// above the widget. /// above the widget.
/// `textStyle` has been used to set the font size of the 'message'. /// `textStyle` has been used to set the font size of the 'message'.
/// `showDuration` accepts a Duration to continue showing the message after the long /// `showDuration` accepts a Duration to continue showing the message after the long
/// press has been released. /// press has been released or the mouse pointer exits the child widget.
/// `waitDuration` accepts a Duration for which a mouse pointer has to hover over the child /// `waitDuration` accepts a Duration for which a mouse pointer has to hover over the child
/// widget before the tooltip is shown. /// widget before the tooltip is shown.
/// ///
@ -190,18 +191,34 @@ class Tooltip extends StatefulWidget {
/// The length of time that a pointer must hover over a tooltip's widget /// The length of time that a pointer must hover over a tooltip's widget
/// before the tooltip will be shown. /// before the tooltip will be shown.
/// ///
/// Once the pointer leaves the widget, the tooltip will immediately
/// disappear.
///
/// Defaults to 0 milliseconds (tooltips are shown immediately upon hover). /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover).
final Duration? waitDuration; final Duration? waitDuration;
/// The length of time that the tooltip will be shown after a long press /// The length of time that the tooltip will be shown after a long press
/// is released. /// is released or mouse pointer exits the widget.
/// ///
/// Defaults to 1.5 seconds. /// Defaults to 1.5 seconds for long press released or 0.1 seconds for mouse
/// pointer exits the widget.
final Duration? showDuration; final Duration? showDuration;
static final Set<_TooltipState> _openedToolTips = <_TooltipState>{};
/// Dismiss all of the tooltips that are currently shown on the screen.
///
/// This method returns true if it successfully dismisses the tooltips. It
/// returns false if there is no tooltip shown on the screen.
static bool dismissAllToolTips() {
if (_openedToolTips.isNotEmpty) {
// Avoid concurrent modification.
final List<_TooltipState> openedToolTips = List<_TooltipState>.from(_openedToolTips);
for (final _TooltipState state in openedToolTips) {
state._hideTooltip(immediately: true);
}
return true;
}
return false;
}
@override @override
State<Tooltip> createState() => _TooltipState(); State<Tooltip> createState() => _TooltipState();
@ -227,6 +244,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
static const Duration _fadeInDuration = Duration(milliseconds: 150); static const Duration _fadeInDuration = Duration(milliseconds: 150);
static const Duration _fadeOutDuration = Duration(milliseconds: 75); static const Duration _fadeOutDuration = Duration(milliseconds: 75);
static const Duration _defaultShowDuration = Duration(milliseconds: 1500); static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
static const Duration _defaultWaitDuration = Duration.zero; static const Duration _defaultWaitDuration = Duration.zero;
static const bool _defaultExcludeFromSemantics = false; static const bool _defaultExcludeFromSemantics = false;
@ -243,6 +261,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
Timer? _hideTimer; Timer? _hideTimer;
Timer? _showTimer; Timer? _showTimer;
late Duration showDuration; late Duration showDuration;
late Duration hoverShowDuration;
late Duration waitDuration; late Duration waitDuration;
late bool _mouseIsConnected; late bool _mouseIsConnected;
bool _longPressActivated = false; bool _longPressActivated = false;
@ -328,12 +347,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
return; return;
} }
if (_longPressActivated) { if (_longPressActivated) {
// Tool tips activated by long press should stay around for the showDuration.
_hideTimer ??= Timer(showDuration, _controller.reverse); _hideTimer ??= Timer(showDuration, _controller.reverse);
} else { } else {
// Tool tips activated by hover should disappear as soon as the mouse _hideTimer ??= Timer(hoverShowDuration, _controller.reverse);
// leaves the control.
_controller.reverse();
} }
_longPressActivated = false; _longPressActivated = false;
} }
@ -389,6 +405,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
height: height, height: height,
padding: padding, padding: padding,
margin: margin, margin: margin,
onEnter: _mouseIsConnected ? (PointerEnterEvent event) => _showTooltip() : null,
onExit: _mouseIsConnected ? (PointerExitEvent event) => _hideTooltip() : null,
decoration: decoration, decoration: decoration,
textStyle: textStyle, textStyle: textStyle,
animation: CurvedAnimation( animation: CurvedAnimation(
@ -403,9 +421,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_entry = OverlayEntry(builder: (BuildContext context) => overlay); _entry = OverlayEntry(builder: (BuildContext context) => overlay);
overlayState.insert(_entry!); overlayState.insert(_entry!);
SemanticsService.tooltip(widget.message); SemanticsService.tooltip(widget.message);
Tooltip._openedToolTips.add(this);
} }
void _removeEntry() { void _removeEntry() {
Tooltip._openedToolTips.remove(this);
_hideTimer?.cancel(); _hideTimer?.cancel();
_hideTimer = null; _hideTimer = null;
_showTimer?.cancel(); _showTimer?.cancel();
@ -438,8 +458,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
void dispose() { void dispose() {
GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent); GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent);
RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange); RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange);
if (_entry != null) _removeEntry();
_removeEntry();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@ -488,6 +507,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle; textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle;
waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration; waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration; showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
Widget result = GestureDetector( Widget result = GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@ -575,6 +595,8 @@ class _TooltipOverlay extends StatelessWidget {
required this.target, required this.target,
required this.verticalOffset, required this.verticalOffset,
required this.preferBelow, required this.preferBelow,
this.onEnter,
this.onExit,
}) : super(key: key); }) : super(key: key);
final String message; final String message;
@ -587,40 +609,50 @@ class _TooltipOverlay extends StatelessWidget {
final Offset target; final Offset target;
final double verticalOffset; final double verticalOffset;
final bool preferBelow; final bool preferBelow;
final PointerEnterEventListener? onEnter;
final PointerExitEventListener? onExit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned.fill( Widget result = IgnorePointer(
child: IgnorePointer( child: FadeTransition(
child: CustomSingleChildLayout( opacity: animation,
delegate: _TooltipPositionDelegate( child: ConstrainedBox(
target: target, constraints: BoxConstraints(minHeight: height),
verticalOffset: verticalOffset, child: DefaultTextStyle(
preferBelow: preferBelow, style: Theme.of(context).textTheme.bodyText2!,
), child: Container(
child: FadeTransition( decoration: decoration,
opacity: animation, padding: padding,
child: ConstrainedBox( margin: margin,
constraints: BoxConstraints(minHeight: height), child: Center(
child: DefaultTextStyle( widthFactor: 1.0,
style: Theme.of(context).textTheme.bodyText2!, heightFactor: 1.0,
child: Container( child: Text(
decoration: decoration, message,
padding: padding, style: textStyle,
margin: margin,
child: Center(
widthFactor: 1.0,
heightFactor: 1.0,
child: Text(
message,
style: textStyle,
),
),
), ),
), ),
), ),
), ),
), ),
)
);
if (onEnter != null || onExit != null) {
result = MouseRegion(
onEnter: onEnter,
onExit: onExit,
child: result,
);
}
return Positioned.fill(
child: CustomSingleChildLayout(
delegate: _TooltipPositionDelegate(
target: target,
verticalOffset: verticalOffset,
preferBelow: preferBelow,
),
child: result,
), ),
); );
} }

View file

@ -206,12 +206,15 @@ void main() {
' UnmanagedRestorationScope\n' ' UnmanagedRestorationScope\n'
' RootRestorationScope\n' ' RootRestorationScope\n'
' WidgetsApp-[GlobalObjectKey _MaterialAppState#00000]\n' ' WidgetsApp-[GlobalObjectKey _MaterialAppState#00000]\n'
' Semantics\n'
' _FocusMarker\n'
' Focus\n'
' HeroControllerScope\n' ' HeroControllerScope\n'
' ScrollConfiguration\n' ' ScrollConfiguration\n'
' MaterialApp\n' ' MaterialApp\n'
' [root]\n' ' [root]\n'
' Typically, the Scaffold widget is introduced by the MaterialApp\n' ' Typically, the Scaffold widget is introduced by the MaterialApp\n'
' or WidgetsApp widget at the top of your application widget tree.\n', ' or WidgetsApp widget at the top of your application widget tree.\n'
)); ));
}); });

View file

@ -912,6 +912,171 @@ void main() {
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
testWidgets('Tooltip text is also hoverable', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
if (gesture != null)
return gesture.removePointer();
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
await tester.pump();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Tooltip(
message: tooltipText,
waitDuration: waitDuration,
child: Text('I am tool tip'),
),
),
),
);
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);
// Hover to the tool tip text and verify the tooltip doesn't go away.
await gesture.moveTo(tester.getTopLeft(find.text(tooltipText)));
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.pumpAndSettle();
await gesture.removePointer();
gesture = null;
expect(find.text(tooltipText), findsNothing);
});
testWidgets('Tooltip can be dismissed by escape key', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
if (gesture != null)
return gesture.removePointer();
});
await gesture.addPointer();
await gesture.moveTo(const Offset(1.0, 1.0));
await tester.pump();
await gesture.moveTo(Offset.zero);
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: Tooltip(
message: tooltipText,
waitDuration: waitDuration,
child: Text('I am tool tip'),
),
),
),
);
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);
// Try to dismiss the tooltip with the shortcut key
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text(tooltipText), findsNothing);
await gesture.moveTo(Offset.zero);
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
});
testWidgets('Multiple Tooltips are dismissed by escape key', (WidgetTester tester) async {
const Duration waitDuration = Duration.zero;
TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
addTearDown(() async {
if (gesture != null)
return gesture.removePointer();
});
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: Column(
children: const <Widget>[
Tooltip(
message: 'message1',
waitDuration: waitDuration,
showDuration: Duration(days: 1),
child: Text('tooltip1'),
),
Spacer(flex: 2),
Tooltip(
message: 'message2',
waitDuration: waitDuration,
showDuration: Duration(days: 1),
child: Text('tooltip2'),
)
],
),
),
),
);
final Finder tooltip = find.text('tooltip1');
await gesture.moveTo(Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(tooltip));
await tester.pump();
await tester.pump(waitDuration);
expect(find.text('message1'), findsOneWidget);
final Finder secondTooltip = find.text('tooltip2');
await gesture.moveTo(Offset.zero);
await tester.pump();
await gesture.moveTo(tester.getCenter(secondTooltip));
await tester.pump();
await tester.pump(waitDuration);
// Make sure both messages are on the screen.
expect(find.text('message1'), findsOneWidget);
expect(find.text('message2'), findsOneWidget);
// Try to dismiss the tooltip with the shortcut key
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(find.text('message1'), findsNothing);
expect(find.text('message2'), findsNothing);
await gesture.moveTo(Offset.zero);
await tester.pumpAndSettle();
await gesture.removePointer();
gesture = null;
});
testWidgets('Tooltip does not attempt to show after unmount', (WidgetTester tester) async { testWidgets('Tooltip does not attempt to show after unmount', (WidgetTester tester) async {
// Regression test for https://github.com/flutter/flutter/issues/54096. // Regression test for https://github.com/flutter/flutter/issues/54096.
const Duration waitDuration = Duration(seconds: 1); const Duration waitDuration = Duration(seconds: 1);

View file

@ -865,7 +865,7 @@ void main() {
await tester.pump(); await tester.pump();
// Wait for it to disappear. // Wait for it to disappear.
await tester.pump(Duration.zero); // Should immediately disappear await tester.pump(customWaitDuration);
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });
@ -909,7 +909,7 @@ void main() {
await tester.pump(); await tester.pump();
// Wait for it to disappear. // Wait for it to disappear.
await tester.pump(Duration.zero); // Should immediately disappear await tester.pump(customWaitDuration); // Should disappear after customWaitDuration
expect(find.text(tooltipText), findsNothing); expect(find.text(tooltipText), findsNothing);
}); });