tooltip.dart 27.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 18 19

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

111
  /// The text to display in the tooltip.
112 113 114 115 116 117 118 119
  ///
  /// 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;
120

121
  /// The height of the tooltip's [child].
122
  ///
123
  /// If the [child] is null, then this is the tooltip's intrinsic height.
124
  final double? height;
125

126
  /// The amount of space by which to inset the tooltip's [child].
127 128
  ///
  /// Defaults to 16.0 logical pixels in each direction.
129
  final EdgeInsetsGeometry? padding;
130

131 132 133 134 135 136 137 138 139 140 141
  /// 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.
142
  final EdgeInsetsGeometry? margin;
143

144
  /// The vertical gap between the widget and the displayed tooltip.
145 146 147 148 149 150
  ///
  /// 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.
151
  final double? verticalOffset;
152

153 154 155 156 157
  /// 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.
158
  final bool? preferBelow;
159

160 161
  /// Whether the tooltip's [message] or [richMessage] should be excluded from
  /// the semantics tree.
162
  ///
163
  /// Defaults to false. A tooltip will add a [Semantics] label that is set to
164 165 166
  /// [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.
167
  final bool? excludeFromSemantics;
168

169
  /// The widget below this widget in the tree.
170
  ///
171
  /// {@macro flutter.widgets.ProxyWidget.child}
172
  final Widget? child;
Hixie's avatar
Hixie committed
173

174 175
  /// Specifies the tooltip's shape and background color.
  ///
176 177
  /// 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
178
  /// [Colors.grey]\[700\] if [ThemeData.brightness] is [Brightness.dark], and
179
  /// [Colors.white] if it is [Brightness.light].
180
  final Decoration? decoration;
181

182 183 184 185
  /// 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],
186 187 188 189
  /// [TextTheme.bodyText2] of [ThemeData.textTheme] will be used with
  /// [Colors.white]. Otherwise, if [ThemeData.brightness] is set to
  /// [Brightness.light], [TextTheme.bodyText2] of [ThemeData.textTheme] will be
  /// used with [Colors.black].
190
  final TextStyle? textStyle;
191 192 193

  /// The length of time that a pointer must hover over a tooltip's widget
  /// before the tooltip will be shown.
194
  ///
195
  /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover).
196
  final Duration? waitDuration;
197

198
  /// The length of time that the tooltip will be shown after a long press
199
  /// is released or mouse pointer exits the widget.
200
  ///
201 202
  /// Defaults to 1.5 seconds for long press released or 0.1 seconds for mouse
  /// pointer exits the widget.
203
  final Duration? showDuration;
204

205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
  /// 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;

224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
  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();
    }
  }
248 249 250 251 252 253

  /// 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() {
254
    if (_openedTooltips.isNotEmpty) {
255
      // Avoid concurrent modification.
256 257 258
      final List<_TooltipState> openedTooltips = _openedTooltips.toList();
      for (final _TooltipState state in openedTooltips) {
        state._dismissTooltip(immediately: true);
259 260 261 262 263 264
      }
      return true;
    }
    return false;
  }

265
  @override
266
  State<Tooltip> createState() => _TooltipState();
Hixie's avatar
Hixie committed
267

268
  @override
269 270
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
271 272 273 274 275 276 277 278 279 280 281 282
    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,
    ));
283 284
    properties.add(DoubleProperty('height', height, defaultValue: null));
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
285
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
286
    properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null));
287 288
    properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true, defaultValue: null));
    properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: null));
289 290
    properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
    properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
291
    properties.add(DiagnosticsProperty<TooltipTriggerMode>('triggerMode', triggerMode, defaultValue: null));
292
    properties.add(FlagProperty('enableFeedback', value: enableFeedback, ifTrue: 'true', showName: true, defaultValue: null));
Hixie's avatar
Hixie committed
293
  }
Hixie's avatar
Hixie committed
294 295
}

296
class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
297 298
  static const double _defaultVerticalOffset = 24.0;
  static const bool _defaultPreferBelow = true;
299
  static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.zero;
300 301
  static const Duration _fadeInDuration = Duration(milliseconds: 150);
  static const Duration _fadeOutDuration = Duration(milliseconds: 75);
302
  static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
303
  static const Duration _defaultHoverShowDuration = Duration(milliseconds: 100);
304
  static const Duration _defaultWaitDuration = Duration.zero;
305
  static const bool _defaultExcludeFromSemantics = false;
306 307
  static const TooltipTriggerMode _defaultTriggerMode = TooltipTriggerMode.longPress;
  static const bool _defaultEnableFeedback = true;
308

309 310 311 312 313 314 315 316 317 318
  late double height;
  late EdgeInsetsGeometry padding;
  late EdgeInsetsGeometry margin;
  late Decoration decoration;
  late TextStyle textStyle;
  late double verticalOffset;
  late bool preferBelow;
  late bool excludeFromSemantics;
  late AnimationController _controller;
  OverlayEntry? _entry;
