tooltip.dart 30.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hixie's avatar
Hixie committed
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/gestures.dart';
9
import 'package:flutter/rendering.dart';
10
import 'package:flutter/services.dart';
Hixie's avatar
Hixie committed
11 12
import 'package:flutter/widgets.dart';

13
import 'colors.dart';
14
import 'feedback.dart';
15
import 'theme.dart';
16
import 'tooltip_theme.dart';
17
import 'tooltip_visibility.dart';
18

19 20 21
/// Signature for when a tooltip is triggered.
typedef TooltipTriggeredCallback = void Function();

22
/// A Material Design tooltip.
23
///
24 25 26
/// 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.
27
///
28 29 30 31 32 33 34 35
/// 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.
///
36 37
/// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q}
///
38
/// {@tool dartpad}
39 40
/// This example show a basic [Tooltip] which has a [Text] as child.
/// [message] contains your label to be shown by the tooltip when
41 42
/// the child that Tooltip wraps is hovered over on web or desktop. On mobile,
/// the tooltip is shown when the widget is long pressed.
43
///
44
/// ** See code in examples/api/lib/material/tooltip/tooltip.0.dart **
45 46
/// {@end-tool}
///
47
/// {@tool dartpad}
48 49 50 51 52 53 54 55
/// 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
56
/// press has been released or the mouse pointer exits the child widget.
57 58 59
/// `waitDuration` accepts a Duration for which a mouse pointer has to hover over the child
/// widget before the tooltip is shown.
///
60
/// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart **
61
/// {@end-tool}
62
///
63
/// {@tool dartpad}
64 65 66 67 68 69
/// 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 **
70 71 72 73 74 75 76
/// {@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 **
77 78
/// {@end-tool}
///
79 80
/// See also:
///
81
///  * <https://material.io/design/components/tooltips.html>
82
///  * [TooltipTheme] or [ThemeData.tooltipTheme]
83
///  * [TooltipVisibility]
84
class Tooltip extends StatefulWidget {
85 86
  /// Creates a tooltip.
  ///
87 88 89 90 91
  /// 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].
92
  ///
93 94
  /// All parameters that are defined in the constructor will
  /// override the default values _and_ the values in [TooltipTheme.of].
95 96
  ///
  /// Only one of [message] and [richMessage] may be non-null.
97
  const Tooltip({
98
    super.key,
99 100
    this.message,
    this.richMessage,
101 102
    this.height,
    this.padding,
103
    this.margin,
104 105 106
    this.verticalOffset,
    this.preferBelow,
    this.excludeFromSemantics,
107
    this.decoration,
108
    this.textStyle,
109
    this.textAlign,
110 111
    this.waitDuration,
    this.showDuration,
112 113
    this.triggerMode,
    this.enableFeedback,
114
    this.onTriggered,
115
    this.child,
116 117 118 119 120 121
  }) :  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.',
122
        );
Hixie's avatar
Hixie committed
123

124
  /// The text to display in the tooltip.
125 126 127 128 129 130 131 132
  ///
  /// 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;
133

134
  /// The height of the tooltip's [child].
135
  ///
136
  /// If the [child] is null, then this is the tooltip's intrinsic height.
137
  final double? height;
138

139
  /// The amount of space by which to inset the tooltip's [child].
140
  ///
141 142
  /// 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.
143
  final EdgeInsetsGeometry? padding;
144

145 146 147 148 149 150 151 152 153 154 155
  /// 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.
156
  final EdgeInsetsGeometry? margin;
157

158
  /// The vertical gap between the widget and the displayed tooltip.
159 160 161 162 163 164
  ///
  /// 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.
165
  final double? verticalOffset;
166

167 168 169 170 171
  /// 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.
172
  final bool? preferBelow;
173

174 175
  /// Whether the tooltip's [message] or [richMessage] should be excluded from
  /// the semantics tree.
176
  ///
