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); + }); }