Unverified Commit 15967669 authored by Kenzie (Schmoll) Davisson's avatar Kenzie (Schmoll) Davisson Committed by GitHub

Add `richMessage` parameter to the `Tooltip` widget. (#88539)

parent f95e18ae
// 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.
// Template: dev/snippets/config/templates/stateless_widget_scaffold_center.tmpl
//
// Comment lines marked with "▼▼▼" and "▲▲▲" are used for authoring
// of samples, and may be ignored if you are just exploring the sample.
// Flutter code sample for Tooltip
//
//***************************************************************************
//* ▼▼▼▼▼▼▼▼ description ▼▼▼▼▼▼▼▼ (do not modify or remove section marker)
// This example shows a rich [Tooltip] that specifies the [richMessage]
// parameter instead of the [message] parameter (only one of these may be
// non-null. Any [InlineSpan] can be specified for the [richMessage] attribute,
// including [WidgetSpan].
//* ▲▲▲▲▲▲▲▲ description ▲▲▲▲▲▲▲▲ (do not modify or remove section marker)
//***************************************************************************
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
/// This is the main application widget.
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
static const String _title = 'Flutter Code Sample';
@override
Widget build(BuildContext context) {
return MaterialApp(
title: _title,
home: Scaffold(
appBar: AppBar(title: const Text(_title)),
body: const Center(
child: MyStatelessWidget(),
),
),
);
}
}
/// This is the stateless widget that the main application instantiates.
class MyStatelessWidget extends StatelessWidget {
const MyStatelessWidget({Key? key}) : super(key: key);
@override
//********************************************************************
//* ▼▼▼▼▼▼▼▼ code ▼▼▼▼▼▼▼▼ (do not modify or remove section marker)
Widget build(BuildContext context) {
return const Tooltip(
richMessage: TextSpan(
text: 'I am a rich tooltip. ',
style: TextStyle(color: Colors.red),
children: <InlineSpan>[
TextSpan(
text: 'I am another span of this rich tooltip',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
child: Text('Tap this text and hold down to show a tooltip.'),
);
}
//* ▲▲▲▲▲▲▲▲ code ▲▲▲▲▲▲▲▲ (do not modify or remove section marker)
//********************************************************************
}
...@@ -628,7 +628,7 @@ class FloatingActionButton extends StatelessWidget { ...@@ -628,7 +628,7 @@ class FloatingActionButton extends StatelessWidget {
if (tooltip != null) { if (tooltip != null) {
result = Tooltip( result = Tooltip(
message: tooltip!, message: tooltip,
child: result, child: result,
); );
} }
......
...@@ -323,7 +323,7 @@ class IconButton extends StatelessWidget { ...@@ -323,7 +323,7 @@ class IconButton extends StatelessWidget {
if (tooltip != null) { if (tooltip != null) {
result = Tooltip( result = Tooltip(
message: tooltip!, message: tooltip,
child: result, child: result,
); );
} }
......
...@@ -759,9 +759,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget { ...@@ -759,9 +759,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget {
}) : super(key: key); }) : super(key: key);
/// The text that is rendered in the tooltip when it appears. /// The text that is rendered in the tooltip when it appears.
/// final String message;
/// If [message] is null, no tooltip will be used.
final String? message;
/// The widget that, when pressed, will show a tooltip. /// The widget that, when pressed, will show a tooltip.
final Widget child; final Widget child;
...@@ -772,7 +770,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget { ...@@ -772,7 +770,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget {
return child; return child;
} }
return Tooltip( return Tooltip(
message: message!, message: message,
// TODO(johnsonmh): Make this value configurable/themable. // TODO(johnsonmh): Make this value configurable/themable.
verticalOffset: 42, verticalOffset: 42,
excludeFromSemantics: true, excludeFromSemantics: true,
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -55,6 +56,15 @@ import 'tooltip_theme.dart'; ...@@ -55,6 +56,15 @@ import 'tooltip_theme.dart';
/// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart ** /// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart **
/// {@end-tool} /// {@end-tool}
/// ///
/// {@tool dartpad --template=stateless_widget_scaffold_center}
/// This example shows a rich [Tooltip] that specifies the [richMessage]
/// parameter instead of the [message] parameter (only one of these may be
/// non-null. Any [InlineSpan] can be specified for the [richMessage] attribute,
/// including [WidgetSpan].
///
/// ** See code in examples/api/lib/material/tooltip/tooltip.2.dart **
/// {@end-tool}
///
/// See also: /// See also:
/// ///
/// * <https://material.io/design/components/tooltips.html> /// * <https://material.io/design/components/tooltips.html>
...@@ -70,9 +80,12 @@ class Tooltip extends StatefulWidget { ...@@ -70,9 +80,12 @@ class Tooltip extends StatefulWidget {
/// ///
/// All parameters that are defined in the constructor will /// All parameters that are defined in the constructor will
/// override the default values _and_ the values in [TooltipTheme.of]. /// override the default values _and_ the values in [TooltipTheme.of].
///
/// Only one of [message] and [richMessage] may be non-null.
const Tooltip({ const Tooltip({
Key? key, Key? key,
required this.message, this.message,
this.richMessage,
this.height, this.height,
this.padding, this.padding,
this.margin, this.margin,
...@@ -86,11 +99,24 @@ class Tooltip extends StatefulWidget { ...@@ -86,11 +99,24 @@ class Tooltip extends StatefulWidget {
this.child, this.child,
this.triggerMode, this.triggerMode,
this.enableFeedback, this.enableFeedback,
}) : assert(message != null), }) : assert((message == null) != (richMessage == null), 'Either `message` or `richMessage` must be specified'),
super(key: key); assert(
richMessage == null || textStyle == null,
'If `richMessage` is specified, `textStyle` will have no effect. '
'If you wish to provide a `textStyle` for a rich tooltip, add the '
'`textStyle` directly to the `richMessage` InlineSpan.',
),
super(key: key);
/// The text to display in the tooltip. /// The text to display in the tooltip.
final String message; ///
/// Only one of [message] and [richMessage] may be non-null.
final String? message;
/// The rich text to display in the tooltip.
///
/// Only one of [message] and [richMessage] may be non-null.
final InlineSpan? richMessage;
/// The height of the tooltip's [child]. /// The height of the tooltip's [child].
/// ///
...@@ -131,12 +157,13 @@ class Tooltip extends StatefulWidget { ...@@ -131,12 +157,13 @@ class Tooltip extends StatefulWidget {
/// direction. /// direction.
final bool? preferBelow; final bool? preferBelow;
/// Whether the tooltip's [message] should be excluded from the semantics /// Whether the tooltip's [message] or [richMessage] should be excluded from
/// tree. /// the semantics tree.
/// ///
/// Defaults to false. A tooltip will add a [Semantics] label that is set to /// 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 /// [Tooltip.message] if non-null, or the plain text value of
/// provide its own custom semantics label. /// [Tooltip.richMessage] otherwise. Set this property to true if the app is
/// going to provide its own custom semantics label.
final bool? excludeFromSemantics; final bool? excludeFromSemantics;
/// The widget below this widget in the tree. /// The widget below this widget in the tree.
...@@ -241,7 +268,18 @@ class Tooltip extends StatefulWidget { ...@@ -241,7 +268,18 @@ class Tooltip extends StatefulWidget {
@override @override
void debugFillProperties(DiagnosticPropertiesBuilder properties) { void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties); super.debugFillProperties(properties);
properties.add(StringProperty('message', message, showName: false)); properties.add(StringProperty(
'message',
message,
showName: message == null,
defaultValue: message == null ? null : kNoDefaultValue,
));
properties.add(StringProperty(
'richMessage',
richMessage?.toPlainText(),
showName: richMessage == null,
defaultValue: richMessage == null ? null : kNoDefaultValue,
));
properties.add(DoubleProperty('height', height, defaultValue: null)); properties.add(DoubleProperty('height', height, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null)); properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null)); properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
...@@ -290,6 +328,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -290,6 +328,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late bool _isConcealed; late bool _isConcealed;
late bool _forceRemoval; late bool _forceRemoval;
/// The plain text message for this tooltip.
///
/// This value will either come from [widget.message] or [widget.richMessage].
String get _tooltipMessage => widget.message ?? widget.richMessage!.toPlainText();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -428,7 +471,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -428,7 +471,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
)!; )!;
overlayState.insert(_entry!); overlayState.insert(_entry!);
} }
SemanticsService.tooltip(widget.message); SemanticsService.tooltip(_tooltipMessage);
_controller.forward(); _controller.forward();
} }
...@@ -487,7 +530,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -487,7 +530,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
final Widget overlay = Directionality( final Widget overlay = Directionality(
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
child: _TooltipOverlay( child: _TooltipOverlay(
message: widget.message, richMessage: widget.richMessage ?? TextSpan(text: widget.message),
height: height, height: height,
padding: padding, padding: padding,
margin: margin, margin: margin,
...@@ -507,7 +550,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -507,7 +550,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_entry = OverlayEntry(builder: (BuildContext context) => overlay); _entry = OverlayEntry(builder: (BuildContext context) => overlay);
_isConcealed = false; _isConcealed = false;
overlayState.insert(_entry!); overlayState.insert(_entry!);
SemanticsService.tooltip(widget.message); SemanticsService.tooltip(_tooltipMessage);
if (_mouseIsConnected) { if (_mouseIsConnected) {
// Hovered tooltips shouldn't show more than one at once. For example, a chip with // Hovered tooltips shouldn't show more than one at once. For example, a chip with
// a delete icon shouldn't show both the delete icon tooltip and the chip tooltip // a delete icon shouldn't show both the delete icon tooltip and the chip tooltip
...@@ -580,7 +623,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -580,7 +623,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
// If message is empty then no need to create a tooltip overlay to show // If message is empty then no need to create a tooltip overlay to show
// the empty black container so just return the wrapped child as is or // the empty black container so just return the wrapped child as is or
// empty container if child is not specified. // empty container if child is not specified.
if (widget.message.isEmpty) { if (_tooltipMessage.isEmpty) {
return widget.child ?? const SizedBox(); return widget.child ?? const SizedBox();
} }
assert(Overlay.of(context, debugRequiredFor: widget) != null); assert(Overlay.of(context, debugRequiredFor: widget) != null);
...@@ -629,7 +672,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin { ...@@ -629,7 +672,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null, onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null,
excludeFromSemantics: true, excludeFromSemantics: true,
child: Semantics( child: Semantics(
label: excludeFromSemantics ? null : widget.message, label: excludeFromSemantics
? null
: _tooltipMessage,
child: widget.child, child: widget.child,
), ),
); );
...@@ -700,8 +745,8 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate { ...@@ -700,8 +745,8 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
class _TooltipOverlay extends StatelessWidget { class _TooltipOverlay extends StatelessWidget {
const _TooltipOverlay({ const _TooltipOverlay({
Key? key, Key? key,
required this.message,
required this.height, required this.height,
required this.richMessage,
this.padding, this.padding,
this.margin, this.margin,
this.decoration, this.decoration,
...@@ -714,7 +759,7 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -714,7 +759,7 @@ class _TooltipOverlay extends StatelessWidget {
this.onExit, this.onExit,
}) : super(key: key); }) : super(key: key);
final String message; final InlineSpan richMessage;
final double height; final double height;
final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
...@@ -743,8 +788,8 @@ class _TooltipOverlay extends StatelessWidget { ...@@ -743,8 +788,8 @@ class _TooltipOverlay extends StatelessWidget {
child: Center( child: Center(
widthFactor: 1.0, widthFactor: 1.0,
heightFactor: 1.0, heightFactor: 1.0,
child: Text( child: Text.rich(
message, richMessage,
style: textStyle, style: textStyle,
), ),
), ),
......
...@@ -1319,6 +1319,63 @@ void main() { ...@@ -1319,6 +1319,63 @@ void main() {
expect(tip.size.height, equals(56.0)); expect(tip.size.height, equals(56.0));
}); });
testWidgets('Tooltip text displays with richMessage', (WidgetTester tester) async {
final GlobalKey key = GlobalKey();
const String textSpan1Text = 'I am a rich tooltip message. ';
const String textSpan2Text = 'I am another span of a rich tooltip message';
await tester.pumpWidget(
MaterialApp(
home: Tooltip(
key: key,
richMessage: const TextSpan(
text: textSpan1Text,
children: <InlineSpan>[
TextSpan(
text: textSpan2Text,
),
],
),
child: Container(
width: 100.0,
height: 100.0,
color: Colors.green[500],
),
),
),
);
_ensureTooltipVisible(key);
await tester.pump(const Duration(seconds: 2)); // faded in, show timer started (and at 0.0)
final RichText richText = tester.widget<RichText>(find.byType(RichText));
expect(richText.text.toPlainText(), equals('$textSpan1Text$textSpan2Text'));
});
testWidgets('Tooltip throws assertion error when both message and richMessage are specified', (WidgetTester tester) async {
expect(
() {
MaterialApp(
home: Tooltip(
message: 'I am a tooltip message.',
richMessage: const TextSpan(
text: 'I am a rich tooltip.',
children: <InlineSpan>[
TextSpan(
text: 'I am another span of a rich tooltip.',
),
],
),
child: Container(
width: 100.0,
height: 100.0,
color: Colors.green[500],
),
),
);
},
throwsA(const TypeMatcher<AssertionError>()),
);
});
testWidgets('Haptic feedback', (WidgetTester tester) async { testWidgets('Haptic feedback', (WidgetTester tester) async {
final FeedbackTester feedback = FeedbackTester(); final FeedbackTester feedback = FeedbackTester();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -1476,6 +1533,28 @@ void main() { ...@@ -1476,6 +1533,28 @@ void main() {
'"message"', '"message"',
]); ]);
}); });
testWidgets('default Tooltip debugFillProperties with richMessage', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
const Tooltip(
richMessage: TextSpan(
text: 'This is a ',
children: <InlineSpan>[
TextSpan(
text: 'richMessage',
),
],
),
).debugFillProperties(builder);
final List<String> description = builder.properties
.where((DiagnosticsNode node) => !node.isFiltered(DiagnosticLevel.info))
.map((DiagnosticsNode node) => node.toString()).toList();
expect(description, <String>[
'"This is a richMessage"',
]);
});
testWidgets('Tooltip implements debugFillProperties', (WidgetTester tester) async { testWidgets('Tooltip implements debugFillProperties', (WidgetTester tester) async {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
......
...@@ -841,9 +841,10 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker ...@@ -841,9 +841,10 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
final Widget widget = element.widget; final Widget widget = element.widget;
if (widget is Tooltip) { if (widget is Tooltip) {
final Iterable<Element> matches = find.byTooltip(widget.message).evaluate(); final String message = widget.message ?? widget.richMessage!.toPlainText();
final Iterable<Element> matches = find.byTooltip(message).evaluate();
if (matches.length == 1) { if (matches.length == 1) {
printToConsole(" find.byTooltip('${widget.message}')"); printToConsole(" find.byTooltip('$message')");
continue; continue;
} }
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment