From 9af4d746dfb6b79bd9a822ac9b756664afe262ed Mon Sep 17 00:00:00 2001 From: Ashish Beck Date: Thu, 13 Mar 2025 05:00:16 +0530 Subject: [PATCH] Added `semanticsIdentifier` to `Text` Widgets (#163843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Image 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,
Text.rich Sample ```dart Text.rich( TextSpan( text: 'This text contains both identifier and label.', semanticsLabel: 'Custom label', semanticsIdentifier: 'Custom identifier', style: customStyle1, children: [ 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, ), ], ), ), ```
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
With Identifier ``` 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) ```
Without Identifier Changes ``` 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) ```
fixes https://github.com/flutter/flutter/issues/163842 --------- Co-authored-by: chunhtai <47866232+chunhtai@users.noreply.github.com> --- .../widgets/text/ui_testing_with_text.dart | 156 ++++++++++++++++++ .../flutter/lib/src/painting/inline_span.dart | 16 +- .../flutter/lib/src/painting/text_span.dart | 16 ++ .../flutter/lib/src/rendering/paragraph.dart | 3 +- packages/flutter/lib/src/widgets/text.dart | 18 +- .../flutter/test/painting/text_span_test.dart | 7 +- .../test/widgets/text_semantics_test.dart | 35 ++++ 7 files changed, 242 insertions(+), 9 deletions(-) create mode 100644 examples/api/lib/widgets/text/ui_testing_with_text.dart diff --git a/examples/api/lib/widgets/text/ui_testing_with_text.dart b/examples/api/lib/widgets/text/ui_testing_with_text.dart new file mode 100644 index 00000000000..335f08f6f1c --- /dev/null +++ b/examples/api/lib/widgets/text/ui_testing_with_text.dart @@ -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 createState() => _MyAppState(); +} + +class _MyAppState extends State { + @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: [ + 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( + 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: [ + Center( + child: Text.rich( + TextSpan( + text: 'Please open the ', + semanticsIdentifier: 'please_open', + children: [ + 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: [ + 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, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/painting/inline_span.dart b/packages/flutter/lib/src/painting/inline_span.dart index 41349e76d67..fd42f19efca 100644 --- a/packages/flutter/lib/src/painting/inline_span.dart +++ b/packages/flutter/lib/src/painting/inline_span.dart @@ -65,10 +65,11 @@ class InlineSpanSemanticsInformation { this.text, { this.isPlaceholder = false, this.semanticsLabel, + this.semanticsIdentifier, this.stringAttributes = const [], this.recognizer, }) : assert(!isPlaceholder || (text == '\uFFFC' && semanticsLabel == null && recognizer == null)), - requiresOwnNode = isPlaceholder || recognizer != null; + requiresOwnNode = isPlaceholder || recognizer != null || semanticsIdentifier != null; /// The text info for a [PlaceholderSpan]. static const InlineSpanSemanticsInformation placeholder = InlineSpanSemanticsInformation( @@ -83,6 +84,9 @@ class InlineSpanSemanticsInformation { /// The semanticsLabel, if any. final String? semanticsLabel; + /// The semanticsIdentifier, if any. + final String? semanticsIdentifier; + /// The gesture recognizer, if any, for this span. final GestureRecognizer? recognizer; @@ -91,8 +95,8 @@ class InlineSpanSemanticsInformation { /// True if this configuration should get its own semantics node. /// - /// This will be the case of the [recognizer] is not null, of if - /// [isPlaceholder] is true. + /// This will be the case if the [recognizer] is not null, or if + /// [isPlaceholder] is true, or if [semanticsIdentifier] has a value. final bool requiresOwnNode; /// The string attributes attached to this semantics information @@ -103,17 +107,19 @@ class InlineSpanSemanticsInformation { return other is InlineSpanSemanticsInformation && other.text == text && other.semanticsLabel == semanticsLabel && + other.semanticsIdentifier == semanticsIdentifier && other.recognizer == recognizer && other.isPlaceholder == isPlaceholder && listEquals(other.stringAttributes, stringAttributes); } @override - int get hashCode => Object.hash(text, semanticsLabel, recognizer, isPlaceholder); + int get hashCode => + Object.hash(text, semanticsLabel, semanticsIdentifier, recognizer, isPlaceholder); @override 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. diff --git a/packages/flutter/lib/src/painting/text_span.dart b/packages/flutter/lib/src/painting/text_span.dart index 4ae96de7163..5f6b0fc0da3 100644 --- a/packages/flutter/lib/src/painting/text_span.dart +++ b/packages/flutter/lib/src/painting/text_span.dart @@ -85,6 +85,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati this.onEnter, this.onExit, this.semanticsLabel, + this.semanticsIdentifier, this.locale, this.spellOut, }) : mouseCursor = @@ -230,6 +231,14 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati /// ``` 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. /// /// 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, + semanticsIdentifier: semanticsIdentifier, recognizer: recognizer, ), ); @@ -521,6 +531,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati other.text == text && other.recognizer == recognizer && other.semanticsLabel == semanticsLabel && + other.semanticsIdentifier == semanticsIdentifier && onEnter == other.onEnter && onExit == other.onExit && mouseCursor == other.mouseCursor && @@ -533,6 +544,7 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati text, recognizer, semanticsLabel, + semanticsIdentifier, onEnter, onExit, mouseCursor, @@ -570,6 +582,10 @@ class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotati if (semanticsLabel != null) { properties.add(StringProperty('semanticsLabel', semanticsLabel)); } + + if (semanticsIdentifier != null) { + properties.add(StringProperty('semanticsIdentifier', semanticsIdentifier)); + } } @override diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 45986b2b427..1add01df7dc 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -1145,7 +1145,7 @@ class RenderParagraph extends RenderBox bool needsAssembleSemanticsNode = false; bool needsChildConfigurationsDelegate = false; for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { - if (info.recognizer != null) { + if (info.recognizer != null || info.semanticsIdentifier != null) { needsAssembleSemanticsNode = true; break; } @@ -1332,6 +1332,7 @@ class RenderParagraph extends RenderBox SemanticsConfiguration() ..sortKey = OrdinalSortKey(ordinal++) ..textDirection = initialDirection + ..identifier = info.semanticsIdentifier ?? '' ..attributedLabel = AttributedString( info.semanticsLabel ?? info.text, attributes: info.stringAttributes, diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index ad4a92fd962..7e93563dd63 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -512,6 +512,7 @@ class Text extends StatelessWidget { this.textScaler, this.maxLines, this.semanticsLabel, + this.semanticsIdentifier, this.textWidthBasis, this.textHeightBehavior, this.selectionColor, @@ -548,6 +549,7 @@ class Text extends StatelessWidget { this.textScaler, this.maxLines, this.semanticsLabel, + this.semanticsIdentifier, this.textWidthBasis, this.textHeightBehavior, this.selectionColor, @@ -665,6 +667,14 @@ class Text extends StatelessWidget { /// {@endtemplate} 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} final TextWidthBasis? textWidthBasis; @@ -756,11 +766,12 @@ class Text extends StatelessWidget { ), ); } - if (semanticsLabel != null) { + if (semanticsLabel != null || semanticsIdentifier != null) { result = Semantics( textDirection: textDirection, label: semanticsLabel, - child: ExcludeSemantics(child: result), + identifier: semanticsIdentifier, + child: ExcludeSemantics(excluding: semanticsLabel != null, child: result), ); } return result; @@ -804,6 +815,9 @@ class Text extends StatelessWidget { if (semanticsLabel != null) { properties.add(StringProperty('semanticsLabel', semanticsLabel)); } + if (semanticsIdentifier != null) { + properties.add(StringProperty('semanticsIdentifier', semanticsIdentifier)); + } } } diff --git a/packages/flutter/test/painting/text_span_test.dart b/packages/flutter/test/painting/text_span_test.dart index a9abda34f91..fc148ac93ea 100644 --- a/packages/flutter/test/painting/text_span_test.dart +++ b/packages/flutter/test/painting/text_span_test.dart @@ -289,9 +289,14 @@ void main() { test('TextSpan computeSemanticsInformation', () { final List collector = []; - 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].semanticsLabel, 'bbb'); + expect(collector[0].semanticsIdentifier, 'ccc'); }); test('TextSpan visitDirectChildren', () { diff --git a/packages/flutter/test/widgets/text_semantics_test.dart b/packages/flutter/test/widgets/text_semantics_test.dart index e2864a4bd7c..3232d69fd5c 100644 --- a/packages/flutter/test/widgets/text_semantics_test.dart +++ b/packages/flutter/test/widgets/text_semantics_test.dart @@ -138,4 +138,39 @@ void main() { 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(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 labelToNodeId = {}; + 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); + }); }