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 {
if (tooltip != null) {
result = Tooltip(
message: tooltip!,
message: tooltip,
child: result,
);
}
......
......@@ -323,7 +323,7 @@ class IconButton extends StatelessWidget {
if (tooltip != null) {
result = Tooltip(
message: tooltip!,
message: tooltip,
child: result,
);
}
......
......@@ -759,9 +759,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget {
}) : super(key: key);
/// The text that is rendered in the tooltip when it appears.
///
/// If [message] is null, no tooltip will be used.
final String? message;
final String message;
/// The widget that, when pressed, will show a tooltip.
final Widget child;
......@@ -772,7 +770,7 @@ class _NavigationBarDestinationTooltip extends StatelessWidget {
return child;
}
return Tooltip(
message: message!,
message: message,
// TODO(johnsonmh): Make this value configurable/themable.
verticalOffset: 42,
excludeFromSemantics: true,
......
......@@ -4,6 +4,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
......@@ -55,6 +56,15 @@ import 'tooltip_theme.dart';
/// ** See code in examples/api/lib/material/tooltip/tooltip.1.dart **
/// {@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:
///
/// * <https://material.io/design/components/tooltips.html>
......@@ -70,9 +80,12 @@ class Tooltip extends StatefulWidget {
///
/// All parameters that are defined in the constructor will
/// override the default values _and_ the values in [TooltipTheme.of].
///
/// Only one of [message] and [richMessage] may be non-null.
const Tooltip({
Key? key,
required this.message,
this.message,
this.richMessage,
this.height,
this.padding,
this.margin,
......@@ -86,11 +99,24 @@ class Tooltip extends StatefulWidget {
this.child,
this.triggerMode,
this.enableFeedback,
}) : assert(message != null),
super(key: key);
}) : assert((message == null) != (richMessage == null), 'Either `message` or `richMessage` must be specified'),
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.
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].
///
......@@ -131,12 +157,13 @@ class Tooltip extends StatefulWidget {
/// direction.
final bool? preferBelow;
/// Whether the tooltip's [message] should be excluded from the semantics
/// tree.
/// Whether the tooltip's [message] or [richMessage] 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.
/// [Tooltip.message] if non-null, or the plain text value of
/// [Tooltip.richMessage] otherwise. 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.
......@@ -241,7 +268,18 @@ class Tooltip extends StatefulWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder 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(DiagnosticsProperty<EdgeInsetsGeometry>('padding', padding, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>('margin', margin, defaultValue: null));
......@@ -290,6 +328,11 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
late bool _isConcealed;
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
void initState() {
super.initState();
......@@ -428,7 +471,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
)!;
overlayState.insert(_entry!);
}
SemanticsService.tooltip(widget.message);
SemanticsService.tooltip(_tooltipMessage);
_controller.forward();
}
......@@ -487,7 +530,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
final Widget overlay = Directionality(
textDirection: Directionality.of(context),
child: _TooltipOverlay(
message: widget.message,
richMessage: widget.richMessage ?? TextSpan(text: widget.message),
height: height,
padding: padding,
margin: margin,
......@@ -507,7 +550,7 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
_entry = OverlayEntry(builder: (BuildContext context) => overlay);
_isConcealed = false;
overlayState.insert(_entry!);
SemanticsService.tooltip(widget.message);
SemanticsService.tooltip(_tooltipMessage);
if (_mouseIsConnected) {
// 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
......@@ -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
// the empty black container so just return the wrapped child as is or
// empty container if child is not specified.
if (widget.message.isEmpty) {
if (_tooltipMessage.isEmpty) {
return widget.child ?? const SizedBox();
}
assert(Overlay.of(context, debugRequiredFor: widget) != null);
......@@ -629,7 +672,9 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null,
excludeFromSemantics: true,
child: Semantics(
label: excludeFromSemantics ? null : widget.message,
label: excludeFromSemantics
? null
: _tooltipMessage,
child: widget.child,
),
);
......@@ -700,8 +745,8 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
class _TooltipOverlay extends StatelessWidget {
const _TooltipOverlay({
Key? key,
required this.message,
required this.height,
required this.richMessage,
this.padding,
this.margin,
this.decoration,
......@@ -714,7 +759,7 @@ class _TooltipOverlay extends StatelessWidget {
this.onExit,
}) : super(key: key);
final String message;
final InlineSpan richMessage;
final double height;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
......@@ -743,8 +788,8 @@ class _TooltipOverlay extends StatelessWidget {
child: Center(
widthFactor: 1.0,
heightFactor: 1.0,
child: Text(
message,
child: Text.rich(
richMessage,
style: textStyle,
),
),
......
......@@ -1319,6 +1319,63 @@ void main() {
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 {
final FeedbackTester feedback = FeedbackTester();
await tester.pumpWidget(
......@@ -1476,6 +1533,28 @@ void main() {
'"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 {
final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder();
......
......@@ -841,9 +841,10 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker
final Widget widget = element.widget;
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) {
printToConsole(" find.byTooltip('${widget.message}')");
printToConsole(" find.byTooltip('$message')");
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