| // 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, |
| }); |
| |
| /// 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.maybeViewInsetsOf(context)?.bottom ?? 0.0, |
| child: CustomSingleChildLayout( |
| delegate: _TooltipPositionDelegate( |
| target: target, |
| verticalOffset: verticalOffset, |
| preferBelow: preferBelow, |
| ), |
| child: result, |
| ), |
| ); |
| } |
| } |