Added semanticsIdentifier to Text Widgets (#163843)

This PR aims to add `semanticsIdentifier` to `Text` and some of its
internal objects to pass the semantics information for adding identifier
to the semantics nodes

From the issue filed at #163842, the following is a description of the
problem.

The [semantics
identifier](https://api.flutter.dev/flutter/semantics/SemanticsData/identifier.html)
helps in uniquely identifying elements using UI automation tools like
Appium, UIAutomator and XCUITests by setting identifiers that the screen
readers cannot see but the said tools can. This is especially useful
when working with a multi-lingual or multi-tenant app, where the element
IDs need to be unique but the content can be different. The `Semantics`
widget already has support for declaring it. However, the `Text` and
`Text.rich` variants only support setting `semanticsLabel` without
explicitly setting the identifiers. The widgets themselves can be
wrapped with a `Semantics` widget but it still does not cater for a rich
text that can have multiple text spans, each containing unique lables
and identifiers, and optionally gesture detectors for handling links.

Consider the following UI for two different tenants:
<img width="229" alt="Image"
src="https://github.com/user-attachments/assets/e8a24588-d94d-42fc-ba6c-ce39959207ae"
/>

Here, both the tenants utilise different strings to convey the same
message. The structure of the message stays the same so the identifiers
help in unifying the element identification process across the tenant
apps in the automation tools without having to write another script for
every other tenant.
Without the identifiers, the automation scripts require a rewrite per
tenant to be able to successfully locate the element and even tap on the
hyperlink.

# With PR Changes
## Appium Views
For the given sample code,
<details><summary>Text.rich Sample</summary>

```dart
Text.rich(
  TextSpan(
    text: 'This text contains both identifier and label.',
    semanticsLabel: 'Custom label',
    semanticsIdentifier: 'Custom identifier',
    style: customStyle1,
    children: <TextSpan>[
      TextSpan(
        text: ' While this one contains only label',
        semanticsLabel: 'Hello world',
        style: customStyle2,
      ),
      const TextSpan(
        text: ' and this contains only identifier,',
        semanticsIdentifier: 'Hello to the automation tool',
      ),
      TextSpan(
        text: ' this text contains neither identifier nor label.',
        style: customStyle2,
      ),
    ],
  ),
),
```
</details>
we have the following results with and without the PR code changes:

### With Identifier

![image](https://github.com/user-attachments/assets/abad3b36-61a5-41d9-b269-9977ac6d26e7)
### Without Identifier Changes

![image](https://github.com/user-attachments/assets/91d01be9-d39c-4c65-9251-570284108bfd)


## Semantics Tree Dump
The followings are the semantics tree dump for both the cases
<details><summary>With Identifier</summary>

```
I/flutter ( 8185): SemanticsNode#0
I/flutter ( 8185):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2154.0)
I/flutter ( 8185):  │
I/flutter ( 8185):  └─SemanticsNode#1
I/flutter ( 8185):    │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3) scaled by 2.8x
I/flutter ( 8185):    │ textDirection: ltr
I/flutter ( 8185):    │
I/flutter ( 8185):    └─SemanticsNode#2
I/flutter ( 8185):      │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3)
I/flutter ( 8185):      │ sortKey: OrdinalSortKey#9e46a(order: 0.0)
I/flutter ( 8185):      │
I/flutter ( 8185):      └─SemanticsNode#3
I/flutter ( 8185):        │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3)
I/flutter ( 8185):        │ flags: scopesRoute
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#4
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 40.0, 376.7, 88.0)
I/flutter ( 8185):        │   label: "Demonstration of automation tools support in Semantics
I/flutter ( 8185):        │     for Text and RichText"
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#5
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 104.0, 376.7, 204.0)
I/flutter ( 8185):        │   label: "The identifier property in Semantics widget is used for
I/flutter ( 8185):        │     UI testing with tools that work by querying the native
I/flutter ( 8185):        │     accessibility, like UIAutomator, XCUITest, or Appium. It can be
I/flutter ( 8185):        │     matched with CommonFinders.bySemanticsIdentifier."
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#6
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 220.0, 121.9, 244.0)
I/flutter ( 8185):        │   label: "Text Example:"
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#7
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 244.0, 376.7, 304.0)
I/flutter ( 8185):        │   identifier: "This is a custom identifier that only the automation
I/flutter ( 8185):        │     tools are able to see"
I/flutter ( 8185):        │   label: "This is a custom label"
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#8
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 320.0, 155.1, 344.0)
I/flutter ( 8185):        │   label: "Text.rich Example:"
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#9
I/flutter ( 8185):        │ │ Rect.fromLTRB(16.0, 344.0, 376.7, 400.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#10
I/flutter ( 8185):        │ │   Rect.fromLTRB(-4.0, -3.0, 280.0, 23.0)
I/flutter ( 8185):        │ │   identifier: "Custom identifier"
I/flutter ( 8185):        │ │   label: "Custom label"
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#06bc7(order: 0.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#11
I/flutter ( 8185):        │ │   Rect.fromLTRB(-4.0, -1.0, 345.0, 42.0)
I/flutter ( 8185):        │ │   label: "Hello world"
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#32a12(order: 1.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#12
I/flutter ( 8185):        │ │   Rect.fromLTRB(130.0, 17.0, 348.0, 43.0)
I/flutter ( 8185):        │ │   identifier: "Hello to the automation tool"
I/flutter ( 8185):        │ │   label: " and this contains only identifier,"
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#49d25(order: 2.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ └─SemanticsNode#13
I/flutter ( 8185):        │     Rect.fromLTRB(-4.0, 19.0, 351.0, 60.0)
I/flutter ( 8185):        │     label: " this text contains neither identifier nor label."
I/flutter ( 8185):        │     textDirection: ltr
I/flutter ( 8185):        │     sortKey: OrdinalSortKey#f3624(order: 3.0)
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#14
I/flutter ( 8185):        │   Rect.fromLTRB(16.0, 416.0, 181.0, 440.0)
I/flutter ( 8185):        │   label: "Multi-tenant Example:"
I/flutter ( 8185):        │   textDirection: ltr
I/flutter ( 8185):        │
I/flutter ( 8185):        ├─SemanticsNode#15
I/flutter ( 8185):        │ │ Rect.fromLTRB(108.3, 440.0, 284.5, 480.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#16
I/flutter ( 8185):        │ │   Rect.fromLTRB(-1.0, -3.0, 115.0, 23.0)
I/flutter ( 8185):        │ │   identifier: "please_open"
I/flutter ( 8185):        │ │   label: "Please open the "
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#ea831(order: 0.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#17
I/flutter ( 8185):        │ │   Rect.fromLTRB(106.0, -3.0, 177.0, 23.0)
I/flutter ( 8185):        │ │   identifier: "product_name"
I/flutter ( 8185):        │ │   label: "product 1"
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#589fe(order: 1.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ ├─SemanticsNode#18
I/flutter ( 8185):        │ │   Rect.fromLTRB(-4.0, -3.0, 177.0, 43.0)
I/flutter ( 8185):        │ │   identifier: "to_use_app"
I/flutter ( 8185):        │ │   label:
I/flutter ( 8185):        │ │     "
I/flutter ( 8185):        │ │     to use this app."
I/flutter ( 8185):        │ │   textDirection: ltr
I/flutter ( 8185):        │ │   sortKey: OrdinalSortKey#c2762(order: 2.0)
I/flutter ( 8185):        │ │
I/flutter ( 8185):        │ └─SemanticsNode#19
I/flutter ( 8185):        │     Rect.fromLTRB(95.0, 17.0, 181.0, 43.0)
I/flutter ( 8185):        │     actions: tap
I/flutter ( 8185):        │     flags: isLink
I/flutter ( 8185):        │     identifier: "learn_more_link"
I/flutter ( 8185):        │     label: " Learn more"
I/flutter ( 8185):        │     textDirection: ltr
I/flutter ( 8185):        │     sortKey: OrdinalSortKey#7d560(order: 3.0)
I/flutter ( 8185):        │
I/flutter ( 8185):        └─SemanticsNode#20
I/flutter ( 8185):          │ Rect.fromLTRB(97.0, 496.0, 295.7, 536.0)
I/flutter ( 8185):          │
I/flutter ( 8185):          ├─SemanticsNode#21
I/flutter ( 8185):          │   Rect.fromLTRB(11.0, -3.0, 127.0, 23.0)
I/flutter ( 8185):          │   identifier: "please_open"
I/flutter ( 8185):          │   label: "Please open the "
I/flutter ( 8185):          │   textDirection: ltr
I/flutter ( 8185):          │   sortKey: OrdinalSortKey#7bb57(order: 0.0)
I/flutter ( 8185):          │
I/flutter ( 8185):          ├─SemanticsNode#22
I/flutter ( 8185):          │   Rect.fromLTRB(118.0, -3.0, 188.0, 23.0)
I/flutter ( 8185):          │   identifier: "product_name"
I/flutter ( 8185):          │   label: "product 2"
I/flutter ( 8185):          │   textDirection: ltr
I/flutter ( 8185):          │   sortKey: OrdinalSortKey#6c7c6(order: 1.0)
I/flutter ( 8185):          │
I/flutter ( 8185):          ├─SemanticsNode#23
I/flutter ( 8185):          │   Rect.fromLTRB(-4.0, -3.0, 188.0, 43.0)
I/flutter ( 8185):          │   identifier: "to_use_app"
I/flutter ( 8185):          │   label:
I/flutter ( 8185):          │     "
I/flutter ( 8185):          │     to access this app."
I/flutter ( 8185):          │   textDirection: ltr
I/flutter ( 8185):          │   sortKey: OrdinalSortKey#1e8e7(order: 2.0)
I/flutter ( 8185):          │
I/flutter ( 8185):          └─SemanticsNode#24
I/flutter ( 8185):              Rect.fromLTRB(117.0, 17.0, 203.0, 43.0)
I/flutter ( 8185):              actions: tap
I/flutter ( 8185):              flags: isLink
I/flutter ( 8185):              identifier: "learn_more_link"
I/flutter ( 8185):              label: " Find out more"
I/flutter ( 8185):              textDirection: ltr
I/flutter ( 8185):              sortKey: OrdinalSortKey#db7e6(order: 3.0)
```

</details>
<details><summary>Without Identifier Changes</summary>

```
I/flutter (18659): SemanticsNode#0
I/flutter (18659):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2154.0)
I/flutter (18659):  │
I/flutter (18659):  └─SemanticsNode#1
I/flutter (18659):    │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3) scaled by 2.8x
I/flutter (18659):    │ textDirection: ltr
I/flutter (18659):    │
I/flutter (18659):    └─SemanticsNode#2
I/flutter (18659):      │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3)
I/flutter (18659):      │ sortKey: OrdinalSortKey#102d4(order: 0.0)
I/flutter (18659):      │
I/flutter (18659):      └─SemanticsNode#3
I/flutter (18659):        │ Rect.fromLTRB(0.0, 0.0, 392.7, 783.3)
I/flutter (18659):        │ flags: scopesRoute
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#4
I/flutter (18659):        │   Rect.fromLTRB(16.0, 40.0, 376.7, 88.0)
I/flutter (18659):        │   label: "Demonstration of automation tools support in Semantics
I/flutter (18659):        │     for Text and RichText"
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#5
I/flutter (18659):        │   Rect.fromLTRB(16.0, 104.0, 376.7, 204.0)
I/flutter (18659):        │   label: "The identifier property in Semantics widget is used for
I/flutter (18659):        │     UI testing with tools that work by querying the native
I/flutter (18659):        │     accessibility, like UIAutomator, XCUITest, or Appium. It can be
I/flutter (18659):        │     matched with CommonFinders.bySemanticsIdentifier."
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#6
I/flutter (18659):        │   Rect.fromLTRB(16.0, 220.0, 121.9, 244.0)
I/flutter (18659):        │   label: "Text Example:"
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#7
I/flutter (18659):        │   Rect.fromLTRB(16.0, 244.0, 376.7, 304.0)
I/flutter (18659):        │   label: "This is a custom label"
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#8
I/flutter (18659):        │   Rect.fromLTRB(16.0, 320.0, 155.1, 344.0)
I/flutter (18659):        │   label: "Text.rich Example:"
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#9
I/flutter (18659):        │   Rect.fromLTRB(16.0, 344.0, 376.7, 400.0)
I/flutter (18659):        │   label: "Custom labelHello world and this contains only
I/flutter (18659):        │     identifier, this text contains neither identifier nor label."
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#10
I/flutter (18659):        │   Rect.fromLTRB(16.0, 416.0, 181.0, 440.0)
I/flutter (18659):        │   label: "Multi-tenant Example:"
I/flutter (18659):        │   textDirection: ltr
I/flutter (18659):        │
I/flutter (18659):        ├─SemanticsNode#11
I/flutter (18659):        │ │ Rect.fromLTRB(108.3, 456.0, 284.5, 496.0)
I/flutter (18659):        │ │
I/flutter (18659):        │ ├─SemanticsNode#12
I/flutter (18659):        │ │   Rect.fromLTRB(-4.0, -3.0, 177.0, 43.0)
I/flutter (18659):        │ │   label:
I/flutter (18659):        │ │     "Please open the product 1
I/flutter (18659):        │ │     to use this app."
I/flutter (18659):        │ │   textDirection: ltr
I/flutter (18659):        │ │   sortKey: OrdinalSortKey#493fc(order: 0.0)
I/flutter (18659):        │ │
I/flutter (18659):        │ └─SemanticsNode#13
I/flutter (18659):        │     Rect.fromLTRB(95.0, 17.0, 181.0, 43.0)
I/flutter (18659):        │     actions: tap
I/flutter (18659):        │     flags: isLink
I/flutter (18659):        │     label: " Learn more"
I/flutter (18659):        │     textDirection: ltr
I/flutter (18659):        │     sortKey: OrdinalSortKey#587bf(order: 1.0)
I/flutter (18659):        │
I/flutter (18659):        └─SemanticsNode#14
I/flutter (18659):          │ Rect.fromLTRB(88.9, 512.0, 303.8, 552.0)
I/flutter (18659):          │
I/flutter (18659):          ├─SemanticsNode#15
I/flutter (18659):          │   Rect.fromLTRB(-4.0, -3.0, 196.0, 43.0)
I/flutter (18659):          │   label:
I/flutter (18659):          │     "Please open the product 2
I/flutter (18659):          │     to access this app."
I/flutter (18659):          │   textDirection: ltr
I/flutter (18659):          │   sortKey: OrdinalSortKey#69083(order: 0.0)
I/flutter (18659):          │
I/flutter (18659):          └─SemanticsNode#16
I/flutter (18659):              Rect.fromLTRB(117.0, 17.0, 219.0, 43.0)
I/flutter (18659):              actions: tap
I/flutter (18659):              flags: isLink
I/flutter (18659):              label: " Find out more"
I/flutter (18659):              textDirection: ltr
I/flutter (18659):              sortKey: OrdinalSortKey#ed706(order: 1.0)
```

</details>


fixes https://github.com/flutter/flutter/issues/163842

---------

Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com>
This commit is contained in:
Ashish Beck 2025-03-13 05:00:16 +05:30 committed by GitHub
parent 6f1c04f54f
commit 9af4d746df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 242 additions and 9 deletions

View File

@ -0,0 +1,156 @@
// 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 'package:flutter/gestures.dart' show TapGestureRecognizer;
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
final TextStyle? bodyStyle = Theme.of(context).textTheme.bodyLarge;
final TextStyle customStyle1 = Theme.of(
context,
).textTheme.bodyMedium!.copyWith(color: Colors.blue);
final TextStyle customStyle2 = Theme.of(
context,
).textTheme.labelMedium!.copyWith(color: Colors.green);
return MaterialApp(
title: 'UI Testing with Text and RichText',
home: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: 16.0),
Align(
child: Text(
'Demonstration of automation tools support in Semantics for Text and RichText',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16.0),
const Text(
'The identifier property in Semantics widget is used for UI testing with tools that work by querying the native accessibility, like UIAutomator, XCUITest, or Appium. It can be matched with CommonFinders.bySemanticsIdentifier.',
),
const Divider(),
Text('Text Example:', style: bodyStyle),
const Text(
'This text has a custom label and an identifier. In Android, the label is used as the content-desc, and the identifier is used as the resource-id.',
semanticsLabel: 'This is a custom label',
semanticsIdentifier:
'This is a custom identifier that only the automation tools are able to see',
),
const Divider(),
Text('Text.rich Example:', style: bodyStyle),
Text.rich(
TextSpan(
text: 'This text contains both identifier and label.',
semanticsLabel: 'Custom label',
semanticsIdentifier: 'Custom identifier',
style: customStyle1,
children: <TextSpan>[
TextSpan(
text: ' While this one contains only label',
semanticsLabel: 'Hello world',
style: customStyle2,
),
const TextSpan(
text: ' and this contains only identifier,',
semanticsIdentifier: 'Hello to the automation tool',
),
TextSpan(
text: ' this text contains neither identifier nor label.',
style: customStyle2,
),
],
),
),
const Divider(),
Text('Multi-tenant Example:', style: bodyStyle),
const SizedBox(height: 16),
Column(
spacing: 16.0,
children: <Widget>[
Center(
child: Text.rich(
TextSpan(
text: 'Please open the ',
semanticsIdentifier: 'please_open',
children: <InlineSpan>[
const TextSpan(
text: 'product 1',
semanticsIdentifier: 'product_name',
),
const TextSpan(
text: '\nto use this app.',
semanticsIdentifier: 'to_use_app',
),
TextSpan(
text: ' Learn more',
semanticsIdentifier: 'learn_more_link',
style: const TextStyle(color: Colors.blue),
recognizer:
TapGestureRecognizer()
..onTap = () {
print('Learn more');
},
),
],
),
textAlign: TextAlign.center,
),
),
Center(
child: Text.rich(
TextSpan(
text: 'Please open the ',
semanticsIdentifier: 'please_open',
children: <InlineSpan>[
const TextSpan(
text: 'product 2',
semanticsIdentifier: 'product_name',
),
const TextSpan(
text: '\nto access this app.',
semanticsIdentifier: 'to_use_app',
),
TextSpan(
text: ' Find out more',
semanticsIdentifier: 'learn_more_link',
style: const TextStyle(color: Colors.blue),
recognizer:
TapGestureRecognizer()
..onTap = () {
print('Learn more');
},
),
],
),
textAlign: TextAlign.center,
),
),
],
),
],
),
),
),
),
),
);
}
}

