// 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; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'typography.dart'; const double _kScreenEdgeMargin = 10.0; const Duration _kFadeDuration = const Duration(milliseconds: 200); const Duration _kShowDuration = const Duration(milliseconds: 1500); /// 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> class Tooltip extends StatefulWidget { /// 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. Tooltip({ Key key, this.message, this.height: 32.0, this.padding: const EdgeInsets.symmetric(horizontal: 16.0), this.verticalOffset: 24.0, this.preferBelow: true, this.child }) : super(key: key) { assert(message != null); assert(height != null); assert(padding != null); assert(verticalOffset != null); assert(preferBelow != null); assert(child != null); } /// The text to display in the tooltip. final String message; /// The amount of vertical space the tooltip should occupy (inside its padding). final double height; /// The amount of space by which to inset the child. /// /// Defaults to 16.0 logical pixels in each direction. final EdgeInsets padding; /// The amount of vertical distance between the widget and the displayed tooltip. final double verticalOffset; /// 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. final bool preferBelow; /// The widget below this widget in the tree. final Widget child; @override _TooltipState createState() => new _TooltipState(); @override void debugFillDescription(List<String> description) { super.debugFillDescription(description); description.add('"$message"'); description.add('vertical offset: $verticalOffset'); description.add('position: ${preferBelow ? "below" : "above"}'); } } class _TooltipState extends State<Tooltip> { AnimationController _controller; OverlayEntry _entry; Timer _timer; @override void initState() { super.initState(); _controller = new AnimationController(duration: _kFadeDuration) ..addStatusListener(_handleStatusChanged); } void _handleStatusChanged(AnimationStatus status) { if (status == AnimationStatus.dismissed) _removeEntry(); } @override 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(); } void ensureTooltipVisible() { if (_entry != null) { _timer?.cancel(); _timer = null; _controller.forward(); return; // Already visible. } 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(); } void _removeEntry() { assert(_entry != null); _timer?.cancel(); _timer = null; _entry.remove(); _entry = null; GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent); } void _handlePointerEvent(PointerEvent event) { assert(_entry != null); if (event is PointerUpEvent || event is PointerCancelEvent) _timer ??= new Timer(_kShowDuration, _controller.reverse); else if (event is PointerDownEvent) _controller.reverse(); } @override void deactivate() { if (_entry != null) _controller.reverse(); super.deactivate(); } @override void dispose() { if (_entry != null) _removeEntry(); _controller.stop(); super.dispose(); } @override Widget build(BuildContext context) { assert(Overlay.of(context, debugRequiredFor: config) != null); return new GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: ensureTooltipVisible, excludeFromSemantics: true, child: new Semantics( label: config.message, child: config.child ) ); } } class _TooltipPositionDelegate extends SingleChildLayoutDelegate { _TooltipPositionDelegate({ this.target, this.verticalOffset, this.preferBelow }); final Point target; final double verticalOffset; final bool preferBelow; @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen(); @override Offset getPositionForChild(Size size, Size childSize) { // VERTICAL DIRECTION final bool fitsBelow = target.y + verticalOffset + childSize.height <= size.height - _kScreenEdgeMargin; final bool fitsAbove = target.y - verticalOffset - childSize.height >= _kScreenEdgeMargin; final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow); double y; if (tooltipBelow) y = math.min(target.y + verticalOffset, size.height - _kScreenEdgeMargin); else y = math.max(target.y - verticalOffset - childSize.height, _kScreenEdgeMargin); // HORIZONTAL DIRECTION double normalizedTargetX = target.x.clamp(_kScreenEdgeMargin, size.width - _kScreenEdgeMargin); double x; 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; } else { x = normalizedTargetX - childSize.width / 2.0; } return new Offset(x, y); } @override bool shouldRelayout(_TooltipPositionDelegate oldDelegate) { return target != oldDelegate.target || verticalOffset != oldDelegate.verticalOffset || preferBelow != oldDelegate.preferBelow; } } class _TooltipOverlay extends StatelessWidget { _TooltipOverlay({ Key key, this.message, this.height, this.padding, this.animation, this.target, this.verticalOffset, this.preferBelow }) : super(key: key); final String message; final double height; final EdgeInsets padding; final Animation<double> animation; final Point target; final double verticalOffset; final bool preferBelow; @override Widget build(BuildContext context) { return new Positioned( top: 0.0, left: 0.0, right: 0.0, bottom: 0.0, child: new IgnorePointer( child: new CustomSingleChildLayout( delegate: new _TooltipPositionDelegate( target: target, verticalOffset: verticalOffset, preferBelow: preferBelow ), child: new FadeTransition( opacity: animation, child: new Opacity( opacity: 0.9, child: new Container( decoration: new BoxDecoration( backgroundColor: Colors.grey[700], borderRadius: new BorderRadius.circular(2.0) ), height: height, padding: padding, child: new Center( widthFactor: 1.0, child: new Text(message, style: Typography.white.body1) ) ) ) ) ) ) ); } }