177
  /// Defaults to false. A tooltip will add a [Semantics] label that is set to
178 179 180
  /// [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.
181
  final bool? excludeFromSemantics;
182

183
  /// The widget below this widget in the tree.
184
  ///
185
  /// {@macro flutter.widgets.ProxyWidget.child}
186
  final Widget? child;
Hixie's avatar
Hixie committed
187

188 189
  /// Specifies the tooltip's shape and background color.
  ///
190 191
  /// 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
192
  /// [Colors.grey]\[700\] if [ThemeData.brightness] is [Brightness.dark], and
193
  /// [Colors.white] if it is [Brightness.light].
194
  final Decoration? decoration;
195

196 197 198 199
  /// 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],
200
  /// [TextTheme.bodyMedium] of [ThemeData.textTheme] will be used with
201
  /// [Colors.white]. Otherwise, if [ThemeData.brightness] is set to
202
  /// [Brightness.light], [TextTheme.bodyMedium] of [ThemeData.textTheme] will be
203
  /// used with [Colors.black].
204
  final TextStyle? textStyle;
205

206 207 208 209 210 211 212
  /// 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;

213 214
  /// The length of time that a pointer must hover over a tooltip's widget
  /// before the tooltip will be shown.
215
  ///
216
  /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover).
217
  final Duration? waitDuration;
218

219 220 221 222
  /// 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.
223
  ///
224 225
  /// Defaults to 1.5 seconds for long press and tap released or 0.1 seconds
  /// for mouse pointer exits the widget.
226
  final Duration? showDuration;
227

228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246
  /// 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;

247 248 249 250 251 252
  /// 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;

253
  static final List<TooltipState> _openedTooltips = <TooltipState>[];
254 255 256

  // Causes any current tooltips to be concealed. Only called for mouse hover enter
  // detections. Won't conceal the supplied tooltip.
257
  static void _concealOtherTooltips(TooltipState current) {
258 259
    if (_openedTooltips.isNotEmpty) {
      // Avoid concurrent modification.
260 261
      final List<TooltipState> openedTooltips = _openedTooltips.toList();
      for (final TooltipState state in openedTooltips) {
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
        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();
    }
  }
277 278 279 280 281 282

  /// 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() {
283
    if (_openedTooltips.isNotEmpty) {
284
      // Avoid concurrent modification.
285 286
      final List<TooltipState> openedTooltips = _openedTooltips.toList();
      for (final TooltipState state in openedTooltips) {
287
        state._dismissTooltip(immediately: true);
288 289 290 291 292 293
      }
      return true;
    }
    return false;
  }

294
  @override
295
  State<Tooltip> createState() => TooltipState();
Hixie's avatar
Hixie committed
296

297
  @override
298 299
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
300 301 302 303 304 305 306 307 308 309 310 311
    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,
    ));
312 313
    properties.add(DoubleProperty('height', height, defaultValue: null));
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
314
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
315
    properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null));
316 317
    properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
    properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true));
318 319
    properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
    properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
320
    properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
321
    properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true));
322
    properties.add(DiagnosticsProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
Hixie's avatar
Hixie committed
323
  }
Hixie's avatar
Hixie committed
324 325
}

326 327 328 329 330
/// 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 {
331 332
  static const double _defaultVerticalOffset = 24.0;
  static const bool _defaultPreferBelow = true;
333
  static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero;
334 335
  static const Duration _fadeInDuration = Duration(milliseconds: 150);
  static const Duration _fadeOutDuration = Duration(milliseconds: 75);
336
  static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
337
  static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
338
  static const Duration _defaultWaitDuration = Duration.zero;
339
  static const bool _defaultExcludeFromSemantics = false;
340 341
  static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress;
  static const bool _defaultEnableFeedback = true;
342
  static const TextAlign _defaultTextAlign = TextAlign.start;
343

