Add string attribute api to text span (#86667)
diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart
index 91b792c..f51375a 100644
--- a/packages/flutter/lib/src/painting/inline_span.dart
+++ b/packages/flutter/lib/src/painting/inline_span.dart
@@ -3,7 +3,7 @@
// found in the LICENSE file.
-import 'dart:ui' as ui show ParagraphBuilder;
+import 'dart:ui' as ui show ParagraphBuilder, StringAttribute;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
@@ -56,6 +56,7 @@
this.text, {
this.isPlaceholder = false,
this.semanticsLabel,
+ this.stringAttributes = const <ui.StringAttribute>[],
this.recognizer,
}) : assert(text != null),
assert(isPlaceholder != null),
@@ -84,13 +85,17 @@
/// [isPlaceholder] is true.
final bool requiresOwnNode;
+ /// The string attributes attached to this semantics information
+ final List<ui.StringAttribute> stringAttributes;
+
@override
bool operator ==(Object other) {
return other is InlineSpanSemanticsInformation
&& other.text == text
&& other.semanticsLabel == semanticsLabel
&& other.recognizer == recognizer
- && other.isPlaceholder == isPlaceholder;
+ && other.isPlaceholder == isPlaceholder
+ && listEquals<ui.StringAttribute>(other.stringAttributes, stringAttributes);
}
@override
@@ -107,31 +112,40 @@
List<InlineSpanSemanticsInformation> combineSemanticsInfo(List<InlineSpanSemanticsInformation> infoList) {
final List<InlineSpanSemanticsInformation> combined = <InlineSpanSemanticsInformation>[];
String workingText = '';
- // TODO(ianh): this algorithm is internally inconsistent. workingText
- // never becomes null, but we check for it being so below.
- String? workingLabel;
+ String workingLabel = '';
+ List<ui.StringAttribute> workingAttributes = <ui.StringAttribute>[];
for (final InlineSpanSemanticsInformation info in infoList) {
if (info.requiresOwnNode) {
combined.add(InlineSpanSemanticsInformation(
workingText,
- semanticsLabel: workingLabel ?? workingText,
+ semanticsLabel: workingLabel,
+ stringAttributes: workingAttributes,
));
workingText = '';
- workingLabel = null;
+ workingLabel = '';
+ workingAttributes = <ui.StringAttribute>[];
combined.add(info);
} else {
workingText += info.text;
- workingLabel ??= '';
- if (info.semanticsLabel != null) {
- workingLabel += info.semanticsLabel!;
- } else {
- workingLabel += info.text;
+ final String effectiveLabel = info.semanticsLabel ?? info.text;
+ for (final ui.StringAttribute infoAttribute in info.stringAttributes) {
+ workingAttributes.add(
+ infoAttribute.copy(
+ range: TextRange(
+ start: infoAttribute.range.start + workingLabel.length,
+ end: infoAttribute.range.end + workingLabel.length,
+ ),
+ ),
+ );
}
+ workingLabel += effectiveLabel;
+
}
}
combined.add(InlineSpanSemanticsInformation(
workingText,
semanticsLabel: workingLabel,
+ stringAttributes: workingAttributes,
));
return combined;
}
diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart
index cd6d43a..984eb40 100644
--- a/packages/flutter/lib/src/painting/text_span.dart
+++ b/packages/flutter/lib/src/painting/text_span.dart
@@ -2,7 +2,7 @@
// 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 ParagraphBuilder;
+import 'dart:ui' as ui show ParagraphBuilder, Locale, StringAttribute, LocaleStringAttribute, SpellOutStringAttribute;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
@@ -74,6 +74,8 @@
this.onEnter,
this.onExit,
this.semanticsLabel,
+ this.locale,
+ this.spellOut,
}) : mouseCursor = mouseCursor ??
(recognizer == null ? MouseCursor.defer : SystemMouseCursors.click),
assert(!(text == null && semanticsLabel != null)),
@@ -218,6 +220,32 @@
/// ```
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;
@@ -333,18 +361,39 @@
}
@override
- void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
+ 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) {
collector.add(InlineSpanSemanticsInformation(
text!,
+ stringAttributes: <ui.StringAttribute>[
+ if (effectiveSpellOut)
+ ui.SpellOutStringAttribute(range: TextRange(start: 0, end: semanticsLabel?.length ?? text!.length)),
+ if (effectiveLocale != null)
+ ui.LocaleStringAttribute(locale: effectiveLocale, range: TextRange(start: 0, end: semanticsLabel?.length ?? text!.length)),
+ ],
semanticsLabel: semanticsLabel,
recognizer: recognizer,
));
}
if (children != null) {
for (final InlineSpan child in children!) {
- child.computeSemanticsInformation(collector);
+ if (child is TextSpan) {
+ child.computeSemanticsInformation(
+ collector,
+ inheritedLocale: effectiveLocale,
+ inheritedSpellOut: effectiveSpellOut,
+ );
+ } else {
+ child.computeSemanticsInformation(collector);
+ }
}
}
}
diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart
index 3ae8315..1d94665 100644
--- a/packages/flutter/lib/src/rendering/editable.dart
+++ b/packages/flutter/lib/src/rendering/editable.dart
@@ -2274,11 +2274,15 @@
/// The text to display.
InlineSpan? get text => _textPainter.text;
final TextPainter _textPainter;
+ AttributedString? _cachedAttributedValue;
+ List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos;
set text(InlineSpan? value) {
if (_textPainter.text == value)
return;
_textPainter.text = value;
_cachedPlainText = null;
+ _cachedAttributedValue = null;
+ _cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
markNeedsTextLayout();
markNeedsSemanticsUpdate();
@@ -2739,10 +2743,31 @@
..explicitChildNodes = true;
return;
}
+ if (_cachedAttributedValue == null) {
+ if (obscureText) {
+ _cachedAttributedValue = AttributedString(obscuringCharacter * _plainText.length);
+ } else {
+ final StringBuffer buffer = StringBuffer();
+ int offset = 0;
+ final List<StringAttribute> attributes = <StringAttribute>[];
+ for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
+ final String label = info.semanticsLabel ?? info.text;
+ for (final StringAttribute infoAttribute in info.stringAttributes) {
+ final TextRange originalRange = infoAttribute.range;
+ attributes.add(
+ infoAttribute.copy(
+ range: TextRange(start: offset + originalRange.start, end: offset + originalRange.end),
+ ),
+ );
+ }
+ buffer.write(label);
+ offset += label.length;
+ }
+ _cachedAttributedValue = AttributedString(buffer.toString(), attributes: attributes);
+ }
+ }
config
- ..value = obscureText
- ? obscuringCharacter * _plainText.length
- : _plainText
+ ..attributedValue = _cachedAttributedValue!
..isObscured = obscureText
..isMultiline = _isMultiline
..textDirection = textDirection
@@ -2793,7 +2818,8 @@
int childIndex = 0;
RenderBox? child = firstChild;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
- for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) {
+ _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
+ for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) {
final TextSelection selection = TextSelection(
baseOffset: start,
extentOffset: start + info.text.length,
@@ -2849,7 +2875,7 @@
final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection
- ..label = info.semanticsLabel ?? info.text;
+ ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes);
final GestureRecognizer? recognizer = info.recognizer;
if (recognizer != null) {
if (recognizer is TapGestureRecognizer) {
diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart
index 6be6743..1bd12f5 100644
--- a/packages/flutter/lib/src/rendering/paragraph.dart
+++ b/packages/flutter/lib/src/rendering/paragraph.dart
@@ -118,6 +118,8 @@
}
final TextPainter _textPainter;
+ AttributedString? _cachedAttributedLabel;
+ List<InlineSpanSemanticsInformation>? _cachedCombinedSemanticsInfos;
/// The text to display.
InlineSpan get text => _textPainter.text!;
@@ -129,6 +131,8 @@
return;
case RenderComparison.paint:
_textPainter.text = value;
+ _cachedAttributedLabel = null;
+ _cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
markNeedsPaint();
markNeedsSemanticsUpdate();
@@ -136,6 +140,8 @@
case RenderComparison.layout:
_textPainter.text = value;
_overflowShader = null;
+ _cachedAttributedLabel = null;
+ _cachedCombinedSemanticsInfos = null;
_extractPlaceholderSpans(value);
markNeedsLayout();
break;
@@ -869,11 +875,27 @@
config.explicitChildNodes = true;
config.isSemanticBoundary = true;
} else {
- final StringBuffer buffer = StringBuffer();
- for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
- buffer.write(info.semanticsLabel ?? info.text);
+ if (_cachedAttributedLabel == null) {
+ final StringBuffer buffer = StringBuffer();
+ int offset = 0;
+ final List<StringAttribute> attributes = <StringAttribute>[];
+ for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
+ final String label = info.semanticsLabel ?? info.text;
+ for (final StringAttribute infoAttribute in info.stringAttributes) {
+ final TextRange originalRange = infoAttribute.range;
+ attributes.add(
+ infoAttribute.copy(
+ range: TextRange(start: offset + originalRange.start,
+ end: offset + originalRange.end)
+ ),
+ );
+ }
+ buffer.write(label);
+ offset += label.length;
+ }
+ _cachedAttributedLabel = AttributedString(buffer.toString(), attributes: attributes);
}
- config.label = buffer.toString();
+ config.attributedLabel = _cachedAttributedLabel!;
config.textDirection = textDirection;
}
}
@@ -896,7 +918,8 @@
int childIndex = 0;
RenderBox? child = firstChild;
final Queue<SemanticsNode> newChildCache = Queue<SemanticsNode>();
- for (final InlineSpanSemanticsInformation info in combineSemanticsInfo(_semanticsInfo!)) {
+ _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!);
+ for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) {
final TextSelection selection = TextSelection(
baseOffset: start,
extentOffset: start + info.text.length,
@@ -952,7 +975,7 @@
final SemanticsConfiguration configuration = SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection
- ..label = info.semanticsLabel ?? info.text;
+ ..attributedLabel = AttributedString(info.semanticsLabel ?? info.text, attributes: info.stringAttributes);
final GestureRecognizer? recognizer = info.recognizer;
if (recognizer != null) {
if (recognizer is TapGestureRecognizer) {
diff --git a/packages/flutter/test/painting/text_span_test.dart b/packages/flutter/test/painting/text_span_test.dart
index c43fd8d..bcec033 100644
--- a/packages/flutter/test/painting/text_span_test.dart
+++ b/packages/flutter/test/painting/text_span_test.dart
@@ -364,4 +364,53 @@
expect(logEvents[1], isA<PointerExitEvent>());
});
+ testWidgets('TextSpan can compute StringAttributes', (WidgetTester tester) async {
+ const TextSpan span = TextSpan(
+ text: 'aaaaa',
+ spellOut: true,
+ children: <InlineSpan>[
+ TextSpan(text: 'yyyyy', locale: Locale('es', 'MX')),
+ TextSpan(
+ text: 'xxxxx',
+ spellOut: false,
+ children: <InlineSpan>[
+ TextSpan(text: 'zzzzz'),
+ TextSpan(text: 'bbbbb', spellOut: true),
+ ]
+ ),
+ ],
+ );
+ final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
+ span.computeSemanticsInformation(collector);
+ expect(collector.length, 5);
+ expect(collector[0].stringAttributes.length, 1);
+ expect(collector[0].stringAttributes[0], isA<SpellOutStringAttribute>());
+ expect(collector[0].stringAttributes[0].range, const TextRange(start: 0, end: 5));
+ expect(collector[1].stringAttributes.length, 2);
+ expect(collector[1].stringAttributes[0], isA<SpellOutStringAttribute>());
+ expect(collector[1].stringAttributes[0].range, const TextRange(start: 0, end: 5));
+ expect(collector[1].stringAttributes[1], isA<LocaleStringAttribute>());
+ expect(collector[1].stringAttributes[1].range, const TextRange(start: 0, end: 5));
+ final LocaleStringAttribute localeStringAttribute = collector[1].stringAttributes[1] as LocaleStringAttribute;
+ expect(localeStringAttribute.locale, const Locale('es', 'MX'));
+ expect(collector[2].stringAttributes.length, 0);
+ expect(collector[3].stringAttributes.length, 0);
+ expect(collector[4].stringAttributes.length, 1);
+ expect(collector[4].stringAttributes[0], isA<SpellOutStringAttribute>());
+ expect(collector[4].stringAttributes[0].range, const TextRange(start: 0, end: 5));
+
+ final List<InlineSpanSemanticsInformation> combined = combineSemanticsInfo(collector);
+ expect(combined.length, 1);
+ expect(combined[0].stringAttributes.length, 4);
+ expect(combined[0].stringAttributes[0], isA<SpellOutStringAttribute>());
+ expect(combined[0].stringAttributes[0].range, const TextRange(start: 0, end: 5));
+ expect(combined[0].stringAttributes[1], isA<SpellOutStringAttribute>());
+ expect(combined[0].stringAttributes[1].range, const TextRange(start: 5, end: 10));
+ expect(combined[0].stringAttributes[2], isA<LocaleStringAttribute>());
+ expect(combined[0].stringAttributes[2].range, const TextRange(start: 5, end: 10));
+ final LocaleStringAttribute combinedLocaleStringAttribute = combined[0].stringAttributes[2] as LocaleStringAttribute;
+ expect(combinedLocaleStringAttribute.locale, const Locale('es', 'MX'));
+ expect(combined[0].stringAttributes[3], isA<SpellOutStringAttribute>());
+ expect(combined[0].stringAttributes[3].range, const TextRange(start: 20, end: 25));
+ });
}
diff --git a/packages/flutter/test/widgets/rich_text_test.dart b/packages/flutter/test/widgets/rich_text_test.dart
index 62e7c70..8ea17fd 100644
--- a/packages/flutter/test/widgets/rich_text_test.dart
+++ b/packages/flutter/test/widgets/rich_text_test.dart
@@ -48,8 +48,105 @@
));
});
+ testWidgets('TextSpan Locale works', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(
+ text: TextSpan(
+ text: 'root',
+ locale: const Locale('es', 'MX'),
+ children: <InlineSpan>[
+ TextSpan(text: 'one', recognizer: TapGestureRecognizer()),
+ const WidgetSpan(
+ child: SizedBox(),
+ ),
+ TextSpan(text: 'three', recognizer: DoubleTapGestureRecognizer()),
+ ]
+ ),
+ ),
+ ),
+ );
+ expect(tester.getSemantics(find.byType(RichText)), matchesSemantics(
+ children: <Matcher>[
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'root',
+ attributes: <StringAttribute>[
+ LocaleStringAttribute(range: const TextRange(start: 0, end: 4), locale: const Locale('es', 'MX')),
+ ]
+ ),
+ ),
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'one',
+ attributes: <StringAttribute>[
+ LocaleStringAttribute(range: const TextRange(start: 0, end: 3), locale: const Locale('es', 'MX')),
+ ]
+ ),
+ ),
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'three',
+ attributes: <StringAttribute>[
+ LocaleStringAttribute(range: const TextRange(start: 0, end: 5), locale: const Locale('es', 'MX')),
+ ]
+ ),
+ ),
+ ],
+ ));
+ });
+
+ testWidgets('TextSpan spellOut works', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ Directionality(
+ textDirection: TextDirection.ltr,
+ child: RichText(
+ text: TextSpan(
+ text: 'root',
+ spellOut: true,
+ children: <InlineSpan>[
+ TextSpan(text: 'one', recognizer: TapGestureRecognizer()),
+ const WidgetSpan(
+ child: SizedBox(),
+ ),
+ TextSpan(text: 'three', recognizer: DoubleTapGestureRecognizer()),
+ ]
+ ),
+ ),
+ ),
+ );
+ expect(tester.getSemantics(find.byType(RichText)), matchesSemantics(
+ children: <Matcher>[
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'root',
+ attributes: <StringAttribute>[
+ SpellOutStringAttribute(range: const TextRange(start: 0, end: 4)),
+ ]
+ ),
+ ),
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'one',
+ attributes: <StringAttribute>[
+ SpellOutStringAttribute(range: const TextRange(start: 0, end: 3)),
+ ]
+ ),
+ ),
+ matchesSemantics(
+ attributedLabel: AttributedString(
+ 'three',
+ attributes: <StringAttribute>[
+ SpellOutStringAttribute(range: const TextRange(start: 0, end: 5)),
+ ]
+ ),
+ ),
+ ],
+ ));
+ });
+
testWidgets('WidgetSpan calculate correct intrinsic heights', (WidgetTester tester) async {
- // Regression test for https://github.com/flutter/flutter/issues/48679.
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
diff --git a/packages/flutter/test/widgets/selectable_text_test.dart b/packages/flutter/test/widgets/selectable_text_test.dart
index f039c68..dd63bf7 100644
--- a/packages/flutter/test/widgets/selectable_text_test.dart
+++ b/packages/flutter/test/widgets/selectable_text_test.dart
@@ -1488,6 +1488,72 @@
semantics.dispose();
});
+ testWidgets('Selectable text rich text with spell out in semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText.rich(TextSpan(text: 'some text', spellOut: true)),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ includesNodeWith(
+ attributedValue: AttributedString(
+ 'some text',
+ attributes: <StringAttribute>[
+ SpellOutStringAttribute(range: const TextRange(start: 0, end:9)),
+ ],
+ ),
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isMultiline,
+ ],
+ ),
+ );
+
+ semantics.dispose();
+ });
+
+ testWidgets('Selectable text rich text with locale in semantics', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ const MaterialApp(
+ home: Material(
+ child: Center(
+ child: SelectableText.rich(TextSpan(text: 'some text', locale: Locale('es', 'MX'))),
+ ),
+ ),
+ ),
+ );
+
+ expect(
+ semantics,
+ includesNodeWith(
+ attributedValue: AttributedString(
+ 'some text',
+ attributes: <StringAttribute>[
+ LocaleStringAttribute(range: const TextRange(start: 0, end:9), locale: const Locale('es', 'MX')),
+ ],
+ ),
+ flags: <SemanticsFlag>[
+ SemanticsFlag.isTextField,
+ SemanticsFlag.isReadOnly,
+ SemanticsFlag.isMultiline,
+ ],
+ ),
+ );
+
+ semantics.dispose();
+ });
+
testWidgets('Selectable rich text with gesture recognizer has correct semantics', (WidgetTester tester) async {
final SemanticsTester semantics = SemanticsTester(tester);
await tester.pumpWidget(
diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart
index 506b43a..ed2e8eb 100644
--- a/packages/flutter/test/widgets/semantics_tester.dart
+++ b/packages/flutter/test/widgets/semantics_tester.dart
@@ -427,6 +427,25 @@
@override
String toString() => 'SemanticsTester for ${tester.binding.pipelineOwner.semanticsOwner?.rootSemanticsNode}';
+ bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
+ if (first.length != second.length)
+ return false;
+ for (int i = 0; i < first.length; i++) {
+ if (first[i] is SpellOutStringAttribute &&
+ (second[i] is! SpellOutStringAttribute ||
+ second[i].range != first[i].range)) {
+ return false;
+ }
+ if (first[i] is LocaleStringAttribute &&
+ (second[i] is! LocaleStringAttribute ||
+ second[i].range != first[i].range ||
+ (second[i] as LocaleStringAttribute).locale != (second[i] as LocaleStringAttribute).locale)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
/// Returns all semantics nodes in the current semantics tree whose properties
/// match the non-null arguments.
///
@@ -435,6 +454,9 @@
///
/// If `ancestor` is not null, only the descendants of it are returned.
Iterable<SemanticsNode> nodesWith({
+ AttributedString? attributedLabel,
+ AttributedString? attributedValue,
+ AttributedString? attributedHint,
String? label,
String? value,
String? hint,
@@ -451,10 +473,25 @@
bool checkNode(SemanticsNode node) {
if (label != null && node.label != label)
return false;
+ if (attributedLabel != null &&
+ (attributedLabel.string != node.attributedLabel.string ||
+ !_stringAttributesEqual(attributedLabel.attributes, node.attributedLabel.attributes))) {
+ return false;
+ }
if (value != null && node.value != value)
return false;
+ if (attributedValue != null &&
+ (attributedValue.string != node.attributedValue.string ||
+ !_stringAttributesEqual(attributedValue.attributes, node.attributedValue.attributes))) {
+ return false;
+ }
if (hint != null && node.hint != hint)
return false;
+ if (attributedHint != null &&
+ (attributedHint.string != node.attributedHint.string ||
+ !_stringAttributesEqual(attributedHint.attributes, node.attributedHint.attributes))) {
+ return false;
+ }
if (textDirection != null && node.textDirection != textDirection)
return false;
if (actions != null) {
@@ -714,6 +751,9 @@
class _IncludesNodeWith extends Matcher {
const _IncludesNodeWith({
+ this.attributedLabel,
+ this.attributedValue,
+ this.attributedHint,
this.label,
this.value,
this.hint,
@@ -725,7 +765,7 @@
this.scrollExtentMin,
this.maxValueLength,
this.currentValueLength,
-}) : assert(
+ }) : assert(
label != null ||
value != null ||
actions != null ||
@@ -736,7 +776,9 @@
maxValueLength != null ||
currentValueLength != null,
);
-
+ final AttributedString? attributedLabel;
+ final AttributedString? attributedValue;
+ final AttributedString? attributedHint;
final String? label;
final String? value;
final String? hint;
@@ -752,6 +794,9 @@
@override
bool matches(covariant SemanticsTester item, Map<dynamic, dynamic> matchState) {
return item.nodesWith(
+ attributedLabel: attributedLabel,
+ attributedValue: attributedValue,
+ attributedHint: attributedHint,
label: label,
value: value,
hint: hint,
@@ -800,8 +845,11 @@
/// If null is provided for an argument, it will match against any value.
Matcher includesNodeWith({
String? label,
+ AttributedString? attributedLabel,
String? value,
+ AttributedString? attributedValue,
String? hint,
+ AttributedString? attributedHint,
TextDirection? textDirection,
List<SemanticsAction>? actions,
List<SemanticsFlag>? flags,
@@ -813,8 +861,11 @@
}) {
return _IncludesNodeWith(
label: label,
+ attributedLabel: attributedLabel,
value: value,
+ attributedValue: attributedValue,
hint: hint,
+ attributedHint: attributedHint,
textDirection: textDirection,
actions: actions,
flags: flags,
diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart
index c2dbe52..d4c0c11 100644
--- a/packages/flutter_test/lib/src/matchers.dart
+++ b/packages/flutter_test/lib/src/matchers.dart
@@ -431,10 +431,15 @@
/// * [WidgetTester.getSemantics], the tester method which retrieves semantics.
Matcher matchesSemantics({
String? label,
+ AttributedString? attributedLabel,
String? hint,
+ AttributedString? attributedHint,
String? value,
+ AttributedString? attributedValue,
String? increasedValue,
+ AttributedString? attributedIncreasedValue,
String? decreasedValue,
+ AttributedString? attributedDecreasedValue,
TextDirection? textDirection,
Rect? rect,
Size? size,
@@ -559,10 +564,15 @@
return _MatchesSemanticsData(
label: label,
+ attributedLabel: attributedLabel,
hint: hint,
+ attributedHint: attributedHint,
value: value,
+ attributedValue: attributedValue,
increasedValue: increasedValue,
+ attributedIncreasedValue: attributedIncreasedValue,
decreasedValue: decreasedValue,
+ attributedDecreasedValue: attributedDecreasedValue,
actions: actions,
flags: flags,
textDirection: textDirection,
@@ -1708,10 +1718,15 @@
class _MatchesSemanticsData extends Matcher {
_MatchesSemanticsData({
this.label,
- this.value,
- this.increasedValue,
- this.decreasedValue,
+ this.attributedLabel,
this.hint,
+ this.attributedHint,
+ this.value,
+ this.attributedValue,
+ this.increasedValue,
+ this.attributedIncreasedValue,
+ this.decreasedValue,
+ this.attributedDecreasedValue,
this.flags,
this.actions,
this.textDirection,
@@ -1728,10 +1743,15 @@
});
final String? label;
- final String? value;
+ final AttributedString? attributedLabel;
final String? hint;
+ final AttributedString? attributedHint;
+ final String? value;
+ final AttributedString? attributedValue;
final String? increasedValue;
+ final AttributedString? attributedIncreasedValue;
final String? decreasedValue;
+ final AttributedString? attributedDecreasedValue;
final SemanticsHintOverrides? hintOverrides;
final List<SemanticsAction>? actions;
final List<CustomSemanticsAction>? customActions;
@@ -1751,14 +1771,24 @@
description.add('has semantics');
if (label != null)
description.add(' with label: $label');
+ if (attributedLabel != null)
+ description.add(' with attributedLabel: $attributedLabel');
if (value != null)
description.add(' with value: $value');
+ if (attributedValue != null)
+ description.add(' with attributedValue: $attributedValue');
if (hint != null)
description.add(' with hint: $hint');
+ if (attributedHint != null)
+ description.add(' with attributedHint: $attributedHint');
if (increasedValue != null)
description.add(' with increasedValue: $increasedValue ');
+ if (attributedIncreasedValue != null)
+ description.add(' with attributedIncreasedValue: $attributedIncreasedValue');
if (decreasedValue != null)
description.add(' with decreasedValue: $decreasedValue ');
+ if (attributedDecreasedValue != null)
+ description.add(' with attributedDecreasedValue: $attributedDecreasedValue');
if (actions != null)
description.add(' with actions: ').addDescriptionOf(actions);
if (flags != null)
@@ -1791,6 +1821,24 @@
return description;
}
+ bool _stringAttributesEqual(List<StringAttribute> first, List<StringAttribute> second) {
+ if (first.length != second.length)
+ return false;
+ for (int i = 0; i < first.length; i++) {
+ if (first[i] is SpellOutStringAttribute &&
+ (second[i] is! SpellOutStringAttribute ||
+ second[i].range != first[i].range)) {
+ return false;
+ }
+ if (first[i] is LocaleStringAttribute &&
+ (second[i] is! LocaleStringAttribute ||
+ second[i].range != first[i].range ||
+ (second[i] as LocaleStringAttribute).locale != (second[i] as LocaleStringAttribute).locale)) {
+ return false;
+ }
+ }
+ return true;
+ }
@override
bool matches(dynamic node, Map<dynamic, dynamic> matchState) {
@@ -1801,14 +1849,44 @@
final SemanticsData data = node is SemanticsNode ? node.getSemanticsData() : (node as SemanticsData);
if (label != null && label != data.label)
return failWithDescription(matchState, 'label was: ${data.label}');
+ if (attributedLabel != null &&
+ (attributedLabel!.string != data.attributedLabel.string ||
+ !_stringAttributesEqual(attributedLabel!.attributes, data.attributedLabel.attributes))) {
+ return failWithDescription(
+ matchState, 'attributedLabel was: ${data.attributedLabel}');
+ }
if (hint != null && hint != data.hint)
return failWithDescription(matchState, 'hint was: ${data.hint}');
+ if (attributedHint != null &&
+ (attributedHint!.string != data.attributedHint.string ||
+ !_stringAttributesEqual(attributedHint!.attributes, data.attributedHint.attributes))) {
+ return failWithDescription(
+ matchState, 'attributedHint was: ${data.attributedHint}');
+ }
if (value != null && value != data.value)
return failWithDescription(matchState, 'value was: ${data.value}');
+ if (attributedValue != null &&
+ (attributedValue!.string != data.attributedValue.string ||
+ !_stringAttributesEqual(attributedValue!.attributes, data.attributedValue.attributes))) {
+ return failWithDescription(
+ matchState, 'attributedValue was: ${data.attributedValue}');
+ }
if (increasedValue != null && increasedValue != data.increasedValue)
return failWithDescription(matchState, 'increasedValue was: ${data.increasedValue}');
+ if (attributedIncreasedValue != null &&
+ (attributedIncreasedValue!.string != data.attributedIncreasedValue.string ||
+ !_stringAttributesEqual(attributedIncreasedValue!.attributes, data.attributedIncreasedValue.attributes))) {
+ return failWithDescription(
+ matchState, 'attributedIncreasedValue was: ${data.attributedIncreasedValue}');
+ }
if (decreasedValue != null && decreasedValue != data.decreasedValue)
return failWithDescription(matchState, 'decreasedValue was: ${data.decreasedValue}');
+ if (attributedDecreasedValue != null &&
+ (attributedDecreasedValue!.string != data.attributedDecreasedValue.string ||
+ !_stringAttributesEqual(attributedDecreasedValue!.attributes, data.attributedDecreasedValue.attributes))) {
+ return failWithDescription(
+ matchState, 'attributedDecreasedValue was: ${data.attributedDecreasedValue}');
+ }
if (textDirection != null && textDirection != data.textDirection)
return failWithDescription(matchState, 'textDirection was: $textDirection');
if (rect != null && rect != data.rect)