tooltip.dart 8.87 KB
Newer Older
Hixie's avatar
Hixie committed
1 2 3 4 5 6 7
// 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';
import 'dart:math' as math;

8
import 'package:flutter/gestures.dart';
Hixie's avatar
Hixie committed
9 10 11
import 'package:flutter/widgets.dart';

import 'colors.dart';
12 13 14 15
import 'typography.dart';

const double _kScreenEdgeMargin = 10.0;
const Duration _kFadeDuration = const Duration(milliseconds: 200);
16
const Duration _kShowDuration = const Duration(milliseconds: 1500);
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35

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

  /// The widget below this widget in the tree.
Hixie's avatar
Hixie committed
82 83
  final Widget child;

84
  @override
Hixie's avatar
Hixie committed
85
  _TooltipState createState() => new _TooltipState();
Hixie's avatar
Hixie committed
86

87
  @override
Hixie's avatar
Hixie committed
88 89 90 91 92 93
  void debugFillDescription(List<String> description) {
    super.debugFillDescription(description);
    description.add('"$message"');
    description.add('vertical offset: $verticalOffset');
    description.add('position: ${preferBelow ? "below" : "above"}');
  }
Hixie's avatar
Hixie committed
94 95 96
}

class _TooltipState extends State<Tooltip> {
97
  AnimationController _controller;
Hixie's avatar
Hixie committed
98 99 100
  OverlayEntry _entry;
  Timer _timer;

101
  @override
Hixie's avatar
Hixie committed
102 103
  void initState() {
    super.initState();
104 105 106 107 108
    _controller = new AnimationController(duration: _kFadeDuration)
      ..addStatusListener(_handleStatusChanged);
  }

  void _handleStatusChanged(AnimationStatus status) {
109 110
    if (status == AnimationStatus.dismissed)
      _removeEntry();
Hixie's avatar
Hixie committed
111 112
  }

113
  @override
Hixie's avatar
Hixie committed
114 115 116 117 118 119 120 121 122 123 124
  void didUpdateConfig(Tooltip oldConfig) {
    super.didUpdateConfig(oldConfig);
    if (_entry != null &&
        (config.message != oldConfig.message ||
         config.height != oldConfig.height ||
         config.padding != oldConfig.padding ||
         config.verticalOffset != oldConfig.verticalOffset ||
         config.preferBelow != oldConfig.preferBelow))
      _entry.markNeedsBuild();
  }

125
  void ensureTooltipVisible() {
126 127 128 129
    if (_entry != null) {
      _timer?.cancel();
      _timer = null;
      _controller.forward();
130
      return;  // Already visible.
131
    }
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150
    RenderBox box = context.findRenderObject();
    Point target = box.localToGlobal(box.size.center(Point.origin));
    _entry = new OverlayEntry(builder: (BuildContext context) {
      return new _TooltipOverlay(
        message: config.message,
        height: config.height,
        padding: config.padding,
        animation: new CurvedAnimation(
          parent: _controller,
          curve: Curves.ease
        ),
        target: target,
        verticalOffset: config.verticalOffset,
        preferBelow: config.preferBelow
      );
    });
    Overlay.of(context, debugRequiredFor: config).insert(_entry);
    GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent);
    _controller.forward();
Hixie's avatar
Hixie committed
151 152
  }

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

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

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

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

185
  @override
Hixie's avatar
Hixie committed
186
  Widget build(BuildContext context) {
187
    assert(Overlay.of(context, debugRequiredFor: config) != null);
Hixie's avatar
Hixie committed
188 189
    return new GestureDetector(
      behavior: HitTestBehavior.opaque,
190
      onLongPress: ensureTooltipVisible,
Hixie's avatar
Hixie committed
191 192 193 194 195
      excludeFromSemantics: true,
      child: new Semantics(
        label: config.message,
        child: config.child
      )
Hixie's avatar
Hixie committed
196 197 198 199
    );
  }
}