344 345 346 347 348
  late double _height;
  late EdgeInsetsGeometry _padding;
  late EdgeInsetsGeometry _margin;
  late Decoration _decoration;
  late TextStyle _textStyle;
349
  late TextAlign _textAlign;
350 351 352
  late double _verticalOffset;
  late bool _preferBelow;
  late bool _excludeFromSemantics;
353 354
  late AnimationController _controller;
  OverlayEntry? _entry;
355
  Timer? _dismissTimer;
356
  Timer? _showTimer;
357 358 359
  late Duration _showDuration;
  late Duration _hoverShowDuration;
  late Duration _waitDuration;
360
  late bool _mouseIsConnected;
361
  bool _pressActivated = false;
362 363
  late TooltipTriggerMode _triggerMode;
  late bool _enableFeedback;
364 365
  late bool _isConcealed;
  late bool _forceRemoval;
366
  late bool _visible;
Hixie's avatar
Hixie committed
367

368 369 370 371 372
  /// 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();

373
  @override
Hixie's avatar
Hixie committed
374 375
  void initState() {
    super.initState();
376 377
    _isConcealed = false;
    _forceRemoval = false;
378
    _mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
379 380 381 382 383
    _controller = AnimationController(
      duration: _fadeInDuration,
      reverseDuration: _fadeOutDuration,
      vsync: this,
    )
384
      ..addStatusListener(_handleStatusChanged);
385
    // Listen to see when a mouse is added.
386
    RendererBinding.instance.mouseTracker.addListener(_handleMouseTrackerChange);
387 388
    // Listen to global pointer events so that we can hide a tooltip immediately
    // if some other control is clicked on.
389
    GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
390 391
  }

392 393 394 395 396 397
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _visible = TooltipVisibility.of(context);
  }

398 399
  // https://material.io/components/tooltips#specs
  double _getDefaultTooltipHeight() {
400
    final ThemeData theme = Theme.of(context);
401 402 403 404 405
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return 24.0;
406 407 408
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.iOS:
409 410 411 412 413
        return 32.0;
    }
  }

  EdgeInsets _getDefaultPadding() {
414
    final ThemeData theme = Theme.of(context);
415 416 417 418
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
419
        return const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0);
420 421 422
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.iOS:
423
        return const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0);
424 425 426 427
    }
  }

  double _getDefaultFontSize() {
428
    final ThemeData theme = Theme.of(context);
429 430 431 432
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
433
        return 12.0;
434 435 436
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.iOS:
437 438 439 440
        return 14.0;
    }
  }

441 442 443 444 445
  // Forces a rebuild if a mouse has been added or removed.
  void _handleMouseTrackerChange() {
    if (!mounted) {
      return;
    }
446
    final bool mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected;
447
    if (mouseIsConnected != _mouseIsConnected) {
448
      setState(() {
449 450 451
        _mouseIsConnected = mouseIsConnected;
      });
    }
452 453 454
  }

  void _handleStatusChanged(AnimationStatus status) {
455 456 457 458
    // 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();
459 460 461
    }
  }

462
  void _dismissTooltip({ bool immediately = false }) {
463 464 465
    _showTimer?.cancel();
    _showTimer = null;
    if (immediately) {
466
      _removeEntry();
467 468
      return;
    }
469 470 471
    // So it will be removed when it's done reversing, regardless of whether it is
    // still concealed or not.
    _forceRemoval = true;
472
    if (_pressActivated) {
473
      _dismissTimer ??= Timer(_showDuration, _controller.reverse);
474
    } else {
475
      _dismissTimer ??= Timer(_hoverShowDuration, _controller.reverse);
476
    }
477
    _pressActivated = false;
478 479 480
  }

  void _showTooltip({ bool immediately = false }) {
481 482
    _dismissTimer?.cancel();
    _dismissTimer = null;
483 484 485 486
    if (immediately) {
      ensureTooltipVisible();
      return;
    }
487
    _showTimer ??= Timer(_waitDuration, ensureTooltipVisible);
Hixie's avatar
Hixie committed
488 489
  }

490 491 492 493 494 495 496 497 498 499
  void _concealTooltip() {
    if (_isConcealed || _forceRemoval) {
      // Already concealed, or it's being removed.
      return;
    }
    _isConcealed = true;
    _dismissTimer?.cancel();
    _dismissTimer = null;
    _showTimer?.cancel();
    _showTimer = null;
500
    if (_entry != null) {
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519
      _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,
520
      );
521 522
      overlayState.insert(_entry!);
    }
523
    SemanticsService.tooltip(_tooltipMessage);
524 525 526
    _controller.forward();
  }

527 528
  /// Shows the tooltip if it is not already visible.
  ///
529 530
  /// Returns `false` when the tooltip shouldn't be shown or when the tooltip
  /// was already visible.
531
  bool ensureTooltipVisible() {
532
    if (!_visible || !mounted) {
533
      return false;
534
    }
535 536
    _showTimer?.cancel();
    _showTimer = null;
537 538 539 540 541 542 543 544
    _forceRemoval = false;
    if (_isConcealed) {
      if (_mouseIsConnected) {
        Tooltip._concealOtherTooltips(this);
      }
      _revealTooltip();
      return true;
    }
545
    if (_entry != null) {
546
      // Stop trying to hide, if we were.
547 548
      _dismissTimer?.cancel();
      _dismissTimer = null;
549
      _controller.forward();
550
      return false; // Already visible.
551
    }
552 553 554 555 556
    _createNewEntry();
    _controller.forward();
    return true;
  }

557
  static final Set<TooltipState> _mouseIn = <TooltipState>{};
558 559

  void _handleMouseEnter() {
560 561 562
    if (mounted) {
      _showTooltip();
    }
563 564 565
  }

  void _handleMouseExit({bool immediately = false}) {
566 567 568 569
    if (mounted) {
      // If the tip is currently covered, we can just remove it without waiting.
      _dismissTooltip(immediately: _isConcealed || immediately);
    }
570 571
  }

572
  void _createNewEntry() {
573 574 575
    final OverlayState overlayState = Overlay.of(
      context,
      debugRequiredFor: widget,
576
    );
577

578
    final RenderBox box = context.findRenderObject()! as RenderBox;
579 580 581 582
    final Offset target = box.localToGlobal(
      box.size.center(Offset.zero),
      ancestor: overlayState.context.findRenderObject(),
    );
583

584 585 586
    // 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.
587
    final Widget overlay = Directionality(
588
      textDirection: Directionality.of(context),
589
      child: _TooltipOverlay(
590
        richMessage: widget.richMessage ?? TextSpan(text: widget.message),
591 592 593
        height: _height,
        padding: _padding,
        margin: _margin,
594 595
        onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
        onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
596 597
        decoration: _decoration,
        textStyle: _textStyle,
598
        textAlign: _textAlign,
599 600 601 602 603
        animation: CurvedAnimation(
          parent: _controller,
          curve: Curves.fastOutSlowIn,
        ),
        target: target,
604 605
        verticalOffset: _verticalOffset,
        preferBelow: _preferBelow,
606 607
      ),
    );
608
    _entry = OverlayEntry(builder: (BuildContext context) => overlay);
609
    _isConcealed = false;
610
    overlayState.insert(_entry!);
611
    SemanticsService.tooltip(_tooltipMessage);
612 613 614 615 616 617 618 619
    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);
Hixie's avatar
Hixie committed
620 621
  }

622
  void _removeEntry() {
623 624 625 626
    Tooltip._openedTooltips.remove(this);
    _mouseIn.remove(this);
    _dismissTimer?.cancel();
    _dismissTimer = null;
627 628
    _showTimer?.cancel();
    _showTimer = null;
629 630 631 632
    if (!_isConcealed) {
      _entry?.remove();
    }
    _isConcealed = false;
633
    _entry?.dispose();
634
    _entry = null;
635 636 637
    if (_mouseIsConnected) {
      Tooltip._revealLastTooltip();
    }
Hixie's avatar
Hixie committed
638 639
  }

