diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index edeaa36cd1..ca49d7ffa2 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -116,6 +116,7 @@ export 'src/material/time.dart'; export 'src/material/time_picker.dart'; export 'src/material/toggleable.dart'; export 'src/material/tooltip.dart'; +export 'src/material/tooltip_theme.dart'; export 'src/material/typography.dart'; export 'src/material/user_accounts_drawer_header.dart'; export 'widgets.dart'; diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index e0c1dd6c86..1e23da22b0 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -27,6 +27,7 @@ import 'slider_theme.dart'; import 'snack_bar_theme.dart'; import 'tab_bar_theme.dart'; import 'text_theme.dart'; +import 'tooltip_theme.dart'; import 'typography.dart'; export 'package:flutter/services.dart' show Brightness; @@ -155,6 +156,7 @@ class ThemeData extends Diagnosticable { IconThemeData accentIconTheme, SliderThemeData sliderTheme, TabBarTheme tabBarTheme, + TooltipThemeData tooltipTheme, CardTheme cardTheme, ChipThemeData chipTheme, TargetPlatform platform, @@ -257,6 +259,7 @@ class ThemeData extends Diagnosticable { sliderTheme ??= const SliderThemeData(); tabBarTheme ??= const TabBarTheme(); + tooltipTheme ??= const TooltipThemeData(); appBarTheme ??= const AppBarTheme(); bottomAppBarTheme ??= const BottomAppBarTheme(); cardTheme ??= const CardTheme(); @@ -313,6 +316,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: accentIconTheme, sliderTheme: sliderTheme, tabBarTheme: tabBarTheme, + tooltipTheme: tooltipTheme, cardTheme: cardTheme, chipTheme: chipTheme, platform: platform, @@ -383,6 +387,7 @@ class ThemeData extends Diagnosticable { @required this.accentIconTheme, @required this.sliderTheme, @required this.tabBarTheme, + @required this.tooltipTheme, @required this.cardTheme, @required this.chipTheme, @required this.platform, @@ -438,6 +443,7 @@ class ThemeData extends Diagnosticable { assert(accentIconTheme != null), assert(sliderTheme != null), assert(tabBarTheme != null), + assert(tooltipTheme != null), assert(cardTheme != null), assert(chipTheme != null), assert(platform != null), @@ -658,12 +664,17 @@ class ThemeData extends Diagnosticable { /// A theme for customizing the size, shape, and color of the tab bar indicator. final TabBarTheme tabBarTheme; + /// A theme for customizing the visual properties of [Tooltip]s. + /// + /// This is the value returned from [TooltipTheme.of]. + final TooltipThemeData tooltipTheme; + /// The colors and styles used to render [Card]. /// /// This is the value returned from [CardTheme.of]. final CardTheme cardTheme; - /// The colors and styles used to render [Chip], [ + /// The colors and styles used to render [Chip]s. /// /// This is the value returned from [ChipTheme.of]. final ChipThemeData chipTheme; @@ -811,6 +822,7 @@ class ThemeData extends Diagnosticable { IconThemeData accentIconTheme, SliderThemeData sliderTheme, TabBarTheme tabBarTheme, + TooltipThemeData tooltipTheme, CardTheme cardTheme, ChipThemeData chipTheme, TargetPlatform platform, @@ -870,6 +882,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: accentIconTheme ?? this.accentIconTheme, sliderTheme: sliderTheme ?? this.sliderTheme, tabBarTheme: tabBarTheme ?? this.tabBarTheme, + tooltipTheme: tooltipTheme ?? this.tooltipTheme, cardTheme: cardTheme ?? this.cardTheme, chipTheme: chipTheme ?? this.chipTheme, platform: platform ?? this.platform, @@ -1007,6 +1020,7 @@ class ThemeData extends Diagnosticable { accentIconTheme: IconThemeData.lerp(a.accentIconTheme, b.accentIconTheme, t), sliderTheme: SliderThemeData.lerp(a.sliderTheme, b.sliderTheme, t), tabBarTheme: TabBarTheme.lerp(a.tabBarTheme, b.tabBarTheme, t), + tooltipTheme: TooltipThemeData.lerp(a.tooltipTheme, b.tooltipTheme, t), cardTheme: CardTheme.lerp(a.cardTheme, b.cardTheme, t), chipTheme: ChipThemeData.lerp(a.chipTheme, b.chipTheme, t), platform: t < 0.5 ? a.platform : b.platform, @@ -1072,6 +1086,7 @@ class ThemeData extends Diagnosticable { (otherData.accentIconTheme == accentIconTheme) && (otherData.sliderTheme == sliderTheme) && (otherData.tabBarTheme == tabBarTheme) && + (otherData.tooltipTheme == tooltipTheme) && (otherData.cardTheme == cardTheme) && (otherData.chipTheme == chipTheme) && (otherData.platform == platform) && @@ -1136,6 +1151,7 @@ class ThemeData extends Diagnosticable { accentIconTheme, sliderTheme, tabBarTheme, + tooltipTheme, cardTheme, chipTheme, platform, @@ -1198,6 +1214,7 @@ class ThemeData extends Diagnosticable { properties.add(DiagnosticsProperty('accentIconTheme', accentIconTheme)); properties.add(DiagnosticsProperty('sliderTheme', sliderTheme)); properties.add(DiagnosticsProperty('tabBarTheme', tabBarTheme)); + properties.add(DiagnosticsProperty('tooltipTheme', tooltipTheme)); properties.add(DiagnosticsProperty('cardTheme', cardTheme)); properties.add(DiagnosticsProperty('chipTheme', chipTheme)); properties.add(DiagnosticsProperty('materialTapTargetSize', materialTapTargetSize)); diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index ca0177c7df..365c4d50fc 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -8,9 +8,11 @@ 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. /// @@ -32,55 +34,54 @@ import 'theme_data.dart'; /// See also: /// /// * +/// * [TooltipTheme] or [ThemeData.tooltipTheme] 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. + /// 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 of the arguments except [child] and [decoration] must not be null. + /// 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 = _defaultTooltipHeight, - this.padding = _defaultPadding, - this.verticalOffset = _defaultVerticalOffset, - this.preferBelow = true, - this.excludeFromSemantics = false, + this.height, + this.padding, + this.verticalOffset, + this.preferBelow, + this.excludeFromSemantics, this.decoration, - this.waitDuration = _defaultWaitDuration, - this.showDuration = _defaultShowDuration, + this.textStyle, + this.waitDuration, + this.showDuration, this.child, }) : assert(message != null), - assert(height != null), - assert(padding != null), - assert(verticalOffset != null), - assert(preferBelow != null), - assert(excludeFromSemantics != null), - assert(waitDuration != null), - assert(showDuration != null), super(key: key); - static const Duration _defaultShowDuration = Duration(milliseconds: 1500); - static const Duration _defaultWaitDuration = Duration(milliseconds: 0); - static const double _defaultTooltipHeight = 32.0; - static const double _defaultVerticalOffset = 24.0; - static const EdgeInsetsGeometry _defaultPadding = EdgeInsets.symmetric(horizontal: 16.0); - /// The text to display in the tooltip. final String message; - /// They height of the tooltip's [child]. + /// The height of the tooltip's [child]. /// - /// If the [child] is null, then this is the intrinsic height. + /// 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 child. + /// 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 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. @@ -92,6 +93,10 @@ class Tooltip extends StatefulWidget { /// 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. @@ -101,18 +106,28 @@ class Tooltip extends StatefulWidget { /// Specifies the tooltip's shape and background color. /// - /// If not specified, defaults to a rounded rectangle with a border radius of - /// 4.0, and a color derived from the [ThemeData.textTheme] if the - /// [ThemeData.brightness] is dark, and [ThemeData.primaryTextTheme] if not. + /// 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 amount of time that a pointer must hover over the widget before it - /// will show a tooltip. + /// The style to use for the message of the tooltip. /// - /// Defaults to 0 milliseconds (tooltips show immediately upon hover). + /// If null, the message's [TextStyle] will be determined based on + /// [ThemeData]. If [ThemeData.brightness] is set to [Brightness.dark], + /// [ThemeData.textTheme.body1] will be used with [Colors.white]. Otherwise, + /// if [ThemeData.brightness] is set to [Brightness.light], + /// [ThemeData.textTheme.body1] 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. + /// + /// Defaults to 0 milliseconds (tooltips are shown immediately upon hover). final Duration waitDuration; - /// The amount of time that the tooltip will be shown once it has appeared. + /// The length of time that the tooltip will be shown once it has appeared. /// /// Defaults to 1.5 seconds. final Duration showDuration; @@ -124,24 +139,40 @@ class Tooltip extends StatefulWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties.add(StringProperty('message', message, showName: false)); - properties.add(DoubleProperty('height', height, defaultValue: _defaultTooltipHeight)); - properties.add(DiagnosticsProperty('padding', padding, defaultValue: _defaultPadding)); - properties.add(DoubleProperty('vertical offset', verticalOffset, defaultValue: _defaultVerticalOffset)); - properties.add(FlagProperty('position', value: preferBelow, ifTrue: 'below', ifFalse: 'above', showName: true)); - properties.add(FlagProperty('semantics', value: excludeFromSemantics, ifTrue: 'excluded', showName: true, defaultValue: false)); - properties.add(DiagnosticsProperty('wait duration', waitDuration, defaultValue: _defaultWaitDuration)); - properties.add(DiagnosticsProperty('show duration', showDuration, defaultValue: _defaultShowDuration)); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, 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('wait duration', waitDuration, defaultValue: null)); + properties.add(DiagnosticsProperty('show duration', showDuration, defaultValue: null)); } } class _TooltipState extends State 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 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; + 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; @@ -190,7 +221,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { } if (_longPressActivated) { // Tool tips activated by long press should stay around for 1.5s. - _hideTimer ??= Timer(widget.showDuration, _controller.reverse); + _hideTimer ??= Timer(showDuration, _controller.reverse); } else { // Tool tips activated by hover should disappear as soon as the mouse // leaves the control. @@ -206,7 +237,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { ensureTooltipVisible(); return; } - _showTimer ??= Timer(widget.waitDuration, ensureTooltipVisible); + _showTimer ??= Timer(waitDuration, ensureTooltipVisible); } /// Shows the tooltip if it is not already visible. @@ -236,16 +267,17 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { // rebuilds. final Widget overlay = _TooltipOverlay( message: widget.message, - height: widget.height, - padding: widget.padding, - decoration: widget.decoration, + height: height, + padding: padding, + decoration: decoration, + textStyle: textStyle, animation: CurvedAnimation( parent: _controller, curve: Curves.fastOutSlowIn, ), target: target, - verticalOffset: widget.verticalOffset, - preferBelow: widget.preferBelow, + verticalOffset: verticalOffset, + preferBelow: preferBelow, ); _entry = OverlayEntry(builder: (BuildContext context) => overlay); Overlay.of(context, debugRequiredFor: widget).insert(_entry); @@ -300,12 +332,46 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { @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.body1.copyWith( + color: Colors.black, + debugLabel: theme.textTheme.body1.debugLabel.replaceAll('white', 'black'), + ); + defaultDecoration = BoxDecoration( + color: Colors.white.withOpacity(0.9), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ); + } else { + defaultTextStyle = theme.textTheme.body1.copyWith( + color: Colors.white, + debugLabel: theme.textTheme.body1.debugLabel.replaceAll('black', '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; + 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: widget.excludeFromSemantics ? null : widget.message, + label: excludeFromSemantics ? null : widget.message, child: widget.child, ), ); @@ -345,7 +411,7 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate { /// tooltip. final double verticalOffset; - /// Whether the tooltip defaults to being displayed below the widget. + /// 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. @@ -380,6 +446,7 @@ class _TooltipOverlay extends StatelessWidget { this.height, this.padding, this.decoration, + this.textStyle, this.animation, this.target, this.verticalOffset, @@ -390,6 +457,7 @@ class _TooltipOverlay extends StatelessWidget { final double height; final EdgeInsetsGeometry padding; final Decoration decoration; + final TextStyle textStyle; final Animation animation; final Offset target; final double verticalOffset; @@ -397,12 +465,6 @@ class _TooltipOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - final ThemeData theme = Theme.of(context); - final ThemeData tooltipTheme = ThemeData( - brightness: Brightness.dark, - textTheme: theme.brightness == Brightness.dark ? theme.textTheme : theme.primaryTextTheme, - platform: theme.platform, - ); return Positioned.fill( child: IgnorePointer( child: CustomSingleChildLayout( @@ -416,15 +478,15 @@ class _TooltipOverlay extends StatelessWidget { child: ConstrainedBox( constraints: BoxConstraints(minHeight: height), child: Container( - decoration: decoration ?? BoxDecoration( - color: tooltipTheme.backgroundColor.withOpacity(0.9), - borderRadius: BorderRadius.circular(4.0), - ), + decoration: decoration, padding: padding, child: Center( widthFactor: 1.0, heightFactor: 1.0, - child: Text(message, style: tooltipTheme.textTheme.body1), + child: Text( + message, + style: textStyle, + ), ), ), ), diff --git a/packages/flutter/lib/src/material/tooltip_theme.dart b/packages/flutter/lib/src/material/tooltip_theme.dart new file mode 100644 index 0000000000..713229f8e9 --- /dev/null +++ b/packages/flutter/lib/src/material/tooltip_theme.dart @@ -0,0 +1,251 @@ +// Copyright 2019 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:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'theme.dart'; + +/// Defines the visual properties of [Tooltip] widgets. +/// +/// Used by [TooltipTheme] to control the visual properties of tooltips in a +/// widget subtree. +/// +/// To obtain this configuration, use [TooltipTheme.of] to access the closest +/// ancestor [TooltipTheme] of the current [BuildContext]. +/// +/// See also: +/// +/// * [TooltipTheme], an [InheritedWidget] that propagates the theme down its +/// subtree. +/// * [TooltipThemeData], which describes the actual configuration of a +/// tooltip theme. +class TooltipThemeData extends Diagnosticable { + /// Creates the set of properties used to configure [Tooltip]s. + const TooltipThemeData({ + this.height, + this.padding, + this.verticalOffset, + this.preferBelow, + this.excludeFromSemantics, + this.decoration, + this.textStyle, + this.waitDuration, + this.showDuration, + }); + + /// The height of [Tooltip.child]. + final double height; + + /// If provided, the amount of space by which to inset [Tooltip.child]. + final EdgeInsetsGeometry padding; + + /// 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 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; + + /// Whether the tooltip's [message] should be excluded from the semantics + /// tree. + /// + /// By default, [Tooltip]s 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 [Tooltip]'s shape and background color. + final Decoration decoration; + + /// The style to use for the message of [Tooltip]s. + final TextStyle textStyle; + + /// The length of time that a pointer must hover over a tooltip's widget + /// before the tooltip will be shown. + final Duration waitDuration; + + /// The length of time that the tooltip will be shown once it has appeared. + final Duration showDuration; + + /// Creates a copy of this object but with the given fields replaced with the + /// new values. + TooltipThemeData copyWith({ + double height, + EdgeInsetsGeometry padding, + double verticalOffset, + bool preferBelow, + bool excludeFromSemantics, + Decoration decoration, + TextStyle textStyle, + Duration waitDuration, + Duration showDuration, + }) { + return TooltipThemeData( + height: height ?? this.height, + padding: padding ?? this.padding, + verticalOffset: verticalOffset ?? this.verticalOffset, + preferBelow: preferBelow ?? this.preferBelow, + excludeFromSemantics: excludeFromSemantics ?? this.excludeFromSemantics, + decoration: decoration ?? this.decoration, + textStyle: textStyle ?? this.textStyle, + waitDuration: waitDuration ?? this.waitDuration, + showDuration: showDuration ?? this.showDuration, + ); + } + + /// Linearly interpolate between two tooltip themes. + /// + /// If both arguments are null, then null is returned. + /// + /// {@macro dart.ui.shadow.lerp} + static TooltipThemeData lerp(TooltipThemeData a, TooltipThemeData b, double t) { + if (a == null && b == null) + return null; + assert(t != null); + return TooltipThemeData( + height: lerpDouble(a?.height, b?.height, t), + padding: EdgeInsets.lerp(a?.padding, b?.padding, t), + verticalOffset: lerpDouble(a?.verticalOffset, b?.verticalOffset, t), + preferBelow: t < 0.5 ? a.preferBelow: b.preferBelow, + excludeFromSemantics: t < 0.5 ? a.excludeFromSemantics : b.excludeFromSemantics, + decoration: Decoration.lerp(a?.decoration, b?.decoration, t), + textStyle: TextStyle.lerp(a?.textStyle, b?.textStyle, t), + ); + } + + @override + int get hashCode { + return hashValues( + height, + padding, + verticalOffset, + preferBelow, + excludeFromSemantics, + decoration, + textStyle, + waitDuration, + showDuration, + ); + } + + @override + bool operator==(Object other) { + if (identical(this, other)) + return true; + if (other.runtimeType != runtimeType) + return false; + final TooltipThemeData typedOther = other; + return typedOther.height == height + && typedOther.padding == padding + && typedOther.verticalOffset == verticalOffset + && typedOther.preferBelow == preferBelow + && typedOther.excludeFromSemantics == excludeFromSemantics + && typedOther.decoration == decoration + && typedOther.textStyle == textStyle + && typedOther.waitDuration == waitDuration + && typedOther.showDuration == showDuration; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('height', height, defaultValue: null)); + properties.add(DiagnosticsProperty('padding', padding, 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('decoration', decoration, defaultValue: null)); + properties.add(DiagnosticsProperty('textStyle', textStyle, defaultValue: null)); + properties.add(DiagnosticsProperty('wait duration', waitDuration, defaultValue: null)); + properties.add(DiagnosticsProperty('show duration', showDuration, defaultValue: null)); + } +} + +/// An inherited widget that defines the configuration for +/// [Tooltip]s in this widget's subtree. +/// +/// Values specified here are used for [Tooltip] properties that are not +/// given an explicit non-null value. +/// +/// {@tool sample} +/// +/// Here is an example of a tooltip theme that applies a blue foreground +/// with non-rounded corners. +/// +/// ```dart +/// TooltipTheme( +/// decoration: BoxDecoration( +/// color: Colors.blue.withOpacity(0.9), +/// borderRadius: BorderRadius.zero, +/// ), +/// child: Tooltip( +/// message: 'Example tooltip', +/// child: IconButton( +/// iconSize: 36.0, +/// icon: Icon(Icons.touch_app), +/// onPressed: () {}, +/// ), +/// ), +/// ), +/// ``` +/// {@end-tool} +class TooltipTheme extends InheritedWidget { + /// Creates a tooltip theme that controls the configurations for + /// [Tooltip]. + TooltipTheme({ + Key key, + double height, + EdgeInsetsGeometry padding, + double verticalOffset, + bool preferBelow, + bool excludeFromSemantics, + Decoration decoration, + TextStyle textStyle, + Duration waitDuration, + Duration showDuration, + Widget child, + }) : data = TooltipThemeData( + height: height, + padding: padding, + verticalOffset: verticalOffset, + preferBelow: preferBelow, + excludeFromSemantics: excludeFromSemantics, + decoration: decoration, + textStyle: textStyle, + waitDuration: waitDuration, + showDuration: showDuration, + ), + super(key: key, child: child); + + /// The properties for descendant [Tooltip] widgets. + final TooltipThemeData data; + + /// Returns the [data] from the closest [TooltipTheme] ancestor. If there is + /// no ancestor, it returns [ThemeData.tooltipTheme]. Applications can assume + /// that the returned value will not be null. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// TooltipThemeData theme = TooltipTheme.of(context); + /// ``` + static TooltipThemeData of(BuildContext context) { + final TooltipTheme tooltipTheme = context.inheritFromWidgetOfExactType(TooltipTheme); + return tooltipTheme?.data ?? Theme.of(context).tooltipTheme; + } + + @override + bool updateShouldNotify(TooltipTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index b03a0a4c97..2e730a9996 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -423,6 +423,81 @@ void main() { expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(324.0)); }, skip: isBrowser); + testWidgets('Default tooltip message textStyle - light', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + )); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget(find.text(tooltipText)).style; + expect(textStyle.color, Colors.white); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect(textStyle.debugLabel, '(englishLike body1 2014).merge(whiteMountainView body1)'); + }); + + testWidgets('Default tooltip message textStyle - dark', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + brightness: Brightness.dark, + ), + home: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + )); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget(find.text(tooltipText)).style; + expect(textStyle.color, Colors.black); + expect(textStyle.fontFamily, 'Roboto'); + expect(textStyle.decoration, TextDecoration.none); + expect(textStyle.debugLabel, '(englishLike body1 2014).merge(blackMountainView body1)'); + }); + + testWidgets('Custom tooltip message textStyle', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: Tooltip( + key: key, + textStyle: const TextStyle( + color: Colors.orange, + decoration: TextDecoration.underline + ), + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + )); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget(find.text(tooltipText)).style; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + testWidgets('Does tooltip end up with the right default size, shape, and color', (WidgetTester tester) async { final GlobalKey key = GlobalKey(); await tester.pumpWidget( @@ -856,7 +931,6 @@ void main() { expect(description, [ '"message"', - 'position: below', ]); }); testWidgets('Tooltip implements debugFillProperties', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/tooltip_theme_test.dart b/packages/flutter/test/material/tooltip_theme_test.dart new file mode 100644 index 0000000000..722b6760c7 --- /dev/null +++ b/packages/flutter/test/material/tooltip_theme_test.dart @@ -0,0 +1,1118 @@ +// Copyright 2019 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 'package:flutter/services.dart'; +import 'package:flutter/src/material/tooltip_theme.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; + +// This file uses "as dynamic" in a few places to defeat the static +// analysis. In general you want to avoid using this style in your +// code, as it will cause the analyzer to be unable to help you catch +// errors. +// +// In this case, we do it because we are trying to call internal +// methods of the tooltip code in order to test it. Normally, the +// state of a tooltip is a private class, but by using a GlobalKey we +// can get a handle to that object and by using "as dynamic" we can +// bypass the analyzer's type checks and call methods that we aren't +// supposed to be able to know about. +// +// It's ok to do this in tests, but you really don't want to do it in +// production code. + +const String tooltipText = 'TIP'; + +void main() { + test('TooltipThemeData copyWith, ==, hashCode basics', () { + expect(const TooltipThemeData(), const TooltipThemeData().copyWith()); + expect(const TooltipThemeData().hashCode, const TooltipThemeData().copyWith().hashCode); + }); + + test('TooltipThemeData defaults', () { + const TooltipThemeData theme = TooltipThemeData(); + expect(theme.height, null); + expect(theme.padding, null); + expect(theme.verticalOffset, null); + expect(theme.preferBelow, null); + expect(theme.excludeFromSemantics, null); + expect(theme.decoration, null); + expect(theme.textStyle, null); + expect(theme.waitDuration, null); + expect(theme.showDuration, null); + }); + + testWidgets('Default TooltipThemeData debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const TooltipThemeData().debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, []); + }); + + testWidgets('TooltipThemeData implements debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + const TooltipThemeData( + height: 15.0, + padding: EdgeInsets.all(20.0), + verticalOffset: 10.0, + preferBelow: false, + excludeFromSemantics: true, + decoration: BoxDecoration(color: Color(0xffffffff)), + textStyle: TextStyle(decoration: TextDecoration.underline), + waitDuration: Duration(milliseconds: 100), + showDuration: Duration(milliseconds: 200), + ).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()) + .toList(); + + expect(description, [ + 'height: 15.0', + 'padding: EdgeInsets.all(20.0)', + 'vertical offset: 10.0', + 'position: above', + 'semantics: excluded', + 'decoration: BoxDecoration(color: Color(0xffffffff))', + 'textStyle: TextStyle(inherit: true, decoration: TextDecoration.underline)', + 'wait duration: 0:00:00.100000', + 'show duration: 0:00:00.200000' + ]); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer above fits - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 100.0, + padding: EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: false, + ), + ), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-100.0 height + * | * }-100.0 vertical offset + * o * y=300.0 + * * + * * + * * + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(100.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(100.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(200.0)); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer above fits - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TooltipTheme( + height: 100.0, + padding: const EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: false, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-100.0 height + * | * }-100.0 vertical offset + * o * y=300.0 + * * + * * + * * + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(100.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(100.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(200.0)); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer above does not fit - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: false, + ), + ), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 299.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + // we try to put it here but it doesn't fit: + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-190.0 height (starts at y=9.0) + * | * }-100.0 vertical offset + * o * y=299.0 + * * + * * + * * + *********************/ + + // so we put it here: + /********************* 800x600 screen + * * + * * + * o * y=299.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(399.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(589.0)); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer above does not fit - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TooltipTheme( + height: 190.0, + padding: const EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: false, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 299.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + // we try to put it here but it doesn't fit: + /********************* 800x600 screen + * ___ * }- 10.0 margin + * |___| * }-190.0 height (starts at y=9.0) + * | * }-100.0 vertical offset + * o * y=299.0 + * * + * * + * * + *********************/ + + // so we put it here: + /********************* 800x600 screen + * * + * * + * o * y=299.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(399.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(589.0)); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center preferBelow fits - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + height: 190.0, + padding: EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: true, + ), + ), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pumpAndSettle(); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(400.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(590.0)); + }); + + testWidgets('Tooltip verticalOffset, preferBelow; center prefer below fits - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TooltipTheme( + height: 190.0, + padding: const EdgeInsets.all(0.0), + verticalOffset: 100.0, + preferBelow: true, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Stack( + children: [ + Positioned( + left: 400.0, + top: 300.0, + child: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ), + ), + ], + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pumpAndSettle(); // faded in, show timer started (and at 0.0) + + /********************* 800x600 screen + * * + * * + * o * y=300.0 + * _|_ * }-100.0 vertical offset + * |___| * }-190.0 height + * * }- 10.0 margin + *********************/ + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent; + expect(tip.size.height, equals(190.0)); + expect(tip.localToGlobal(tip.size.topLeft(Offset.zero)).dy, equals(400.0)); + expect(tip.localToGlobal(tip.size.bottomRight(Offset.zero)).dy, equals(590.0)); + }); + + testWidgets('Tooltip message textStyle - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + textStyle: TextStyle( + color: Colors.orange, + decoration: TextDecoration.underline + ), + ), + ), + home: Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + )); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget(find.text(tooltipText)).style; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + + testWidgets('Tooltip message textStyle - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: TooltipTheme( + child: Tooltip( + textStyle: const TextStyle( + color: Colors.orange, + decoration: TextDecoration.underline + ), + key: key, + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + ), + )); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final TextStyle textStyle = tester.widget(find.text(tooltipText)).style; + expect(textStyle.color, Colors.orange); + expect(textStyle.fontFamily, null); + expect(textStyle.decoration, TextDecoration.underline); + }); + + testWidgets('Tooltip decoration - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + decoration: customDecoration, + ), + ), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent.parent.parent.parent; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect(tip, paints..path( + color: const Color(0x80800000), + )); + }, skip: isBrowser); + + testWidgets('Tooltip decoration - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + const Decoration customDecoration = ShapeDecoration( + shape: StadiumBorder(), + color: Color(0x80800000), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TooltipTheme( + decoration: customDecoration, + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: key, + message: tooltipText, + child: Container( + width: 0.0, + height: 0.0, + ), + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0) + + final RenderBox tip = tester.renderObject(find.text(tooltipText)).parent.parent.parent.parent; + + expect(tip.size.height, equals(32.0)); + expect(tip.size.width, equals(74.0)); + expect(tip, paints..path( + color: const Color(0x80800000), + )); + }, skip: isBrowser); + + testWidgets('Tooltip height and padding - ThemeData.tooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + const double customTooltipHeight = 100.0; + const double customPaddingVal = 20.0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + height: customTooltipHeight, + padding: EdgeInsets.all(customPaddingVal), + ), + ), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: key, + message: tooltipText, + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pumpAndSettle(); + + final RenderBox tip = tester.renderObject(find.ancestor( + of: find.text(tooltipText), + matching: find.byType(Padding), + )); + final RenderBox content = tester.renderObject(find.ancestor( + of: find.text(tooltipText), + matching: find.byType(Center), + )); + + expect(tip.size.height, equals(customTooltipHeight)); + expect(content.size.height, equals(customTooltipHeight - 2 * customPaddingVal)); + expect(content.size.width, equals(tip.size.width - 2 * customPaddingVal)); + }, skip: isBrowser); + + testWidgets('Tooltip height and padding - TooltipTheme', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + const double customTooltipHeight = 100.0; + const double customPaddingVal = 20.0; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: TooltipTheme( + height: customTooltipHeight, + padding: const EdgeInsets.all(customPaddingVal), + child: Overlay( + initialEntries: [ + OverlayEntry( + builder: (BuildContext context) { + return Tooltip( + key: key, + message: tooltipText, + ); + }, + ), + ], + ), + ), + ), + ); + (key.currentState as dynamic).ensureTooltipVisible(); // Before using "as dynamic" in your code, see note at the top of the file. + await tester.pumpAndSettle(); + + final RenderBox tip = tester.renderObject(find.ancestor( + of: find.text(tooltipText), + matching: find.byType(Padding), + )); + final RenderBox content = tester.renderObject(find.ancestor( + of: find.text(tooltipText), + matching: find.byType(Center), + )); + + expect(tip.size.height, equals(customTooltipHeight)); + expect(content.size.height, equals(customTooltipHeight - 2 * customPaddingVal)); + expect(content.size.width, equals(tip.size.width - 2 * customPaddingVal)); + }, skip: isBrowser); + + testWidgets('Tooltip waitDuration - ThemeData.tooltipTheme', (WidgetTester tester) async { + const Duration customWaitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + waitDuration: customWaitDuration, + ), + ), + child: Center( + child: Tooltip( + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ) + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsNothing); // Should not appear yet + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsOneWidget); // Should appear after customWaitDuration + + await gesture.moveTo(Offset.zero); + await tester.pump(); + + // Wait for it to disappear. + await tester.pump(const Duration(milliseconds: 0)); // Should immediately disappear + expect(find.text(tooltipText), findsNothing); + await gesture.removePointer(); + }); + + testWidgets('Tooltip waitDuration - TooltipTheme', (WidgetTester tester) async { + const Duration customWaitDuration = Duration(milliseconds: 500); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + waitDuration: customWaitDuration, + child: Center( + child: Tooltip( + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ) + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsNothing); // Should not appear yet + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text(tooltipText), findsOneWidget); // Should appear after customWaitDuration + + await gesture.moveTo(Offset.zero); + await tester.pump(); + + // Wait for it to disappear. + await tester.pump(const Duration(milliseconds: 0)); // Should immediately disappear + expect(find.text(tooltipText), findsNothing); + await gesture.removePointer(); + }); + + testWidgets('Tooltip showDuration - ThemeData.tooltipTheme', (WidgetTester tester) async { + const Duration customShowDuration = Duration(milliseconds: 3000); + await tester.pumpWidget( + MaterialApp( + home: Theme( + data: ThemeData( + tooltipTheme: const TooltipThemeData( + showDuration: customShowDuration, + ), + ), + child: Center( + child: Tooltip( + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ) + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gesture.up(); + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2000)); // Tooltip should remain + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); // Tooltip should fade out after + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip showDuration - TooltipTheme', (WidgetTester tester) async { + const Duration customShowDuration = Duration(milliseconds: 3000); + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + showDuration: customShowDuration, + child: Center( + child: Tooltip( + message: tooltipText, + child: Container( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ) + ); + + final Finder tooltip = find.byType(Tooltip); + final TestGesture gesture = await tester.startGesture(tester.getCenter(tooltip)); + await tester.pump(); + await tester.pump(kLongPressTimeout); + await gesture.up(); + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 2000)); // Tooltip should remain + expect(find.text(tooltipText), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); // Tooltip should fade out after + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Semantics included by default - ThemeData.tooltipTheme', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: const Center( + child: Tooltip( + message: 'Foo', + child: Text('Bar'), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + children: [ + TestSemantics( + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + label: 'Foo\nBar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), ignoreRect: true, ignoreId: true, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('Semantics included by default - TooltipTheme', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + child: const Center( + child: Tooltip( + message: 'Foo', + child: Text('Bar'), + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + children: [ + TestSemantics( + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + label: 'Foo\nBar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), ignoreRect: true, ignoreId: true, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('Semantics excluded - ThemeData.tooltipTheme', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + tooltipTheme: const TooltipThemeData( + excludeFromSemantics: true, + ), + ), + home: const Center( + child: Tooltip( + message: 'Foo', + child: Text('Bar'), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + children: [ + TestSemantics( + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + label: 'Bar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), ignoreRect: true, ignoreId: true, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('Semantics excluded - TooltipTheme', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + excludeFromSemantics: true, + child: const Center( + child: Tooltip( + message: 'Foo', + child: Text('Bar'), + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + children: [ + TestSemantics( + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + label: 'Bar', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ), ignoreRect: true, ignoreId: true, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('has semantic events by default - ThemeData.tooltipTheme', (WidgetTester tester) async { + final List semanticEvents = []; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) async { + semanticEvents.add(message); + }); + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(), + home: Center( + child: Tooltip( + message: 'Foo', + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + final RenderObject object = tester.firstRenderObject(find.byType(Tooltip)); + + expect(semanticEvents, unorderedEquals([ + { + 'type': 'longPress', + 'nodeId': findDebugSemantics(object).id, + 'data': {}, + }, + { + 'type': 'tooltip', + 'data': { + 'message': 'Foo', + }, + }, + ])); + semantics.dispose(); + SystemChannels.accessibility.setMockMessageHandler(null); + }); + + testWidgets('has semantic events by default - TooltipTheme', (WidgetTester tester) async { + final List semanticEvents = []; + SystemChannels.accessibility.setMockMessageHandler((dynamic message) async { + semanticEvents.add(message); + }); + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + MaterialApp( + home: TooltipTheme( + child: Center( + child: Tooltip( + message: 'Foo', + child: Container( + width: 100.0, + height: 100.0, + color: Colors.green[500], + ), + ), + ), + ), + ), + ); + + await tester.longPress(find.byType(Tooltip)); + final RenderObject object = tester.firstRenderObject(find.byType(Tooltip)); + + expect(semanticEvents, unorderedEquals([ + { + 'type': 'longPress', + 'nodeId': findDebugSemantics(object).id, + 'data': {}, + }, + { + 'type': 'tooltip', + 'data': { + 'message': 'Foo', + }, + }, + ])); + semantics.dispose(); + SystemChannels.accessibility.setMockMessageHandler(null); + }); + + testWidgets('default Tooltip debugFillProperties', (WidgetTester tester) async { + final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); + + const Tooltip(message: 'message',).debugFillProperties(builder); + + final List description = builder.properties + .where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info)) + .map((DiagnosticsNode node) => node.toString()).toList(); + + expect(description, [ + '"message"', + ]); + }); +} + +SemanticsNode findDebugSemantics(RenderObject object) { + if (object.debugSemantics != null) + return object.debugSemantics; + return findDebugSemantics(object.parent); +}