319
  Timer? _dismissTimer;
320 321
  Timer? _showTimer;
  late Duration showDuration;
322
  late Duration hoverShowDuration;
323 324
  late Duration waitDuration;
  late bool _mouseIsConnected;
325 326 327
  bool _pressActivated = false;
  late TooltipTriggerMode triggerMode;
  late bool enableFeedback;
328 329
  late bool _isConcealed;
  late bool _forceRemoval;
Hixie's avatar
Hixie committed
330

331 332 333 334 335
  /// 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();

336
  @override
Hixie's avatar
Hixie committed
337 338
  void initState() {
    super.initState();
339 340
    _isConcealed = false;
    _forceRemoval = false;
341
    _mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
342 343 344 345 346
    _controller = AnimationController(
      duration: _fadeInDuration,
      reverseDuration: _fadeOutDuration,
      vsync: this,
    )
347
      ..addStatusListener(_handleStatusChanged);
348
    // Listen to see when a mouse is added.
349
    RendererBinding.instance!.mouseTracker.addListener(_handleMouseTrackerChange);
350 351
    // Listen to global pointer events so that we can hide a tooltip immediately
    // if some other control is clicked on.
352
    GestureBinding.instance!.pointerRouter.addGlobalRoute(_handlePointerEvent);
353 354
  }

355 356
  // https://material.io/components/tooltips#specs
  double _getDefaultTooltipHeight() {
357
    final ThemeData theme = Theme.of(context);
358 359 360 361 362 363 364 365 366 367 368
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return 24.0;
      default:
        return 32.0;
    }
  }

  EdgeInsets _getDefaultPadding() {
369
    final ThemeData theme = Theme.of(context);
370 371 372 373 374 375 376 377 378 379 380
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return const EdgeInsets.symmetric(horizontal: 8.0);
      default:
        return const EdgeInsets.symmetric(horizontal: 16.0);
    }
  }

  double _getDefaultFontSize() {
381
    final ThemeData theme = Theme.of(context);
382 383 384 385 386 387 388 389 390 391
    switch (theme.platform) {
      case TargetPlatform.macOS:
      case TargetPlatform.linux:
      case TargetPlatform.windows:
        return 10.0;
      default:
        return 14.0;
    }
  }

392 393 394 395 396
  // Forces a rebuild if a mouse has been added or removed.
  void _handleMouseTrackerChange() {
    if (!mounted) {
      return;
    }
397
    final bool mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
398
    if (mouseIsConnected != _mouseIsConnected) {
399
      setState(() {
400 401 402
        _mouseIsConnected = mouseIsConnected;
      });
    }
403 404 405
  }

  void _handleStatusChanged(AnimationStatus status) {
406 407 408 409
    // 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();
410 411 412
    }
  }

413
  void _dismissTooltip({ bool immediately = false }) {
414 415 416
    _showTimer?.cancel();
    _showTimer = null;
    if (immediately) {
417
      _removeEntry();
418 419
      return;
    }
420 421 422
    // So it will be removed when it's done reversing, regardless of whether it is
    // still concealed or not.
    _forceRemoval = true;
423
    if (_pressActivated) {
424
      _dismissTimer ??= Timer(showDuration, _controller.reverse);
425
    } else {
426
      _dismissTimer ??= Timer(hoverShowDuration, _controller.reverse);
427
    }
428
    _pressActivated = false;
429 430 431
  }

  void _showTooltip({ bool immediately = false }) {
432 433
    _dismissTimer?.cancel();
    _dismissTimer = null;
434 435 436 437
    if (immediately) {
      ensureTooltipVisible();
      return;
    }
438
    _showTimer ??= Timer(waitDuration, ensureTooltipVisible);
Hixie's avatar
Hixie committed
439 440
  }

441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473
  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!);
    }
474
    SemanticsService.tooltip(_tooltipMessage);
475 476 477
    _controller.forward();
  }

478 479
  /// Shows the tooltip if it is not already visible.
  ///
480
  /// Returns `false` when the tooltip was already visible.
481
  bool ensureTooltipVisible() {
482 483
    _showTimer?.cancel();
    _showTimer = null;
484 485 486 487 488 489 490 491
    _forceRemoval = false;
    if (_isConcealed) {
      if (_mouseIsConnected) {
        Tooltip._concealOtherTooltips(this);
      }
      _revealTooltip();
      return true;
    }
492
    if (_entry != null) {
493
      // Stop trying to hide, if we were.
494 495
      _dismissTimer?.cancel();
      _dismissTimer = null;
496
      _controller.forward();
497
      return false; // Already visible.
498
    }
499 500 501 502 503
    _createNewEntry();
    _controller.forward();
    return true;
  }

