| // 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 'text_theme.dart'; |
| import 'theme.dart'; |
| import 'tooltip_theme.dart'; |
| import 'tooltip_visibility.dart'; |
| |
| /// Signature for when a tooltip is triggered. |
| typedef TooltipTriggeredCallback = void Function(); |
| |
| /// A special [MouseRegion] that when nested, only the first [_ExclusiveMouseRegion] |
| /// to be hit in hit-testing order will be added to the BoxHitTestResult (i.e., |
| /// child over parent, last sibling over first sibling). |
| /// |
| /// The [onEnter] method will be called when a mouse pointer enters this |
| /// [MouseRegion], and there is no other [_ExclusiveMouseRegion]s obstructing |
| /// this [_ExclusiveMouseRegion] from receiving the events. This includes the |
| /// case where the mouse cursor stays within the paint bounds of an outer |
| /// [_ExclusiveMouseRegion], but moves outside of the bounds of the inner |
| /// [_ExclusiveMouseRegion] that was initially blocking the outer widget. |
| /// |
| /// Likewise, [onExit] is called when the a mouse pointer moves out of the paint |
| /// bounds of this widget, or moves into another [_ExclusiveMouseRegion] that |
| /// overlaps this widget in hit-testing order. |
| /// |
| /// This widget doesn't affect [MouseRegion]s that aren't [_ExclusiveMouseRegion]s, |
| /// or other [HitTestTarget]s in the tree. |
| class _ExclusiveMouseRegion extends MouseRegion { |
| const _ExclusiveMouseRegion({ |
| super.onEnter, |
| super.onExit, |
| super.child, |
| }); |
| |
| @override |
| _RenderExclusiveMouseRegion createRenderObject(BuildContext context) { |
| return _RenderExclusiveMouseRegion( |
| onEnter: onEnter, |
| onExit: onExit, |
| ); |
| } |
| } |
| |
| class _RenderExclusiveMouseRegion extends RenderMouseRegion { |
| _RenderExclusiveMouseRegion({ |
| super.onEnter, |
| super.onExit, |
| }); |
| |
| static bool isOutermostMouseRegion = true; |
| static bool foundInnermostMouseRegion = false; |
| |
| @override |
| bool hitTest(BoxHitTestResult result, {required Offset position}) { |
| bool isHit = false; |
| final bool outermost = isOutermostMouseRegion; |
| isOutermostMouseRegion = false; |
| if (size.contains(position)) { |
| isHit = hitTestChildren(result, position: position) || hitTestSelf(position); |
| if ((isHit || behavior == HitTestBehavior.translucent) && !foundInnermostMouseRegion) { |
| foundInnermostMouseRegion = true; |
| result.add(BoxHitTestEntry(this, position)); |
| } |
| } |
| |
| if (outermost) { |
| // The outermost region resets the global states. |
| isOutermostMouseRegion = true; |
| foundInnermostMouseRegion = false; |
| } |
| return isHit; |
| } |
| } |
| |
| /// 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]. |
| /// |
| /// This property does not affect mouse devices. Setting [triggerMode] to |
| /// [TooltipTriggerMode.manual] will not prevent the tooltip from showing when |
| /// the mouse cursor hovers over it. |
| 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>[]; |
| |
| /// Dismiss all of the tooltips that are currently shown on the screen, |
| /// including those with mouse cursors currently hovering over them. |
| /// |
| /// 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) { |
| assert(state.mounted); |
| state._scheduleDismissTooltip(withDelay: Duration.zero); |
| } |
| 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; |
| |
| final OverlayPortalController _overlayController = OverlayPortalController(); |
| |
| // From InheritedWidgets |
| late bool _visible; |
| late TooltipThemeData _tooltipTheme; |
| |
| Duration get _showDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultShowDuration; |
| Duration get _hoverShowDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultHoverShowDuration; |
| Duration get _waitDuration => widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration; |
| TooltipTriggerMode get _triggerMode => widget.triggerMode ?? _tooltipTheme.triggerMode ?? _defaultTriggerMode; |
| bool get _enableFeedback => widget.enableFeedback ?? _tooltipTheme.enableFeedback ?? _defaultEnableFeedback; |
| |
| /// 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(); |
| |
| Timer? _timer; |
| AnimationController? _backingController; |
| AnimationController get _controller { |
| return _backingController ??= AnimationController( |
| duration: _fadeInDuration, |
| reverseDuration: _fadeOutDuration, |
| vsync: this, |
| )..addStatusListener(_handleStatusChanged); |
| } |
| |
| LongPressGestureRecognizer? _longPressRecognizer; |
| TapGestureRecognizer? _tapRecognizer; |
| |
| // The ids of mouse devices that are keeping the tooltip from being dismissed. |
| // |
| // Device ids are added to this set in _handleMouseEnter, and removed in |
| // _handleMouseExit. The set is cleared in _handleTapToDismiss, typically when |
| // a PointerDown event interacts with some other UI component. |
| final Set<int> _activeHoveringPointerDevices = <int>{}; |
| |
| static bool _isTooltipVisible(AnimationStatus status) { |
| return switch (status) { |
| AnimationStatus.completed || AnimationStatus.forward || AnimationStatus.reverse => true, |
| AnimationStatus.dismissed => false, |
| }; |
| } |
| |
| AnimationStatus _animationStatus = AnimationStatus.dismissed; |
| void _handleStatusChanged(AnimationStatus status) { |
| assert(mounted); |
| switch ((_isTooltipVisible(_animationStatus), _isTooltipVisible(status))) { |
| case (true, false): |
| Tooltip._openedTooltips.remove(this); |
| _overlayController.hide(); |
| case (false, true): |
| _overlayController.show(); |
| Tooltip._openedTooltips.add(this); |
| SemanticsService.tooltip(_tooltipMessage); |
| case (true, true) || (false, false): |
| break; |
| } |
| _animationStatus = status; |
| } |
| |
| void _scheduleShowTooltip({ required Duration withDelay, Duration? showDuration }) { |
| assert(mounted); |
| void show() { |
| assert(mounted); |
| if (!_visible) { |
| return; |
| } |
| _controller.forward(); |
| _timer?.cancel(); |
| _timer = showDuration == null ? null : Timer(showDuration, _controller.reverse); |
| } |
| |
| assert( |
| !(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse, |
| 'timer must not be active when the tooltip is fading out', |
| ); |
| switch (_controller.status) { |
| case AnimationStatus.dismissed when withDelay.inMicroseconds > 0: |
| _timer ??= Timer(withDelay, show); |
| // If the tooltip is already fading in or fully visible, skip the |
| // animation and show the tooltip immediately. |
| case AnimationStatus.dismissed: |
| case AnimationStatus.forward: |
| case AnimationStatus.reverse: |
| case AnimationStatus.completed: |
| show(); |
| } |
| } |
| |
| void _scheduleDismissTooltip({ required Duration withDelay }) { |
| assert(mounted); |
| assert( |
| !(_timer?.isActive ?? false) || _backingController?.status != AnimationStatus.reverse, |
| 'timer must not be active when the tooltip is fading out', |
| ); |
| |
| _timer?.cancel(); |
| _timer = null; |
| // Use _backingController instead of _controller to prevent the lazy getter |
| // from instaniating an AnimationController unnecessarily. |
| switch (_backingController?.status) { |
| case null: |
| case AnimationStatus.reverse: |
| case AnimationStatus.dismissed: |
| break; |
| // Dismiss when the tooltip is fading in: if there's a dismiss delay we'll |
| // allow the fade in animation to continue until the delay timer fires. |
| case AnimationStatus.forward: |
| case AnimationStatus.completed: |
| if (withDelay.inMicroseconds > 0) { |
| _timer = Timer(withDelay, _controller.reverse); |
| } else { |
| _controller.reverse(); |
| } |
| } |
| } |
| |
| void _handlePointerDown(PointerDownEvent event) { |
| assert(mounted); |
| // PointerDeviceKinds that don't support hovering. |
| const Set<PointerDeviceKind> triggerModeDeviceKinds = <PointerDeviceKind> { |
| PointerDeviceKind.invertedStylus, |
| PointerDeviceKind.stylus, |
| PointerDeviceKind.touch, |
| PointerDeviceKind.unknown, |
| // MouseRegion only tracks PointerDeviceKind == mouse. |
| PointerDeviceKind.trackpad, |
| }; |
| switch (_triggerMode) { |
| case TooltipTriggerMode.longPress: |
| final LongPressGestureRecognizer recognizer = _longPressRecognizer ??= LongPressGestureRecognizer( |
| debugOwner: this, supportedDevices: triggerModeDeviceKinds, |
| ); |
| recognizer |
| ..onLongPressCancel = _handleTapToDismiss |
| ..onLongPress = _handleLongPress |
| ..onLongPressUp = _handlePressUp |
| ..addPointer(event); |
| case TooltipTriggerMode.tap: |
| final TapGestureRecognizer recognizer = _tapRecognizer ??= TapGestureRecognizer( |
| debugOwner: this, supportedDevices: triggerModeDeviceKinds |
| ); |
| recognizer |
| ..onTapCancel = _handleTapToDismiss |
| ..onTap = _handleTap |
| ..addPointer(event); |
| case TooltipTriggerMode.manual: |
| break; |
| } |
| } |
| |
| // For PointerDownEvents, this method will be called after _handlePointerDown. |
| void _handleGlobalPointerEvent(PointerEvent event) { |
| assert(mounted); |
| if (_tapRecognizer?.primaryPointer == event.pointer || _longPressRecognizer?.primaryPointer == event.pointer) { |
| // This is a pointer of interest specified by the trigger mode, since it's |
| // picked up by the recognizer. |
| // |
| // The recognizer will later determine if this is indeed a "trigger" |
| // gesture and dismiss the tooltip if that's not the case. However there's |
| // still a chance that the PointerEvent was cancelled before the gesture |
| // recognizer gets to emit a tap/longPress down, in which case the onCancel |
| // callback (_handleTapToDismiss) will not be called. |
| return; |
| } |
| if ((_timer == null && _controller.status == AnimationStatus.dismissed) || event is! PointerDownEvent) { |
| return; |
| } |
| _handleTapToDismiss(); |
| } |
| |
| // The primary pointer is not part of a "trigger" gesture so the tooltip |
| // should be dismissed. |
| void _handleTapToDismiss() { |
| _scheduleDismissTooltip(withDelay: Duration.zero); |
| _activeHoveringPointerDevices.clear(); |
| } |
| |
| void _handleTap() { |
| if (!_visible) { |
| return; |
| } |
| final bool tooltipCreated = _controller.status == AnimationStatus.dismissed; |
| if (tooltipCreated && _enableFeedback) { |
| assert(_triggerMode == TooltipTriggerMode.tap); |
| Feedback.forTap(context); |
| } |
| widget.onTriggered?.call(); |
| _scheduleShowTooltip( |
| withDelay: Duration.zero, |
| // _activeHoveringPointerDevices keep the tooltip visible. |
| showDuration: _activeHoveringPointerDevices.isEmpty ? _showDuration : null, |
| ); |
| } |
| |
| // When a "trigger" gesture is recognized and the pointer down even is a part |
| // of it. |
| void _handleLongPress() { |
| if (!_visible) { |
| return; |
| } |
| final bool tooltipCreated = _visible && _controller.status == AnimationStatus.dismissed; |
| if (tooltipCreated && _enableFeedback) { |
| assert(_triggerMode == TooltipTriggerMode.longPress); |
| Feedback.forLongPress(context); |
| } |
| widget.onTriggered?.call(); |
| _scheduleShowTooltip(withDelay: Duration.zero); |
| } |
| |
| void _handlePressUp() { |
| if (_activeHoveringPointerDevices.isNotEmpty) { |
| return; |
| } |
| _scheduleDismissTooltip(withDelay: _showDuration); |
| } |
| |
| // # Current Hovering Behavior: |
| // 1. Hovered tooltips don't show more than one at a time, for each mouse |
| // device. For example, a chip with a delete icon typically shouldn't show |
| // both the delete icon tooltip and the chip tooltip at the same time. |
| // 2. Hovered tooltips are dismissed when: |
| // i. [dismissAllToolTips] is called, even these tooltips are still hovered |
| // ii. a unrecognized PointerDownEvent occured withint the application |
| // (even these tooltips are still hovered), |
| // iii. The last hovering device leaves the tooltip. |
| void _handleMouseEnter(PointerEnterEvent event) { |
| // _handleMouseEnter is only called when the mouse starts to hover over this |
| // tooltip (including the actual tooltip it shows on the overlay), and this |
| // tooltip is the first to be hit in the widget tree's hit testing order. |
| // See also _ExclusiveMouseRegion for the exact behavior. |
| _activeHoveringPointerDevices.add(event.device); |
| final List<TooltipState> openedTooltips = Tooltip._openedTooltips.toList(); |
| bool otherTooltipsDismissed = false; |
| for (final TooltipState tooltip in openedTooltips) { |
| assert(tooltip.mounted); |
| final Set<int> hoveringDevices = tooltip._activeHoveringPointerDevices; |
| final bool shouldDismiss = tooltip != this |
| && (hoveringDevices.length == 1 && hoveringDevices.single == event.device); |
| if (shouldDismiss) { |
| otherTooltipsDismissed = true; |
| tooltip._scheduleDismissTooltip(withDelay: Duration.zero); |
| } |
| } |
| _scheduleShowTooltip(withDelay: otherTooltipsDismissed ? Duration.zero : _waitDuration); |
| } |
| |
| void _handleMouseExit(PointerExitEvent event) { |
| if (_activeHoveringPointerDevices.isEmpty) { |
| return; |
| } |
| _activeHoveringPointerDevices.remove(event.device); |
| if (_activeHoveringPointerDevices.isEmpty) { |
| _scheduleDismissTooltip(withDelay: _hoverShowDuration); |
| } |
| } |
| |
| /// Shows the tooltip if it is not already visible. |
| /// |
| /// After made visible by this method, The tooltip does not automatically |
| /// dismiss after `waitDuration`, until the user dismisses/re-triggers it, or |
| /// [Tooltip.dismissAllToolTips] is called. |
| /// |
| /// Returns `false` when the tooltip shouldn't be shown or when the tooltip |
| /// was already visible. |
| bool ensureTooltipVisible() { |
| if (!_visible) { |
| return false; |
| } |
| |
| _timer?.cancel(); |
| _timer = null; |
| switch (_controller.status) { |
| case AnimationStatus.dismissed: |
| case AnimationStatus.reverse: |
| _scheduleShowTooltip(withDelay: Duration.zero); |
| return true; |
| case AnimationStatus.forward: |
| case AnimationStatus.completed: |
| return false; |
| } |
| } |
| |
| @override |
| void initState() { |
| super.initState(); |
| // Listen to global pointer events so that we can hide a tooltip immediately |
| // if some other control is clicked on. Pointer events are dispatched to |
| // global routes **after** other routes. |
| GestureBinding.instance.pointerRouter.addGlobalRoute(_handleGlobalPointerEvent); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| _visible = TooltipVisibility.of(context); |
| _tooltipTheme = TooltipTheme.of(context); |
| } |
| |
| // https://material.io/components/tooltips#specs |
| double _getDefaultTooltipHeight() { |
| return switch (Theme.of(context).platform) { |
| TargetPlatform.macOS || |
| TargetPlatform.linux || |
| TargetPlatform.windows => 24.0, |
| TargetPlatform.android || |
| TargetPlatform.fuchsia || |
| TargetPlatform.iOS => 32.0, |
| }; |
| } |
| |
| EdgeInsets _getDefaultPadding() { |
| return switch (Theme.of(context).platform) { |
| TargetPlatform.macOS || |
| TargetPlatform.linux || |
| TargetPlatform.windows => const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), |
| TargetPlatform.android || |
| TargetPlatform.fuchsia || |
| TargetPlatform.iOS => const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0), |
| }; |
| } |
| |
| static double _getDefaultFontSize(TargetPlatform platform) { |
| return switch (platform) { |
| TargetPlatform.macOS || |
| TargetPlatform.linux || |
| TargetPlatform.windows => 12.0, |
| TargetPlatform.android || |
| TargetPlatform.fuchsia || |
| TargetPlatform.iOS => 14.0, |
| }; |
| } |
| |
| Widget _buildTooltipOverlay(BuildContext context) { |
| final OverlayState overlayState = Overlay.of(context, debugRequiredFor: widget); |
| final RenderBox box = this.context.findRenderObject()! as RenderBox; |
| final Offset target = box.localToGlobal( |
| box.size.center(Offset.zero), |
| ancestor: overlayState.context.findRenderObject(), |
| ); |
| |
| final (TextStyle defaultTextStyle, BoxDecoration defaultDecoration) = switch (Theme.of(context)) { |
| ThemeData(brightness: Brightness.dark, :final TextTheme textTheme, :final TargetPlatform platform) => ( |
| textTheme.bodyMedium!.copyWith(color: Colors.black, fontSize: _getDefaultFontSize(platform)), |
| BoxDecoration(color: Colors.white.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))), |
| ), |
| ThemeData(brightness: Brightness.light, :final TextTheme textTheme, :final TargetPlatform platform) => ( |
| textTheme.bodyMedium!.copyWith(color: Colors.white, fontSize: _getDefaultFontSize(platform)), |
| BoxDecoration(color: Colors.grey[700]!.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))), |
| ), |
| }; |
| |
| final TooltipThemeData tooltipTheme = _tooltipTheme; |
| final _TooltipOverlay overlayChild = _TooltipOverlay( |
| richMessage: widget.richMessage ?? TextSpan(text: widget.message), |
| height: widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(), |
| padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(), |
| margin: widget.margin ?? tooltipTheme.margin ?? _defaultMargin, |
| onEnter: _handleMouseEnter, |
| onExit: _handleMouseExit, |
| decoration: widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration, |
| textStyle: widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle, |
| textAlign: widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign, |
| animation: CurvedAnimation(parent: _controller, curve: Curves.fastOutSlowIn), |
| target: target, |
| verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset, |
| preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow, |
| ); |
| |
| return SelectionContainer.maybeOf(context) == null |
| ? overlayChild |
| : SelectionContainer.disabled(child: overlayChild); |
| } |
| |
| @override |
| void dispose() { |
| GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent); |
| Tooltip._openedTooltips.remove(this); |
| // _longPressRecognizer.dispose() and _tapRecognizer.dispose() may call |
| // their registered onCancel callbacks if there's a gesture in progress. |
| // Remove the onCancel callbacks to prevent the registered callbacks from |
| // triggering unnecessary side effects (such as animations). |
| _longPressRecognizer?.onLongPressCancel = null; |
| _longPressRecognizer?.dispose(); |
| _tapRecognizer?.onTapCancel = null; |
| _tapRecognizer?.dispose(); |
| _timer?.cancel(); |
| _backingController?.dispose(); |
| super.dispose(); |
| } |
| |
| @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 bool excludeFromSemantics = widget.excludeFromSemantics ?? _tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics; |
| Widget result = Semantics( |
| tooltip: excludeFromSemantics ? null : _tooltipMessage, |
| child: widget.child, |
| ); |
| |
| // Only check for gestures if tooltip should be visible. |
| if (_visible) { |
| result = _ExclusiveMouseRegion( |
| onEnter: _handleMouseEnter, |
| onExit: _handleMouseExit, |
| child: Listener( |
| onPointerDown: _handlePointerDown, |
| behavior: HitTestBehavior.opaque, |
| child: result, |
| ), |
| ); |
| } |
| return OverlayPortal( |
| controller: _overlayController, |
| overlayChildBuilder: _buildTooltipOverlay, |
| child: 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 = 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 = _ExclusiveMouseRegion( |
| 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, |
| ), |
| ); |
| } |
| } |