tooltip.dart 9.73 KB
Newer Older
Hixie's avatar
Hixie committed
1 2 3 4 5 6
// Copyright 2015 The Chromium 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';

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 'feedback.dart';
12 13
import 'theme.dart';
import 'theme_data.dart';
14

15 16
const Duration _kFadeDuration = Duration(milliseconds: 200);
const Duration _kShowDuration = Duration(milliseconds: 1500);
17 18 19 20 21 22 23 24

/// A material design tooltip.
///
/// Tooltips provide text labels that help explain the function of a button or
/// other user interface action. Wrap the button in a [Tooltip] widget to
/// show a label when the widget long pressed (or when the user takes some
/// other appropriate action).
///
25 26
/// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q}
///
27 28 29 30 31 32 33 34 35 36
/// 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.
///
/// See also:
///
37
///  * <https://material.io/design/components/tooltips.html>
38
class Tooltip extends StatefulWidget {
39 40 41 42 43
  /// Creates a tooltip.
  ///
  /// By default, tooltips prefer to appear below the [child] widget when the
  /// user long presses on the widget.
  ///
44
  /// The [message] argument must not be null.
45
  const Tooltip({
Hixie's avatar
Hixie committed
46
    Key key,
47
    @required this.message,
48 49 50 51 52
    this.height = 32.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 16.0),
    this.verticalOffset = 24.0,
    this.preferBelow = true,
    this.excludeFromSemantics = false,
53
    this.child,
54 55 56 57 58
  }) : assert(message != null),
       assert(height != null),
       assert(padding != null),
       assert(verticalOffset != null),
       assert(preferBelow != null),
59
       assert(excludeFromSemantics != null),
60
       super(key: key);
Hixie's avatar
Hixie committed
61

62
  /// The text to display in the tooltip.
Hixie's avatar
Hixie committed
63
  final String message;
64

65
  /// The amount of vertical space the tooltip should occupy (inside its padding).
Hixie's avatar
Hixie committed
66
  final double height;
67

68 69 70
  /// The amount of space by which to inset the child.
  ///
  /// Defaults to 16.0 logical pixels in each direction.
71
  final EdgeInsetsGeometry padding;
72

73
  /// The amount of vertical distance between the widget and the displayed tooltip.
Hixie's avatar
Hixie committed
74
  final double verticalOffset;
75

76 77 78 79 80
  /// 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.
Hixie's avatar
Hixie committed
81
  final bool preferBelow;
82

83 84 85 86
  /// Whether the tooltip's [message] should be excluded from the semantics
  /// tree.
  final bool excludeFromSemantics;

87
  /// The widget below this widget in the tree.
88 89
  ///
  /// {@macro flutter.widgets.child}
Hixie's avatar
Hixie committed
90 91
  final Widget child;

92
  @override
93
  _TooltipState createState() => _TooltipState();
Hixie's avatar
Hixie committed
94

95
  @override
96 97
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
98 99 100
    properties.add(StringProperty('message', message, showName: false));
    properties.add(DoubleProperty('vertical offset', verticalOffset));
    properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
Hixie's avatar
Hixie committed
101
  }
Hixie's avatar
Hixie committed
102 103
}

104
class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
105
  AnimationController _controller;
Hixie's avatar
Hixie committed
106 107 108
  OverlayEntry _entry;
  Timer _timer;

109
  @override
Hixie's avatar
Hixie committed
110 111
  void initState() {
    super.initState();
112
    _controller = AnimationController(duration: _kFadeDuration, vsync: this)
113 114 115 116
      ..addStatusListener(_handleStatusChanged);
  }

  void _handleStatusChanged(AnimationStatus status) {
117 118
    if (status == AnimationStatus.dismissed)
      _removeEntry();
Hixie's avatar
Hixie committed
119 120
  }

121 122 123 124
  /// Shows the tooltip if it is not already visible.
  ///
  /// Returns `false` when the tooltip was already visible.
  bool ensureTooltipVisible() {
125 126 127 128
    if (_entry != null) {
      _timer?.cancel();
      _timer = null;
      _controller.forward();
129
      return false; // Already visible.
130
    }
131
    final RenderBox box = context.findRenderObject();
132
    final Offset target = box.localToGlobal(box.size.center(Offset.zero));
133 134 135
    // 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.
136
    final Widget overlay = _TooltipOverlay(
137 138 139
      message: widget.message,
      height: widget.height,
      padding: widget.padding,
140
      animation: CurvedAnimation(
141
        parent: _controller,
142
        curve: Curves.fastOutSlowIn,
143 144
      ),
      target: target,
145
      verticalOffset: widget.verticalOffset,
146
      preferBelow: widget.preferBelow,
147
    );
148
    _entry = OverlayEntry(builder: (BuildContext context) => overlay);
149
    Overlay.of(context, debugRequiredFor: widget).insert(_entry);
150
    GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
151
    SemanticsService.tooltip(widget.message);
152
    _controller.forward();
153
    return true;
Hixie's avatar
Hixie committed
154 155
  }

156 157
  void _removeEntry() {
    assert(_entry != null);
Hixie's avatar
Hixie committed
158
    _timer?.cancel();
159 160 161 162
    _timer = null;
    _entry.remove();
    _entry = null;
    GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
Hixie's avatar
Hixie committed
163 164
  }

