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

12
import 'feedback.dart';
13 14
import 'theme.dart';
import 'theme_data.dart';
15 16

const Duration _kFadeDuration = const Duration(milliseconds: 200);
17
const Duration _kShowDuration = const Duration(milliseconds: 1500);
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:
///
36
///  * <https://material.google.com/components/tooltips.html>
37
class Tooltip extends StatefulWidget {
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.
  ///
43
  /// The [message] argument must not be null.
44
  const Tooltip({
Hixie's avatar
Hixie committed
45
    Key key,
46
    @required this.message,
47 48 49
    this.height: 32.0,
    this.padding: const EdgeInsets.symmetric(horizontal: 16.0),
    this.verticalOffset: 24.0,
Hixie's avatar
Hixie committed
50
    this.preferBelow: true,
51
    this.child,
52 53 54 55 56 57
  }) : assert(message != null),
       assert(height != null),
       assert(padding != null),
       assert(verticalOffset != null),
       assert(preferBelow != null),
       super(key: key);
Hixie's avatar
Hixie committed
58

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

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

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

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

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

  /// The widget below this widget in the tree.
81 82
  ///
  /// {@macro flutter.widgets.child}
Hixie's avatar
Hixie committed
83 84
  final Widget child;

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

88
  @override
89
  void debugFillProperties(DiagnosticPropertiesBuilder description) {
90 91 92 93
    super.debugFillProperties(description);
    description.add(new StringProperty('message', message, showName: false));
    description.add(new DoubleProperty('vertical offset', verticalOffset));
    description.add(new FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true));
Hixie's avatar
Hixie committed
94
  }
Hixie's avatar
Hixie committed
95 96
}

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

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

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

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

148 149
  void _removeEntry() {
    assert(_entry != null);
Hixie's avatar
Hixie committed
150
    _timer?.cancel();
151 152 153 154
    _timer = null;
    _entry.remove();
    _entry = null;
    GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent);
Hixie's avatar
Hixie committed
155 156
  }

157
  void _handlePointerEvent(PointerEvent event) {
Hixie's avatar
Hixie committed
158
    assert(_entry != null);
159
    if (event is PointerUpEvent || event is PointerCancelEvent)
160
      _timer ??= new Timer(_kShowDuration, _controller.reverse);
161 162
    else if (event is PointerDownEvent)
      _controller.reverse();
Hixie's avatar
Hixie committed
163 164
  }

165
  @override
Hixie's avatar
Hixie committed
166 167
  void deactivate() {
    if (_entry != null)
168
      _controller.reverse();
Hixie's avatar
Hixie committed
169 170 171
    super.deactivate();
  }

172 173
  @override
  void dispose() {
174 175
    if (_entry != null)
      _removeEntry();
176
    _controller.dispose();
177 178 179
    super.dispose();
  }

180 181 182 183 184 185
  void _handleLongPress() {
    final bool tooltipCreated = ensureTooltipVisible();
    if (tooltipCreated)
      Feedback.forLongPress(context);
  }

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

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

215 216
  /// The offset of the target the tooltip is positioned near in the global
  /// coordinate system.
217
  final Offset target;
218 219 220

  /// The amount of vertical distance between the target and the displayed
  /// tooltip.
Hixie's avatar
Hixie committed
221
  final double verticalOffset;
222 223 224 225 226

  /// 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
227 228
  final bool preferBelow;

229
  @override
Hixie's avatar
Hixie committed
230 231
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen();

232
  @override
Hixie's avatar
Hixie committed
233
  Offset getPositionForChild(Size size, Size childSize) {
234 235 236 237 238 239 240
    return positionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      verticalOffset: verticalOffset,
      preferBelow: preferBelow,
    );
Hixie's avatar
Hixie committed
241 242
  }

243
  @override
Hixie's avatar
Hixie committed
244
  bool shouldRelayout(_TooltipPositionDelegate oldDelegate) {
245 246 247
    return target != oldDelegate.target
        || verticalOffset != oldDelegate.verticalOffset
        || preferBelow != oldDelegate.preferBelow;
Hixie's avatar
Hixie committed
248 249 250
  }
}

251
class _TooltipOverlay extends StatelessWidget {
252
  const _TooltipOverlay({
Hixie's avatar
Hixie committed
253 254 255 256
    Key key,
    this.message,
    this.height,
    this.padding,
257
    this.animation,
Hixie's avatar
Hixie committed
258 259
    this.target,
    this.verticalOffset,
260
    this.preferBelow,
Hixie's avatar
Hixie committed
261 262 263 264
  }) : super(key: key);

  final String message;
  final double height;
265
  final EdgeInsetsGeometry padding;
266
  final Animation<double> animation;
267
  final Offset target;
Hixie's avatar
Hixie committed
268 269 270
  final double verticalOffset;
  final bool preferBelow;

271
  @override
Hixie's avatar
Hixie committed
272
  Widget build(BuildContext context) {
273 274
    final ThemeData theme = Theme.of(context);
    final ThemeData darkTheme = new ThemeData(
275
      brightness: Brightness.dark,
276 277
      textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme,
      platform: theme.platform,
278
    );
279
    return new Positioned.fill(
Hixie's avatar
Hixie committed
280
      child: new IgnorePointer(
281
        child: new CustomSingleChildLayout(
Hixie's avatar
Hixie committed
282 283 284
          delegate: new _TooltipPositionDelegate(
            target: target,
            verticalOffset: verticalOffset,
285
            preferBelow: preferBelow,
Hixie's avatar
Hixie committed
286
          ),
287 288
          child: new FadeTransition(
            opacity: animation,
Hixie's avatar
Hixie committed
289
            child: new Opacity(
290
              opacity: 0.9,
291 292 293 294 295 296 297 298 299 300 301 302 303
              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),
                  ),
304 305 306 307 308 309
                ),
              ),
            ),
          ),
        ),
      ),
Hixie's avatar
Hixie committed
310 311 312
    );
  }
}