504 505 506 507 508 509 510 511 512 513 514
  static final Set<_TooltipState> _mouseIn = <_TooltipState>{};

  void _handleMouseEnter() {
    _showTooltip();
  }

  void _handleMouseExit({bool immediately = false}) {
    // If the tip is currently covered, we can just remove it without waiting.
    _dismissTooltip(immediately: _isConcealed || immediately);
  }

515
  void _createNewEntry() {
516 517 518
    final OverlayState overlayState = Overlay.of(
      context,
      debugRequiredFor: widget,
519
    )!;
520

521
    final RenderBox box = context.findRenderObject()! as RenderBox;
522 523 524 525
    final Offset target = box.localToGlobal(
      box.size.center(Offset.zero),
      ancestor: overlayState.context.findRenderObject(),
    );
526

527 528 529
    // 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.
530
    final Widget overlay = Directionality(
531
      textDirection: Directionality.of(context),
532
      child: _TooltipOverlay(
533
        richMessage: widget.richMessage ?? TextSpan(text: widget.message),
534 535 536
        height: height,
        padding: padding,
        margin: margin,
537 538
        onEnter: _mouseIsConnected ? (_) => _handleMouseEnter() : null,
        onExit: _mouseIsConnected ? (_) => _handleMouseExit() : null,
539 540 541 542 543 544 545 546 547
        decoration: decoration,
        textStyle: textStyle,
        animation: CurvedAnimation(
          parent: _controller,
          curve: Curves.fastOutSlowIn,
        ),
        target: target,
        verticalOffset: verticalOffset,
        preferBelow: preferBelow,
548 549
      ),
    );
550
    _entry = OverlayEntry(builder: (BuildContext context) => overlay);
551
    _isConcealed = false;
552
    overlayState.insert(_entry!);
553
    SemanticsService.tooltip(_tooltipMessage);
554 555 556 557 558 559 560 561
    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
562 563
  }

564
  void _removeEntry() {
565 566 567 568
    Tooltip._openedTooltips.remove(this);
    _mouseIn.remove(this);
    _dismissTimer?.cancel();
    _dismissTimer = null;
569 570
    _showTimer?.cancel();
    _showTimer = null;
571 572 573 574
    if (!_isConcealed) {
      _entry?.remove();
    }
    _isConcealed = false;
575
    _entry = null;
576 577 578
    if (_mouseIsConnected) {
      Tooltip._revealLastTooltip();
    }
Hixie's avatar
Hixie committed
579 580
  }

581
  void _handlePointerEvent(PointerEvent event) {
582 583 584 585
    if (_entry == null) {
      return;
    }
    if (event is PointerUpEvent || event is PointerCancelEvent) {
586
      _handleMouseExit();
587
    } else if (event is PointerDownEvent) {
588
      _handleMouseExit(immediately: true);
589
    }
Hixie's avatar
Hixie committed
590 591
  }

592
  @override
Hixie's avatar
Hixie committed
593
  void deactivate() {
594
    if (_entry != null) {
595
      _dismissTooltip(immediately: true);
596
    }
597
    _showTimer?.cancel();
598
    super.deactivate();
Hixie's avatar
Hixie committed
599 600
  }

601 602
  @override
  void dispose() {
603 604
    GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent);
    RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange);
605
    _removeEntry();
606
    _controller.dispose();
607 608 609
    super.dispose();
  }

610 611
  void _handlePress() {
    _pressActivated = true;
612
    final bool tooltipCreated = ensureTooltipVisible();
613 614 615 616 617 618
    if (tooltipCreated && enableFeedback) {
      if (triggerMode == TooltipTriggerMode.longPress)
        Feedback.forLongPress(context);
      else
        Feedback.forTap(context);
    }
619 620
  }

621
  @override
