| // Copyright 2014 The Flutter 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 'package:flutter/gestures.dart'; |
| import 'package:flutter/rendering.dart'; |
| import 'package:flutter/widgets.dart'; |
| |
| import 'colors.dart'; |
| import 'feedback.dart'; |
| import 'theme.dart'; |
| import 'theme_data.dart'; |
| import 'tooltip_theme.dart'; |
| |
| /// 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). |
| /// |
| /// {@youtube 560 315 https://www.youtube.com/watch?v=EeEfD5fI-5Q} |
| /// |
| /// 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://material.io/design/components/tooltips.html> |
| /// * [TooltipTheme] or [ThemeData.tooltipTheme] |
| class Tooltip extends StatefulWidget { |
| /// Creates a tooltip. |
| /// |
| /// By default, tooltips should adhere to the |
| /// [Material specification](https://material.io/design/components/tooltips.html#spec). |
| /// If the optional constructor parameters are not defined, the values |
| /// provided by [TooltipTheme.of] will be used if a [TooltipTheme] is present |
| /// or specified in [ThemeData]. |
| /// |
| /// All parameters that are defined in the constructor will |
| /// override the default values _and_ the values in [TooltipTheme.of]. |
| const Tooltip({ |
| Key key, |
| @required this.message, |
| this.height, |
| this.padding, |
| this.margin, |
| this.verticalOffset, |
| this.preferBelow, |
| this.excludeFromSemantics, |
| this.decoration, |
| this.textStyle, |
| this.waitDuration, |
| this.showDuration, |
| this.child, |
| }) : assert(message != null), |
| super(key: key); |
| |
| /// The text to display in the tooltip. |
| final String message; |
| |
| /// The height of the tooltip's [child]. |
| /// |
| /// If the [child] is null, then this is the tooltip's intrinsic height. |
| final double height; |
| |
| /// The amount of space by which to inset the tooltip's [child]. |
| /// |
| /// Defaults to 16.0 logical pixels in each direction. |
| final EdgeInsetsGeometry padding; |
| |
| /// The empty space that surrounds the tooltip. |
| /// |
| /// Defines the tooltip's outer [Container.margin]. By default, a |
| /// long tooltip will span the width of its window. If long enough, |
| /// a tooltip might also span the window's height. This property allows |
| /// one to define how much space the tooltip must be inset from the edges |
| /// of their display window. |
| /// |
| /// If this property is null, then [TooltipThemeData.margin] is used. |
| /// If [TooltipThemeData.margin] is also null, the default margin is |
| /// 0.0 logical pixels on all sides. |
| final EdgeInsetsGeometry margin; |
| |
| /// The vertical gap between the widget and the displayed tooltip. |
| /// |
| /// When [preferBelow] is set to true and tooltips have sufficient space to |
| /// display themselves, this property defines how much vertical space |
| /// tooltips will position themselves under their corresponding widgets. |
| /// Otherwise, tooltips will position themselves above their corresponding |
| /// widgets with the given offset. |
| 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; |
| |
| /// Whether the tooltip's [message] should be excluded from the semantics |
| /// tree. |
| /// |
| /// Defaults to false. A tooltip will add a [Semantics.label] that is set to |
| /// [Tooltip.message]. Set this property to true if the app is going to |
| /// provide its own custom semantics label. |
| final bool excludeFromSemantics; |
| |
| /// The widget below this widget in the tree. |
| /// |
| /// {@macro flutter.widgets.child} |
| final Widget child; |
| |
| /// Specifies the tooltip's shape and background color. |
| /// |
| /// The tooltip shape defaults to a rounded rectangle with a border radius of |
| /// 4.0. Tooltips will also default to an opacity of 90% and with the color |
| /// [Colors.grey[700]] if [ThemeData.brightness] is [Brightness.dark], and |
| /// [Colors.white] if it is [Brightness.light]. |
| final Decoration decoration; |
| |
| /// The style to use for the message of the tooltip. |
| /// |
| /// If null, the message's [TextStyle] will be determined based on |
| /// [ThemeData]. If [ThemeData.brightness] is set to [Brightness.dark], |
| /// [ThemeData.textTheme.bodyText2] will be used with [Colors.white]. Otherwise, |
| /// if [ThemeData.brightness] is set to [Brightness.light], |
| /// [ThemeData.textTheme.bodyText2] will be used with [Colors.black]. |
| final TextStyle textStyle; |
| |
| /// The length of time that a pointer must hover over a tooltip's widget |
| /// before the tooltip will be shown. |
| /// |
| /// Once the pointer leaves the widget, the tooltip will immediately |
| /// disappear. |
| /// |
| /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover). |
| final Duration waitDuration; |
| |
| /// The length of time that the tooltip will be shown after a long press |
| /// is released. |
| /// |
| /// Defaults to 1.5 seconds. |
| final Duration showDuration; |
| |
| @override |
| _TooltipState createState() => _TooltipState(); |
| |
| @override |
| void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
| super.debugFillProperties(properties); |
| properties.add(StringProperty('message', message, showName: false)); |
| properties.add(DoubleProperty('height', height, defaultValue: null)); |
| properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); |
| properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); |
| properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: null)); |
| properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true, defaultValue: null)); |
| properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Duration>('wait duration', waitDuration, defaultValue: null)); |
| properties.add(DiagnosticsProperty<Duration>('show duration', showDuration, defaultValue: null)); |
| } |
| } |
| |
| class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { |
| static const double _defaultTooltipHeight = 32.0; |
| static const double _defaultVerticalOffset = 24.0; |
| static const bool _defaultPreferBelow = true; |
| static const EdgeInsetsGeometry _defaultPadding = EdgeInsets.symmetric(horizontal: 16.0); |
| static const EdgeInsetsGeometry _defaultMargin = EdgeInsets.all(0.0); |
| static const Duration _fadeInDuration = Duration(milliseconds: 150); |
| static const Duration _fadeOutDuration = Duration(milliseconds: 75); |
| static const Duration _defaultShowDuration = Duration(milliseconds: 1500); |
| static const Duration _defaultWaitDuration = Duration(milliseconds: 0); |
| static const bool _defaultExcludeFromSemantics = false; |
| |
| double height; |
| EdgeInsetsGeometry padding; |
| EdgeInsetsGeometry margin; |
| Decoration decoration; |
| TextStyle textStyle; |
| double verticalOffset; |
| bool preferBelow; |
| bool excludeFromSemantics; |
| AnimationController _controller; |
| OverlayEntry _entry; |
| Timer _hideTimer; |
| Timer _showTimer; |
| Duration showDuration; |
| Duration waitDuration; |
| bool _mouseIsConnected; |
| bool _longPressActivated = false; |
| |
| @override |
| void initState() { |
| super.initState(); |
| _mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected; |
| _controller = AnimationController( |
| duration: _fadeInDuration, |
| reverseDuration: _fadeOutDuration, |
| vsync: this, |
| ) |
| ..addStatusListener(_handleStatusChanged); |
| // Listen to see when a mouse is added. |
| RendererBinding.instance.mouseTracker.addListener(_handleMouseTrackerChange); |
| // Listen to global pointer events so that we can hide a tooltip immediately |
| // if some other control is clicked on. |
| GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent); |
| } |
| |
| // Forces a rebuild if a mouse has been added or removed. |
| void _handleMouseTrackerChange() { |
| if (!mounted) { |
| return; |
| } |
| final bool mouseIsConnected = RendererBinding.instance.mouseTracker.mouseIsConnected; |
| if (mouseIsConnected != _mouseIsConnected) { |
| setState((){ |
| _mouseIsConnected = mouseIsConnected; |
| }); |
| } |
| } |
| |
| void _handleStatusChanged(AnimationStatus status) { |
| if (status == AnimationStatus.dismissed) { |
| _hideTooltip(immediately: true); |
| } |
| } |
| |
| void _hideTooltip({ bool immediately = false }) { |
| _showTimer?.cancel(); |
| _showTimer = null; |
| if (immediately) { |
| _removeEntry(); |
| return; |
| } |
| if (_longPressActivated) { |
| // Tool tips activated by long press should stay around for the showDuration. |
| _hideTimer ??= Timer(showDuration, _controller.reverse); |
| } else { |
| // Tool tips activated by hover should disappear as soon as the mouse |
| // leaves the control. |
| _controller.reverse(); |
| } |
| _longPressActivated = false; |
| } |
| |
| void _showTooltip({ bool immediately = false }) { |
| _hideTimer?.cancel(); |
| _hideTimer = null; |
| if (immediately) { |
| ensureTooltipVisible(); |
| return; |
| } |
| _showTimer ??= Timer(waitDuration, ensureTooltipVisible); |
| } |
| |
| /// Shows the tooltip if it is not already visible. |
| /// |
| /// Returns `false` when the tooltip was already visible. |
| bool ensureTooltipVisible() { |
| _showTimer?.cancel(); |
| _showTimer = null; |
| if (_entry != null) { |
| // Stop trying to hide, if we were. |
| _hideTimer?.cancel(); |
| _hideTimer = null; |
| _controller.forward(); |
| return false; // Already visible. |
| } |
| _createNewEntry(); |
| _controller.forward(); |
| return true; |
| } |
| |
| void _createNewEntry() { |
| final RenderBox box = context.findRenderObject() as RenderBox; |
| final Offset target = box.localToGlobal(box.size.center(Offset.zero)); |
| |
| // 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 = Directionality( |
| textDirection: Directionality.of(context), |
| child: _TooltipOverlay( |
| message: widget.message, |
| height: height, |
| padding: padding, |
| margin: margin, |
| decoration: decoration, |
| textStyle: textStyle, |
| animation: CurvedAnimation( |
| parent: _controller, |
| curve: Curves.fastOutSlowIn, |
| ), |
| target: target, |
| verticalOffset: verticalOffset, |
| preferBelow: preferBelow, |
| ), |
| ); |
| _entry = OverlayEntry(builder: (BuildContext context) => overlay); |
| Overlay.of(context, debugRequiredFor: widget).insert(_entry); |
| SemanticsService.tooltip(widget.message); |
| } |
| |
| void _removeEntry() { |
| _hideTimer?.cancel(); |
| _hideTimer = null; |
| _showTimer?.cancel(); |
| _showTimer = null; |
| _entry?.remove(); |
| _entry = null; |
| } |
| |
| void _handlePointerEvent(PointerEvent event) { |
| if (_entry == null) { |
| return; |
| } |
| if (event is PointerUpEvent || event is PointerCancelEvent) { |
| _hideTooltip(); |
| } else if (event is PointerDownEvent) { |
| _hideTooltip(immediately: true); |
| } |
| } |
| |
| @override |
| void deactivate() { |
| if (_entry != null) { |
| _hideTooltip(immediately: true); |
| } |
| super.deactivate(); |
| } |
| |
| @override |
| void dispose() { |
| GestureBinding.instance.pointerRouter.removeGlobalRoute(_handlePointerEvent); |
| RendererBinding.instance.mouseTracker.removeListener(_handleMouseTrackerChange); |
| if (_entry != null) |
| _removeEntry(); |
| _controller.dispose(); |
| super.dispose(); |
| } |
| |
| void _handleLongPress() { |
| _longPressActivated = true; |
| final bool tooltipCreated = ensureTooltipVisible(); |
| if (tooltipCreated) |
| Feedback.forLongPress(context); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| assert(Overlay.of(context, debugRequiredFor: widget) != null); |
| final ThemeData theme = Theme.of(context); |
| final TooltipThemeData tooltipTheme = TooltipTheme.of(context); |
| TextStyle defaultTextStyle; |
| BoxDecoration defaultDecoration; |
| if (theme.brightness == Brightness.dark) { |
| defaultTextStyle = theme.textTheme.bodyText2.copyWith( |
| color: Colors.black, |
| ); |
| defaultDecoration = BoxDecoration( |
| color: Colors.white.withOpacity(0.9), |
| borderRadius: const BorderRadius.all(Radius.circular(4)), |
| ); |
| } else { |
| defaultTextStyle = theme.textTheme.bodyText2.copyWith( |
| color: Colors.white, |
| ); |
| defaultDecoration = BoxDecoration( |
| color: Colors.grey[700].withOpacity(0.9), |
| borderRadius: const BorderRadius.all(Radius.circular(4)), |
| ); |
| } |
| |
| height = widget.height ?? tooltipTheme.height ?? _defaultTooltipHeight; |
| padding = widget.padding ?? tooltipTheme.padding ?? _defaultPadding; |
| margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin; |
| verticalOffset = widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset; |
| preferBelow = widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow; |
| excludeFromSemantics = widget.excludeFromSemantics ?? tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics; |
| decoration = widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration; |
| textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle; |
| waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration; |
| showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration; |
| |
| Widget result = GestureDetector( |
| behavior: HitTestBehavior.opaque, |
| onLongPress: _handleLongPress, |
| excludeFromSemantics: true, |
| child: Semantics( |
| label: excludeFromSemantics ? null : widget.message, |
| child: widget.child, |
| ), |
| ); |
| |
| // Only check for hovering if there is a mouse connected. |
| if (_mouseIsConnected) { |
| result = MouseRegion( |
| onEnter: (PointerEnterEvent event) => _showTooltip(), |
| onExit: (PointerExitEvent event) => _hideTooltip(), |
| child: result, |
| ); |
| } |
| |
| return result; |
| } |
| } |
| |
| /// A delegate for computing the layout of a tooltip to be displayed above or |
| /// bellow a target specified in the global coordinate system. |
| class _TooltipPositionDelegate extends SingleChildLayoutDelegate { |
| /// Creates a delegate for computing the layout of a tooltip. |
| /// |
| /// The arguments must not be null. |
| _TooltipPositionDelegate({ |
| @required this.target, |
| @required this.verticalOffset, |
| @required this.preferBelow, |
| }) : assert(target != null), |
| assert(verticalOffset != null), |
| assert(preferBelow != null); |
| |
| /// The offset of the target the tooltip is positioned near in the global |
| /// coordinate system. |
| final Offset target; |
| |
| /// The amount of vertical distance between the target and the displayed |
| /// tooltip. |
| final double verticalOffset; |
| |
| /// Whether the tooltip is displayed below its widget by default. |
| /// |
| /// 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; |
| |
| @override |
| BoxConstraints getConstraintsForChild(BoxConstraints constraints) => constraints.loosen(); |
| |
| @override |
| Offset getPositionForChild(Size size, Size childSize) { |
| return positionDependentBox( |
| size: size, |
| childSize: childSize, |
| target: target, |
| verticalOffset: verticalOffset, |
| preferBelow: preferBelow, |
| ); |
| } |
| |
| @override |
| bool shouldRelayout(_TooltipPositionDelegate oldDelegate) { |
| return target != oldDelegate.target |
| || verticalOffset != oldDelegate.verticalOffset |
| || preferBelow != oldDelegate.preferBelow; |
| } |
| } |
| |
| class _TooltipOverlay extends StatelessWidget { |
| const _TooltipOverlay({ |
| Key key, |
| this.message, |
| this.height, |
| this.padding, |
| this.margin, |
| this.decoration, |
| this.textStyle, |
| this.animation, |
| this.target, |
| this.verticalOffset, |
| this.preferBelow, |
| }) : super(key: key); |
| |
| final String message; |
| final double height; |
| final EdgeInsetsGeometry padding; |
| final EdgeInsetsGeometry margin; |
| final Decoration decoration; |
| final TextStyle textStyle; |
| final Animation<double> animation; |
| final Offset target; |
| final double verticalOffset; |
| final bool preferBelow; |
| |
| @override |
| Widget build(BuildContext context) { |
| return Positioned.fill( |
| child: IgnorePointer( |
| child: CustomSingleChildLayout( |
| delegate: _TooltipPositionDelegate( |
| target: target, |
| verticalOffset: verticalOffset, |
| preferBelow: preferBelow, |
| ), |
| child: FadeTransition( |
| opacity: animation, |
| child: ConstrainedBox( |
| constraints: BoxConstraints(minHeight: height), |
| child: DefaultTextStyle( |
| style: Theme.of(context).textTheme.bodyText2, |
| child: Container( |
| decoration: decoration, |
| padding: padding, |
| margin: margin, |
| child: Center( |
| widthFactor: 1.0, |
| heightFactor: 1.0, |
| child: Text( |
| message, |
| style: textStyle, |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } |
| } |