165
  void _handlePointerEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
166
    assert(_entry != null);
167
    if (event is PointerUpEvent || event is PointerCancelEvent)
168
      _timer ??= Timer(_kShowDuration, _controller.reverse);
169 170
    else if (event is PointerDownEvent)
      _controller.reverse();
Hixie's avatar
Hixie committed
171 172
  }

173
  @override
Hixie's avatar
Hixie committed
174 175
  void deactivate() {
    if (_entry != null)
176
      _controller.reverse();
177
    super.deactivate();
Hixie's avatar
Hixie committed
178 179
  }

180 181
  @override
  void dispose() {
182 183
    if (_entry != null)
      _removeEntry();
184
    _controller.dispose();
185 186 187
    super.dispose();
  }

188 189 190 191 192 193
  void _handleLongPress() {
    final bool tooltipCreated = ensureTooltipVisible();
    if (tooltipCreated)
      Feedback.forLongPress(context);
  }

194
  @override
Hixie's avatar
Hixie committed
195
  Widget build(BuildContext context) {
196
    assert(Overlay.of(context, debugRequiredFor: widget) != null);
197
    return GestureDetector(
Hixie's avatar
Hixie committed
198
      behavior: HitTestBehavior.opaque,
199
      onLongPress: _handleLongPress,
Hixie's avatar
Hixie committed
200
      excludeFromSemantics: true,
201
      child: Semantics(
202
        label: widget.excludeFromSemantics ? null : widget.message,
203
        child: widget.child,
204
      ),
Hixie's avatar
Hixie committed
205 206 207 208
    );
  }
}

209 210
/// A delegate for computing the layout of a tooltip to be displayed above or
/// bellow a target specified in the global coordinate system.
211
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
212 213 214
  /// Creates a delegate for computing the layout of a tooltip.
  ///
  /// The arguments must not be null.
Hixie's avatar
Hixie committed
215
  _TooltipPositionDelegate({
216 217 218 219 220 221
    @required this.target,
    @required this.verticalOffset,
    @required this.preferBelow,
  }) : assert(target != null),
       assert(verticalOffset != null),
       assert(preferBelow != null);
222

223 224
  /// The offset of the target the tooltip is positioned near in the global
  /// coordinate system.
225
  final Offset target;
226 227 228

  /// The amount of vertical distance between the target and the displayed
  /// tooltip.
Hixie's avatar
Hixie committed
229
  final double verticalOffset;
230 231 232 233 234

  /// Whether the tooltip defaults to being displayed below the widget.
  ///
  /// 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
235 236
  final bool preferBelow;

237
  @override
Hixie's avatar
Hixie committed
238 239
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

240
  @override
Hixie's avatar
Hixie committed
241
  Offset getPositionForChild(Size size, Size childSize) {
242 243 244 245 246 247 248
    return positionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      verticalOffset: verticalOffset,
      preferBelow: preferBelow,
    );
Hixie's avatar
Hixie committed
249 250
  }

251
  @override
Hixie's avatar
Hixie committed
252
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
253 254 255
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
256 257 258
  }
}

259
class _TooltipOverlay extends StatelessWidget {
260
  const _TooltipOverlay({
Hixie's avatar
Hixie committed
261 262 263 264
    Key key,
    this.message,
    this.height,
    this.padding,
265
    this.animation,
Hixie's avatar
Hixie committed
266 267
    this.target,
    this.verticalOffset,
268
    this.preferBelow,
Hixie's avatar
Hixie committed
269 270 271 272
  }) : super(key: key);

  final String message;
  final double height;
273
  final EdgeInsetsGeometry padding;
274
  final Animation<double> animation;
275
  final Offset target;
Hixie's avatar
Hixie committed
276 277 278
  final double verticalOffset;
  final bool preferBelow;

279
  @override
Hixie's avatar
Hixie committed
280
  Widget build(BuildContext context) {
281
    final ThemeData theme = Theme.of(context);
282
    final ThemeData darkTheme = ThemeData(
283
      brightness: Brightness.dark,
284 285
      textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme,
      platform: theme.platform,
286
    );
287 288 289 290
    return Positioned.fill(
      child: IgnorePointer(
        child: CustomSingleChildLayout(
          delegate: _TooltipPositionDelegate(
Hixie's avatar
Hixie committed
291 292
            target: target,
            verticalOffset: verticalOffset,
293
            preferBelow: preferBelow,
Hixie's avatar
Hixie committed
294
          ),
295
          child: FadeTransition(
296
            opacity: animation,
297
            child: Opacity(
298
              opacity: 0.9,
299 300 301 302
              child: ConstrainedBox(
                constraints: BoxConstraints(minHeight: height),
                child: Container(
                  decoration: BoxDecoration(
303
                    color: darkTheme.backgroundColor,
304
                    borderRadius: BorderRadius.circular(2.0),
305 306
                  ),
                  padding: padding,
307
                  child: Center(
308 309
                    widthFactor: 1.0,
                    heightFactor: 1.0,
310
                    child: Text(message, style: darkTheme.textTheme.body1),
311
                  ),
312 313 314 315 316 317
                ),
              ),
            ),
          ),
        ),
      ),
Hixie's avatar
Hixie committed
318 319 320
    );
  }
}