View File

@ -65,10 +65,11 @@ class InlineSpanSemanticsInformation {
this.text, { this.text, {
this.isPlaceholder = false, this.isPlaceholder = false,
this.semanticsLabel, this.semanticsLabel,
this.semanticsIdentifier,
this.stringAttributes = const <ui.StringAttribute>[], this.stringAttributes = const <ui.StringAttribute>[],
this.recognizer, this.recognizer,
}) : assert(!isPlaceholder || (text == '\uFFFC' && semanticsLabel == null && recognizer == null)), }) : assert(!isPlaceholder || (text == '\uFFFC' && semanticsLabel == null && recognizer == null)),
requiresOwnNode = isPlaceholder || recognizer != null; requiresOwnNode = isPlaceholder || recognizer != null || semanticsIdentifier != null;
/// The text info for a [PlaceholderSpan]. /// The text info for a [PlaceholderSpan].
static const InlineSpanSemanticsInformation placeholder = InlineSpanSemanticsInformation( static const InlineSpanSemanticsInformation placeholder = InlineSpanSemanticsInformation(
@ -83,6 +84,9 @@ class InlineSpanSemanticsInformation {
/// The semanticsLabel, if any. /// The semanticsLabel, if any.
final String? semanticsLabel; final String? semanticsLabel;
/// The semanticsIdentifier, if any.
final String? semanticsIdentifier;
/// The gesture recognizer, if any, for this span. /// The gesture recognizer, if any, for this span.
final GestureRecognizer? recognizer; final GestureRecognizer? recognizer;
@ -91,8 +95,8 @@ class InlineSpanSemanticsInformation {
/// True if this configuration should get its own semantics node. /// True if this configuration should get its own semantics node.
/// ///
/// This will be the case of the [recognizer] is not null, of if /// This will be the case if the [recognizer] is not null, or if
/// [isPlaceholder] is true. /// [isPlaceholder] is true, or if [semanticsIdentifier] has a value.
final bool requiresOwnNode; final bool requiresOwnNode;
/// The string attributes attached to this semantics information /// The string attributes attached to this semantics information
@ -103,17 +107,19 @@ class InlineSpanSemanticsInformation {
return other is InlineSpanSemanticsInformation && return other is InlineSpanSemanticsInformation &&
other.text == text && other.text == text &&
other.semanticsLabel == semanticsLabel && other.semanticsLabel == semanticsLabel &&
other.semanticsIdentifier == semanticsIdentifier &&
other.recognizer == recognizer && other.recognizer == recognizer &&
other.isPlaceholder == isPlaceholder && other.isPlaceholder == isPlaceholder &&
listEquals<ui.StringAttribute>(other.stringAttributes, stringAttributes); listEquals<ui.StringAttribute>(other.stringAttributes, stringAttributes);
} }
@override @override
int get hashCode => Object.hash(text, semanticsLabel, recognizer, isPlaceholder); int get hashCode =>
Object.hash(text, semanticsLabel, semanticsIdentifier, recognizer, isPlaceholder);
@override @override
String toString() => String toString() =>
'${objectRuntimeType(this, 'InlineSpanSemanticsInformation')}{text: $text, semanticsLabel: $semanticsLabel, recognizer: $recognizer}'; '${objectRuntimeType(this, 'InlineSpanSemanticsInformation')}{text: $text, semanticsLabel: $semanticsLabel, semanticsIdentifier: $semanticsIdentifier, recognizer: $recognizer}';
} }
/// Combines _semanticsInfo entries where permissible. /// Combines _semanticsInfo entries where permissible.

View File

@ -85,6 +85,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
this.onEnter, this.onEnter,
this.onExit, this.onExit,
this.semanticsLabel, this.semanticsLabel,
this.semanticsIdentifier,
this.locale, this.locale,
this.spellOut, this.spellOut,
}) : mouseCursor = }) : mouseCursor =
@ -230,6 +231,14 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
/// ``` /// ```
final String? semanticsLabel; final String? semanticsLabel;
/// A unique identifier for the semantics node for this [TextSpan].
///
/// This is useful for cases where the text content of the [TextSpan] needs
/// to be uniquely identified through the automation tools without having
/// a dependency on the actual content of the text that can possibly be
/// dynamic in nature.
final String? semanticsIdentifier;
/// The language of the text in this span and its span children. /// The language of the text in this span and its span children.
/// ///
/// Setting the locale of this text span affects the way that assistive /// Setting the locale of this text span affects the way that assistive
@ -414,6 +423,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
), ),
], ],
semanticsLabel: semanticsLabel, semanticsLabel: semanticsLabel,
semanticsIdentifier: semanticsIdentifier,
recognizer: recognizer, recognizer: recognizer,
), ),
); );
@ -521,6 +531,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
other.text == text && other.text == text &&
other.recognizer == recognizer && other.recognizer == recognizer &&
other.semanticsLabel == semanticsLabel && other.semanticsLabel == semanticsLabel &&
other.semanticsIdentifier == semanticsIdentifier &&
onEnter == other.onEnter && onEnter == other.onEnter &&
onExit == other.onExit && onExit == other.onExit &&
mouseCursor == other.mouseCursor && mouseCursor == other.mouseCursor &&
@ -533,6 +544,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
text, text,
recognizer, recognizer,
semanticsLabel, semanticsLabel,
semanticsIdentifier,
onEnter, onEnter,
onExit, onExit,
mouseCursor, mouseCursor,
@ -570,6 +582,10 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati
if (semanticsLabel != null) { if (semanticsLabel != null) {
properties.add(StringProperty('semanticsLabel', semanticsLabel)); properties.add(StringProperty('semanticsLabel', semanticsLabel));
} }
if (semanticsIdentifier != null) {
properties.add(StringProperty('semanticsIdentifier', semanticsIdentifier));
}
} }
@override @override