640
  void _handlePointerEvent(PointerEvent event) {
641 642 643 644
    if (_entry == null) {
      return;
    }
    if (event is PointerUpEvent || event is PointerCancelEvent) {
645
      _handleMouseExit();
646
    } else if (event is PointerDownEvent) {
647
      _handleMouseExit(immediately: true);
648
    }
Hixie's avatar
Hixie committed
649 650
  }

651
  @override
Hixie's avatar
Hixie committed
652
  void deactivate() {
653
    if (_entry != null) {
654
      _dismissTooltip(immediately: true);
655
    }
656
    _showTimer?.cancel();
657
    super.deactivate();
Hixie's avatar
Hixie committed
658 659
  }

660 661
  @override
  void dispose() {
662 663
    GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
    RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange);
664
    _removeEntry();
665
    _controller.dispose();
666 667 668
    super.dispose();
  }

669 670
  void _handlePress() {
    _pressActivated = true;
671
    final bool tooltipCreated = ensureTooltipVisible();
672
    if (tooltipCreated && _enableFeedback) {
673
      if (_triggerMode == TooltipTriggerMode.longPress) {
674
        Feedback.forLongPress(context);
675
      } else {
676
        Feedback.forTap(context);
677
      }
678
    }
679
    widget.onTriggered?.call();
680 681
  }

682 683 684 685 686 687 688 689 690 691 692 693
  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();
  }

694
  @override
Hixie's avatar
Hixie committed
695
  Widget build(BuildContext context) {
696 697 698
    // 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.
699
    if (_tooltipMessage.isEmpty) {
700
      return widget.child ?? const SizedBox.shrink();
701
    }
702
    assert(debugCheckHasOverlay(context));
703
    final ThemeData theme = Theme.of(context);
704
    final TooltipThemeData tooltipTheme = TooltipTheme.of(context);
705 706
    final TextStyle defaultTextStyle;
    final BoxDecoration defaultDecoration;
707
    if (theme.brightness == Brightness.dark) {
708
      defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
709
        color: Colors.black,
710
        fontSize: _getDefaultFontSize(),
711 712 713 714 715 716
      );
      defaultDecoration = BoxDecoration(
        color: Colors.white.withOpacity(0.9),
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    } else {
717
      defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
718
        color: Colors.white,
719
        fontSize: _getDefaultFontSize(),
720 721
      );
      defaultDecoration = BoxDecoration(
722
        color: Colors.grey[700]!.withOpacity(0.9),
723 724 725 726
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    }

727 728 729 730 731 732 733 734
    _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;
735
    _textAlign = widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign;
736 737 738 739 740
    _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;
741

742
    Widget result = Semantics(
743
      tooltip: _excludeFromSemantics
744 745 746
          ? null
          : _tooltipMessage,
      child: widget.child,
Hixie's avatar
Hixie committed
747
    );
748

749 750 751 752
    // Only check for gestures if tooltip should be visible.
    if (_visible) {
      result = GestureDetector(
        behavior: HitTestBehavior.opaque,
753 754
        onLongPress: (_triggerMode == TooltipTriggerMode.longPress) ? _handlePress : null,
        onTap: (_triggerMode == TooltipTriggerMode.tap) ? _handleTap : null,
755
        excludeFromSemantics: true,
756 757
        child: result,
      );
758 759 760 761 762 763 764 765
      // Only check for hovering if there is a mouse connected.
      if (_mouseIsConnected) {
        result = MouseRegion(
          onEnter: (_) => _handleMouseEnter(),
          onExit: (_) => _handleMouseExit(),
          child: result,
        );
      }
766 767 768
    }

    return result;
Hixie's avatar
Hixie committed
769 770 771
  }
}

