mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
899 lines
31 KiB
Dart
899 lines
31 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'colors.dart';
|
|
import 'feedback.dart';
|
|
import 'theme.dart';
|
|
import 'tooltip_theme.dart';
|
|
import 'tooltip_visibility.dart';
|
|
|
|
/// Signature for when a tooltip is triggered.
|
|
typedef TooltipTriggeredCallback = void Function();
|
|
|
|
/// A Material Design tooltip.
|
|
///
|
|
/// Tooltips provide text labels which help explain the function of a button or
|
|
/// other user interface action. Wrap the button in a [Tooltip] widget and provide
|
|
/// a message which will be shown when the widget is long pressed.
|
|
///
|
|
/// Many widgets, such as [IconButton], [FloatingActionButton], and
|
|
/// [PopupMenuButton] have a `tooltip` property that, when non-null, causes the
|
|
/// widget to include a [Tooltip] in its build.
|
|
///
|
|
/// Tooltips improve the accessibility of visual widgets by proving a textual
|
|
/// representation of the widget, which, for example, can be vocalized by a
|
|
/// screen reader.
|
|
///
|
|
/// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This example show a basic [Tooltip] which has a [Text] as child.
|
|
/// [message] contains your label to be shown by the tooltip when
|
|
/// the child that Tooltip wraps is hovered over on web or desktop. On mobile,
|
|
/// the tooltip is shown when the widget is long pressed.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tooltip/tooltip.0.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This example covers most of the attributes available in Tooltip.
|
|
/// `decoration` has been used to give a gradient and borderRadius to Tooltip.
|
|
/// `height` has been used to set a specific height of the Tooltip.
|
|
/// `preferBelow` is false, the tooltip will prefer showing above [Tooltip]'s child widget.
|
|
/// However, it may show the tooltip below if there's not enough space
|
|
/// 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 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.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This example shows a rich [Tooltip] that specifies the [richMessage]
|
|
/// parameter instead of the [message] parameter (only one of these may be
|
|
/// non-null. Any [InlineSpan] can be specified for the [richMessage] attribute,
|
|
/// including [WidgetSpan].
|
|
///
|
|
/// ** See code in examples/api/lib/material/tooltip/tooltip.2.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// {@tool dartpad}
|
|
/// This example shows how [Tooltip] can be shown manually with [TooltipTriggerMode.manual]
|
|
/// by calling the [TooltipState.ensureTooltipVisible] function.
|
|
///
|
|
/// ** See code in examples/api/lib/material/tooltip/tooltip.3.dart **
|
|
/// {@end-tool}
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * <https://material.io/design/components/tooltips.html>
|
|
/// * [TooltipTheme] or [ThemeData.tooltipTheme]
|
|
/// * [TooltipVisibility]
|
|
class Tooltip extends StatefulWidget {
|
|
/// Creates a tooltip.
|
|
///
|
|
/// By default, tooltips should adhere to the
|
|
/// [Material specification](https://material.io/design/components/tooltips.html#spec).
|
|
/// If the optional constructor parameters are not defined, the values
|
|
/// provided by [TooltipTheme.of] will be used if a [TooltipTheme] is present
|
|
/// or specified in [ThemeData].
|
|
///
|
|
/// All parameters that are defined in the constructor will
|
|
/// override the default values _and_ the values in [TooltipTheme.of].
|
|
///
|
|
/// Only one of [message] and [richMessage] may be non-null.
|
|
const Tooltip({
|
|
super.key,
|
|
this.message,
|
|
this.richMessage,
|
|
this.height,
|
|
this.padding,
|
|
this.margin,
|
|
this.verticalOffset,
|
|
this.preferBelow,
|
|
this.excludeFromSemantics,
|
|
this.decoration,
|
|
this.textStyle,
|
|
this.textAlign,
|
|
this.waitDuration,
|
|
this.showDuration,
|
|
this.triggerMode,
|
|
this.enableFeedback,
|
|
this.onTriggered,
|
|
this.child,
|
|
}) : assert((message == null) != (richMessage == null), 'Either `message` or `richMessage` must be specified'),
|
|
assert(
|
|
richMessage == null || textStyle == null,
|
|
'If `richMessage` is specified, `textStyle` will have no effect. '
|
|
'If you wish to provide a `textStyle` for a rich tooltip, add the '
|
|
'`textStyle` directly to the `richMessage` InlineSpan.',
|
|
);
|
|
|
|
/// The text to display in the tooltip.
|
|
///
|
|
/// Only one of [message] and [richMessage] may be non-null.
|
|
final String? message;
|
|
|
|
/// The rich text to display in the tooltip.
|
|
///
|
|
/// Only one of [message] and [richMessage] may be non-null.
|
|
final InlineSpan? richMessage;
|
|
|
|
/// The height of the tooltip's [child].
|
|
///
|
|
/// If the [child] is null, then this is the tooltip's intrinsic height.
|
|
final double? height;
|
|
|
|
/// The amount of space by which to inset the tooltip's [child].
|
|
///
|
|
/// On mobile, defaults to 16.0 logical pixels horizontally and 4.0 vertically.
|
|
/// On desktop, defaults to 8.0 logical pixels horizontally and 4.0 vertically.
|
|
final EdgeInsetsGeometry? padding;
|
|
|
|
/// The empty space that surrounds the tooltip.
|
|
///
|
|
/// Defines the tooltip's outer [Container.margin]. By default, a
|
|
/// long tooltip will span the width of its window. If long enough,
|
|
/// a tooltip might also span the window's height. This property allows
|
|
/// one to define how much space the tooltip must be inset from the edges
|
|
/// of their display window.
|
|
///
|
|
/// If this property is null, then [TooltipThemeData.margin] is used.
|
|
/// If [TooltipThemeData.margin] is also null, the default margin is
|
|
/// 0.0 logical pixels on all sides.
|
|
final EdgeInsetsGeometry? margin;
|
|
|
|
/// The vertical gap between the widget and the displayed tooltip.
|
|
///
|
|
/// When [preferBelow] is set to true and tooltips have sufficient space to
|
|
/// display themselves, this property defines how much vertical space
|
|
/// tooltips will position themselves under their corresponding widgets.
|
|
/// Otherwise, tooltips will position themselves above their corresponding
|
|
/// widgets with the given offset.
|
|
final double? verticalOffset;
|
|
|
|
/// Whether the tooltip defaults to being displayed below the widget.
|
|
///
|
|
/// Defaults to true. If there is insufficient space to display the tooltip in
|
|
/// the preferred direction, the tooltip will be displayed in the opposite
|
|
/// direction.
|
|
final bool? preferBelow;
|
|
|
|
/// Whether the tooltip's [message] or [richMessage] should be excluded from
|
|
/// the semantics tree.
|
|
///
|
|
/// Defaults to false. A tooltip will add a [Semantics] label that is set to
|
|
/// [Tooltip.message] if non-null, or the plain text value of
|
|
/// [Tooltip.richMessage] otherwise. Set this property to true if the app is
|
|
/// going to provide its own custom semantics label.
|
|
final bool? excludeFromSemantics;
|
|
|
|
/// The widget below this widget in the tree.
|
|
///
|
|
/// {@macro flutter.widgets.ProxyWidget.child}
|
|
final Widget? child;
|
|
|
|
/// Specifies the tooltip's shape and background color.
|
|
///
|
|
/// The tooltip shape defaults to a rounded rectangle with a border radius of
|
|
/// 4.0. Tooltips will also default to an opacity of 90% and with the color
|
|
/// [Colors.grey]\[700\] if [ThemeData.brightness] is [Brightness.dark], and
|
|
/// [Colors.white] if it is [Brightness.light].
|
|
final Decoration? decoration;
|
|
|
|
/// The style to use for the message of the tooltip.
|
|
///
|
|
/// If null, the message's [TextStyle] will be determined based on
|
|
/// [ThemeData]. If [ThemeData.brightness] is set to [Brightness.dark],
|
|
/// [TextTheme.bodyMedium] of [ThemeData.textTheme] will be used with
|
|
/// [Colors.white]. Otherwise, if [ThemeData.brightness] is set to
|
|
/// [Brightness.light], [TextTheme.bodyMedium] of [ThemeData.textTheme] will be
|
|
/// used with [Colors.black].
|
|
final TextStyle? textStyle;
|
|
|
|
/// How the message of the tooltip is aligned horizontally.
|
|
///
|
|
/// If this property is null, then [TooltipThemeData.textAlign] is used.
|
|
/// If [TooltipThemeData.textAlign] is also null, the default value is
|
|
/// [TextAlign.start].
|
|
final TextAlign? textAlign;
|
|
|
|
/// The length of time that a pointer must hover over a tooltip's widget
|
|
/// before the tooltip will be shown.
|
|
///
|
|
/// 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 (if triggerMode is [TooltipTriggerMode.longPress]) or a tap is
|
|
/// released (if triggerMode is [TooltipTriggerMode.tap]) or mouse pointer
|
|
/// exits the widget.
|
|
///
|
|
/// Defaults to 1.5 seconds for long press and tap released or 0.1 seconds
|
|
/// for mouse pointer exits the widget.
|
|
final Duration? showDuration;
|
|
|
|
/// The [TooltipTriggerMode] that will show the tooltip.
|
|
///
|
|
/// If this property is null, then [TooltipThemeData.triggerMode] is used.
|
|
/// If [TooltipThemeData.triggerMode] is also null, the default mode is
|
|
/// [TooltipTriggerMode.longPress].
|
|
final TooltipTriggerMode? triggerMode;
|
|
|
|
/// Whether the tooltip should provide acoustic and/or haptic feedback.
|
|
///
|
|
/// For example, on Android a tap will produce a clicking sound and a
|
|
/// long-press will produce a short vibration, when feedback is enabled.
|
|
///
|
|
/// When null, the default value is true.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [Feedback], for providing platform-specific feedback to certain actions.
|
|
final bool? enableFeedback;
|
|
|
|
/// Called when the Tooltip is triggered.
|
|
///
|
|
/// The tooltip is triggered after a tap when [triggerMode] is [TooltipTriggerMode.tap]
|
|
/// or after a long press when [triggerMode] is [TooltipTriggerMode.longPress].
|
|
final TooltipTriggeredCallback? onTriggered;
|
|
|
|
static final List<TooltipState> _openedTooltips = <TooltipState>[];
|
|
|
|
// Causes any current tooltips to be concealed. Only called for mouse hover enter
|
|
// detections. Won't conceal the supplied tooltip.
|
|
static void _concealOtherTooltips(TooltipState current) {
|
|
if (_openedTooltips.isNotEmpty) {
|
|
// Avoid concurrent modification.
|
|
final List<TooltipState> openedTooltips = _openedTooltips.toList();
|
|
for (final TooltipState state in openedTooltips) {
|
|
if (state == current) {
|
|
continue;
|
|
}
|
|
state._concealTooltip();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Causes the most recently concealed tooltip to be revealed. Only called for mouse
|
|
// hover exit detections.
|
|
static void _revealLastTooltip() {
|
|
if (_openedTooltips.isNotEmpty) {
|
|
_openedTooltips.last._revealTooltip();
|
|
}
|
|
}
|
|
|
|
/// 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 = _openedTooltips.toList();
|
|
for (final TooltipState state in openedTooltips) {
|
|
state._dismissTooltip(immediately: true);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
State<Tooltip> createState() => TooltipState();
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(StringProperty(
|
|
'message',
|
|
message,
|
|
showName: message == null,
|
|
defaultValue: message == null ? null : kNoDefaultValue,
|
|
));
|
|
properties.add(StringProperty(
|
|
'richMessage',
|
|
richMessage?.toPlainText(),
|
|
showName: richMessage == null,
|
|
defaultValue: richMessage == null ? null : kNoDefaultValue,
|
|
));
|
|
properties.add(DoubleProperty('height', height, defaultValue: null));
|
|
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
|
|
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
|
|
properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null));
|
|
properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
|
|
properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true));
|
|
properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
|
|
properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
|
|
properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
|
|
properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true));
|
|
properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
|
|
}
|
|
}
|
|
|
|
/// Contains the state for a [Tooltip].
|
|
///
|
|
/// This class can be used to programmatically show the Tooltip, see the
|
|
/// [ensureTooltipVisible] method.
|
|
class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
|
|
static const double _defaultVerticalOffset = 24.0;
|
|
static const bool _defaultPreferBelow = true;
|
|
static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero;
|
|
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;
|
|
static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress;
|
|
static const bool _defaultEnableFeedback = true;
|
|
static const TextAlign _defaultTextAlign = TextAlign.start;
|
|
|
|
late double _height;
|
|
late EdgeInsetsGeometry _padding;
|
|
late EdgeInsetsGeometry _margin;
|
|
late Decoration _decoration;
|
|
late TextStyle _textStyle;
|
|
late TextAlign _textAlign;
|
|
late double _verticalOffset;
|
|
late bool _preferBelow;
|
|
late bool _excludeFromSemantics;
|
|
late AnimationController _controller;
|
|
OverlayEntry? _entry;
|
|
Timer? _dismissTimer;
|
|
Timer? _showTimer;
|
|
late Duration _showDuration;
|
|
late Duration _hoverShowDuration;
|
|
late Duration _waitDuration;
|
|
late bool _mouseIsConnected;
|
|
bool _pressActivated = false;
|
|
late TooltipTriggerMode _triggerMode;
|
|
late bool _enableFeedback;
|
|
late bool _isConcealed;
|
|
late bool _forceRemoval;
|
|
late bool _visible;
|
|
|
|
/// The plain text message for this tooltip.
|
|
///
|
|
/// This value will either come from [widget.message] or [widget.richMessage].
|
|
String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_isConcealed = false;
|
|
_forceRemoval = false;
|
|
_mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
|
|
_controller = AnimationController(
|
|
duration: _fadeInDuration,
|
|
reverseDuration: _fadeOutDuration,
|
|
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);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_visible = TooltipVisibility.of(context);
|
|
}
|
|
|
|
// https://material.io/components/tooltips#specs
|
|
double _getDefaultTooltipHeight() {
|
|
final ThemeData theme = Theme.of(context);
|
|
switch (theme.platform) {
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
return 24.0;
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.iOS:
|
|
return 32.0;
|
|
}
|
|
}
|
|
|
|
EdgeInsets _getDefaultPadding() {
|
|
final ThemeData theme = Theme.of(context);
|
|
switch (theme.platform) {
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
return const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0);
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.iOS:
|
|
return const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0);
|
|
}
|
|
}
|
|
|
|
double _getDefaultFontSize() {
|
|
final ThemeData theme = Theme.of(context);
|
|
switch (theme.platform) {
|
|
case TargetPlatform.macOS:
|
|
case TargetPlatform.linux:
|
|
case TargetPlatform.windows:
|
|
return 12.0;
|
|
case TargetPlatform.android:
|
|
case TargetPlatform.fuchsia:
|
|
case TargetPlatform.iOS:
|
|
return 14.0;
|
|
}
|
|
}
|
|
|
|
// 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 this tip is concealed, don't remove it, even if it is dismissed, so that we can
|
|
// reveal it later, unless it has explicitly been hidden with _dismissTooltip.
|
|
if (status == AnimationStatus.dismissed && (_forceRemoval || !_isConcealed)) {
|
|
_removeEntry();
|
|
}
|
|
}
|
|
|
|
void _dismissTooltip({ bool immediately = false }) {
|
|
_showTimer?.cancel();
|
|
_showTimer = null;
|
|
if (immediately) {
|
|
_removeEntry();
|
|
return;
|
|
}
|
|
// So it will be removed when it's done reversing, regardless of whether it is
|
|
// still concealed or not.
|
|
_forceRemoval = true;
|
|
if (_pressActivated) {
|
|
_dismissTimer ??= Timer(_showDuration, _controller.reverse);
|
|
} else {
|
|
_dismissTimer ??= Timer(_hoverShowDuration, _controller.reverse);
|
|
}
|
|
_pressActivated = false;
|
|
}
|
|
|
|
void _showTooltip({ bool immediately = false }) {
|
|
_dismissTimer?.cancel();
|
|
_dismissTimer = null;
|
|
if (immediately) {
|
|
ensureTooltipVisible();
|
|
return;
|
|
}
|
|
_showTimer ??= Timer(_waitDuration, ensureTooltipVisible);
|
|
}
|
|
|
|
void _concealTooltip() {
|
|
if (_isConcealed || _forceRemoval) {
|
|
// Already concealed, or it's being removed.
|
|
return;
|
|
}
|
|
_isConcealed = true;
|
|
_dismissTimer?.cancel();
|
|
_dismissTimer = null;
|
|
_showTimer?.cancel();
|
|
_showTimer = null;
|
|
if (_entry != null) {
|
|
_entry!.remove();
|
|
}
|
|
_controller.reverse();
|
|
}
|
|
|
|
void _revealTooltip() {
|
|
if (!_isConcealed) {
|
|
// Already uncovered.
|
|
return;
|
|
}
|
|
_isConcealed = false;
|
|
_dismissTimer?.cancel();
|
|
_dismissTimer = null;
|
|
_showTimer?.cancel();
|
|
_showTimer = null;
|
|
if (!_entry!.mounted) {
|
|
final OverlayState overlayState = Overlay.of(
|
|
context,
|
|
debugRequiredFor: widget,
|
|
);
|
|
overlayState.insert(_entry!);
|
|
}
|
|
SemanticsService.tooltip(_tooltipMessage);
|
|
_controller.forward();
|
|
}
|
|
|
|
/// Shows the tooltip if it is not already visible.
|
|
///
|
|
/// Returns `false` when the tooltip shouldn't be shown or when the tooltip
|
|
/// was already visible.
|
|
bool ensureTooltipVisible() {
|
|
if (!_visible || !mounted) {
|
|
return false;
|
|
}
|
|
_showTimer?.cancel();
|
|
_showTimer = null;
|
|
_forceRemoval = false;
|
|
if (_isConcealed) {
|
|
if (_mouseIsConnected) {
|
|
Tooltip._concealOtherTooltips(this);
|
|
}
|
|
_revealTooltip();
|
|
return true;
|
|
}
|
|
if (_entry != null) {
|
|
// Stop trying to hide, if we were.
|
|
_dismissTimer?.cancel();
|
|
_dismissTimer = null;
|
|
_controller.forward();
|
|
return false; // Already visible.
|
|
}
|
|
_createNewEntry();
|
|
_controller.forward();
|
|
return true;
|
|
}
|
|
|
|
static final Set<TooltipState> _mouseIn = <TooltipState>{};
|
|
|
|
void _handleMouseEnter() {
|
|
if (mounted) {
|
|
_showTooltip();
|
|
}
|
|
}
|
|
|
|
void _handleMouseExit({bool immediately = false}) {
|
|
if (mounted) {
|
|
// If the tip is currently covered, we can just remove it without waiting.
|
|
_dismissTooltip(immediately: _isConcealed || immediately);
|
|
}
|
|
}
|
|
|
|
void _createNewEntry() {
|
|
final OverlayState overlayState = Overlay.of(
|
|
context,
|
|
debugRequiredFor: widget,
|
|
);
|
|
|
|
final RenderBox box = context.findRenderObject()! as RenderBox;
|
|
final Offset target = box.localToGlobal(
|
|
box.size.center(Offset.zero),
|
|
ancestor: overlayState.context.findRenderObject(),
|
|
);
|
|
|
|
// 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.
|
|
final Widget overlay = Directionality(
|
|
textDirection: Directionality.of(context),
|
|
child: _TooltipOverlay(
|
|
richMessage: widget.richMessage ?? TextSpan(text: widget.message),
|
|
height: _height,
|
|
padding: _padding,
|
|
margin: _margin,
|
|
onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
|
|
onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
|
|
decoration: _decoration,
|
|
textStyle: _textStyle,
|
|
textAlign: _textAlign,
|
|
animation: CurvedAnimation(
|
|
parent: _controller,
|
|
curve: Curves.fastOutSlowIn,
|
|
),
|
|
target: target,
|
|
verticalOffset: _verticalOffset,
|
|
preferBelow: _preferBelow,
|
|
),
|
|
);
|
|
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
|
|
_isConcealed = false;
|
|
overlayState.insert(_entry!);
|
|
SemanticsService.tooltip(_tooltipMessage);
|
|
if (_mouseIsConnected) {
|
|
// Hovered tooltips shouldn't show more than one at once. For example, a chip with
|
|
// a delete icon shouldn't show both the delete icon tooltip and the chip tooltip
|
|
// at the same time.
|
|
Tooltip._concealOtherTooltips(this);
|
|
}
|
|
assert(!Tooltip._openedTooltips.contains(this));
|
|
Tooltip._openedTooltips.add(this);
|
|
}
|
|
|
|
void _removeEntry() {
|
|
Tooltip._openedTooltips.remove(this);
|
|
_mouseIn.remove(this);
|
|
_dismissTimer?.cancel();
|
|
_dismissTimer = null;
|
|
_showTimer?.cancel();
|
|
_showTimer = null;
|
|
if (!_isConcealed) {
|
|
_entry?.remove();
|
|
}
|
|
_isConcealed = false;
|
|
_entry = null;
|
|
if (_mouseIsConnected) {
|
|
Tooltip._revealLastTooltip();
|
|
}
|
|
}
|
|
|
|
void _handlePointerEvent(PointerEvent event) {
|
|
if (_entry == null) {
|
|
return;
|
|
}
|
|
if (event is PointerUpEvent || event is PointerCancelEvent) {
|
|
_handleMouseExit();
|
|
} else if (event is PointerDownEvent) {
|
|
_handleMouseExit(immediately: true);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void deactivate() {
|
|
if (_entry != null) {
|
|
_dismissTooltip(immediately: true);
|
|
}
|
|
_showTimer?.cancel();
|
|
super.deactivate();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
|
|
RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
|
|
_removeEntry();
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handlePress() {
|
|
_pressActivated = true;
|
|
final bool tooltipCreated = ensureTooltipVisible();
|
|
if (tooltipCreated && _enableFeedback) {
|
|
if (_triggerMode == TooltipTriggerMode.longPress) {
|
|
Feedback.forLongPress(context);
|
|
} else {
|
|
Feedback.forTap(context);
|
|
}
|
|
}
|
|
widget.onTriggered?.call();
|
|
}
|
|
|
|
void _handleTap() {
|
|
_handlePress();
|
|
// When triggerMode is not [TooltipTriggerMode.tap] the tooltip is dismissed
|
|
// by _handlePointerEvent, which listens to the global pointer events.
|
|
// When triggerMode is [TooltipTriggerMode.tap] and the Tooltip GestureDetector
|
|
// competes with other GestureDetectors, the disambiguation process will complete
|
|
// after the global pointer event is received. As we can't rely on the global
|
|
// pointer events to dismiss the Tooltip, we have to call _handleMouseExit
|
|
// to dismiss the tooltip after _showDuration expired.
|
|
_handleMouseExit();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// If message is empty then no need to create a tooltip overlay to show
|
|
// the empty black container so just return the wrapped child as is or
|
|
// empty container if child is not specified.
|
|
if (_tooltipMessage.isEmpty) {
|
|
return widget.child ?? const SizedBox.shrink();
|
|
}
|
|
assert(debugCheckHasOverlay(context));
|
|
final ThemeData theme = Theme.of(context);
|
|
final TooltipThemeData tooltipTheme = TooltipTheme.of(context);
|
|
final TextStyle defaultTextStyle;
|
|
final BoxDecoration defaultDecoration;
|
|
if (theme.brightness == Brightness.dark) {
|
|
defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
|
|
color: Colors.black,
|
|
fontSize: _getDefaultFontSize(),
|
|
);
|
|
defaultDecoration = BoxDecoration(
|
|
color: Colors.white.withOpacity(0.9),
|
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
);
|
|
} else {
|
|
defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
|
|
color: Colors.white,
|
|
fontSize: _getDefaultFontSize(),
|
|
);
|
|
defaultDecoration = BoxDecoration(
|
|
color: Colors.grey[700]!.withOpacity(0.9),
|
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
);
|
|
}
|
|
|
|
_height = widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight();
|
|
_padding = widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding();
|
|
_margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin;
|
|
_verticalOffset = widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset;
|
|
_preferBelow = widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow;
|
|
_excludeFromSemantics = widget.excludeFromSemantics ?? tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics;
|
|
_decoration = widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration;
|
|
_textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle;
|
|
_textAlign = widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign;
|
|
_waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
|
|
_showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
|
|
_hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
|
|
_triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode;
|
|
_enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
|
|
|
|
Widget result = Semantics(
|
|
tooltip: _excludeFromSemantics
|
|
? null
|
|
: _tooltipMessage,
|
|
child: widget.child,
|
|
);
|
|
|
|
// Only check for gestures if tooltip should be visible.
|
|
if (_visible) {
|
|
result = GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onLongPress: (_triggerMode == TooltipTriggerMode.longPress) ? _handlePress : null,
|
|
onTap: (_triggerMode == TooltipTriggerMode.tap) ? _handleTap : null,
|
|
excludeFromSemantics: true,
|
|
child: result,
|
|
);
|
|
// Only check for hovering if there is a mouse connected.
|
|
if (_mouseIsConnected) {
|
|
result = MouseRegion(
|
|
onEnter: (_) => _handleMouseEnter(),
|
|
onExit: (_) => _handleMouseExit(),
|
|
child: result,
|
|
);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// A delegate for computing the layout of a tooltip to be displayed above or
|
|
/// below a target specified in the global coordinate system.
|
|
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
|
|
/// Creates a delegate for computing the layout of a tooltip.
|
|
///
|
|
/// The arguments must not be null.
|
|
_TooltipPositionDelegate({
|
|
required this.target,
|
|
required this.verticalOffset,
|
|
required this.preferBelow,
|
|
}) : assert(target != null),
|
|
assert(verticalOffset != null),
|
|
assert(preferBelow != null);
|
|
|
|
/// The offset of the target the tooltip is positioned near in the global
|
|
/// coordinate system.
|
|
final Offset target;
|
|
|
|
/// The amount of vertical distance between the target and the displayed
|
|
/// tooltip.
|
|
final double verticalOffset;
|
|
|
|
/// Whether the tooltip is displayed below its widget by default.
|
|
///
|
|
/// If there is insufficient space to display the tooltip in the preferred
|
|
/// direction, the tooltip will be displayed in the opposite direction.
|
|
final bool preferBelow;
|
|
|
|
@override
|
|
BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();
|
|
|
|
@override
|
|
Offset getPositionForChild(Size size, Size childSize) {
|
|
return positionDependentBox(
|
|
size: size,
|
|
childSize: childSize,
|
|
target: target,
|
|
verticalOffset: verticalOffset,
|
|
preferBelow: preferBelow,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
|
|
return target != oldDelegate.target
|
|
|| verticalOffset != oldDelegate.verticalOffset
|
|
|| preferBelow != oldDelegate.preferBelow;
|
|
}
|
|
}
|
|
|
|
class _TooltipOverlay extends StatelessWidget {
|
|
const _TooltipOverlay({
|
|
required this.height,
|
|
required this.richMessage,
|
|
this.padding,
|
|
this.margin,
|
|
this.decoration,
|
|
this.textStyle,
|
|
this.textAlign,
|
|
required this.animation,
|
|
required this.target,
|
|
required this.verticalOffset,
|
|
required this.preferBelow,
|
|
this.onEnter,
|
|
this.onExit,
|
|
});
|
|
|
|
final InlineSpan richMessage;
|
|
final double height;
|
|
final EdgeInsetsGeometry? padding;
|
|
final EdgeInsetsGeometry? margin;
|
|
final Decoration? decoration;
|
|
final TextStyle? textStyle;
|
|
final TextAlign? textAlign;
|
|
final Animation<double> animation;
|
|
final Offset target;
|
|
final double verticalOffset;
|
|
final bool preferBelow;
|
|
final PointerEnterEventListener? onEnter;
|
|
final PointerExitEventListener? onExit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
Widget result = IgnorePointer(
|
|
child: FadeTransition(
|
|
opacity: animation,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minHeight: height),
|
|
child: DefaultTextStyle(
|
|
style: Theme.of(context).textTheme.bodyMedium!,
|
|
child: Container(
|
|
decoration: decoration,
|
|
padding: padding,
|
|
margin: margin,
|
|
child: Center(
|
|
widthFactor: 1.0,
|
|
heightFactor: 1.0,
|
|
child: Text.rich(
|
|
richMessage,
|
|
style: textStyle,
|
|
textAlign: textAlign,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
);
|
|
if (onEnter != null || onExit != null) {
|
|
result = MouseRegion(
|
|
onEnter: onEnter,
|
|
onExit: onExit,
|
|
child: result,
|
|
);
|
|
}
|
|
return Positioned.fill(
|
|
bottom: MediaQuery.maybeOf(context)?.viewInsets.bottom ?? 0.0,
|
|
child: CustomSingleChildLayout(
|
|
delegate: _TooltipPositionDelegate(
|
|
target: target,
|
|
verticalOffset: verticalOffset,
|
|
preferBelow: preferBelow,
|
|
),
|
|
child: result,
|
|
),
|
|
);
|
|
}
|
|
}
|