tooltip.dart 9.76 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 25 26 27 28 29 30 31 32 33 34

/// 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).
///
/// 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:
///
35
///  * <https://material.google.com/components/tooltips.html>
36
class Tooltip extends StatefulWidget {
37 38 39 40 41
  /// Creates a tooltip.
  ///
  /// By default, tooltips prefer to appear below the [child] widget when the
  /// user long presses on the widget.
  ///
42
  /// The [message] argument must not be null.
43
  const Tooltip({
Hixie's avatar
Hixie committed
44
    Key key,
45
    @required this.message,
46 47 48 49 50
    this.height = 32.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 16.0),
    this.verticalOffset = 24.0,
    this.preferBelow = true,
    this.excludeFromSemantics = false,
51
    this.child,
52 53 54 55 56
  }) : assert(message != null),
       assert(height != null),
       assert(padding != null),
       assert(verticalOffset != null),
       assert(preferBelow != null),
57
       assert(excludeFromSemantics != null),
58
       super(key: key);
Hixie's avatar
Hixie committed
59

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

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

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

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

74 75 76 77 78
  /// 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
79
  final bool preferBelow;
80

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

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

90
  @override
Hixie's avatar
Hixie committed
91
  _TooltipState createState() => new _TooltipState();
Hixie's avatar
Hixie committed
92

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  /// 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
233 234
  final bool preferBelow;

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

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

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

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

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

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