772
/// A delegate for computing the layout of a tooltip to be displayed above or
773
/// below a target specified in the global coordinate system.
774
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
775 776 777
  /// Creates a delegate for computing the layout of a tooltip.
  ///
  /// The arguments must not be null.
Hixie's avatar
Hixie committed
778
  _TooltipPositionDelegate({
779 780 781
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
782
  });
783

784 785
  /// The offset of the target the tooltip is positioned near in the global
  /// coordinate system.
786
  final Offset target;
787 788 789

  /// The amount of vertical distance between the target and the displayed
  /// tooltip.
Hixie's avatar
Hixie committed
790
  final double verticalOffset;
791

792
  /// Whether the tooltip is displayed below its widget by default.
793 794 795
  ///
  /// If there is insufficient space to display the tooltip in the preferred
  /// direction, the tooltip will be displayed in the opposite direction.
Hixie's avatar
Hixie committed
796 797
  final bool preferBelow;

798
  @override
Hixie's avatar
Hixie committed
799 800
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

801
  @override
Hixie's avatar
Hixie committed
802
  Offset getPositionForChild(Size size, Size childSize) {
803 804 805 806 807 808 809
    return positionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      verticalOffset: verticalOffset,
      preferBelow: preferBelow,
    );
Hixie's avatar
Hixie committed
810 811
  }

812
  @override
Hixie's avatar
Hixie committed
813
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
814 815 816
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
817 818 819
  }
}

820
class _TooltipOverlay extends StatelessWidget {
821
  const _TooltipOverlay({
822
    required this.height,
823
    required this.richMessage,
Hixie's avatar
Hixie committed
824
    this.padding,
825
    this.margin,
826
    this.decoration,
827
    this.textStyle,
828
    this.textAlign,
829 830 831 832
    required this.animation,
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
833 834
    this.onEnter,
    this.onExit,
835
  });
Hixie's avatar
Hixie committed
836

837
  final InlineSpan richMessage;
Hixie's avatar
Hixie committed
838
  final double height;
839 840 841 842
  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;
  final Decoration? decoration;
  final TextStyle? textStyle;
843
  final TextAlign? textAlign;
844
  final Animation<double> animation;
845
  final Offset target;
Hixie's avatar
Hixie committed
846 847
  final double verticalOffset;
  final bool preferBelow;
848 849
  final PointerEnterEventListener? onEnter;
  final PointerExitEventListener? onExit;
Hixie's avatar
Hixie committed
850

851
  @override
Hixie's avatar
Hixie committed
852
  Widget build(BuildContext context) {
853 854 855 856 857 858
    Widget result = IgnorePointer(
      child: FadeTransition(
        opacity: animation,
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: height),
          child: DefaultTextStyle(
859
            style: Theme.of(context).textTheme.bodyMedium!,
860 861 862 863 864 865 866
            child: Container(
              decoration: decoration,
              padding: padding,
              margin: margin,
              child: Center(
                widthFactor: 1.0,
                heightFactor: 1.0,
867 868
                child: Text.rich(
                  richMessage,
869
                  style: textStyle,
870
                  textAlign: textAlign,
871 872 873 874 875
                ),
              ),
            ),
          ),
        ),
876 877 878 879 880 881 882 883 884 885
      )
    );
    if (onEnter != null || onExit != null) {
      result = MouseRegion(
        onEnter: onEnter,
        onExit: onExit,
        child: result,
      );
    }
    return Positioned.fill(
886
      bottom: MediaQuery.maybeViewInsetsOf(context)?.bottom ?? 0.0,
887 888 889 890 891 892 893
      child: CustomSingleChildLayout(
        delegate: _TooltipPositionDelegate(
          target: target,
          verticalOffset: verticalOffset,
          preferBelow: preferBelow,
        ),
        child: result,
894
      ),
Hixie's avatar
Hixie committed
895 896 897
    );
  }
}