View File

@ -1145,7 +1145,7 @@ class RenderParagraph extends RenderBox
bool needsAssembleSemanticsNode = false; bool needsAssembleSemanticsNode = false;
bool needsChildConfigurationsDelegate = false; bool needsChildConfigurationsDelegate = false;
for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { for (final InlineSpanSemanticsInformation info in _semanticsInfo!) {
if (info.recognizer != null) { if (info.recognizer != null || info.semanticsIdentifier != null) {
needsAssembleSemanticsNode = true; needsAssembleSemanticsNode = true;
break; break;
} }
@ -1332,6 +1332,7 @@ class RenderParagraph extends RenderBox
SemanticsConfiguration() SemanticsConfiguration()
..sortKey = OrdinalSortKey(ordinal++) ..sortKey = OrdinalSortKey(ordinal++)
..textDirection = initialDirection ..textDirection = initialDirection
..identifier = info.semanticsIdentifier ?? ''
..attributedLabel = AttributedString( ..attributedLabel = AttributedString(
info.semanticsLabel ?? info.text, info.semanticsLabel ?? info.text,
attributes: info.stringAttributes, attributes: info.stringAttributes,

View File

@ -512,6 +512,7 @@ class Text extends StatelessWidget {
this.textScaler, this.textScaler,
this.maxLines, this.maxLines,
this.semanticsLabel, this.semanticsLabel,
this.semanticsIdentifier,
this.textWidthBasis, this.textWidthBasis,
this.textHeightBehavior, this.textHeightBehavior,
this.selectionColor, this.selectionColor,
@ -548,6 +549,7 @@ class Text extends StatelessWidget {
this.textScaler, this.textScaler,
this.maxLines, this.maxLines,
this.semanticsLabel, this.semanticsLabel,
this.semanticsIdentifier,
this.textWidthBasis, this.textWidthBasis,
this.textHeightBehavior, this.textHeightBehavior,
this.selectionColor, this.selectionColor,
@ -665,6 +667,14 @@ class Text extends StatelessWidget {
/// {@endtemplate} /// {@endtemplate}
final String? semanticsLabel; final String? semanticsLabel;
/// A unique identifier for the semantics node for this widget.
///
/// This is useful for cases where the text widget needs to have a uniquely
/// identifiable ID that is recognized through the automation tools without
/// having a dependency on the actual content of the text that can possibly be
/// dynamic in nature.
final String? semanticsIdentifier;
/// {@macro flutter.painting.textPainter.textWidthBasis} /// {@macro flutter.painting.textPainter.textWidthBasis}
final TextWidthBasis? textWidthBasis; final TextWidthBasis? textWidthBasis;
@ -756,11 +766,12 @@ class Text extends StatelessWidget {
), ),
); );
} }
if (semanticsLabel != null) { if (semanticsLabel != null || semanticsIdentifier != null) {
result = Semantics( result = Semantics(
textDirection: textDirection, textDirection: textDirection,
label: semanticsLabel, label: semanticsLabel,
child: ExcludeSemantics(child: result), identifier: semanticsIdentifier,
child: ExcludeSemantics(excluding: semanticsLabel != null, child: result),
); );
} }
return result; return result;
@ -804,6 +815,9 @@ class Text extends StatelessWidget {
if (semanticsLabel != null) { if (semanticsLabel != null) {
properties.add(StringProperty('semanticsLabel', semanticsLabel)); properties.add(StringProperty('semanticsLabel', semanticsLabel));
} }
if (semanticsIdentifier != null) {
properties.add(StringProperty('semanticsIdentifier', semanticsIdentifier));
}
} }
} }

