tooltip.dart 18.1 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/gestures.dart';
8
import 'package:flutter/rendering.dart';
Hixie's avatar
Hixie committed
9 10
import 'package:flutter/widgets.dart';

11
import 'colors.dart';
12
import 'feedback.dart';
13 14
import 'theme.dart';
import 'theme_data.dart';
15
import 'tooltip_theme.dart';
16 17 18

/// A material design tooltip.
///
19 20 21
/// 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.
22
///
23 24 25 26 27 28 29 30
/// 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.
///
31 32 33
/// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q}
///
///
34 35
/// See also:
///
36
///  * <https://material.io/design/components/tooltips.html>
37
///  * [TooltipTheme] or [ThemeData.tooltipTheme]
38
class Tooltip extends StatefulWidget {
39 40
  /// Creates a tooltip.
  ///
41 42 43 44 45
  /// 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].
46
  ///
47 48
  /// All parameters that are defined in the constructor will
  /// override the default values _and_ the values in [TooltipTheme.of].
49
  const Tooltip({
50 51
    Key? key,
    required this.message,
52 53
    this.height,
    this.padding,
54
    this.margin,
55 56 57
    this.verticalOffset,
    this.preferBelow,
    this.excludeFromSemantics,
58
    this.decoration,
59 60 61
    this.textStyle,
    this.waitDuration,
    this.showDuration,
62
    this.child,
63 64
  }) : assert(message != null),
       super(key: key);
Hixie's avatar
Hixie committed
65

66
  /// The text to display in the tooltip.
Hixie's avatar
Hixie committed
67
  final String message;
68

69
  /// The height of the tooltip's [child].
70
  ///
71
  /// If the [child] is null, then this is the tooltip's intrinsic height.
72
  final double? height;
73

74
  /// The amount of space by which to inset the tooltip's [child].
75 76
  ///
  /// Defaults to 16.0 logical pixels in each direction.
77
  final EdgeInsetsGeometry? padding;
78

79 80 81 82 83 84 85 86 87 88 89
  /// 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.
90
  final EdgeInsetsGeometry? margin;
91

92
  /// The vertical gap between the widget and the displayed tooltip.
93 94 95 96 97 98
  ///
  /// 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.
99
  final double? verticalOffset;
100

101 102 103 104 105
  /// 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.
106
  final bool? preferBelow;
107

108 109
  /// Whether the tooltip's [message] should be excluded from the semantics
  /// tree.
110
  ///
111
  /// Defaults to false. A tooltip will add a [Semantics] label that is set to
112 113
  /// [Tooltip.message]. Set this property to true if the app is going to
  /// provide its own custom semantics label.
114
  final bool? excludeFromSemantics;
115

116
  /// The widget below this widget in the tree.
117 118
  ///
  /// {@macro flutter.widgets.child}
119
  final Widget? child;
Hixie's avatar
Hixie committed
120

121 122
  /// Specifies the tooltip's shape and background color.
  ///
123 124 125 126
  /// 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].
127
  final Decoration? decoration;
128

129 130 131 132
  /// 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],
133 134 135 136
  /// [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].
137
  final TextStyle? textStyle;
138 139 140

  /// The length of time that a pointer must hover over a tooltip's widget
  /// before the tooltip will be shown.
141
  ///
142 143 144
  /// Once the pointer leaves the widget, the tooltip will immediately
  /// disappear.
  ///
145
  /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover).
146
  final Duration? waitDuration;
147

148 149
  /// The length of time that the tooltip will be shown after a long press
  /// is released.
150 151
  ///
  /// Defaults to 1.5 seconds.
152
  final Duration? showDuration;
153

154
  @override
155
  _TooltipState createState() => _TooltipState();
Hixie's avatar
Hixie committed
156

157
  @override
158 159
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
160
    properties.add(StringProperty('message', message, showName: false));
161 162
    properties.add(DoubleProperty('height', height, defaultValue: null));
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
163
    properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
164 165 166 167 168
    properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null));
    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));
    properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null));
    properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null));
