mirror of
https://github.com/flutter/flutter
synced 2024-10-13 03:32:55 +00:00
Make tooltip hoverable and dismissible (#83830)
This commit is contained in:
parent
92992550e6
commit
97dfafbb62
|
@ -6,6 +6,7 @@ import 'dart:ui' as ui;
|
|||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'arc.dart';
|
||||
import 'colors.dart';
|
||||
|
@ -16,6 +17,7 @@ import 'page.dart';
|
|||
import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState;
|
||||
import 'scrollbar.dart';
|
||||
import 'theme.dart';
|
||||
import 'tooltip.dart';
|
||||
|
||||
/// [MaterialApp] uses this [TextStyle] as its [DefaultTextStyle] to encourage
|
||||
/// developers to be intentional about their [DefaultTextStyle].
|
||||
|
@ -896,7 +898,15 @@ class _MaterialAppState extends State<MaterialApp> {
|
|||
@override
|
||||
Widget build(BuildContext 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(() {
|
||||
if (widget.debugShowMaterialGrid) {
|
||||
result = GridPaper(
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'colors.dart';
|
||||
|
@ -56,7 +57,7 @@ import 'tooltip_theme.dart';
|
|||
/// above the widget.
|
||||
/// `textStyle` has been used to set the font size of the 'message'.
|
||||
/// `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
|
||||
/// 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
|
||||
/// 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).
|
||||
final Duration? waitDuration;
|
||||
|
||||
/// 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;
|
||||
|
||||
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
|
||||
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 _fadeOutDuration = Duration(milliseconds: 75);
|
||||
static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
|
||||
static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
|
||||
static const Duration _defaultWaitDuration = Duration.zero;
|
||||
static const bool _defaultExcludeFromSemantics = false;
|
||||
|
||||
|
@ -243,6 +261,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
Timer? _hideTimer;
|
||||
Timer? _showTimer;
|
||||
late Duration showDuration;
|
||||
late Duration hoverShowDuration;
|
||||
late Duration waitDuration;
|
||||
late bool _mouseIsConnected;
|
||||
bool _longPressActivated = false;
|
||||
|
@ -328,12 +347,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
return;
|
||||
}
|
||||
if (_longPressActivated) {
|
||||
// Tool tips activated by long press should stay around for the showDuration.
|
||||
_hideTimer ??= Timer(showDuration, _controller.reverse);
|
||||
} else {
|
||||
// Tool tips activated by hover should disappear as soon as the mouse
|
||||
// leaves the control.
|
||||
_controller.reverse();
|
||||
_hideTimer ??= Timer(hoverShowDuration, _controller.reverse);
|
||||
}
|
||||
_longPressActivated = false;
|
||||
}
|
||||
|
@ -389,6 +405,8 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
height: height,
|
||||
padding: padding,
|
||||
margin: margin,
|
||||
onEnter: _mouseIsConnected ? (PointerEnterEvent event) => _showTooltip() : null,
|
||||
onExit: _mouseIsConnected ? (PointerExitEvent event) => _hideTooltip() : null,
|
||||
decoration: decoration,
|
||||
textStyle: textStyle,
|
||||
animation: CurvedAnimation(
|
||||
|
@ -403,9 +421,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
|
||||
overlayState.insert(_entry!);
|
||||
SemanticsService.tooltip(widget.message);
|
||||
Tooltip._openedToolTips.add(this);
|
||||
}
|
||||
|
||||
void _removeEntry() {
|
||||
Tooltip._openedToolTips.remove(this);
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = null;
|
||||
_showTimer?.cancel();
|
||||
|
@ -438,8 +458,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
void dispose() {
|
||||
GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent);
|
||||
RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange);
|
||||
if (_entry != null)
|
||||
_removeEntry();
|
||||
_removeEntry();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -488,6 +507,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|||
textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle;
|
||||
waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
|
||||
showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
|
||||
hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
|
||||
|
||||
Widget result = GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
@ -575,6 +595,8 @@ class _TooltipOverlay extends StatelessWidget {
|
|||
required this.target,
|
||||
required this.verticalOffset,
|
||||
required this.preferBelow,
|
||||
this.onEnter,
|
||||
this.onExit,
|
||||
}) : super(key: key);
|
||||
|
||||
final String message;
|
||||
|
@ -587,40 +609,50 @@ class _TooltipOverlay extends StatelessWidget {
|
|||
final Offset target;
|
||||
final double verticalOffset;
|
||||
final bool preferBelow;
|
||||
final PointerEnterEventListener? onEnter;
|
||||
final PointerExitEventListener? onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomSingleChildLayout(
|
||||
delegate: _TooltipPositionDelegate(
|
||||
target: target,
|
||||
verticalOffset: verticalOffset,
|
||||
preferBelow: preferBelow,
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: height),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2!,
|
||||
child: Container(
|
||||
decoration: decoration,
|
||||
padding: padding,
|
||||
margin: margin,
|
||||
child: Center(
|
||||
widthFactor: 1.0,
|
||||
heightFactor: 1.0,
|
||||
child: Text(
|
||||
message,
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
Widget result = IgnorePointer(
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: height),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.bodyText2!,
|
||||
child: Container(
|
||||
decoration: decoration,
|
||||
padding: padding,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -206,12 +206,15 @@ void main() {
|
|||
' UnmanagedRestorationScope\n'
|
||||
' RootRestorationScope\n'
|
||||
' WidgetsApp-[GlobalObjectKey _MaterialAppState#00000]\n'
|
||||
' Semantics\n'
|
||||
' _FocusMarker\n'
|
||||
' Focus\n'
|
||||
' HeroControllerScope\n'
|
||||
' ScrollConfiguration\n'
|
||||
' MaterialApp\n'
|
||||
' [root]\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'
|
||||
));
|
||||
});
|
||||
|
||||
|
|
|
@ -912,6 +912,171 @@ void main() {
|
|||
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 {
|
||||
// Regression test for https://github.com/flutter/flutter/issues/54096.
|
||||
const Duration waitDuration = Duration(seconds: 1);
|
||||
|
|
|
@ -865,7 +865,7 @@ void main() {
|
|||
await tester.pump();
|
||||
|
||||
// Wait for it to disappear.
|
||||
await tester.pump(Duration.zero); // Should immediately disappear
|
||||
await tester.pump(customWaitDuration);
|
||||
expect(find.text(tooltipText), findsNothing);
|
||||
});
|
||||
|
||||
|
@ -909,7 +909,7 @@ void main() {
|
|||
await tester.pump();
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in a new issue