View File

@ -289,9 +289,14 @@ void main() {
test('TextSpan computeSemanticsInformation', () { test('TextSpan computeSemanticsInformation', () {
final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[]; final List<InlineSpanSemanticsInformation> collector = <InlineSpanSemanticsInformation>[];
const TextSpan(text: 'aaa', semanticsLabel: 'bbb').computeSemanticsInformation(collector); const TextSpan(
text: 'aaa',
semanticsLabel: 'bbb',
semanticsIdentifier: 'ccc',
).computeSemanticsInformation(collector);
expect(collector[0].text, 'aaa'); expect(collector[0].text, 'aaa');
expect(collector[0].semanticsLabel, 'bbb'); expect(collector[0].semanticsLabel, 'bbb');
expect(collector[0].semanticsIdentifier, 'ccc');
}); });
test('TextSpan visitDirectChildren', () { test('TextSpan visitDirectChildren', () {

View File

@ -138,4 +138,39 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets('SemanticsIdentifier creates a functional SemanticsNode', (
WidgetTester tester,
) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: Text.rich(
TextSpan(
text: 'Hello, ',
children: <TextSpan>[
TextSpan(text: '1 new '),
TextSpan(text: 'semantics node ', semanticsIdentifier: 'new_semantics_node'),
TextSpan(text: 'has been '),
TextSpan(text: 'created.'),
],
),
),
),
);
expect(find.text('Hello, 1 new semantics node has been created.'), findsOneWidget);
final SemanticsNode node = tester.getSemantics(
find.text('Hello, 1 new semantics node has been created.'),
);
final Map<String, String> labelToNodeId = <String, String>{};
node.visitChildren((SemanticsNode node) {
labelToNodeId[node.label] = node.identifier;
return true;
});
expect(node.id, 1);
expect(labelToNodeId['Hello, 1 new '], '');
expect(labelToNodeId['semantics node '], 'new_semantics_node');
expect(labelToNodeId['has been created.'], '');
expect(labelToNodeId.length, 3);
});
} }