Hixie's avatar
Hixie committed
622
  Widget build(BuildContext context) {
623 624 625
    // 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.
626
    if (_tooltipMessage.isEmpty) {
627 628
      return widget.child ?? const SizedBox();
    }
629
    assert(Overlay.of(context, debugRequiredFor: widget) != null);
630
    final ThemeData theme = Theme.of(context);
631
    final TooltipThemeData tooltipTheme = TooltipTheme.of(context);
632 633
    final TextStyle defaultTextStyle;
    final BoxDecoration defaultDecoration;
634
    if (theme.brightness == Brightness.dark) {
635
      defaultTextStyle = theme.textTheme.bodyText2!.copyWith(
636
        color: Colors.black,
637
        fontSize: _getDefaultFontSize(),
638 639 640 641 642 643
      );
      defaultDecoration = BoxDecoration(
        color: Colors.white.withOpacity(0.9),
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    } else {
644
      defaultTextStyle = theme.textTheme.bodyText2!.copyWith(
645
        color: Colors.white,
646
        fontSize: _getDefaultFontSize(),
647 648
      );
      defaultDecoration = BoxDecoration(
649
        color: Colors.grey[700]!.withOpacity(0.9),
650 651 652 653
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    }

654 655
    height = widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight();
    padding = widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding();
656
    margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin;
657 658 659 660 661 662 663
    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;
    waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
    showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
664
    hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
665 666
    triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode;
    enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
667

668
    Widget result = GestureDetector(
669
      behavior: HitTestBehavior.opaque,
670 671 672
      onLongPress: (triggerMode == TooltipTriggerMode.longPress) ?
        _handlePress : null,
      onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null,
673 674
      excludeFromSemantics: true,
      child: Semantics(
675 676 677
        label: excludeFromSemantics
            ? null
            : _tooltipMessage,
678
        child: widget.child,
679
      ),
Hixie's avatar
Hixie committed
680
    );
681 682 683

    // Only check for hovering if there is a mouse connected.
    if (_mouseIsConnected) {
684
      result = MouseRegion(
685 686
        onEnter: (_) => _handleMouseEnter(),
        onExit: (_) => _handleMouseExit(),
687 688 689 690 691
        child: result,
      );
    }

    return result;
Hixie's avatar
Hixie committed
692 693 694
  }
}

695 696
/// A delegate for computing the layout of a tooltip to be displayed above or
/// bellow a target specified in the global coordinate system.
697
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
698 699 700
  /// Creates a delegate for computing the layout of a tooltip.
  ///
  /// The arguments must not be null.
Hixie's avatar
Hixie committed
701
  _TooltipPositionDelegate({
702 703 704
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
705 706 707
  }) : assert(target != null),
       assert(verticalOffset != null),
       assert(preferBelow != null);
708

709 710
  /// The offset of the target the tooltip is positioned near in the global
  /// coordinate system.
711
  final Offset target;
712 713 714

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

717
  /// Whether the tooltip is displayed below its widget by default.
718 719 720
  ///
  /// 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
721 722
  final bool preferBelow;

723
  @override
Hixie's avatar
Hixie committed
724 725
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

726
  @override
Hixie's avatar
Hixie committed
727
  Offset getPositionForChild(Size size, Size childSize) {
728 729 730 731 732 733 734
    return positionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      verticalOffset: verticalOffset,
      preferBelow: preferBelow,
    );
Hixie's avatar
Hixie committed
735 736
  }

737
  @override
Hixie's avatar
Hixie committed
738
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
739 740 741
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
742 743 744
  }
}

745
class _TooltipOverlay extends StatelessWidget {
746
  const _TooltipOverlay({
747 748
    Key? key,
    required this.height,
749
    required this.richMessage,
Hixie's avatar
Hixie committed
750
    this.padding,
751
    this.margin,
752
    this.decoration,
753
    this.textStyle,
754 755 756 757
    required this.animation,
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
758 759
    this.onEnter,
    this.onExit,
Hixie's avatar
Hixie committed
760 761
  }) : super(key: key);

762
  final InlineSpan richMessage;
Hixie's avatar
Hixie committed
763
  final double height;
764 765 766 767
  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;
  final Decoration? decoration;
  final TextStyle? textStyle;
768
  final Animation<double> animation;
769
  final Offset target;
Hixie's avatar
Hixie committed
770 771
  final double verticalOffset;
  final bool preferBelow;
772 773
  final PointerEnterEventListener? onEnter;
  final PointerExitEventListener? onExit;
Hixie's avatar
Hixie committed
774

775
  @override
Hixie's avatar
Hixie committed
776
  Widget build(BuildContext context) {
777 778 779 780 781 782 783 784 785 786 787 788 789 790
    Widget result = IgnorePointer(
      child: FadeTransition(
        opacity: animation,
        child: ConstrainedBox(
          constraints: BoxConstraints(minHeight: height),
          child: DefaultTextStyle(
            style: Theme.of(context).textTheme.bodyText2!,
            child: Container(
              decoration: decoration,
              padding: padding,
              margin: margin,
              child: Center(
                widthFactor: 1.0,
                heightFactor: 1.0,
791 792
                child: Text.rich(
                  richMessage,
793
                  style: textStyle,
794 795 796 797 798
                ),
              ),
            ),
          ),
        ),
799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815
      )
    );
    if (onEnter != null || onExit != null) {
      result = MouseRegion(
        onEnter: onEnter,
        onExit: onExit,
        child: result,
      );
    }
    return Positioned.fill(
      child: CustomSingleChildLayout(
        delegate: _TooltipPositionDelegate(
          target: target,
          verticalOffset: verticalOffset,
          preferBelow: preferBelow,
        ),
        child: result,
816
      ),
Hixie's avatar
Hixie committed
817 818 819
    );
  }
}