Hixie's avatar
Hixie committed
169
  }
Hixie's avatar
Hixie committed
170 171
}

172
class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
173 174 175 176
  static const double _defaultTooltipHeight = 32.0;
  static const double _defaultVerticalOffset = 24.0;
  static const bool _defaultPreferBelow = true;
  static const EdgeInsetsGeometry _defaultPadding = EdgeInsets.symmetric(horizontal: 16.0);
177
  static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.all(0.0);
178 179
  static const Duration _fadeInDuration = Duration(milliseconds: 150);
  static const Duration _fadeOutDuration = Duration(milliseconds: 75);
180 181 182 183
  static const Duration _defaultShowDuration = Duration(milliseconds: 1500);
  static const Duration _defaultWaitDuration = Duration(milliseconds: 0);
  static const bool _defaultExcludeFromSemantics = false;

184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
  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;
  Timer? _hideTimer;
  Timer? _showTimer;
  late Duration showDuration;
  late Duration waitDuration;
  late bool _mouseIsConnected;
199
  bool _longPressActivated = false;
Hixie's avatar
Hixie committed
200

201
  @override
Hixie's avatar
Hixie committed
202 203
  void initState() {
    super.initState();
204
    _mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
205 206 207 208 209
    _controller = AnimationController(
      duration: _fadeInDuration,
      reverseDuration: _fadeOutDuration,
      vsync: this,
    )
210
      ..addStatusListener(_handleStatusChanged);
211
    // Listen to see when a mouse is added.
212
    RendererBinding.instance!.mouseTracker.addListener(_handleMouseTrackerChange);
213 214
    // Listen to global pointer events so that we can hide a tooltip immediately
    // if some other control is clicked on.
215
    GestureBinding.instance!.pointerRouter.addGlobalRoute(_handlePointerEvent);
216 217 218 219 220 221 222
  }

  // Forces a rebuild if a mouse has been added or removed.
  void _handleMouseTrackerChange() {
    if (!mounted) {
      return;
    }
223
    final bool mouseIsConnected = RendererBinding.instance!.mouseTracker.mouseIsConnected;
224 225 226 227 228
    if (mouseIsConnected != _mouseIsConnected) {
      setState((){
        _mouseIsConnected = mouseIsConnected;
      });
    }
229 230 231
  }

  void _handleStatusChanged(AnimationStatus status) {
232 233 234 235 236 237 238 239 240
    if (status == AnimationStatus.dismissed) {
      _hideTooltip(immediately: true);
    }
  }

  void _hideTooltip({ bool immediately = false }) {
    _showTimer?.cancel();
    _showTimer = null;
    if (immediately) {
241
      _removeEntry();
242 243 244
      return;
    }
    if (_longPressActivated) {
245
      // Tool tips activated by long press should stay around for the showDuration.
246
      _hideTimer ??= Timer(showDuration, _controller.reverse);
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261
    } else {
      // Tool tips activated by hover should disappear as soon as the mouse
      // leaves the control.
      _controller.reverse();
    }
    _longPressActivated = false;
  }

  void _showTooltip({ bool immediately = false }) {
    _hideTimer?.cancel();
    _hideTimer = null;
    if (immediately) {
      ensureTooltipVisible();
      return;
    }
262
    _showTimer ??= Timer(waitDuration, ensureTooltipVisible);
Hixie's avatar
Hixie committed
263 264
  }

265 266
  /// Shows the tooltip if it is not already visible.
  ///
267 268
  /// Returns `false` when the tooltip was already visible or if the context has
  /// become null.
