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)