blob: b9b386d1ae8aeb3b76f576566c836a7e464798c2 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui show Locale, LocaleStringAttribute, ParagraphBuilder, SpellOutStringAttribute, StringAttribute;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'basic_types.dart';
import 'inline_span.dart';
import 'text_painter.dart';
// Examples can assume:
// late TextSpan myTextSpan;
/// An immutable span of text.
///
/// A [TextSpan] object can be styled using its [style] property. The style will
/// be applied to the [text] and the [children].
///
/// A [TextSpan] object can just have plain text, or it can have children
/// [TextSpan] objects with their own styles that (possibly only partially)
/// override the [style] of this object. If a [TextSpan] has both [text] and
/// [children], then the [text] is treated as if it was an un-styled [TextSpan]
/// at the start of the [children] list. Leaving the [TextSpan.text] field null
/// results in the [TextSpan] acting as an empty node in the [InlineSpan] tree
/// with a list of children.
///
/// To paint a [TextSpan] on a [Canvas], use a [TextPainter]. To display a text
/// span in a widget, use a [RichText]. For text with a single style, consider
/// using the [Text] widget.
///
/// {@tool snippet}
///
/// The text "Hello world!", in black:
///
/// ```dart
/// const TextSpan(
/// text: 'Hello world!',
/// style: TextStyle(color: Colors.black),
/// )
/// ```
/// {@end-tool}
///
/// _There is some more detailed sample code in the documentation for the
/// [recognizer] property._
///
/// The [TextSpan.text] will be used as the semantics label unless overridden
/// by the [TextSpan.semanticsLabel] property. Any [PlaceholderSpan]s in the
/// [TextSpan.children] list will separate the text before and after it into two
/// semantics nodes.
///
/// See also:
///
/// * [WidgetSpan], a leaf node that represents an embedded inline widget in an
/// [InlineSpan] tree. Specify a widget within the [children] list by
/// wrapping the widget with a [WidgetSpan]. The widget will be laid out
/// inline within the paragraph.
/// * [Text], a widget for showing uniformly-styled text.
/// * [RichText], a widget for finer control of text rendering.
/// * [TextPainter], a class for painting [TextSpan] objects on a [Canvas].
@immutable
class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotation {
/// Creates a [TextSpan] with the given values.
///
/// For the object to be useful, at least one of [text] or
/// [children] should be set.
const TextSpan({
this.text,
this.children,
super.style,
this.recognizer,
MouseCursor? mouseCursor,
this.onEnter,
this.onExit,
this.semanticsLabel,
this.locale,
this.spellOut,
}) : mouseCursor = mouseCursor ??
(recognizer == null ? MouseCursor.defer : SystemMouseCursors.click),
assert(!(text == null && semanticsLabel != null));
/// The text contained in this span.
///
/// If both [text] and [children] are non-null, the text will precede the
/// children.
///
/// This getter does not include the contents of its children.
final String? text;
/// Additional spans to include as children.
///
/// If both [text] and [children] are non-null, the text will precede the
/// children.
///
/// Modifying the list after the [TextSpan] has been created is not supported
/// and may have unexpected results.
///
/// The list must not contain any nulls.
final List<InlineSpan>? children;
/// A gesture recognizer that will receive events that hit this span.
///
/// [InlineSpan] itself does not implement hit testing or event dispatch. The
/// object that manages the [InlineSpan] painting is also responsible for
/// dispatching events. In the rendering library, that is the
/// [RenderParagraph] object, which corresponds to the [RichText] widget in
/// the widgets layer; these objects do not bubble events in [InlineSpan]s,
/// so a [recognizer] is only effective for events that directly hit the
/// [text] of that [InlineSpan], not any of its [children].
///
/// [InlineSpan] also does not manage the lifetime of the gesture recognizer.
/// The code that owns the [GestureRecognizer] object must call
/// [GestureRecognizer.dispose] when the [InlineSpan] object is no longer
/// used.
///
/// {@tool snippet}
///
/// This example shows how to manage the lifetime of a gesture recognizer
/// provided to an [InlineSpan] object. It defines a `BuzzingText` widget
/// which uses the [HapticFeedback] class to vibrate the device when the user
/// long-presses the "find the" span, which is underlined in wavy green. The
/// hit-testing is handled by the [RichText] widget. It also changes the
/// hovering mouse cursor to `precise`.
///
/// ```dart
/// class BuzzingText extends StatefulWidget {
/// const BuzzingText({super.key});
///
/// @override
/// State<BuzzingText> createState() => _BuzzingTextState();
/// }
///
/// class _BuzzingTextState extends State<BuzzingText> {
/// late LongPressGestureRecognizer _longPressRecognizer;
///
/// @override
/// void initState() {
/// super.initState();
/// _longPressRecognizer = LongPressGestureRecognizer()
/// ..onLongPress = _handlePress;
/// }
///
/// @override
/// void dispose() {
/// _longPressRecognizer.dispose();
/// super.dispose();
/// }
///
/// void _handlePress() {
/// HapticFeedback.vibrate();
/// }
///
/// @override
/// Widget build(BuildContext context) {
/// return Text.rich(
/// TextSpan(
/// text: 'Can you ',
/// style: const TextStyle(color: Colors.black),
/// children: <InlineSpan>[
/// TextSpan(
/// text: 'find the',
/// style: const TextStyle(
/// color: Colors.green,
/// decoration: TextDecoration.underline,
/// decorationStyle: TextDecorationStyle.wavy,
/// ),
/// recognizer: _longPressRecognizer,
/// mouseCursor: SystemMouseCursors.precise,
/// ),
/// const TextSpan(
/// text: ' secret?',
/// ),
/// ],
/// ),
/// );
/// }
/// }
/// ```
/// {@end-tool}
final GestureRecognizer? recognizer;
/// Mouse cursor when the mouse hovers over this span.
///
/// The default value is [SystemMouseCursors.click] if [recognizer] is not
/// null, or [MouseCursor.defer] otherwise.
///
/// [TextSpan] itself does not implement hit testing or cursor changing.
/// The object that manages the [TextSpan] painting is responsible
/// to return the [TextSpan] in its hit test, as well as providing the
/// correct mouse cursor when the [TextSpan]'s mouse cursor is
/// [MouseCursor.defer].
final MouseCursor mouseCursor;
@override
final PointerEnterEventListener? onEnter;
@override
final PointerExitEventListener? onExit;
/// Returns the value of [mouseCursor].
///
/// This field, required by [MouseTrackerAnnotation], is hidden publicly to
/// avoid the confusion as a text cursor.
@protected
@override
MouseCursor get cursor => mouseCursor;
/// An alternative semantics label for this [TextSpan].
///
/// If present, the semantics of this span will contain this value instead
/// of the actual text.
///
/// This is useful for replacing abbreviations or shorthands with the full
/// text value:
///
/// ```dart
/// const TextSpan(text: r'$$', semanticsLabel: 'Double dollars')
/// ```
final String? semanticsLabel;
/// The language of the text in this span and its span children.
///
/// Setting the locale of this text span affects the way that assistive
/// technologies, such as VoiceOver or TalkBack, pronounce the text.
///
/// If this span contains other text span children, they also inherit the
/// locale from this span unless explicitly set to different locales.
final ui.Locale? locale;
/// Whether the assistive technologies should spell out this text character
/// by character.
///
/// If the text is 'hello world', setting this to true causes the assistive
/// technologies, such as VoiceOver or TalkBack, to pronounce
/// 'h-e-l-l-o-space-w-o-r-l-d' instead of complete words. This is useful for
/// texts, such as passwords or verification codes.
///
/// If this span contains other text span children, they also inherit the
/// property from this span unless explicitly set.
///
/// If the property is not set, this text span inherits the spell out setting
/// from its parent. If this text span does not have a parent or the parent
/// does not have a spell out setting, this text span does not spell out the
/// text by default.
final bool? spellOut;
@override
bool get validForMouseTracker => true;
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
if (event is PointerDownEvent) {
recognizer?.addPointer(event);
}
}
/// Apply the [style], [text], and [children] of this object to the
/// given [ParagraphBuilder], from which a [Paragraph] can be obtained.
/// [Paragraph] objects can be drawn on [Canvas] objects.
///
/// Rather than using this directly, it's simpler to use the
/// [TextPainter] class to paint [TextSpan] objects onto [Canvas]
/// objects.
@override
void build(
ui.ParagraphBuilder builder, {
double textScaleFactor = 1.0,
List<PlaceholderDimensions>? dimensions,
}) {
assert(debugAssertIsValid());
final bool hasStyle = style != null;
if (hasStyle) {
builder.pushStyle(style!.getTextStyle(textScaleFactor: textScaleFactor));
}
if (text != null) {
try {
builder.addText(text!);
} on ArgumentError catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetails(
exception: exception,
stack: stack,
library: 'painting library',
context: ErrorDescription('while building a TextSpan'),
));
// Use a Unicode replacement character as a substitute for invalid text.
builder.addText('\uFFFD');
}
}
if (children != null) {
for (final InlineSpan child in children!) {
assert(child != null);
child.build(
builder,
textScaleFactor: textScaleFactor,
dimensions: dimensions,
);
}
}
if (hasStyle) {
builder.pop();
}
}
/// Walks this [TextSpan] and its descendants in pre-order and calls [visitor]
/// for each span that has text.
///
/// When `visitor` returns true, the walk will continue. When `visitor`
/// returns false, then the walk will end.
@override
bool visitChildren(InlineSpanVisitor visitor) {
if (text != null) {
if (!visitor(this)) {
return false;
}
}
if (children != null) {
for (final InlineSpan child in children!) {
if (!child.visitChildren(visitor)) {
return false;
}
}
}
return true;
}
/// Returns the text span that contains the given position in the text.
@override
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
if (text == null) {
return null;
}
final TextAffinity affinity = position.affinity;
final int targetOffset = position.offset;
final int endOffset = offset.value + text!.length;
if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
offset.value < targetOffset && targetOffset < endOffset ||
endOffset == targetOffset && affinity == TextAffinity.upstream) {
return this;
}
offset.increment(text!.length);
return null;
}
@override
void computeToPlainText(
StringBuffer buffer, {
bool includeSemanticsLabels = true,
bool includePlaceholders = true,
}) {
assert(debugAssertIsValid());
if (semanticsLabel != null && includeSemanticsLabels) {
buffer.write(semanticsLabel);
} else if (text != null) {
buffer.write(text);
}
if (children != null) {
for (final InlineSpan child in children!) {
child.computeToPlainText(buffer,
includeSemanticsLabels: includeSemanticsLabels,
includePlaceholders: includePlaceholders,
);
}
}
}
@override
void computeSemanticsInformation(
List<InlineSpanSemanticsInformation> collector, {
ui.Locale? inheritedLocale,
bool inheritedSpellOut = false,
}) {
assert(debugAssertIsValid());
final ui.Locale? effectiveLocale = locale ?? inheritedLocale;
final bool effectiveSpellOut = spellOut ?? inheritedSpellOut;
if (text != null) {
final int textLength = semanticsLabel?.length ?? text!.length;
collector.add(InlineSpanSemanticsInformation(
text!,
stringAttributes: <ui.StringAttribute>[
if (effectiveSpellOut && textLength > 0)
ui.SpellOutStringAttribute(range: TextRange(start: 0, end: textLength)),
if (effectiveLocale != null && textLength > 0)
ui.LocaleStringAttribute(locale: effectiveLocale, range: TextRange(start: 0, end: textLength)),
],
semanticsLabel: semanticsLabel,
recognizer: recognizer,
));
}
if (children != null) {
for (final InlineSpan child in children!) {
if (child is TextSpan) {
child.computeSemanticsInformation(
collector,
inheritedLocale: effectiveLocale,
inheritedSpellOut: effectiveSpellOut,
);
} else {
child.computeSemanticsInformation(collector);
}
}
}
}
@override
int? codeUnitAtVisitor(int index, Accumulator offset) {
if (text == null) {
return null;
}
if (index - offset.value < text!.length) {
return text!.codeUnitAt(index - offset.value);
}
offset.increment(text!.length);
return null;
}
/// Populates the `semanticsOffsets` and `semanticsElements` with the appropriate data
/// to be able to construct a [SemanticsNode].
///
/// If applicable, the beginning and end text offset are added to [semanticsOffsets].
/// [PlaceholderSpan]s have a text length of 1, which corresponds to the object
/// replacement character (0xFFFC) that is inserted to represent it.
///
/// Any [GestureRecognizer]s are added to `semanticsElements`. Null is added to
/// `semanticsElements` for [PlaceholderSpan]s.
void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements) {
if (
recognizer != null &&
(recognizer is TapGestureRecognizer || recognizer is LongPressGestureRecognizer)
) {
final int length = semanticsLabel?.length ?? text!.length;
semanticsOffsets.add(offset.value);
semanticsOffsets.add(offset.value + length);
semanticsElements.add(recognizer);
}
offset.increment(text != null ? text!.length : 0);
}
/// In debug mode, throws an exception if the object is not in a valid
/// configuration. Otherwise, returns true.
///
/// This is intended to be used as follows:
///
/// ```dart
/// assert(myTextSpan.debugAssertIsValid());
/// ```
@override
bool debugAssertIsValid() {
assert(() {
if (children != null) {
for (final InlineSpan child in children!) {
if (child == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary('TextSpan contains a null child.'),
ErrorDescription(
'A TextSpan object with a non-null child list should not have any nulls in its child list.',
),
toDiagnosticsNode(
name: 'The full text in question was',
style: DiagnosticsTreeStyle.errorProperty,
),
]);
}
assert(child.debugAssertIsValid());
}
}
return true;
}());
return super.debugAssertIsValid();
}
@override
RenderComparison compareTo(InlineSpan other) {
if (identical(this, other)) {
return RenderComparison.identical;
}
if (other.runtimeType != runtimeType) {
return RenderComparison.layout;
}
final TextSpan textSpan = other as TextSpan;
if (textSpan.text != text ||
children?.length != textSpan.children?.length ||
(style == null) != (textSpan.style == null)) {
return RenderComparison.layout;
}
RenderComparison result = recognizer == textSpan.recognizer ?
RenderComparison.identical :
RenderComparison.metadata;
if (style != null) {
final RenderComparison candidate = style!.compareTo(textSpan.style!);
if (candidate.index > result.index) {
result = candidate;
}
if (result == RenderComparison.layout) {
return result;
}
}
if (children != null) {
for (int index = 0; index < children!.length; index += 1) {
final RenderComparison candidate = children![index].compareTo(textSpan.children![index]);
if (candidate.index > result.index) {
result = candidate;
}
if (result == RenderComparison.layout) {
return result;
}
}
}
return result;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
if (super != other) {
return false;
}
return other is TextSpan
&& other.text == text
&& other.recognizer == recognizer
&& other.semanticsLabel == semanticsLabel
&& onEnter == other.onEnter
&& onExit == other.onExit
&& mouseCursor == other.mouseCursor
&& listEquals<InlineSpan>(other.children, children);
}
@override
int get hashCode => Object.hash(
super.hashCode,
text,
recognizer,
semanticsLabel,
onEnter,
onExit,
mouseCursor,
children == null ? null : Object.hashAll(children!),
);
@override
String toStringShort() => objectRuntimeType(this, 'TextSpan');
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(
StringProperty(
'text',
text,
showName: false,
defaultValue: null,
),
);
if (style == null && text == null && children == null) {
properties.add(DiagnosticsNode.message('(empty)'));
}
properties.add(DiagnosticsProperty<GestureRecognizer>(
'recognizer', recognizer,
description: recognizer?.runtimeType.toString(),
defaultValue: null,
));
properties.add(FlagsSummary<Function?>(
'callbacks',
<String, Function?> {
'enter': onEnter,
'exit': onExit,
},
));
properties.add(DiagnosticsProperty<MouseCursor>('mouseCursor', cursor, defaultValue: MouseCursor.defer));
if (semanticsLabel != null) {
properties.add(StringProperty('semanticsLabel', semanticsLabel));
}
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
if (children == null) {
return const <DiagnosticsNode>[];
}
return children!.map<DiagnosticsNode>((InlineSpan child) {
// `child` has a non-nullable return type, but might be null when running
// with weak checking, so we need to null check it anyway (and ignore the
// warning that the null-handling logic is dead code).
if (child != null) {
return child.toDiagnosticsNode();
} else {
return DiagnosticsNode.message('<null child>');
}
}).toList();
}
}