269
  bool ensureTooltipVisible() {
270 271
    _showTimer?.cancel();
    _showTimer = null;
272
    if (_entry != null) {
273 274 275
      // Stop trying to hide, if we were.
      _hideTimer?.cancel();
      _hideTimer = null;
276
      _controller.forward();
277
      return false; // Already visible.
278
    }
279 280 281 282 283 284
    _createNewEntry();
    _controller.forward();
    return true;
  }

  void _createNewEntry() {
285 286 287
    final OverlayState overlayState = Overlay.of(
      context,
      debugRequiredFor: widget,
288
    )!;
289

290
    final RenderBox box = context.findRenderObject()! as RenderBox;
291 292 293 294
    final Offset target = box.localToGlobal(
      box.size.center(Offset.zero),
      ancestor: overlayState.context.findRenderObject(),
    );
295

296 297 298
    // 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.
299
    final Widget overlay = Directionality(
300
      textDirection: Directionality.of(context)!,
301 302 303 304 305 306 307 308 309 310 311 312 313 314
      child: _TooltipOverlay(
        message: widget.message,
        height: height,
        padding: padding,
        margin: margin,
        decoration: decoration,
        textStyle: textStyle,
        animation: CurvedAnimation(
          parent: _controller,
          curve: Curves.fastOutSlowIn,
        ),
        target: target,
        verticalOffset: verticalOffset,
        preferBelow: preferBelow,
315 316
      ),
    );
317
    _entry = OverlayEntry(builder: (BuildContext context) => overlay);
318
    overlayState.insert(_entry!);
319
    SemanticsService.tooltip(widget.message);
Hixie's avatar
Hixie committed
320 321
  }

322
  void _removeEntry() {
323 324 325 326 327
    _hideTimer?.cancel();
    _hideTimer = null;
    _showTimer?.cancel();
    _showTimer = null;
    _entry?.remove();
328
    _entry = null;
Hixie's avatar
Hixie committed
329 330
  }

331
  void _handlePointerEvent(PointerEvent event) {
332 333 334 335 336 337 338 339
    if (_entry == null) {
      return;
    }
    if (event is PointerUpEvent || event is PointerCancelEvent) {
      _hideTooltip();
    } else if (event is PointerDownEvent) {
      _hideTooltip(immediately: true);
    }
Hixie's avatar
Hixie committed
340 341
  }

342
  @override
Hixie's avatar
Hixie committed
343
  void deactivate() {
344 345 346
    if (_entry != null) {
      _hideTooltip(immediately: true);
    }
347
    _showTimer?.cancel();
348
    super.deactivate();
Hixie's avatar
Hixie committed
349 350
  }

351 352
  @override
  void dispose() {
353 354
    GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent);
    RendererBinding.instance!.mouseTracker.removeListener(_handleMouseTrackerChange);
355 356
    if (_entry != null)
      _removeEntry();
357
    _controller.dispose();
358 359 360
    super.dispose();
  }

361
  void _handleLongPress() {
362
    _longPressActivated = true;
363 364 365 366 367
    final bool tooltipCreated = ensureTooltipVisible();
    if (tooltipCreated)
      Feedback.forLongPress(context);
  }

368
  @override
Hixie's avatar
Hixie committed
369
  Widget build(BuildContext context) {
370
    assert(Overlay.of(context, debugRequiredFor: widget) != null);
371
    final ThemeData theme = Theme.of(context)!;
372
    final TooltipThemeData tooltipTheme = TooltipTheme.of(context);
373 374
    final TextStyle defaultTextStyle;
    final BoxDecoration defaultDecoration;
375
    if (theme.brightness == Brightness.dark) {
376
      defaultTextStyle = theme.textTheme.bodyText2!.copyWith(
377 378 379 380 381 382 383
        color: Colors.black,
      );
      defaultDecoration = BoxDecoration(
        color: Colors.white.withOpacity(0.9),
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    } else {
384
      defaultTextStyle = theme.textTheme.bodyText2!.copyWith(
385 386 387
        color: Colors.white,
      );
      defaultDecoration = BoxDecoration(
388
        color: Colors.grey[700]!.withOpacity(0.9),
389 390 391 392 393 394
        borderRadius: const BorderRadius.all(Radius.circular(4)),
      );
    }

    height = widget.height ?? tooltipTheme.height ?? _defaultTooltipHeight;
    padding = widget.padding ?? tooltipTheme.padding ?? _defaultPadding;
395
    margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin;
396 397 398 399 400 401 402 403
    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;

404
    Widget result = GestureDetector(
405 406 407 408
      behavior: HitTestBehavior.opaque,
      onLongPress: _handleLongPress,
      excludeFromSemantics: true,
      child: Semantics(
409
        label: excludeFromSemantics ? null : widget.message,
410
        child: widget.child,
411
      ),
Hixie's avatar
Hixie committed
412
    );
413 414 415

    // Only check for hovering if there is a mouse connected.
    if (_mouseIsConnected) {
416 417 418
      result = MouseRegion(
        onEnter: (PointerEnterEvent event) => _showTooltip(),
        onExit: (PointerExitEvent event) => _hideTooltip(),
419 420 421 422 423
        child: result,
      );
    }

    return result;
Hixie's avatar
Hixie committed
424 425 426
  }
}

