diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index 251f346ef47..2933c6397ec 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -15,6 +15,7 @@ import 'package:flutter/semantics.dart'; import 'debug.dart'; import 'layer.dart'; +import 'proxy_box.dart'; export 'package:flutter/foundation.dart' show DiagnosticPropertiesBuilder, @@ -3227,7 +3228,7 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im final SemanticsConfiguration config = _semanticsConfiguration; bool dropSemanticsOfPreviousSiblings = config.isBlockingSemanticsOfPreviouslyPaintedNodes; - final bool producesForkingFragment = !config.hasBeenAnnotated && !config.isSemanticBoundary; + bool producesForkingFragment = !config.hasBeenAnnotated && !config.isSemanticBoundary; final bool childrenMergeIntoParent = mergeIntoParent || config.isMergingSemanticsOfDescendants; final List childConfigurations = []; final bool explicitChildNode = config.explicitChildNodes || parent is! RenderObject; @@ -3235,6 +3236,7 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im final Map configToFragment = {}; final List<_InterestingSemanticsFragment> mergeUpFragments = <_InterestingSemanticsFragment>[]; final List> siblingMergeFragmentGroups = >[]; + final bool hasTags = config.tagsForChildren?.isNotEmpty ?? false; visitChildrenForSemantics((RenderObject renderChild) { assert(!_needsLayout); final _SemanticsFragment parentFragment = renderChild._getSemanticsForParent( @@ -3250,7 +3252,9 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im } for (final _InterestingSemanticsFragment fragment in parentFragment.mergeUpFragments) { fragment.addAncestor(this); - fragment.addTags(config.tagsForChildren); + if (hasTags) { + fragment.addTags(config.tagsForChildren!); + } if (hasChildConfigurationsDelegate && fragment.config != null) { // This fragment need to go through delegate to determine whether it // merge up or not. @@ -3266,7 +3270,9 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im for (final List<_InterestingSemanticsFragment> siblingMergeGroup in parentFragment.siblingMergeGroups) { for (final _InterestingSemanticsFragment siblingMergingFragment in siblingMergeGroup) { siblingMergingFragment.addAncestor(this); - siblingMergingFragment.addTags(config.tagsForChildren); + if (hasTags) { + siblingMergingFragment.addTags(config.tagsForChildren!); + } } siblingMergeFragmentGroups.add(siblingMergeGroup); } @@ -3279,14 +3285,25 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im for (final _InterestingSemanticsFragment fragment in mergeUpFragments) { fragment.markAsExplicit(); } - } else if (hasChildConfigurationsDelegate && childConfigurations.isNotEmpty) { + } else if (hasChildConfigurationsDelegate) { final ChildSemanticsConfigurationsResult result = config.childConfigurationsDelegate!(childConfigurations); mergeUpFragments.addAll( - result.mergeUp.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!), + result.mergeUp.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) { + final _InterestingSemanticsFragment? fragment = configToFragment[config]; + if (fragment == null) { + // Parent fragment of Incomplete fragments can't be a forking + // fragment since they need to be merged. + producesForkingFragment = false; + return _IncompleteSemanticsFragment(config: config, owner: this); + } + return fragment; + }), ); for (final Iterable group in result.siblingMergeGroups) { siblingMergeFragmentGroups.add( - group.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!).toList() + group.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) { + return configToFragment[config] ?? _IncompleteSemanticsFragment(config: config, owner: this); + }).toList(), ); } } @@ -4167,10 +4184,10 @@ abstract class _InterestingSemanticsFragment extends _SemanticsFragment { Set? _tagsForChildren; /// Tag all children produced by [compileChildren] with `tags`. - void addTags(Iterable? tags) { - if (tags == null || tags.isEmpty) { - return; - } + /// + /// `tags` must not be empty. + void addTags(Iterable tags) { + assert(tags.isNotEmpty); _tagsForChildren ??= {}; _tagsForChildren!.addAll(tags); } @@ -4264,6 +4281,48 @@ class _RootSemanticsFragment extends _InterestingSemanticsFragment { } } +/// A fragment with partial information that must not form an explicit +/// semantics node without merging into another _SwitchableSemanticsFragment. +/// +/// This fragment is generated from synthetic SemanticsConfiguration returned from +/// [SemanticsConfiguration.childConfigurationsDelegate]. +class _IncompleteSemanticsFragment extends _InterestingSemanticsFragment { + _IncompleteSemanticsFragment({ + required this.config, + required super.owner, + }) : super(dropsSemanticsOfPreviousSiblings: false); + + @override + void addAll(Iterable<_InterestingSemanticsFragment> fragments) { + assert(false, 'This fragment must be a leaf node'); + } + + @override + void compileChildren({ + required Rect? parentSemanticsClipRect, + required Rect? parentPaintClipRect, + required double elevationAdjustment, + required List result, + required List siblingNodes, + }) { + // There is nothing to do because this fragment must be a leaf node and + // must not be explicit. + } + + @override + final SemanticsConfiguration config; + + @override + void markAsExplicit() { + assert( + false, + 'SemanticsConfiguration created in ' + 'SemanticsConfiguration.childConfigurationsDelegate must not produce ' + 'its own semantics node' + ); + } +} + /// An [_InterestingSemanticsFragment] that can be told to only add explicit /// [SemanticsNode]s to the parent. /// @@ -4542,6 +4601,17 @@ class _SwitchableSemanticsFragment extends _InterestingSemanticsFragment { } } + @override + void addTags(Iterable tags) { + super.addTags(tags); + // _ContainerSemanticsFragments add their tags to child fragments through + // this method. This fragment must make sure its _config is in sync. + if (tags.isNotEmpty) { + _ensureConfigIsWritable(); + tags.forEach(_config.addTagForChildren); + } + } + void _ensureConfigIsWritable() { if (!_isConfigWritable) { _config = _config.copy(); diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index 9b66ed999b1..8a2c9ab71aa 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -119,7 +119,9 @@ class RenderParagraph extends RenderBox static final String _placeholderCharacter = String.fromCharCode(PlaceholderSpan.placeholderCodeUnit); final TextPainter _textPainter; - AttributedString? _cachedAttributedLabel; + + List? _cachedAttributedLabels; + List? _cachedCombinedSemanticsInfos; /// The text to display. @@ -135,7 +137,7 @@ class RenderParagraph extends RenderBox break; case RenderComparison.paint: _textPainter.text = value; - _cachedAttributedLabel = null; + _cachedAttributedLabels = null; _cachedCombinedSemanticsInfos = null; _extractPlaceholderSpans(value); markNeedsPaint(); @@ -144,7 +146,7 @@ class RenderParagraph extends RenderBox case RenderComparison.layout: _textPainter.text = value; _overflowShader = null; - _cachedAttributedLabel = null; + _cachedAttributedLabels = null; _cachedCombinedSemanticsInfos = null; _extractPlaceholderSpans(value); markNeedsLayout(); @@ -1035,12 +1037,23 @@ class RenderParagraph extends RenderBox void describeSemanticsConfiguration(SemanticsConfiguration config) { super.describeSemanticsConfiguration(config); _semanticsInfo = text.getSemanticsInformation(); + bool needsAssembleSemanticsNode = false; + bool needsChildConfigrationsDelegate = false; + for (final InlineSpanSemanticsInformation info in _semanticsInfo!) { + if (info.recognizer != null) { + needsAssembleSemanticsNode = true; + break; + } + needsChildConfigrationsDelegate = needsChildConfigrationsDelegate || info.isPlaceholder; + } - if (_semanticsInfo!.any((InlineSpanSemanticsInformation info) => info.recognizer != null)) { + if (needsAssembleSemanticsNode) { config.explicitChildNodes = true; config.isSemanticBoundary = true; + } else if (needsChildConfigrationsDelegate) { + config.childConfigurationsDelegate = _childSemanticsConfigurationsDelegate; } else { - if (_cachedAttributedLabel == null) { + if (_cachedAttributedLabels == null) { final StringBuffer buffer = StringBuffer(); int offset = 0; final List attributes = []; @@ -1050,21 +1063,77 @@ class RenderParagraph extends RenderBox final TextRange originalRange = infoAttribute.range; attributes.add( infoAttribute.copy( - range: TextRange(start: offset + originalRange.start, - end: offset + originalRange.end) + range: TextRange( + start: offset + originalRange.start, + end: offset + originalRange.end, + ), ), ); } buffer.write(label); offset += label.length; } - _cachedAttributedLabel = AttributedString(buffer.toString(), attributes: attributes); + _cachedAttributedLabels = [AttributedString(buffer.toString(), attributes: attributes)]; } - config.attributedLabel = _cachedAttributedLabel!; + config.attributedLabel = _cachedAttributedLabels![0]; config.textDirection = textDirection; } } + ChildSemanticsConfigurationsResult _childSemanticsConfigurationsDelegate(List childConfigs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + int placeholderIndex = 0; + int childConfigsIndex = 0; + int attributedLabelCacheIndex = 0; + InlineSpanSemanticsInformation? seenTextInfo; + _cachedCombinedSemanticsInfos ??= combineSemanticsInfo(_semanticsInfo!); + for (final InlineSpanSemanticsInformation info in _cachedCombinedSemanticsInfos!) { + if (info.isPlaceholder) { + if (seenTextInfo != null) { + builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); + attributedLabelCacheIndex += 1; + } + // Mark every childConfig belongs to this placeholder to merge up group. + while (childConfigsIndex < childConfigs.length && + childConfigs[childConfigsIndex].tagsChildrenWith(PlaceholderSpanIndexSemanticsTag(placeholderIndex))) { + builder.markAsMergeUp(childConfigs[childConfigsIndex]); + childConfigsIndex += 1; + } + placeholderIndex += 1; + } else { + seenTextInfo = info; + } + } + + // Handle plain text info at the end. + if (seenTextInfo != null) { + builder.markAsMergeUp(_createSemanticsConfigForTextInfo(seenTextInfo, attributedLabelCacheIndex)); + } + return builder.build(); + } + + SemanticsConfiguration _createSemanticsConfigForTextInfo(InlineSpanSemanticsInformation textInfo, int cacheIndex) { + assert(!textInfo.requiresOwnNode); + final List cachedStrings = _cachedAttributedLabels ??= []; + assert(cacheIndex <= cachedStrings.length); + final bool hasCache = cacheIndex < cachedStrings.length; + + late AttributedString attributedLabel; + if (hasCache) { + attributedLabel = cachedStrings[cacheIndex]; + } else { + assert(cachedStrings.length == cacheIndex); + attributedLabel = AttributedString( + textInfo.semanticsLabel ?? textInfo.text, + attributes: textInfo.stringAttributes, + ); + cachedStrings.add(attributedLabel); + } + return SemanticsConfiguration() + ..textDirection = textDirection + ..attributedLabel = attributedLabel; + } + // Caches [SemanticsNode]s created during [assembleSemanticsNode] so they // can be re-used when [assembleSemanticsNode] is called again. This ensures // stable ids for the [SemanticsNode]s of [TextSpan]s across diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index e5fde935508..f1c33f644e8 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -346,7 +346,7 @@ class AttributedString { } @override - int get hashCode => Object.hash(string, attributes,); + int get hashCode => Object.hash(string, attributes); @override String toString() { @@ -3805,7 +3805,8 @@ class SemanticsConfiguration { /// which of them should be merged upwards into the parent SemanticsNode. /// /// The input list of [SemanticsConfiguration]s can be empty if the rendering - /// object of this semantics configuration is a leaf node. + /// object of this semantics configuration is a leaf node or child rendering + /// objects do not contribute to the semantics. ChildSemanticsConfigurationsDelegate? get childConfigurationsDelegate => _childConfigurationsDelegate; ChildSemanticsConfigurationsDelegate? _childConfigurationsDelegate; set childConfigurationsDelegate(ChildSemanticsConfigurationsDelegate? value) { diff --git a/packages/flutter/test/widgets/semantics_test.dart b/packages/flutter/test/widgets/semantics_test.dart index 36a9cb00e48..1fd6f5a77b6 100644 --- a/packages/flutter/test/widgets/semantics_test.dart +++ b/packages/flutter/test/widgets/semantics_test.dart @@ -60,6 +60,42 @@ void main() { semantics.dispose(); }, semanticsEnabled: false); + testWidgets('Semantics tag only applies to immediate child', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListView( + children: [ + SingleChildScrollView( + child: Column( + children: [ + Container(padding: const EdgeInsets.only(top: 20.0)), + const Text('label'), + ], + ), + ), + ], + ), + ), + ); + + expect(semantics, isNot(includesNodeWith( + flags: [SemanticsFlag.hasImplicitScrolling], + tags: {RenderViewport.useTwoPaneSemantics}, + ))); + + await tester.pump(); + // Semantics should stay the same after a frame update. + expect(semantics, isNot(includesNodeWith( + flags: [SemanticsFlag.hasImplicitScrolling], + tags: {RenderViewport.useTwoPaneSemantics}, + ))); + + semantics.dispose(); + }, semanticsEnabled: false); + testWidgets('Semantics tooltip', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index 1f1efbdc18f..dc9e8ccd622 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -310,6 +310,160 @@ void main() { semantics.dispose(); }); + testWidgets('semantics label is in order when uses widget span', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'before '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics(label: 'foo'), + ), + const TextSpan(text: ' after'), + ], + ), + ), + ), + ); + expect( + tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \nfoo\n after'), + ); + + // If the Paragraph is not dirty it should use the cache correctly. + final RenderObject parent = tester.renderObject(find.byType(Directionality)); + parent.markNeedsSemanticsUpdate(); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \nfoo\n after'), + ); + }); + + testWidgets('semantics can handle some widget spans without semantics', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'before '), + const WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: 10.0), + ), + const TextSpan(text: ' mid'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics(label: 'foo'), + ), + const TextSpan(text: ' after'), + const WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: 10.0), + ), + ], + ), + ), + ), + ); + expect(tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n mid\nfoo\n after')); + + // If the Paragraph is not dirty it should use the cache correctly. + final RenderObject parent = tester.renderObject(find.byType(Directionality)); + parent.markNeedsSemanticsUpdate(); + await tester.pumpAndSettle(); + + expect(tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n mid\nfoo\n after')); + }); + + testWidgets('semantics can handle all widget spans without semantics', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + children: [ + TextSpan(text: 'before '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: 10.0), + ), + TextSpan(text: ' mid'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: 10.0), + ), + TextSpan(text: ' after'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: SizedBox(width: 10.0), + ), + ], + ), + ), + ), + ); + expect(tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n mid\n after')); + + // If the Paragraph is not dirty it should use the cache correctly. + final RenderObject parent = tester.renderObject(find.byType(Directionality)); + parent.markNeedsSemanticsUpdate(); + await tester.pumpAndSettle(); + + expect(tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n mid\n after')); + }); + + testWidgets('semantics can handle widget spans with explicit semantics node', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Text.rich( + TextSpan( + children: [ + const TextSpan(text: 'before '), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Semantics(label: 'inner', container: true), + ), + const TextSpan(text: ' after'), + ], + ), + ), + ), + ); + expect( + tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n after', children: [matchesSemantics(label: 'inner')]), + ); + + // If the Paragraph is not dirty it should use the cache correctly. + final RenderObject parent = tester.renderObject(find.byType(Directionality)); + parent.markNeedsSemanticsUpdate(); + await tester.pumpAndSettle(); + + expect( + tester.getSemantics(find.byType(Text)), + matchesSemantics(label: 'before \n after', children: [matchesSemantics(label: 'inner')]), + ); + }); + testWidgets('semanticsLabel can be shorter than text', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); await tester.pumpWidget(Directionality(