diff --git a/packages/flutter/lib/src/material/input_decorator.dart b/packages/flutter/lib/src/material/input_decorator.dart index 7d693a8e2175..f3fdfd0626d7 100644 --- a/packages/flutter/lib/src/material/input_decorator.dart +++ b/packages/flutter/lib/src/material/input_decorator.dart @@ -1329,6 +1329,35 @@ class _RenderDecoration extends RenderBox with SlottedContainerRenderObjectMixin return Size.zero; } + ChildSemanticsConfigurationsResult _childSemanticsConfigurationDelegate(List childConfigs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + List? prefixMergeGroup; + List? suffixMergeGroup; + for (final SemanticsConfiguration childConfig in childConfigs) { + if (childConfig.tagsChildrenWith(_InputDecoratorState._kPrefixSemanticsTag)) { + prefixMergeGroup ??= []; + prefixMergeGroup.add(childConfig); + } else if (childConfig.tagsChildrenWith(_InputDecoratorState._kSuffixSemanticsTag)) { + suffixMergeGroup ??= []; + suffixMergeGroup.add(childConfig); + } else { + builder.markAsMergeUp(childConfig); + } + } + if (prefixMergeGroup != null) { + builder.markAsSiblingMergeGroup(prefixMergeGroup); + } + if (suffixMergeGroup != null) { + builder.markAsSiblingMergeGroup(suffixMergeGroup); + } + return builder.build(); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + config.childConfigurationsDelegate = _childSemanticsConfigurationDelegate; + } + @override void performLayout() { final BoxConstraints constraints = this.constraints; @@ -1716,12 +1745,16 @@ class _AffixText extends StatelessWidget { this.text, this.style, this.child, + this.semanticsSortKey, + required this.semanticsTag, }); final bool labelIsFloating; final String? text; final TextStyle? style; final Widget? child; + final SemanticsSortKey? semanticsSortKey; + final SemanticsTag semanticsTag; @override Widget build(BuildContext context) { @@ -1731,7 +1764,11 @@ class _AffixText extends StatelessWidget { duration: _kTransitionDuration, curve: _kTransitionCurve, opacity: labelIsFloating ? 1.0 : 0.0, - child: child ?? (text == null ? null : Text(text!, style: style)), + child: Semantics( + sortKey: semanticsSortKey, + tagForChildren: semanticsTag, + child: child ?? (text == null ? null : Text(text!, style: style)), + ), ), ); } @@ -1903,6 +1940,11 @@ class _InputDecoratorState extends State with TickerProviderStat late final Animation _floatingLabelAnimation; late final AnimationController _shakingLabelController; final _InputBorderGap _borderGap = _InputBorderGap(); + static const OrdinalSortKey _kPrefixSemanticsSortOrder = OrdinalSortKey(0); + static const OrdinalSortKey _kInputSemanticsSortOrder = OrdinalSortKey(1); + static const OrdinalSortKey _kSuffixSemanticsSortOrder = OrdinalSortKey(2); + static const SemanticsTag _kPrefixSemanticsTag = SemanticsTag('_InputDecoratorState.prefix'); + static const SemanticsTag _kSuffixSemanticsTag = SemanticsTag('_InputDecoratorState.suffix'); @override void initState() { @@ -2227,22 +2269,42 @@ class _InputDecoratorState extends State with TickerProviderStat ), ); - final Widget? prefix = decoration.prefix == null && decoration.prefixText == null ? null : - _AffixText( - labelIsFloating: widget._labelShouldWithdraw, - text: decoration.prefixText, - style: MaterialStateProperty.resolveAs(decoration.prefixStyle, materialState) ?? hintStyle, - child: decoration.prefix, - ); - - final Widget? suffix = decoration.suffix == null && decoration.suffixText == null ? null : - _AffixText( - labelIsFloating: widget._labelShouldWithdraw, - text: decoration.suffixText, - style: MaterialStateProperty.resolveAs(decoration.suffixStyle, materialState) ?? hintStyle, - child: decoration.suffix, + final bool hasPrefix = decoration.prefix != null || decoration.prefixText != null; + final bool hasSuffix = decoration.suffix != null || decoration.suffixText != null; + + Widget? input = widget.child; + // If at least two out of the three are visible, it needs semantics sort + // order. + final bool needsSemanticsSortOrder = widget._labelShouldWithdraw && (input != null ? (hasPrefix || hasSuffix) : (hasPrefix && hasSuffix)); + + final Widget? prefix = hasPrefix + ? _AffixText( + labelIsFloating: widget._labelShouldWithdraw, + text: decoration.prefixText, + style: MaterialStateProperty.resolveAs(decoration.prefixStyle, materialState) ?? hintStyle, + semanticsSortKey: needsSemanticsSortOrder ? _kPrefixSemanticsSortOrder : null, + semanticsTag: _kPrefixSemanticsTag, + child: decoration.prefix, + ) + : null; + + final Widget? suffix = hasSuffix + ? _AffixText( + labelIsFloating: widget._labelShouldWithdraw, + text: decoration.suffixText, + style: MaterialStateProperty.resolveAs(decoration.suffixStyle, materialState) ?? hintStyle, + semanticsSortKey: needsSemanticsSortOrder ? _kSuffixSemanticsSortOrder : null, + semanticsTag: _kSuffixSemanticsTag, + child: decoration.suffix, + ) + : null; + + if (input != null && needsSemanticsSortOrder) { + input = Semantics( + sortKey: _kInputSemanticsSortOrder, + child: input, ); - + } final bool decorationIsDense = decoration.isDense ?? false; final double iconSize = decorationIsDense ? 18.0 : 24.0; @@ -2281,7 +2343,9 @@ class _InputDecoratorState extends State with TickerProviderStat color: _getPrefixIconColor(themeData, defaults), size: iconSize, ), - child: decoration.prefixIcon!, + child: Semantics( + child: decoration.prefixIcon, + ), ), ), ), @@ -2306,7 +2370,9 @@ class _InputDecoratorState extends State with TickerProviderStat color: _getSuffixIconColor(themeData, defaults), size: iconSize, ), - child: decoration.suffixIcon!, + child: Semantics( + child: decoration.suffixIcon, + ), ), ), ), @@ -2383,7 +2449,7 @@ class _InputDecoratorState extends State with TickerProviderStat isDense: decoration.isDense, visualDensity: themeData.visualDensity, icon: icon, - input: widget.child, + input: input, label: label, hint: hint, prefix: prefix, diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index c3aa820850dd..36cf10a5d0e1 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -3100,6 +3100,10 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im if (_cachedSemanticsConfiguration == null) { _cachedSemanticsConfiguration = SemanticsConfiguration(); describeSemanticsConfiguration(_cachedSemanticsConfiguration!); + assert( + !_cachedSemanticsConfiguration!.explicitChildNodes || _cachedSemanticsConfiguration!.childConfigurationsDelegate == null, + 'A SemanticsConfiguration with explicitChildNode set to true cannot have a non-null childConfigsDelegate.', + ); } return _cachedSemanticsConfiguration!; } @@ -3160,15 +3164,30 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im // the semantics subtree starting at the identified semantics boundary. final bool wasSemanticsBoundary = _semantics != null && (_cachedSemanticsConfiguration?.isSemanticBoundary ?? false); + + bool mayProduceSiblingNodes = + _cachedSemanticsConfiguration?.childConfigurationsDelegate != null || + _semanticsConfiguration.childConfigurationsDelegate != null; _cachedSemanticsConfiguration = null; + bool isEffectiveSemanticsBoundary = _semanticsConfiguration.isSemanticBoundary && wasSemanticsBoundary; RenderObject node = this; - while (!isEffectiveSemanticsBoundary && node.parent is RenderObject) { + // The sibling nodes will be attached to the parent of immediate semantics + // node, thus marking this semantics boundary dirty is not enough, it needs + // to find the first parent semantics boundary that does not have any + // possible sibling node. + while (node.parent is RenderObject && (mayProduceSiblingNodes || !isEffectiveSemanticsBoundary)) { if (node != this && node._needsSemanticsUpdate) { break; } node._needsSemanticsUpdate = true; + // Since this node is a semantics boundary, the produced sibling nodes will + // be attached to the parent semantics boundary. Thus, these sibling nodes + // will not be carried to the next loop. + if (isEffectiveSemanticsBoundary) { + mayProduceSiblingNodes = false; + } node = node.parent! as RenderObject; isEffectiveSemanticsBoundary = node._semanticsConfiguration.isSemanticBoundary; @@ -3213,15 +3232,16 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im assert(fragment is _InterestingSemanticsFragment); final _InterestingSemanticsFragment interestingFragment = fragment as _InterestingSemanticsFragment; final List result = []; + final List siblingNodes = []; interestingFragment.compileChildren( parentSemanticsClipRect: _semantics?.parentSemanticsClipRect, parentPaintClipRect: _semantics?.parentPaintClipRect, elevationAdjustment: _semantics?.elevationAdjustment ?? 0.0, result: result, + siblingNodes: siblingNodes, ); - final SemanticsNode node = result.single; - // Fragment only wants to add this node's SemanticsNode to the parent. - assert(interestingFragment.config == null && node == _semantics); + // Result may contain sibling nodes that are irrelevant for this update. + assert(interestingFragment.config == null && result.any((SemanticsNode node) => node == _semantics)); } /// Returns the semantics that this node would like to add to its parent. @@ -3235,70 +3255,94 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im bool dropSemanticsOfPreviousSiblings = config.isBlockingSemanticsOfPreviouslyPaintedNodes; final bool producesForkingFragment = !config.hasBeenAnnotated && !config.isSemanticBoundary; - final List<_InterestingSemanticsFragment> fragments = <_InterestingSemanticsFragment>[]; - final Set<_InterestingSemanticsFragment> toBeMarkedExplicit = <_InterestingSemanticsFragment>{}; final bool childrenMergeIntoParent = mergeIntoParent || config.isMergingSemanticsOfDescendants; - + final List childConfigurations = []; + final bool explicitChildNode = config.explicitChildNodes || parent is! RenderObject; + final bool hasChildConfigurationsDelegate = config.childConfigurationsDelegate != null; + final Map configToFragment = {}; + final List<_InterestingSemanticsFragment> mergeUpFragments = <_InterestingSemanticsFragment>[]; + final List> siblingMergeFragmentGroups = >[]; visitChildrenForSemantics((RenderObject renderChild) { assert(!_needsLayout); final _SemanticsFragment parentFragment = renderChild._getSemanticsForParent( mergeIntoParent: childrenMergeIntoParent, ); if (parentFragment.dropsSemanticsOfPreviousSiblings) { - fragments.clear(); - toBeMarkedExplicit.clear(); + childConfigurations.clear(); + mergeUpFragments.clear(); + siblingMergeFragmentGroups.clear(); if (!config.isSemanticBoundary) { dropSemanticsOfPreviousSiblings = true; } } - // Figure out which child fragments are to be made explicit. - for (final _InterestingSemanticsFragment fragment in parentFragment.interestingFragments) { - fragments.add(fragment); + for (final _InterestingSemanticsFragment fragment in parentFragment.mergeUpFragments) { fragment.addAncestor(this); fragment.addTags(config.tagsForChildren); - if (config.explicitChildNodes || parent is! RenderObject) { - fragment.markAsExplicit(); - continue; - } - if (!fragment.hasConfigForParent || producesForkingFragment) { - continue; - } - if (!config.isCompatibleWith(fragment.config)) { - toBeMarkedExplicit.add(fragment); + if (hasChildConfigurationsDelegate && fragment.config != null) { + // This fragment need to go through delegate to determine whether it + // merge up or not. + childConfigurations.add(fragment.config!); + configToFragment[fragment.config!] = fragment; + } else { + mergeUpFragments.add(fragment); } - final int siblingLength = fragments.length - 1; - for (int i = 0; i < siblingLength; i += 1) { - final _InterestingSemanticsFragment siblingFragment = fragments[i]; - if (!fragment.config!.isCompatibleWith(siblingFragment.config)) { - toBeMarkedExplicit.add(fragment); - toBeMarkedExplicit.add(siblingFragment); + } + if (parentFragment is _ContainerSemanticsFragment) { + // Container fragments needs to propagate sibling merge group to be + // compiled by _SwitchableSemanticsFragment. + for (final List<_InterestingSemanticsFragment> siblingMergeGroup in parentFragment.siblingMergeGroups) { + for (final _InterestingSemanticsFragment siblingMergingFragment in siblingMergeGroup) { + siblingMergingFragment.addAncestor(this); + siblingMergingFragment.addTags(config.tagsForChildren); } + siblingMergeFragmentGroups.add(siblingMergeGroup); } } }); - for (final _InterestingSemanticsFragment fragment in toBeMarkedExplicit) { - fragment.markAsExplicit(); + assert(hasChildConfigurationsDelegate || configToFragment.isEmpty); + + if (explicitChildNode) { + for (final _InterestingSemanticsFragment fragment in mergeUpFragments) { + fragment.markAsExplicit(); + } + } else if (hasChildConfigurationsDelegate && childConfigurations.isNotEmpty) { + final ChildSemanticsConfigurationsResult result = config.childConfigurationsDelegate!(childConfigurations); + mergeUpFragments.addAll( + result.mergeUp.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!), + ); + for (final Iterable group in result.siblingMergeGroups) { + siblingMergeFragmentGroups.add( + group.map<_InterestingSemanticsFragment>((SemanticsConfiguration config) => configToFragment[config]!).toList() + ); + } } _needsSemanticsUpdate = false; - _SemanticsFragment result; + final _SemanticsFragment result; if (parent is! RenderObject) { assert(!config.hasBeenAnnotated); assert(!mergeIntoParent); + assert(siblingMergeFragmentGroups.isEmpty); + _marksExplicitInMergeGroup(mergeUpFragments, isMergeUp: true); + siblingMergeFragmentGroups.forEach(_marksExplicitInMergeGroup); result = _RootSemanticsFragment( owner: this, dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings, ); } else if (producesForkingFragment) { result = _ContainerSemanticsFragment( + siblingMergeGroups: siblingMergeFragmentGroups, dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings, ); } else { + _marksExplicitInMergeGroup(mergeUpFragments, isMergeUp: true); + siblingMergeFragmentGroups.forEach(_marksExplicitInMergeGroup); result = _SwitchableSemanticsFragment( config: config, mergeIntoParent: mergeIntoParent, + siblingMergeGroups: siblingMergeFragmentGroups, owner: this, dropsSemanticsOfPreviousSiblings: dropSemanticsOfPreviousSiblings, ); @@ -3307,12 +3351,34 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im fragment.markAsExplicit(); } } - - result.addAll(fragments); - + result.addAll(mergeUpFragments); return result; } + void _marksExplicitInMergeGroup(List<_InterestingSemanticsFragment> mergeGroup, {bool isMergeUp = false}) { + final Set<_InterestingSemanticsFragment> toBeExplicit = <_InterestingSemanticsFragment>{}; + for (int i = 0; i < mergeGroup.length; i += 1) { + final _InterestingSemanticsFragment fragment = mergeGroup[i]; + if (!fragment.hasConfigForParent) { + continue; + } + if (isMergeUp && !_semanticsConfiguration.isCompatibleWith(fragment.config)) { + toBeExplicit.add(fragment); + } + final int siblingLength = i; + for (int j = 0; j < siblingLength; j += 1) { + final _InterestingSemanticsFragment siblingFragment = mergeGroup[j]; + if (!fragment.config!.isCompatibleWith(siblingFragment.config)) { + toBeExplicit.add(fragment); + toBeExplicit.add(siblingFragment); + } + } + } + for (final _InterestingSemanticsFragment fragment in toBeExplicit) { + fragment.markAsExplicit(); + } + } + /// Called when collecting the semantics of this node. /// /// The implementation has to return the children in paint order skipping all @@ -3985,8 +4051,9 @@ mixin RelayoutWhenSystemFontsChangeMixin on RenderObject { /// * [_ContainerSemanticsFragment]: a container class to transport the semantic /// information of multiple [_InterestingSemanticsFragment] to a parent. abstract class _SemanticsFragment { - _SemanticsFragment({ required this.dropsSemanticsOfPreviousSiblings }) - : assert (dropsSemanticsOfPreviousSiblings != null); + _SemanticsFragment({ + required this.dropsSemanticsOfPreviousSiblings, + }) : assert (dropsSemanticsOfPreviousSiblings != null); /// Incorporate the fragments of children into this fragment. void addAll(Iterable<_InterestingSemanticsFragment> fragments); @@ -4002,25 +4069,29 @@ abstract class _SemanticsFragment { /// Returns [_InterestingSemanticsFragment] describing the actual semantic /// information that this fragment wants to add to the parent. - List<_InterestingSemanticsFragment> get interestingFragments; + List<_InterestingSemanticsFragment> get mergeUpFragments; } /// A container used when a [RenderObject] wants to add multiple independent /// [_InterestingSemanticsFragment] to its parent. /// /// The [_InterestingSemanticsFragment] to be added to the parent can be -/// obtained via [interestingFragments]. +/// obtained via [mergeUpFragments]. class _ContainerSemanticsFragment extends _SemanticsFragment { + _ContainerSemanticsFragment({ + required super.dropsSemanticsOfPreviousSiblings, + required this.siblingMergeGroups, + }); - _ContainerSemanticsFragment({ required super.dropsSemanticsOfPreviousSiblings }); + final List> siblingMergeGroups; @override void addAll(Iterable<_InterestingSemanticsFragment> fragments) { - interestingFragments.addAll(fragments); + mergeUpFragments.addAll(fragments); } @override - final List<_InterestingSemanticsFragment> interestingFragments = <_InterestingSemanticsFragment>[]; + final List<_InterestingSemanticsFragment> mergeUpFragments = <_InterestingSemanticsFragment>[]; } /// A [_SemanticsFragment] that describes which concrete semantic information @@ -4057,6 +4128,7 @@ abstract class _InterestingSemanticsFragment extends _SemanticsFragment { required Rect? parentPaintClipRect, required double elevationAdjustment, required List result, + required List siblingNodes, }); /// The [SemanticsConfiguration] the child wants to merge into the parent's @@ -4086,7 +4158,7 @@ abstract class _InterestingSemanticsFragment extends _SemanticsFragment { bool get hasConfigForParent => config != null; @override - List<_InterestingSemanticsFragment> get interestingFragments => <_InterestingSemanticsFragment>[this]; + List<_InterestingSemanticsFragment> get mergeUpFragments => <_InterestingSemanticsFragment>[this]; Set? _tagsForChildren; @@ -4124,7 +4196,13 @@ class _RootSemanticsFragment extends _InterestingSemanticsFragment { }); @override - void compileChildren({ Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, required double elevationAdjustment, required List result }) { + void compileChildren({ + Rect? parentSemanticsClipRect, + Rect? parentPaintClipRect, + required double elevationAdjustment, + required List result, + required List siblingNodes, + }) { assert(_tagsForChildren == null || _tagsForChildren!.isEmpty); assert(parentSemanticsClipRect == null); assert(parentPaintClipRect == null); @@ -4150,8 +4228,11 @@ class _RootSemanticsFragment extends _InterestingSemanticsFragment { parentPaintClipRect: parentPaintClipRect, elevationAdjustment: 0.0, result: children, + siblingNodes: siblingNodes, ); } + // Root node does not have a parent and thus can't attach sibling nodes. + assert(siblingNodes.isEmpty); node.updateWith(config: null, childrenInInversePaintOrder: children); // The root node is the only semantics node allowed to be invisible. This @@ -4201,9 +4282,11 @@ class _SwitchableSemanticsFragment extends _InterestingSemanticsFragment { _SwitchableSemanticsFragment({ required bool mergeIntoParent, required SemanticsConfiguration config, + required List> siblingMergeGroups, required super.owner, required super.dropsSemanticsOfPreviousSiblings, - }) : _mergeIntoParent = mergeIntoParent, + }) : _siblingMergeGroups = siblingMergeGroups, + _mergeIntoParent = mergeIntoParent, _config = config, assert(mergeIntoParent != null), assert(config != null); @@ -4211,14 +4294,126 @@ class _SwitchableSemanticsFragment extends _InterestingSemanticsFragment { final bool _mergeIntoParent; SemanticsConfiguration _config; bool _isConfigWritable = false; + bool _mergesToSibling = false; + + final List> _siblingMergeGroups; + + void _mergeSiblingGroup(Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, List result, Set usedSemanticsIds) { + for (final List<_InterestingSemanticsFragment> group in _siblingMergeGroups) { + Rect? rect; + Rect? semanticsClipRect; + Rect? paintClipRect; + SemanticsConfiguration? configuration; + // Use empty set because the _tagsForChildren may not contains all of the + // tags if this fragment is not explicit. The _tagsForChildren are added + // to sibling nodes at the end of compileChildren if this fragment is + // explicit. + final Set tags = {}; + SemanticsNode? node; + for (final _InterestingSemanticsFragment fragment in group) { + if (fragment.config != null) { + final _SwitchableSemanticsFragment switchableFragment = fragment as _SwitchableSemanticsFragment; + switchableFragment._mergesToSibling = true; + node ??= fragment.owner._semantics; + if (configuration == null) { + switchableFragment._ensureConfigIsWritable(); + configuration = switchableFragment.config; + } else { + configuration.absorb(switchableFragment.config!); + } + // It is a child fragment of a _SwitchableFragment, it must have a + // geometry. + final _SemanticsGeometry geometry = switchableFragment._computeSemanticsGeometry( + parentSemanticsClipRect: parentSemanticsClipRect, + parentPaintClipRect: parentPaintClipRect, + )!; + final Rect fragmentRect = MatrixUtils.transformRect(geometry.transform, geometry.rect); + if (rect == null) { + rect = fragmentRect; + } else { + rect = rect.expandToInclude(fragmentRect); + } + if (geometry.semanticsClipRect != null) { + final Rect rect = MatrixUtils.transformRect(geometry.transform, geometry.semanticsClipRect!); + if (semanticsClipRect == null) { + semanticsClipRect = rect; + } else { + semanticsClipRect = semanticsClipRect.intersect(rect); + } + } + if (geometry.paintClipRect != null) { + final Rect rect = MatrixUtils.transformRect(geometry.transform, geometry.paintClipRect!); + if (paintClipRect == null) { + paintClipRect = rect; + } else { + paintClipRect = paintClipRect.intersect(rect); + } + } + if (switchableFragment._tagsForChildren != null) { + tags.addAll(switchableFragment._tagsForChildren!); + } + } + } + // Can be null if all fragments in group are marked as explicit. + if (configuration != null && !rect!.isEmpty) { + if (node == null || usedSemanticsIds.contains(node.id)) { + node = SemanticsNode(showOnScreen: owner.showOnScreen); + } + usedSemanticsIds.add(node.id); + node + ..tags = tags + ..rect = rect + ..transform = null // Will be set when compiling immediate parent node. + ..parentSemanticsClipRect = semanticsClipRect + ..parentPaintClipRect = paintClipRect; + for (final _InterestingSemanticsFragment fragment in group) { + if (fragment.config != null) { + fragment.owner._semantics = node; + } + } + node.updateWith(config: configuration); + result.add(node); + } + } + } + final List<_InterestingSemanticsFragment> _children = <_InterestingSemanticsFragment>[]; @override - void compileChildren({ Rect? parentSemanticsClipRect, Rect? parentPaintClipRect, required double elevationAdjustment, required List result }) { + void compileChildren({ + Rect? parentSemanticsClipRect, + Rect? parentPaintClipRect, + required double elevationAdjustment, + required List result, + required List siblingNodes, + }) { + final Set usedSemanticsIds = {}; + Iterable<_InterestingSemanticsFragment> compilingFragments = _children; + for (final List<_InterestingSemanticsFragment> siblingGroup in _siblingMergeGroups) { + compilingFragments = compilingFragments.followedBy(siblingGroup); + } if (!_isExplicit) { - owner._semantics = null; - for (final _InterestingSemanticsFragment fragment in _children) { + if (!_mergesToSibling) { + owner._semantics = null; + } + _mergeSiblingGroup( + parentSemanticsClipRect, + parentPaintClipRect, + siblingNodes, + usedSemanticsIds, + ); + for (final _InterestingSemanticsFragment fragment in compilingFragments) { assert(_ancestorChain.first == fragment._ancestorChain.last); + if (fragment is _SwitchableSemanticsFragment) { + // Cached semantics node may be part of sibling merging group prior + // to this update. In this case, the semantics node may continue to + // be reused in that sibling merging group. + if (fragment._isExplicit && + fragment.owner._semantics != null && + usedSemanticsIds.contains(fragment.owner._semantics!.id)) { + fragment.owner._semantics = null; + } + } fragment._ancestorChain.addAll(_ancestorChain.skip(1)); fragment.compileChildren( parentSemanticsClipRect: parentSemanticsClipRect, @@ -4228,14 +4423,16 @@ class _SwitchableSemanticsFragment extends _InterestingSemanticsFragment { // its children are placed at the elevation dictated by this config. elevationAdjustment: elevationAdjustment + _config.elevation, result: result, + siblingNodes: siblingNodes, ); } return; } - final _SemanticsGeometry? geometry = _needsGeometryUpdate - ? _SemanticsGeometry(parentSemanticsClipRect: parentSemanticsClipRect, parentPaintClipRect: parentPaintClipRect, ancestors: _ancestorChain) - : null; + final _SemanticsGeometry? geometry = _computeSemanticsGeometry( + parentSemanticsClipRect: parentSemanticsClipRect, + parentPaintClipRect: parentPaintClipRect, + ); if (!_mergeIntoParent && (geometry?.dropFromTree ?? false)) { return; // Drop the node, it's not going to be visible. @@ -4264,22 +4461,66 @@ class _SwitchableSemanticsFragment extends _InterestingSemanticsFragment { _config.isHidden = true; } } - final List children = []; - for (final _InterestingSemanticsFragment fragment in _children) { + _mergeSiblingGroup( + node.parentSemanticsClipRect, + node.parentPaintClipRect, + siblingNodes, + usedSemanticsIds, + ); + for (final _InterestingSemanticsFragment fragment in compilingFragments) { + if (fragment is _SwitchableSemanticsFragment) { + // Cached semantics node may be part of sibling merging group prior + // to this update. In this case, the semantics node may continue to + // be reused in that sibling merging group. + if (fragment._isExplicit && + fragment.owner._semantics != null && + usedSemanticsIds.contains(fragment.owner._semantics!.id)) { + fragment.owner._semantics = null; + } + } + final List childSiblingNodes = []; fragment.compileChildren( parentSemanticsClipRect: node.parentSemanticsClipRect, parentPaintClipRect: node.parentPaintClipRect, elevationAdjustment: 0.0, result: children, + siblingNodes: childSiblingNodes, ); + siblingNodes.addAll(childSiblingNodes); } + if (_config.isSemanticBoundary) { owner.assembleSemanticsNode(node, _config, children); } else { node.updateWith(config: _config, childrenInInversePaintOrder: children); } result.add(node); + // Sibling node needs to attach to the parent of an explicit node. + for (final SemanticsNode siblingNode in siblingNodes) { + // sibling nodes are in the same coordinate of the immediate explicit node. + // They need to share the same transform if they are going to attach to the + // parent of the immediate explicit node. + assert(siblingNode.transform == null); + siblingNode + ..transform = node.transform + ..isMergedIntoParent = node.isMergedIntoParent; + if (_tagsForChildren != null) { + siblingNode.tags ??= {}; + siblingNode.tags!.addAll(_tagsForChildren!); + } + } + result.addAll(siblingNodes); + siblingNodes.clear(); + } + + _SemanticsGeometry? _computeSemanticsGeometry({ + required Rect? parentSemanticsClipRect, + required Rect? parentPaintClipRect, + }) { + return _needsGeometryUpdate + ? _SemanticsGeometry(parentSemanticsClipRect: parentSemanticsClipRect, parentPaintClipRect: parentPaintClipRect, ancestors: _ancestorChain) + : null; } @override diff --git a/packages/flutter/lib/src/semantics/semantics.dart b/packages/flutter/lib/src/semantics/semantics.dart index 001abd19bb71..dde845b1304e 100644 --- a/packages/flutter/lib/src/semantics/semantics.dart +++ b/packages/flutter/lib/src/semantics/semantics.dart @@ -6,6 +6,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'dart:ui' show Offset, Rect, SemanticsAction, SemanticsFlag, StringAttribute, TextDirection; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart' show MatrixUtils, TransformProperty; import 'package:flutter/services.dart'; @@ -53,6 +54,20 @@ typedef SemanticsActionHandler = void Function(Object? args); /// Used by [SemanticsOwner.onSemanticsUpdate]. typedef SemanticsUpdateCallback = void Function(ui.SemanticsUpdate update); +/// Signature for the [SemanticsConfiguration.childConfigurationsDelegate]. +/// +/// The input list contains all [SemanticsConfiguration]s that rendering +/// children want to merge upward. One can tag a render child with a +/// [SemanticsTag] and look up its [SemanticsConfiguration]s through +/// [SemanticsConfiguration.tagsChildrenWith]. +/// +/// The return value is the arrangement of these configs, including which +/// configs continue to merge upward and which configs form sibling merge group. +/// +/// Use [ChildSemanticsConfigurationsResultBuilder] to generate the return +/// value. +typedef ChildSemanticsConfigurationsDelegate = ChildSemanticsConfigurationsResult Function(List); + /// A tag for a [SemanticsNode]. /// /// Tags can be interpreted by the parent of a [SemanticsNode] @@ -85,6 +100,89 @@ class SemanticsTag { String toString() => '${objectRuntimeType(this, 'SemanticsTag')}($name)'; } +/// The result that contains the arrangement for the child +/// [SemanticsConfiguration]s. +/// +/// When the [PipelineOwner] builds the semantics tree, it uses the returned +/// [ChildSemanticsConfigurationsResult] from +/// [SemanticsConfiguration.childConfigurationsDelegate] to decide how semantics nodes +/// should form. +/// +/// Use [ChildSemanticsConfigurationsResultBuilder] to build the result. +class ChildSemanticsConfigurationsResult { + ChildSemanticsConfigurationsResult._(this.mergeUp, this.siblingMergeGroups); + + /// Returns the [SemanticsConfiguration]s that are supposed to be merged into + /// the parent semantics node. + /// + /// [SemanticsConfiguration]s that are either semantics boundaries or are + /// conflicting with other [SemanticsConfiguration]s will form explicit + /// semantics nodes. All others will be merged into the parent. + final List mergeUp; + + /// The groups of child semantics configurations that want to merge together + /// and form a sibling [SemanticsNode]. + /// + /// All the [SemanticsConfiguration]s in a given group that are either + /// semantics boundaries or are conflicting with other + /// [SemanticsConfiguration]s of the same group will be excluded from the + /// sibling merge group and form independent semantics nodes as usual. + /// + /// The result [SemanticsNode]s from the merges are attached as the sibling + /// nodes of the immediate parent semantics node. For example, a `RenderObjectA` + /// has a rendering child, `RenderObjectB`. If both of them form their own + /// semantics nodes, `SemanticsNodeA` and `SemanticsNodeB`, any semantics node + /// created from sibling merge groups of `RenderObjectB` will be attach to + /// `SemanticsNodeA` as a sibling of `SemanticsNodeB`. + final List> siblingMergeGroups; +} + +/// The builder to build a [ChildSemanticsConfigurationsResult] based on its +/// annotations. +/// +/// To use this builder, one can use [markAsMergeUp] and +/// [markAsSiblingMergeGroup] to annotate the arrangement of +/// [SemanticsConfiguration]s. Once all the configs are annotated, use [build] +/// to generate the [ChildSemanticsConfigurationsResult]. +class ChildSemanticsConfigurationsResultBuilder { + /// Creates a [ChildSemanticsConfigurationsResultBuilder]. + ChildSemanticsConfigurationsResultBuilder(); + + final List _mergeUp = []; + final List> _siblingMergeGroups = >[]; + + /// Marks the [SemanticsConfiguration] to be merged into the parent semantics + /// node. + /// + /// The [SemanticsConfiguration] will be added to the + /// [ChildSemanticsConfigurationsResult.mergeUp] that this builder builds. + void markAsMergeUp(SemanticsConfiguration config) => _mergeUp.add(config); + + /// Marks a group of [SemanticsConfiguration]s to merge together + /// and form a sibling [SemanticsNode]. + /// + /// The group of [SemanticsConfiguration]s will be added to the + /// [ChildSemanticsConfigurationsResult.siblingMergeGroups] that this builder builds. + void markAsSiblingMergeGroup(List configs) => _siblingMergeGroups.add(configs); + + /// Builds a [ChildSemanticsConfigurationsResult] contains the arrangement. + ChildSemanticsConfigurationsResult build() { + assert((){ + final Set seenConfigs = {}; + for (final SemanticsConfiguration config in [..._mergeUp, ..._siblingMergeGroups.flattened]) { + assert( + seenConfigs.add(config), + 'Duplicated SemanticsConfigurations. This can happen if the same ' + 'SemanticsConfiguration was marked twice in markAsMergeUp and/or ' + 'markAsSiblingMergeGroup' + ); + } + return true; + }()); + return ChildSemanticsConfigurationsResult._(_mergeUp, _siblingMergeGroups); + } +} + /// An identifier of a custom semantics action. /// /// Custom semantics actions can be provided to make complex user @@ -3724,6 +3822,25 @@ class SemanticsConfiguration { _onDidLoseAccessibilityFocus = value; } + /// A delegate that decides how to handle [SemanticsConfiguration]s produced + /// in the widget subtree. + /// + /// The [SemanticsConfiguration]s are produced by rendering objects in the + /// subtree and want to merge up to their parent. This delegate can decide + /// which of these should be merged together to form sibling SemanticsNodes and + /// 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. + ChildSemanticsConfigurationsDelegate? get childConfigurationsDelegate => _childConfigurationsDelegate; + ChildSemanticsConfigurationsDelegate? _childConfigurationsDelegate; + set childConfigurationsDelegate(ChildSemanticsConfigurationsDelegate? value) { + assert(value != null); + _childConfigurationsDelegate = value; + // Setting the childConfigsDelegate does not annotate any meaningful + // semantics information of the config. + } + /// Returns the action handler registered for [action] or null if none was /// registered. SemanticsActionHandler? getActionHandler(SemanticsAction action) => _actions[action]; @@ -4448,6 +4565,11 @@ class SemanticsConfiguration { /// * [addTagForChildren] to add a tag and for more information about their /// usage. Iterable? get tagsForChildren => _tagsForChildren; + + /// Whether this configuration will tag the child semantics nodes with a + /// given [SemanticsTag]. + bool tagsChildrenWith(SemanticsTag tag) => _tagsForChildren?.contains(tag) ?? false; + Set? _tagsForChildren; /// Specifies a [SemanticsTag] that this configuration wants to apply to all diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 585d5e6ad324..1c4d97e3923a 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -4375,6 +4375,47 @@ void main() { expect(prefixText.style, prefixStyle); }); + testWidgets('TextField prefix and suffix create a sibling node', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + overlay( + child: TextField( + controller: TextEditingController(text: 'some text'), + decoration: const InputDecoration( + prefixText: 'Prefix', + suffixText: 'Suffix', + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 2, + textDirection: TextDirection.ltr, + label: 'Prefix', + ), + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + value: 'some text', + actions: [ + SemanticsAction.tap, + ], + flags: [ + SemanticsFlag.isTextField, + ], + ), + TestSemantics.rootChild( + id: 3, + textDirection: TextDirection.ltr, + label: 'Suffix', + ), + ], + ), ignoreTransform: true, ignoreRect: true)); + }); + testWidgets('TextField with specified suffixStyle', (WidgetTester tester) async { final TextStyle suffixStyle = TextStyle( color: Colors.pink[500], diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 12b1839e0341..4b5e3792904d 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -1429,6 +1429,51 @@ void main() { handle.dispose(); }); + testWidgets('Two panel semantics is added to the sibling nodes of direct children', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final UniqueKey key = UniqueKey(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: ListView( + key: key, + children: const [ + TextField( + autofocus: true, + decoration: InputDecoration( + prefixText: 'prefix', + ), + ), + ], + ), + ), + )); + // Wait for focus. + await tester.pumpAndSettle(); + + final SemanticsNode scrollableNode = tester.getSemantics(find.byKey(key)); + SemanticsNode? intermediateNode; + scrollableNode.visitChildren((SemanticsNode node) { + intermediateNode = node; + return true; + }); + SemanticsNode? syntheticScrollableNode; + intermediateNode!.visitChildren((SemanticsNode node) { + syntheticScrollableNode = node; + return true; + }); + expect(syntheticScrollableNode!.hasFlag(ui.SemanticsFlag.hasImplicitScrolling), isTrue); + + int numberOfChild = 0; + syntheticScrollableNode!.visitChildren((SemanticsNode node) { + expect(node.isTagged(RenderViewport.useTwoPaneSemantics), isTrue); + numberOfChild += 1; + return true; + }); + expect(numberOfChild, 2); + + handle.dispose(); + }); + testWidgets('Scroll inertia cancel event', (WidgetTester tester) async { await pumpTest(tester, null); await tester.fling(find.byType(Scrollable), const Offset(0.0, -dragOffset), 1000.0); diff --git a/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart b/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart new file mode 100644 index 000000000000..be9ba69ea31f --- /dev/null +++ b/packages/flutter/test/widgets/semantics_child_configs_delegate_test.dart @@ -0,0 +1,360 @@ +// 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/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'semantics_tester.dart'; + +void main() { + testWidgets('Semantics can merge sibling group', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const SemanticsTag first = SemanticsTag('1'); + const SemanticsTag second = SemanticsTag('2'); + const SemanticsTag third = SemanticsTag('3'); + ChildSemanticsConfigurationsResult delegate(List configs) { + expect(configs.length, 3); + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + final List sibling = []; + // Merge first and third + for (final SemanticsConfiguration config in configs) { + if (config.tagsChildrenWith(first) || config.tagsChildrenWith(third)) { + sibling.add(config); + } else { + builder.markAsMergeUp(config); + } + } + builder.markAsSiblingMergeGroup(sibling); + return builder.build(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + label: 'parent', + child: TestConfigDelegate( + delegate: delegate, + child: Column( + children: [ + Semantics( + label: '1', + tagForChildren: first, + child: const SizedBox(width: 100, height: 100), + // this tests that empty nodes disappear + ), + Semantics( + label: '2', + tagForChildren: second, + child: const SizedBox(width: 100, height: 100), + ), + Semantics( + label: '3', + tagForChildren: third, + child: const SizedBox(width: 100, height: 100), + ), + ], + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + label: 'parent\n2', + ), + TestSemantics.rootChild( + label: '1\n3', + ), + ], + ), ignoreId: true, ignoreRect: true, ignoreTransform: true)); + }); + + testWidgets('Semantics can drop semantics config', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + const SemanticsTag first = SemanticsTag('1'); + const SemanticsTag second = SemanticsTag('2'); + const SemanticsTag third = SemanticsTag('3'); + ChildSemanticsConfigurationsResult delegate(List configs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + // Merge first and third + for (final SemanticsConfiguration config in configs) { + if (config.tagsChildrenWith(first) || config.tagsChildrenWith(third)) { + continue; + } + builder.markAsMergeUp(config); + } + return builder.build(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + label: 'parent', + child: TestConfigDelegate( + delegate: delegate, + child: Column( + children: [ + Semantics( + label: '1', + tagForChildren: first, + child: const SizedBox(width: 100, height: 100), + // this tests that empty nodes disappear + ), + Semantics( + label: '2', + tagForChildren: second, + child: const SizedBox(width: 100, height: 100), + ), + Semantics( + label: '3', + tagForChildren: third, + child: const SizedBox(width: 100, height: 100), + ), + ], + ), + ), + ), + ), + ); + + expect(semantics, hasSemantics(TestSemantics.root( + children: [ + TestSemantics.rootChild( + label: 'parent\n2', + ), + ], + ), ignoreId: true, ignoreRect: true, ignoreTransform: true)); + }); + + testWidgets('Semantics throws when mark the same config twice case 1', (WidgetTester tester) async { + const SemanticsTag first = SemanticsTag('1'); + const SemanticsTag second = SemanticsTag('2'); + const SemanticsTag third = SemanticsTag('3'); + ChildSemanticsConfigurationsResult delegate(List configs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + // Marks the same one twice. + builder.markAsMergeUp(configs.first); + builder.markAsMergeUp(configs.first); + return builder.build(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + label: 'parent', + child: TestConfigDelegate( + delegate: delegate, + child: Column( + children: [ + Semantics( + label: '1', + tagForChildren: first, + child: const SizedBox(width: 100, height: 100), + // this tests that empty nodes disappear + ), + Semantics( + label: '2', + tagForChildren: second, + child: const SizedBox(width: 100, height: 100), + ), + Semantics( + label: '3', + tagForChildren: third, + child: const SizedBox(width: 100, height: 100), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('Semantics throws when mark the same config twice case 2', (WidgetTester tester) async { + const SemanticsTag first = SemanticsTag('1'); + const SemanticsTag second = SemanticsTag('2'); + const SemanticsTag third = SemanticsTag('3'); + ChildSemanticsConfigurationsResult delegate(List configs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + // Marks the same one twice. + builder.markAsMergeUp(configs.first); + builder.markAsSiblingMergeGroup([configs.first]); + return builder.build(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Semantics( + label: 'parent', + child: TestConfigDelegate( + delegate: delegate, + child: Column( + children: [ + Semantics( + label: '1', + tagForChildren: first, + child: const SizedBox(width: 100, height: 100), + // this tests that empty nodes disappear + ), + Semantics( + label: '2', + tagForChildren: second, + child: const SizedBox(width: 100, height: 100), + ), + Semantics( + label: '3', + tagForChildren: third, + child: const SizedBox(width: 100, height: 100), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isAssertionError); + }); + + testWidgets('RenderObject with semantics child delegate will mark correct boundary dirty', (WidgetTester tester) async { + final UniqueKey inner = UniqueKey(); + final UniqueKey boundaryParent = UniqueKey(); + final UniqueKey grandBoundaryParent = UniqueKey(); + ChildSemanticsConfigurationsResult delegate(List configs) { + final ChildSemanticsConfigurationsResultBuilder builder = ChildSemanticsConfigurationsResultBuilder(); + configs.forEach(builder.markAsMergeUp); + return builder.build(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MarkSemanticsDirtySpy( + key: grandBoundaryParent, + child: MarkSemanticsDirtySpy( + key: boundaryParent, + child: TestConfigDelegate( + delegate: delegate, + child: Column( + children: [ + Semantics( + label: 'label', + child: MarkSemanticsDirtySpy( + key: inner, + child: const Text('inner'), + ), + ), + ], + ), + ), + ), + ), + ), + ); + + final RenderMarkSemanticsDirtySpy innerObject = tester.renderObject(find.byKey(inner)); + final RenderTestConfigDelegate objectWithDelegate = tester.renderObject(find.byType(TestConfigDelegate)); + final RenderMarkSemanticsDirtySpy boundaryParentObject = tester.renderObject(find.byKey(boundaryParent)); + final RenderMarkSemanticsDirtySpy grandBoundaryParentObject = tester.renderObject(find.byKey(grandBoundaryParent)); + void resetBuildState() { + innerObject.hasRebuildSemantics = false; + boundaryParentObject.hasRebuildSemantics = false; + grandBoundaryParentObject.hasRebuildSemantics = false; + } + // Sanity check + expect(innerObject.hasRebuildSemantics, isTrue); + expect(boundaryParentObject.hasRebuildSemantics, isTrue); + expect(grandBoundaryParentObject.hasRebuildSemantics, isTrue); + resetBuildState(); + + innerObject.markNeedsSemanticsUpdate(); + await tester.pump(); + // Inner boundary should not trigger rebuild above it. + expect(innerObject.hasRebuildSemantics, isTrue); + expect(boundaryParentObject.hasRebuildSemantics, isFalse); + expect(grandBoundaryParentObject.hasRebuildSemantics, isFalse); + resetBuildState(); + + objectWithDelegate.markNeedsSemanticsUpdate(); + await tester.pump(); + // object with delegate rebuilds up to grand parent boundary; + expect(innerObject.hasRebuildSemantics, isTrue); + expect(boundaryParentObject.hasRebuildSemantics, isTrue); + expect(grandBoundaryParentObject.hasRebuildSemantics, isTrue); + resetBuildState(); + + boundaryParentObject.markNeedsSemanticsUpdate(); + await tester.pump(); + // Render objects in between child delegate and grand boundary parent does + // not mark the grand boundary parent dirty because it should not change the + // generated sibling nodes. + expect(innerObject.hasRebuildSemantics, isTrue); + expect(boundaryParentObject.hasRebuildSemantics, isTrue); + expect(grandBoundaryParentObject.hasRebuildSemantics, isFalse); + }); +} + +class MarkSemanticsDirtySpy extends SingleChildRenderObjectWidget { + const MarkSemanticsDirtySpy({super.key, super.child}); + @override + RenderMarkSemanticsDirtySpy createRenderObject(BuildContext context) => RenderMarkSemanticsDirtySpy(); +} + +class RenderMarkSemanticsDirtySpy extends RenderProxyBox { + RenderMarkSemanticsDirtySpy(); + bool hasRebuildSemantics = false; + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + config.isSemanticBoundary = true; + } + + @override + void assembleSemanticsNode( + SemanticsNode node, + SemanticsConfiguration config, + Iterable children, + ) { + hasRebuildSemantics = true; + } +} + +class TestConfigDelegate extends SingleChildRenderObjectWidget { + const TestConfigDelegate({super.key, required this.delegate, super.child}); + final ChildSemanticsConfigurationsDelegate delegate; + + @override + RenderTestConfigDelegate createRenderObject(BuildContext context) => RenderTestConfigDelegate( + delegate: delegate, + ); + + @override + void updateRenderObject(BuildContext context, RenderTestConfigDelegate renderObject) { + renderObject.delegate = delegate; + } +} + +class RenderTestConfigDelegate extends RenderProxyBox { + RenderTestConfigDelegate({ + ChildSemanticsConfigurationsDelegate? delegate, + }) : _delegate = delegate; + + ChildSemanticsConfigurationsDelegate? get delegate => _delegate; + ChildSemanticsConfigurationsDelegate? _delegate; + set delegate(ChildSemanticsConfigurationsDelegate? value) { + if (value != _delegate) { + markNeedsSemanticsUpdate(); + } + _delegate = value; + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + config.childConfigurationsDelegate = _delegate; + } +}