200
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
Hixie's avatar
Hixie committed
201 202 203 204 205
  _TooltipPositionDelegate({
    this.target,
    this.verticalOffset,
    this.preferBelow
  });
206

Hixie's avatar
Hixie committed
207 208 209 210
  final Point target;
  final double verticalOffset;
  final bool preferBelow;

211
  @override
Hixie's avatar
Hixie committed
212 213
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

214
  @override
Hixie's avatar
Hixie committed
215 216
  Offset getPositionForChild(Size size, Size childSize) {
    // VERTICAL DIRECTION
217 218
    final bool fitsBelow = target.y + verticalOffset + childSize.height <= size.height - _kScreenEdgeMargin;
    final bool fitsAbove = target.y - verticalOffset - childSize.height >= _kScreenEdgeMargin;
Hixie's avatar
Hixie committed
219 220 221
    final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
    double y;
    if (tooltipBelow)
222
      y = math.min(target.y + verticalOffset, size.height - _kScreenEdgeMargin);
Hixie's avatar
Hixie committed
223
    else
224
      y = math.max(target.y - verticalOffset - childSize.height, _kScreenEdgeMargin);
Hixie's avatar
Hixie committed
225
    // HORIZONTAL DIRECTION
226
    double normalizedTargetX = target.x.clamp(_kScreenEdgeMargin, size.width - _kScreenEdgeMargin);
Hixie's avatar
Hixie committed
227
    double x;
228 229 230 231
    if (normalizedTargetX < _kScreenEdgeMargin + childSize.width / 2.0) {
      x = _kScreenEdgeMargin;
    } else if (normalizedTargetX > size.width - _kScreenEdgeMargin - childSize.width / 2.0) {
      x = size.width - _kScreenEdgeMargin - childSize.width;
Hixie's avatar
Hixie committed
232
    } else {
233
      x = normalizedTargetX - childSize.width / 2.0;
Hixie's avatar
Hixie committed
234 235 236 237
    }
    return new Offset(x, y);
  }

238
  @override
Hixie's avatar
Hixie committed
239
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
240 241 242
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
243 244 245
  }
}

246
class _TooltipOverlay extends StatelessWidget {
Hixie's avatar
Hixie committed
247 248 249 250 251
  _TooltipOverlay({
    Key key,
    this.message,
    this.height,
    this.padding,
252
    this.animation,
Hixie's avatar
Hixie committed
253 254 255 256 257 258 259
    this.target,
    this.verticalOffset,
    this.preferBelow
  }) : super(key: key);

  final String message;
  final double height;
260
  final EdgeInsets padding;
261
  final Animation<double> animation;
Hixie's avatar
Hixie committed
262 263 264 265
  final Point target;
  final double verticalOffset;
  final bool preferBelow;

266
  @override
Hixie's avatar
Hixie committed
267 268 269 270 271 272 273
  Widget build(BuildContext context) {
    return new Positioned(
      top: 0.0,
      left: 0.0,
      right: 0.0,
      bottom: 0.0,
      child: new IgnorePointer(
274
        child: new CustomSingleChildLayout(
Hixie's avatar
Hixie committed
275 276 277 278 279
          delegate: new _TooltipPositionDelegate(
            target: target,
            verticalOffset: verticalOffset,
            preferBelow: preferBelow
          ),
280 281
          child: new FadeTransition(
            opacity: animation,
Hixie's avatar
Hixie committed
282
            child: new Opacity(
283
              opacity: 0.9,
Hixie's avatar
Hixie committed
284 285
              child: new Container(
                decoration: new BoxDecoration(
286 287
                  backgroundColor: Colors.grey[700],
                  borderRadius: 2.0
Hixie's avatar
Hixie committed
288 289 290 291 292
                ),
                height: height,
                padding: padding,
                child: new Center(
                  widthFactor: 1.0,
293
                  child: new Text(message, style: Typography.white.body1)
Hixie's avatar
Hixie committed
294 295 296 297 298 299 300 301 302
                )
              )
            )
          )
        )
      )
    );
  }
}