427 428
/// A delegate for computing the layout of a tooltip to be displayed above or
/// bellow a target specified in the global coordinate system.
429
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
430 431 432
  /// Creates a delegate for computing the layout of a tooltip.
  ///
  /// The arguments must not be null.
Hixie's avatar
Hixie committed
433
  _TooltipPositionDelegate({
434 435 436
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
437 438 439
  }) : assert(target != null),
       assert(verticalOffset != null),
       assert(preferBelow != null);
440

441 442
  /// The offset of the target the tooltip is positioned near in the global
  /// coordinate system.
443
  final Offset target;
444 445 446

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

449
  /// Whether the tooltip is displayed below its widget by default.
450 451 452
  ///
  /// 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
453 454
  final bool preferBelow;

455
  @override
Hixie's avatar
Hixie committed
456 457
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

458
  @override
Hixie's avatar
Hixie committed
459
  Offset getPositionForChild(Size size, Size childSize) {
460 461 462 463 464 465 466
    return positionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      verticalOffset: verticalOffset,
      preferBelow: preferBelow,
    );
Hixie's avatar
Hixie committed
467 468
  }

469
  @override
Hixie's avatar
Hixie committed
470
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
471 472 473
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
474 475 476
  }
}

477
class _TooltipOverlay extends StatelessWidget {
478
  const _TooltipOverlay({
479 480 481
    Key? key,
    required this.message,
    required this.height,
Hixie's avatar
Hixie committed
482
    this.padding,
483
    this.margin,
484
    this.decoration,
485
    this.textStyle,
486 487 488 489
    required this.animation,
    required this.target,
    required this.verticalOffset,
    required this.preferBelow,
Hixie's avatar
Hixie committed
490 491 492 493
  }) : super(key: key);

  final String message;
  final double height;
494 495 496 497
  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;
  final Decoration? decoration;
  final TextStyle? textStyle;
498
  final Animation<double> animation;
499
  final Offset target;
Hixie's avatar
Hixie committed
500 501 502
  final double verticalOffset;
  final bool preferBelow;

503
  @override
Hixie's avatar
Hixie committed
504
  Widget build(BuildContext context) {
505 506 507 508
    return Positioned.fill(
      child: IgnorePointer(
        child: CustomSingleChildLayout(
          delegate: _TooltipPositionDelegate(
Hixie's avatar
Hixie committed
509 510
            target: target,
            verticalOffset: verticalOffset,
511
            preferBelow: preferBelow,
Hixie's avatar
Hixie committed
512
          ),
513
          child: FadeTransition(
514
            opacity: animation,
515 516
            child: ConstrainedBox(
              constraints: BoxConstraints(minHeight: height),
517
              child: DefaultTextStyle(
518
                style: Theme.of(context)!.textTheme.bodyText2!,
519 520 521 522 523 524 525 526 527 528 529
                child: Container(
                  decoration: decoration,
                  padding: padding,
                  margin: margin,
                  child: Center(
                    widthFactor: 1.0,
                    heightFactor: 1.0,
                    child: Text(
                      message,
                      style: textStyle,
                    ),
530
                  ),
531 532 533 534 535 536
                ),
              ),
            ),
          ),
        ),
      ),
Hixie's avatar
Hixie committed
537 538 539
    );
  }
}