diff --git a/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart b/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart index defdb691c23..8567c4eb92d 100644 --- a/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart +++ b/examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart @@ -154,7 +154,8 @@ class _SliverEnsureSemanticsExampleState extends State RenderSliverSemanticsList(); -} - -class RenderSliverSemanticsList extends RenderProxySliver { - @override - void describeSemanticsConfiguration(SemanticsConfiguration config) { - super.describeSemanticsConfiguration(config); - config.role = SemanticsRole.list; - } -} diff --git a/packages/flutter/lib/src/rendering/object.dart b/packages/flutter/lib/src/rendering/object.dart index f6312aeb1f9..895ca0a12aa 100644 --- a/packages/flutter/lib/src/rendering/object.dart +++ b/packages/flutter/lib/src/rendering/object.dart @@ -4595,6 +4595,493 @@ mixin RelayoutWhenSystemFontsChangeMixin on RenderObject { } } +/// A mixin for [RenderObject]s that want to annotate the [SemanticsNode] +/// for their subtree. +mixin SemanticsAnnotationsMixin on RenderObject { + /// Initializes the semantics annotations for this mixin. + // Parameters added to this method should be marked as required to ensure + // callers of the method provide a value. + void initSemanticsAnnotations({ + required SemanticsProperties properties, + required bool container, + required bool explicitChildNodes, + required bool excludeSemantics, + required bool blockUserActions, + required Locale? localeForSubtree, + required TextDirection? textDirection, + }) { + _properties = properties; + _container = container; + _explicitChildNodes = explicitChildNodes; + _excludeSemantics = excludeSemantics; + _blockUserActions = blockUserActions; + _localeForSubtree = localeForSubtree; + _textDirection = textDirection; + _updateAttributedFields(_properties); + } + + /// All of the [SemanticsProperties] for this [SemanticsAnnotationsMixin]. + SemanticsProperties get properties => _properties; + late SemanticsProperties _properties; + set properties(SemanticsProperties value) { + if (_properties == value) { + return; + } + _properties = value; + _updateAttributedFields(_properties); + markNeedsSemanticsUpdate(); + } + + /// If 'container' is true, this [RenderObject] will introduce a new + /// node in the semantics tree. Otherwise, the semantics will be + /// merged with the semantics of any ancestors. + /// + /// Whether descendants of this [RenderObject] can add their semantic information + /// to the [SemanticsNode] introduced by this configuration is controlled by + /// [explicitChildNodes]. + bool get container => _container; + late bool _container; + set container(bool value) { + if (container == value) { + return; + } + _container = value; + markNeedsSemanticsUpdate(); + } + + /// Whether descendants of this [RenderObject] are allowed to add semantic + /// information to the [SemanticsNode] annotated by this widget. + /// + /// When set to false descendants are allowed to annotate [SemanticsNode]s of + /// their parent with the semantic information they want to contribute to the + /// semantic tree. + /// When set to true the only way for descendants to contribute semantic + /// information to the semantic tree is to introduce new explicit + /// [SemanticsNode]s to the tree. + /// + /// This setting is often used in combination with + /// [SemanticsConfiguration.isSemanticBoundary] to create semantic boundaries + /// that are either writable or not for children. + bool get explicitChildNodes => _explicitChildNodes; + late bool _explicitChildNodes; + set explicitChildNodes(bool value) { + if (_explicitChildNodes == value) { + return; + } + _explicitChildNodes = value; + markNeedsSemanticsUpdate(); + } + + /// Whether descendants of this [RenderObject] should have their semantic + /// information ignored. + /// + /// When this flag is set to true, all child semantics nodes are ignored. + /// This can be used as a convenience for cases where a child is wrapped in + /// an [ExcludeSemantics] widget and then another [Semantics] widget. + bool get excludeSemantics => _excludeSemantics; + late bool _excludeSemantics; + set excludeSemantics(bool value) { + if (_excludeSemantics == value) { + return; + } + _excludeSemantics = value; + markNeedsSemanticsUpdate(); + } + + /// Whether to block user interactions for the semantics subtree. + /// + /// Setting this true prevents user from activating pointer related + /// [SemanticsAction]s, such as [SemanticsAction.tap] or + /// [SemanticsAction.longPress]. + bool get blockUserActions => _blockUserActions; + late bool _blockUserActions; + set blockUserActions(bool value) { + if (_blockUserActions == value) { + return; + } + _blockUserActions = value; + markNeedsSemanticsUpdate(); + } + + /// The [Locale] for the semantics subtree. + /// + /// Setting this to null will inherit locale from ancestor semantics node. + Locale? get localeForSubtree => _localeForSubtree; + Locale? _localeForSubtree; + set localeForSubtree(Locale? value) { + if (_localeForSubtree == value) { + return; + } + _localeForSubtree = value; + markNeedsSemanticsUpdate(); + } + + void _updateAttributedFields(SemanticsProperties value) { + _attributedLabel = _effectiveAttributedLabel(value); + _attributedValue = _effectiveAttributedValue(value); + _attributedIncreasedValue = _effectiveAttributedIncreasedValue(value); + _attributedDecreasedValue = _effectiveAttributedDecreasedValue(value); + _attributedHint = _effectiveAttributedHint(value); + } + + AttributedString? _effectiveAttributedLabel(SemanticsProperties value) { + return value.attributedLabel ?? (value.label == null ? null : AttributedString(value.label!)); + } + + AttributedString? _effectiveAttributedValue(SemanticsProperties value) { + return value.attributedValue ?? (value.value == null ? null : AttributedString(value.value!)); + } + + AttributedString? _effectiveAttributedIncreasedValue(SemanticsProperties value) { + return value.attributedIncreasedValue ?? + (value.increasedValue == null ? null : AttributedString(value.increasedValue!)); + } + + AttributedString? _effectiveAttributedDecreasedValue(SemanticsProperties value) { + return properties.attributedDecreasedValue ?? + (value.decreasedValue == null ? null : AttributedString(value.decreasedValue!)); + } + + AttributedString? _effectiveAttributedHint(SemanticsProperties value) { + return value.attributedHint ?? (value.hint == null ? null : AttributedString(value.hint!)); + } + + AttributedString? _attributedLabel; + AttributedString? _attributedValue; + AttributedString? _attributedIncreasedValue; + AttributedString? _attributedDecreasedValue; + AttributedString? _attributedHint; + + /// If non-null, sets the [SemanticsNode.textDirection] semantic to the given + /// value. + /// + /// This must not be null if [SemanticsProperties.attributedLabel], + /// [SemanticsProperties.attributedHint], + /// [SemanticsProperties.attributedValue], + /// [SemanticsProperties.attributedIncreasedValue], or + /// [SemanticsProperties.attributedDecreasedValue] are not null. + TextDirection? get textDirection => _textDirection; + TextDirection? _textDirection; + set textDirection(TextDirection? value) { + if (textDirection == value) { + return; + } + _textDirection = value; + markNeedsSemanticsUpdate(); + } + + @override + void visitChildrenForSemantics(RenderObjectVisitor visitor) { + if (excludeSemantics) { + return; + } + super.visitChildrenForSemantics(visitor); + } + + @override + void describeSemanticsConfiguration(SemanticsConfiguration config) { + super.describeSemanticsConfiguration(config); + config.isSemanticBoundary = container; + config.explicitChildNodes = explicitChildNodes; + config.isBlockingUserActions = blockUserActions; + config.localeForSubtree = localeForSubtree; + assert( + ((_properties.scopesRoute ?? false) && explicitChildNodes) || + !(_properties.scopesRoute ?? false), + 'explicitChildNodes must be set to true if scopes route is true', + ); + assert( + !((_properties.toggled ?? false) && (_properties.checked ?? false)), + 'A semantics node cannot be toggled and checked at the same time', + ); + + if (_properties.enabled != null) { + config.isEnabled = _properties.enabled; + } + if (_properties.checked != null) { + config.isChecked = _properties.checked; + } + if (_properties.mixed != null) { + config.isCheckStateMixed = _properties.mixed; + } + if (_properties.toggled != null) { + config.isToggled = _properties.toggled; + } + if (_properties.selected != null) { + config.isSelected = _properties.selected!; + } + if (_properties.button != null) { + config.isButton = _properties.button!; + } + if (_properties.expanded != null) { + config.isExpanded = _properties.expanded; + } + if (_properties.link != null) { + config.isLink = _properties.link!; + } + if (_properties.linkUrl != null) { + config.linkUrl = _properties.linkUrl; + } + if (_properties.slider != null) { + config.isSlider = _properties.slider!; + } + if (_properties.keyboardKey != null) { + config.isKeyboardKey = _properties.keyboardKey!; + } + if (_properties.header != null) { + config.isHeader = _properties.header!; + } + if (_properties.headingLevel != null) { + config.headingLevel = _properties.headingLevel!; + } + if (_properties.textField != null) { + config.isTextField = _properties.textField!; + } + if (_properties.readOnly != null) { + config.isReadOnly = _properties.readOnly!; + } + if (_properties.focusable != null) { + config.isFocusable = _properties.focusable!; + } + if (_properties.focused != null) { + config.isFocused = _properties.focused!; + } + if (_properties.inMutuallyExclusiveGroup != null) { + config.isInMutuallyExclusiveGroup = _properties.inMutuallyExclusiveGroup!; + } + if (_properties.obscured != null) { + config.isObscured = _properties.obscured!; + } + if (_properties.multiline != null) { + config.isMultiline = _properties.multiline!; + } + if (_properties.hidden != null) { + config.isHidden = _properties.hidden!; + } + if (_properties.image != null) { + config.isImage = _properties.image!; + } + if (_properties.isRequired != null) { + config.isRequired = _properties.isRequired; + } + if (_properties.identifier != null) { + config.identifier = _properties.identifier!; + } + if (_attributedLabel != null) { + config.attributedLabel = _attributedLabel!; + } + if (_attributedValue != null) { + config.attributedValue = _attributedValue!; + } + if (_attributedIncreasedValue != null) { + config.attributedIncreasedValue = _attributedIncreasedValue!; + } + if (_attributedDecreasedValue != null) { + config.attributedDecreasedValue = _attributedDecreasedValue!; + } + if (_attributedHint != null) { + config.attributedHint = _attributedHint!; + } + if (_properties.tooltip != null) { + config.tooltip = _properties.tooltip!; + } + if (_properties.hintOverrides != null && _properties.hintOverrides!.isNotEmpty) { + config.hintOverrides = _properties.hintOverrides; + } + if (_properties.scopesRoute != null) { + config.scopesRoute = _properties.scopesRoute!; + } + if (_properties.namesRoute != null) { + config.namesRoute = _properties.namesRoute!; + } + if (_properties.liveRegion != null) { + config.liveRegion = _properties.liveRegion!; + } + if (_properties.maxValueLength != null) { + config.maxValueLength = _properties.maxValueLength; + } + if (_properties.currentValueLength != null) { + config.currentValueLength = _properties.currentValueLength; + } + if (textDirection != null) { + config.textDirection = textDirection; + } + if (_properties.sortKey != null) { + config.sortKey = _properties.sortKey; + } + if (_properties.tagForChildren != null) { + config.addTagForChildren(_properties.tagForChildren!); + } + if (properties.role != null) { + config.role = _properties.role!; + } + if (_properties.controlsNodes != null) { + config.controlsNodes = _properties.controlsNodes; + } + if (config.validationResult != _properties.validationResult) { + config.validationResult = _properties.validationResult; + } + + if (_properties.inputType != null) { + config.inputType = _properties.inputType!; + } + + // Registering _perform* as action handlers instead of the user provided + // ones to ensure that changing a user provided handler from a non-null to + // another non-null value doesn't require a semantics update. + if (_properties.onTap != null) { + config.onTap = _performTap; + } + if (_properties.onLongPress != null) { + config.onLongPress = _performLongPress; + } + if (_properties.onDismiss != null) { + config.onDismiss = _performDismiss; + } + if (_properties.onScrollLeft != null) { + config.onScrollLeft = _performScrollLeft; + } + if (_properties.onScrollRight != null) { + config.onScrollRight = _performScrollRight; + } + if (_properties.onScrollUp != null) { + config.onScrollUp = _performScrollUp; + } + if (_properties.onScrollDown != null) { + config.onScrollDown = _performScrollDown; + } + if (_properties.onIncrease != null) { + config.onIncrease = _performIncrease; + } + if (_properties.onDecrease != null) { + config.onDecrease = _performDecrease; + } + if (_properties.onCopy != null) { + config.onCopy = _performCopy; + } + if (_properties.onCut != null) { + config.onCut = _performCut; + } + if (_properties.onPaste != null) { + config.onPaste = _performPaste; + } + if (_properties.onMoveCursorForwardByCharacter != null) { + config.onMoveCursorForwardByCharacter = _performMoveCursorForwardByCharacter; + } + if (_properties.onMoveCursorBackwardByCharacter != null) { + config.onMoveCursorBackwardByCharacter = _performMoveCursorBackwardByCharacter; + } + if (_properties.onMoveCursorForwardByWord != null) { + config.onMoveCursorForwardByWord = _performMoveCursorForwardByWord; + } + if (_properties.onMoveCursorBackwardByWord != null) { + config.onMoveCursorBackwardByWord = _performMoveCursorBackwardByWord; + } + if (_properties.onSetSelection != null) { + config.onSetSelection = _performSetSelection; + } + if (_properties.onSetText != null) { + config.onSetText = _performSetText; + } + if (_properties.onDidGainAccessibilityFocus != null) { + config.onDidGainAccessibilityFocus = _performDidGainAccessibilityFocus; + } + if (_properties.onDidLoseAccessibilityFocus != null) { + config.onDidLoseAccessibilityFocus = _performDidLoseAccessibilityFocus; + } + if (_properties.onFocus != null) { + config.onFocus = _performFocus; + } + if (_properties.customSemanticsActions != null) { + config.customSemanticsActions = _properties.customSemanticsActions!; + } + } + + void _performTap() { + _properties.onTap?.call(); + } + + void _performLongPress() { + _properties.onLongPress?.call(); + } + + void _performDismiss() { + _properties.onDismiss?.call(); + } + + void _performScrollLeft() { + _properties.onScrollLeft?.call(); + } + + void _performScrollRight() { + _properties.onScrollRight?.call(); + } + + void _performScrollUp() { + _properties.onScrollUp?.call(); + } + + void _performScrollDown() { + _properties.onScrollDown?.call(); + } + + void _performIncrease() { + _properties.onIncrease?.call(); + } + + void _performDecrease() { + _properties.onDecrease?.call(); + } + + void _performCopy() { + _properties.onCopy?.call(); + } + + void _performCut() { + _properties.onCut?.call(); + } + + void _performPaste() { + _properties.onPaste?.call(); + } + + void _performMoveCursorForwardByCharacter(bool extendSelection) { + _properties.onMoveCursorForwardByCharacter?.call(extendSelection); + } + + void _performMoveCursorBackwardByCharacter(bool extendSelection) { + _properties.onMoveCursorBackwardByCharacter?.call(extendSelection); + } + + void _performMoveCursorForwardByWord(bool extendSelection) { + _properties.onMoveCursorForwardByWord?.call(extendSelection); + } + + void _performMoveCursorBackwardByWord(bool extendSelection) { + _properties.onMoveCursorBackwardByWord?.call(extendSelection); + } + + void _performSetSelection(TextSelection selection) { + _properties.onSetSelection?.call(selection); + } + + void _performSetText(String text) { + _properties.onSetText?.call(text); + } + + void _performDidGainAccessibilityFocus() { + _properties.onDidGainAccessibilityFocus?.call(); + } + + void _performDidLoseAccessibilityFocus() { + _properties.onDidLoseAccessibilityFocus?.call(); + } + + void _performFocus() { + _properties.onFocus?.call(); + } +} + /// Properties of _RenderObjectSemantics that are imposed from parent. @immutable final class _SemanticsParentData { diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 02b046d3d20..74846a9cb4d 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -4231,7 +4231,7 @@ class RenderSemanticsGestureHandler extends RenderProxyBoxWithHitTestBehavior { } /// Add annotations to the [SemanticsNode] for this subtree. -class RenderSemanticsAnnotations extends RenderProxyBox { +class RenderSemanticsAnnotations extends RenderProxyBox with SemanticsAnnotationsMixin { /// Creates a render object that attaches a semantic annotation. /// /// If the [SemanticsProperties.attributedLabel] is not null, the [textDirection] must also not be null. @@ -4244,476 +4244,16 @@ class RenderSemanticsAnnotations extends RenderProxyBox { bool blockUserActions = false, Locale? localeForSubtree, TextDirection? textDirection, - }) : _container = container, - _explicitChildNodes = explicitChildNodes, - _excludeSemantics = excludeSemantics, - _blockUserActions = blockUserActions, - _localeForSubtree = localeForSubtree, - _textDirection = textDirection, - _properties = properties, - super(child) { - _updateAttributedFields(_properties); - } - - /// All of the [SemanticsProperties] for this [RenderSemanticsAnnotations]. - SemanticsProperties get properties => _properties; - SemanticsProperties _properties; - set properties(SemanticsProperties value) { - if (_properties == value) { - return; - } - _properties = value; - _updateAttributedFields(_properties); - markNeedsSemanticsUpdate(); - } - - /// If 'container' is true, this [RenderObject] will introduce a new - /// node in the semantics tree. Otherwise, the semantics will be - /// merged with the semantics of any ancestors. - /// - /// Whether descendants of this [RenderObject] can add their semantic information - /// to the [SemanticsNode] introduced by this configuration is controlled by - /// [explicitChildNodes]. - bool get container => _container; - bool _container; - set container(bool value) { - if (container == value) { - return; - } - _container = value; - markNeedsSemanticsUpdate(); - } - - /// Whether descendants of this [RenderObject] are allowed to add semantic - /// information to the [SemanticsNode] annotated by this widget. - /// - /// When set to false descendants are allowed to annotate [SemanticsNode]s of - /// their parent with the semantic information they want to contribute to the - /// semantic tree. - /// When set to true the only way for descendants to contribute semantic - /// information to the semantic tree is to introduce new explicit - /// [SemanticsNode]s to the tree. - /// - /// This setting is often used in combination with - /// [SemanticsConfiguration.isSemanticBoundary] to create semantic boundaries - /// that are either writable or not for children. - bool get explicitChildNodes => _explicitChildNodes; - bool _explicitChildNodes; - set explicitChildNodes(bool value) { - if (_explicitChildNodes == value) { - return; - } - _explicitChildNodes = value; - markNeedsSemanticsUpdate(); - } - - /// Whether descendants of this [RenderObject] should have their semantic - /// information ignored. - /// - /// When this flag is set to true, all child semantics nodes are ignored. - /// This can be used as a convenience for cases where a child is wrapped in - /// an [ExcludeSemantics] widget and then another [Semantics] widget. - bool get excludeSemantics => _excludeSemantics; - bool _excludeSemantics; - set excludeSemantics(bool value) { - if (_excludeSemantics == value) { - return; - } - _excludeSemantics = value; - markNeedsSemanticsUpdate(); - } - - /// Whether to block user interactions for the semantics subtree. - /// - /// Setting this true prevents user from activating pointer related - /// [SemanticsAction]s, such as [SemanticsAction.tap] or - /// [SemanticsAction.longPress]. - bool get blockUserActions => _blockUserActions; - bool _blockUserActions; - set blockUserActions(bool value) { - if (_blockUserActions == value) { - return; - } - _blockUserActions = value; - markNeedsSemanticsUpdate(); - } - - /// The [Locale] for the semantics subtree. - /// - /// Setting this to null will inherit locale from ancestor semantics node - Locale? get localeForSubtree => _localeForSubtree; - Locale? _localeForSubtree; - set localeForSubtree(Locale? value) { - if (_localeForSubtree == value) { - return; - } - _localeForSubtree = value; - markNeedsSemanticsUpdate(); - } - - void _updateAttributedFields(SemanticsProperties value) { - _attributedLabel = _effectiveAttributedLabel(value); - _attributedValue = _effectiveAttributedValue(value); - _attributedIncreasedValue = _effectiveAttributedIncreasedValue(value); - _attributedDecreasedValue = _effectiveAttributedDecreasedValue(value); - _attributedHint = _effectiveAttributedHint(value); - } - - AttributedString? _effectiveAttributedLabel(SemanticsProperties value) { - return value.attributedLabel ?? (value.label == null ? null : AttributedString(value.label!)); - } - - AttributedString? _effectiveAttributedValue(SemanticsProperties value) { - return value.attributedValue ?? (value.value == null ? null : AttributedString(value.value!)); - } - - AttributedString? _effectiveAttributedIncreasedValue(SemanticsProperties value) { - return value.attributedIncreasedValue ?? - (value.increasedValue == null ? null : AttributedString(value.increasedValue!)); - } - - AttributedString? _effectiveAttributedDecreasedValue(SemanticsProperties value) { - return properties.attributedDecreasedValue ?? - (value.decreasedValue == null ? null : AttributedString(value.decreasedValue!)); - } - - AttributedString? _effectiveAttributedHint(SemanticsProperties value) { - return value.attributedHint ?? (value.hint == null ? null : AttributedString(value.hint!)); - } - - AttributedString? _attributedLabel; - AttributedString? _attributedValue; - AttributedString? _attributedIncreasedValue; - AttributedString? _attributedDecreasedValue; - AttributedString? _attributedHint; - - /// If non-null, sets the [SemanticsNode.textDirection] semantic to the given - /// value. - /// - /// This must not be null if [SemanticsProperties.attributedLabel], - /// [SemanticsProperties.attributedHint], - /// [SemanticsProperties.attributedValue], - /// [SemanticsProperties.attributedIncreasedValue], or - /// [SemanticsProperties.attributedDecreasedValue] are not null. - TextDirection? get textDirection => _textDirection; - TextDirection? _textDirection; - set textDirection(TextDirection? value) { - if (textDirection == value) { - return; - } - _textDirection = value; - markNeedsSemanticsUpdate(); - } - - @override - void visitChildrenForSemantics(RenderObjectVisitor visitor) { - if (excludeSemantics) { - return; - } - super.visitChildrenForSemantics(visitor); - } - - @override - void describeSemanticsConfiguration(SemanticsConfiguration config) { - super.describeSemanticsConfiguration(config); - config.isSemanticBoundary = container; - config.explicitChildNodes = explicitChildNodes; - config.isBlockingUserActions = blockUserActions; - config.localeForSubtree = localeForSubtree; - assert( - ((_properties.scopesRoute ?? false) && explicitChildNodes) || - !(_properties.scopesRoute ?? false), - 'explicitChildNodes must be set to true if scopes route is true', + }) : super(child) { + initSemanticsAnnotations( + properties: properties, + container: container, + explicitChildNodes: explicitChildNodes, + excludeSemantics: excludeSemantics, + blockUserActions: blockUserActions, + localeForSubtree: localeForSubtree, + textDirection: textDirection, ); - assert( - !((_properties.toggled ?? false) && (_properties.checked ?? false)), - 'A semantics node cannot be toggled and checked at the same time', - ); - - if (_properties.enabled != null) { - config.isEnabled = _properties.enabled; - } - if (_properties.checked != null) { - config.isChecked = _properties.checked; - } - if (_properties.mixed != null) { - config.isCheckStateMixed = _properties.mixed; - } - if (_properties.toggled != null) { - config.isToggled = _properties.toggled; - } - if (_properties.selected != null) { - config.isSelected = _properties.selected!; - } - if (_properties.button != null) { - config.isButton = _properties.button!; - } - if (_properties.expanded != null) { - config.isExpanded = _properties.expanded; - } - if (_properties.link != null) { - config.isLink = _properties.link!; - } - if (_properties.linkUrl != null) { - config.linkUrl = _properties.linkUrl; - } - if (_properties.slider != null) { - config.isSlider = _properties.slider!; - } - if (_properties.keyboardKey != null) { - config.isKeyboardKey = _properties.keyboardKey!; - } - if (_properties.header != null) { - config.isHeader = _properties.header!; - } - if (_properties.headingLevel != null) { - config.headingLevel = _properties.headingLevel!; - } - if (_properties.textField != null) { - config.isTextField = _properties.textField!; - } - if (_properties.readOnly != null) { - config.isReadOnly = _properties.readOnly!; - } - if (_properties.focusable != null) { - config.isFocusable = _properties.focusable!; - } - if (_properties.focused != null) { - config.isFocused = _properties.focused!; - } - if (_properties.inMutuallyExclusiveGroup != null) { - config.isInMutuallyExclusiveGroup = _properties.inMutuallyExclusiveGroup!; - } - if (_properties.obscured != null) { - config.isObscured = _properties.obscured!; - } - if (_properties.multiline != null) { - config.isMultiline = _properties.multiline!; - } - if (_properties.hidden != null) { - config.isHidden = _properties.hidden!; - } - if (_properties.image != null) { - config.isImage = _properties.image!; - } - if (_properties.isRequired != null) { - config.isRequired = _properties.isRequired; - } - if (_properties.identifier != null) { - config.identifier = _properties.identifier!; - } - if (_attributedLabel != null) { - config.attributedLabel = _attributedLabel!; - } - if (_attributedValue != null) { - config.attributedValue = _attributedValue!; - } - if (_attributedIncreasedValue != null) { - config.attributedIncreasedValue = _attributedIncreasedValue!; - } - if (_attributedDecreasedValue != null) { - config.attributedDecreasedValue = _attributedDecreasedValue!; - } - if (_attributedHint != null) { - config.attributedHint = _attributedHint!; - } - if (_properties.tooltip != null) { - config.tooltip = _properties.tooltip!; - } - if (_properties.hintOverrides != null && _properties.hintOverrides!.isNotEmpty) { - config.hintOverrides = _properties.hintOverrides; - } - if (_properties.scopesRoute != null) { - config.scopesRoute = _properties.scopesRoute!; - } - if (_properties.namesRoute != null) { - config.namesRoute = _properties.namesRoute!; - } - if (_properties.liveRegion != null) { - config.liveRegion = _properties.liveRegion!; - } - if (_properties.maxValueLength != null) { - config.maxValueLength = _properties.maxValueLength; - } - if (_properties.currentValueLength != null) { - config.currentValueLength = _properties.currentValueLength; - } - if (textDirection != null) { - config.textDirection = textDirection; - } - if (_properties.sortKey != null) { - config.sortKey = _properties.sortKey; - } - if (_properties.tagForChildren != null) { - config.addTagForChildren(_properties.tagForChildren!); - } - if (properties.role != null) { - config.role = _properties.role!; - } - if (_properties.controlsNodes != null) { - config.controlsNodes = _properties.controlsNodes; - } - if (config.validationResult != _properties.validationResult) { - config.validationResult = _properties.validationResult; - } - - if (_properties.inputType != null) { - config.inputType = _properties.inputType!; - } - - // Registering _perform* as action handlers instead of the user provided - // ones to ensure that changing a user provided handler from a non-null to - // another non-null value doesn't require a semantics update. - if (_properties.onTap != null) { - config.onTap = _performTap; - } - if (_properties.onLongPress != null) { - config.onLongPress = _performLongPress; - } - if (_properties.onDismiss != null) { - config.onDismiss = _performDismiss; - } - if (_properties.onScrollLeft != null) { - config.onScrollLeft = _performScrollLeft; - } - if (_properties.onScrollRight != null) { - config.onScrollRight = _performScrollRight; - } - if (_properties.onScrollUp != null) { - config.onScrollUp = _performScrollUp; - } - if (_properties.onScrollDown != null) { - config.onScrollDown = _performScrollDown; - } - if (_properties.onIncrease != null) { - config.onIncrease = _performIncrease; - } - if (_properties.onDecrease != null) { - config.onDecrease = _performDecrease; - } - if (_properties.onCopy != null) { - config.onCopy = _performCopy; - } - if (_properties.onCut != null) { - config.onCut = _performCut; - } - if (_properties.onPaste != null) { - config.onPaste = _performPaste; - } - if (_properties.onMoveCursorForwardByCharacter != null) { - config.onMoveCursorForwardByCharacter = _performMoveCursorForwardByCharacter; - } - if (_properties.onMoveCursorBackwardByCharacter != null) { - config.onMoveCursorBackwardByCharacter = _performMoveCursorBackwardByCharacter; - } - if (_properties.onMoveCursorForwardByWord != null) { - config.onMoveCursorForwardByWord = _performMoveCursorForwardByWord; - } - if (_properties.onMoveCursorBackwardByWord != null) { - config.onMoveCursorBackwardByWord = _performMoveCursorBackwardByWord; - } - if (_properties.onSetSelection != null) { - config.onSetSelection = _performSetSelection; - } - if (_properties.onSetText != null) { - config.onSetText = _performSetText; - } - if (_properties.onDidGainAccessibilityFocus != null) { - config.onDidGainAccessibilityFocus = _performDidGainAccessibilityFocus; - } - if (_properties.onDidLoseAccessibilityFocus != null) { - config.onDidLoseAccessibilityFocus = _performDidLoseAccessibilityFocus; - } - if (_properties.onFocus != null) { - config.onFocus = _performFocus; - } - if (_properties.customSemanticsActions != null) { - config.customSemanticsActions = _properties.customSemanticsActions!; - } - } - - void _performTap() { - _properties.onTap?.call(); - } - - void _performLongPress() { - _properties.onLongPress?.call(); - } - - void _performDismiss() { - _properties.onDismiss?.call(); - } - - void _performScrollLeft() { - _properties.onScrollLeft?.call(); - } - - void _performScrollRight() { - _properties.onScrollRight?.call(); - } - - void _performScrollUp() { - _properties.onScrollUp?.call(); - } - - void _performScrollDown() { - _properties.onScrollDown?.call(); - } - - void _performIncrease() { - _properties.onIncrease?.call(); - } - - void _performDecrease() { - _properties.onDecrease?.call(); - } - - void _performCopy() { - _properties.onCopy?.call(); - } - - void _performCut() { - _properties.onCut?.call(); - } - - void _performPaste() { - _properties.onPaste?.call(); - } - - void _performMoveCursorForwardByCharacter(bool extendSelection) { - _properties.onMoveCursorForwardByCharacter?.call(extendSelection); - } - - void _performMoveCursorBackwardByCharacter(bool extendSelection) { - _properties.onMoveCursorBackwardByCharacter?.call(extendSelection); - } - - void _performMoveCursorForwardByWord(bool extendSelection) { - _properties.onMoveCursorForwardByWord?.call(extendSelection); - } - - void _performMoveCursorBackwardByWord(bool extendSelection) { - _properties.onMoveCursorBackwardByWord?.call(extendSelection); - } - - void _performSetSelection(TextSelection selection) { - _properties.onSetSelection?.call(selection); - } - - void _performSetText(String text) { - _properties.onSetText?.call(text); - } - - void _performDidGainAccessibilityFocus() { - _properties.onDidGainAccessibilityFocus?.call(); - } - - void _performDidLoseAccessibilityFocus() { - _properties.onDidLoseAccessibilityFocus?.call(); - } - - void _performFocus() { - _properties.onFocus?.call(); } } diff --git a/packages/flutter/lib/src/rendering/proxy_sliver.dart b/packages/flutter/lib/src/rendering/proxy_sliver.dart index a1d8f6353ba..c82ecf4504f 100644 --- a/packages/flutter/lib/src/rendering/proxy_sliver.dart +++ b/packages/flutter/lib/src/rendering/proxy_sliver.dart @@ -481,3 +481,30 @@ class RenderSliverConstrainedCrossAxis extends RenderProxySliver { ); } } + +/// Add annotations to the [SemanticsNode] for this subtree. +class RenderSliverSemanticsAnnotations extends RenderProxySliver with SemanticsAnnotationsMixin { + /// Creates a render object that attaches a semantic annotation. + /// + /// If the [SemanticsProperties.attributedLabel] is not null, the [textDirection] must also not be null. + RenderSliverSemanticsAnnotations({ + RenderSliver? child, + required SemanticsProperties properties, + bool container = false, + bool explicitChildNodes = false, + bool excludeSemantics = false, + bool blockUserActions = false, + Locale? localeForSubtree, + TextDirection? textDirection, + }) : super(child) { + initSemanticsAnnotations( + properties: properties, + container: container, + explicitChildNodes: explicitChildNodes, + excludeSemantics: excludeSemantics, + blockUserActions: blockUserActions, + localeForSubtree: localeForSubtree, + textDirection: textDirection, + ); + } +} diff --git a/packages/flutter/lib/src/widgets/basic.dart b/packages/flutter/lib/src/widgets/basic.dart index 7e075584cbd..ffb62fd6d77 100644 --- a/packages/flutter/lib/src/widgets/basic.dart +++ b/packages/flutter/lib/src/widgets/basic.dart @@ -3845,6 +3845,489 @@ class SliverPadding extends SingleChildRenderObjectWidget { } } +/// An abstract class for building widgets that annotate their subtree with a +/// description of the meaning of the widgets. +/// +/// {@template flutter.widgets.SemanticsBase} +/// Used by assistive technologies, search engines, and other semantic analysis +/// software to determine the meaning of the application. +/// +/// See also: +/// +/// * [SemanticsProperties], which contains a complete documentation for each +/// of the constructor parameters that belongs to semantics properties. +/// * [RenderObject.describeSemanticsConfiguration], the rendering library API +/// through which the [Semantics] widget and [SliverSemantics] sliver are +/// actually implemented. +/// * [SemanticsNode], the object used by the rendering library to represent +/// semantics in the semantics tree. +/// * [SemanticsDebugger], an overlay to help visualize the semantics tree. Can +/// be enabled using [WidgetsApp.showSemanticsDebugger], +/// [MaterialApp.showSemanticsDebugger], or [CupertinoApp.showSemanticsDebugger]. +/// * [MergeSemantics], a widget which marks a subtree as being a single node for +/// accessibility purposes. +/// * [ExcludeSemantics], a widget which excludes a subtree from the semantics tree +/// (which might be useful if it is, e.g., totally decorative and not +/// important to the user). +/// {@endtemplate} +@immutable +sealed class _SemanticsBase extends SingleChildRenderObjectWidget { + /// Creates a semantic annotation. + /// + /// To create a `const` instance of [_SemanticsBase], use the + /// [_SemanticsBase.fromProperties] constructor. + /// + /// {@template flutter.widgets.SemanticsBase.constructor} + /// See also: + /// + /// * [SemanticsProperties], which contains a complete documentation for each + /// of the constructor parameters that belongs to semantics properties. + /// * [SemanticsSortKey] for a class that determines accessibility traversal + /// order. + /// {@endtemplate} + // Properties added to this constructor should be marked required + // to enforce its subclasses add it to their constructors. + _SemanticsBase({ + Key? key, + Widget? child, + required bool container, + required bool explicitChildNodes, + required bool excludeSemantics, + required bool blockUserActions, + required bool? enabled, + required bool? checked, + required bool? mixed, + required bool? selected, + required bool? toggled, + required bool? button, + required bool? slider, + required bool? keyboardKey, + required bool? link, + required Uri? linkUrl, + required bool? header, + required int? headingLevel, + required bool? textField, + required bool? readOnly, + required bool? focusable, + required bool? focused, + required bool? inMutuallyExclusiveGroup, + required bool? obscured, + required bool? multiline, + required bool? scopesRoute, + required bool? namesRoute, + required bool? hidden, + required bool? image, + required bool? liveRegion, + required bool? expanded, + required bool? isRequired, + required int? maxValueLength, + required int? currentValueLength, + required String? identifier, + required String? label, + required AttributedString? attributedLabel, + required String? value, + required AttributedString? attributedValue, + required String? increasedValue, + required AttributedString? attributedIncreasedValue, + required String? decreasedValue, + required AttributedString? attributedDecreasedValue, + required String? hint, + required AttributedString? attributedHint, + required String? tooltip, + required String? onTapHint, + required String? onLongPressHint, + required TextDirection? textDirection, + required SemanticsSortKey? sortKey, + required SemanticsTag? tagForChildren, + required VoidCallback? onTap, + required VoidCallback? onLongPress, + required VoidCallback? onScrollLeft, + required VoidCallback? onScrollRight, + required VoidCallback? onScrollUp, + required VoidCallback? onScrollDown, + required VoidCallback? onIncrease, + required VoidCallback? onDecrease, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onDismiss, + required MoveCursorHandler? onMoveCursorForwardByCharacter, + required MoveCursorHandler? onMoveCursorBackwardByCharacter, + required SetSelectionHandler? onSetSelection, + required SetTextHandler? onSetText, + required VoidCallback? onDidGainAccessibilityFocus, + required VoidCallback? onDidLoseAccessibilityFocus, + required VoidCallback? onFocus, + required Map? customSemanticsActions, + required SemanticsRole? role, + required Set? controlsNodes, + required SemanticsValidationResult validationResult, + required ui.SemanticsInputType? inputType, + required Locale? localeForSubtree, + }) : this.fromProperties( + key: key, + child: child, + container: container, + explicitChildNodes: explicitChildNodes, + excludeSemantics: excludeSemantics, + blockUserActions: blockUserActions, + localeForSubtree: localeForSubtree, + properties: SemanticsProperties( + enabled: enabled, + checked: checked, + mixed: mixed, + expanded: expanded, + toggled: toggled, + selected: selected, + button: button, + slider: slider, + keyboardKey: keyboardKey, + link: link, + linkUrl: linkUrl, + header: header, + headingLevel: headingLevel, + textField: textField, + readOnly: readOnly, + focusable: focusable, + focused: focused, + inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, + obscured: obscured, + multiline: multiline, + scopesRoute: scopesRoute, + namesRoute: namesRoute, + hidden: hidden, + image: image, + liveRegion: liveRegion, + isRequired: isRequired, + maxValueLength: maxValueLength, + currentValueLength: currentValueLength, + identifier: identifier, + label: label, + attributedLabel: attributedLabel, + value: value, + attributedValue: attributedValue, + increasedValue: increasedValue, + attributedIncreasedValue: attributedIncreasedValue, + decreasedValue: decreasedValue, + attributedDecreasedValue: attributedDecreasedValue, + hint: hint, + attributedHint: attributedHint, + tooltip: tooltip, + textDirection: textDirection, + sortKey: sortKey, + tagForChildren: tagForChildren, + onTap: onTap, + onLongPress: onLongPress, + onScrollLeft: onScrollLeft, + onScrollRight: onScrollRight, + onScrollUp: onScrollUp, + onScrollDown: onScrollDown, + onIncrease: onIncrease, + onDecrease: onDecrease, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter, + onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter, + onDidGainAccessibilityFocus: onDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus, + onFocus: onFocus, + onDismiss: onDismiss, + onSetSelection: onSetSelection, + onSetText: onSetText, + customSemanticsActions: customSemanticsActions, + hintOverrides: onTapHint != null || onLongPressHint != null + ? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint) + : null, + role: role, + controlsNodes: controlsNodes, + validationResult: validationResult, + inputType: inputType, + ), + ); + + /// {@template flutter.widgets.SemanticsBase.fromProperties} + /// Creates a semantic annotation using [SemanticsProperties]. + /// {@endtemplate} + // Properties added to this constructor should be marked required + // to enforce its subclasses add it to their constructors. + const _SemanticsBase.fromProperties({ + super.key, + super.child, + required this.container, + required this.explicitChildNodes, + required this.excludeSemantics, + required this.blockUserActions, + required this.localeForSubtree, + required this.properties, + }) : assert( + localeForSubtree == null || container, + 'To assign locale for subtree, this widget needs to be a ' + 'container', + ); + + /// Contains properties used by assistive technologies to make the application + /// more accessible. + final SemanticsProperties properties; + + /// If [container] is true, this widget will introduce a new + /// node in the semantics tree. Otherwise, the semantics will be + /// merged with the semantics of any ancestors (if the ancestor allows that). + /// + /// Whether descendants of this widget can add their semantic information to the + /// [SemanticsNode] introduced by this configuration is controlled by + /// [explicitChildNodes]. + final bool container; + + /// Whether descendants of this widget are allowed to add semantic information + /// to the [SemanticsNode] annotated by this widget. + /// + /// When set to false descendants are allowed to annotate [SemanticsNode]s of + /// their parent with the semantic information they want to contribute to the + /// semantic tree. + /// When set to true the only way for descendants to contribute semantic + /// information to the semantic tree is to introduce new explicit + /// [SemanticsNode]s to the tree. + /// + /// If the semantics properties of this node include + /// [SemanticsProperties.scopesRoute] set to true, then [explicitChildNodes] + /// must be true also. + /// + /// This setting is often used in combination with [SemanticsConfiguration.isSemanticBoundary] + /// to create semantic boundaries that are either writable or not for children. + final bool explicitChildNodes; + + /// The [Locale] for widgets in the subtree. + /// + /// If null, the subtree will inherit the locale form ancestor widget. + final Locale? localeForSubtree; + + /// Whether to replace all child semantics with this node. + /// + /// Defaults to false. + /// + /// When this flag is set to true, all child semantics nodes are ignored. + /// This can be used as a convenience for cases where a child is wrapped in + /// an [ExcludeSemantics] widget and then another [Semantics] widget. + final bool excludeSemantics; + + /// Whether to block user interactions for the rendering subtree. + /// + /// Setting this to true will prevent users from interacting with The + /// rendering object configured by this widget and its subtree through + /// pointer-related [SemanticsAction]s in assistive technologies. + /// + /// The [SemanticsNode] created from this widget is still focusable by + /// assistive technologies. Only pointer-related [SemanticsAction]s, such as + /// [SemanticsAction.tap] or its friends, are blocked. + /// + /// If this widget is merged into a parent semantics node, only the + /// [SemanticsAction]s of this widget and the widgets in the subtree are + /// blocked. + /// + /// For example using [Semantics]: + /// ```dart + /// void _myTap() { } + /// void _myLongPress() { } + /// + /// Widget build(BuildContext context) { + /// return Semantics( + /// onTap: _myTap, + /// child: Semantics( + /// blockUserActions: true, + /// onLongPress: _myLongPress, + /// child: const Text('label'), + /// ), + /// ); + /// } + /// ``` + /// + /// The result semantics node will still have `_myTap`, but the `_myLongPress` + /// will be blocked. + /// + /// and similarly using [SliverSemantics]: + /// ```dart + /// void _myTap() { } + /// void _myLongPress() { } + /// + /// Widget build(BuildContext context) { + /// return CustomScrollView( + /// slivers: [ + /// SliverSemantics( + /// onTap: _myTap, + /// sliver: SliverSemantics( + /// blockUserActions: true, + /// onLongPress: _myLongPress, + /// sliver: const SliverToBoxAdapter( + /// child: Text('label'), + /// ), + /// ), + /// ), + /// ], + /// ); + /// } + /// ``` + /// + /// The result semantics node will still have `_myTap`, but the `_myLongPress` + /// will be blocked. + final bool blockUserActions; + + TextDirection? _getTextDirection(BuildContext context) { + if (properties.textDirection != null) { + return properties.textDirection; + } + + final bool containsText = + properties.label != null || + properties.attributedLabel != null || + properties.value != null || + properties.attributedValue != null || + properties.increasedValue != null || + properties.attributedIncreasedValue != null || + properties.decreasedValue != null || + properties.attributedDecreasedValue != null || + properties.hint != null || + properties.attributedHint != null || + properties.tooltip != null; + + if (!containsText) { + return null; + } + + return Directionality.maybeOf(context); + } +} + +/// A sliver that annotates its subtree with a description of the meaning of +/// the slivers. +/// +/// {@macro flutter.widgets.SemanticsBase} +/// * [Semantics], the widget variant of this sliver. +@immutable +class SliverSemantics extends _SemanticsBase { + /// Creates a semantic annotation. + /// + /// To create a `const` instance of [SliverSemantics], use the + /// [SliverSemantics.fromProperties] constructor. + /// + /// {@macro flutter.widgets.SemanticsBase.constructor} + SliverSemantics({ + super.key, + required Widget sliver, + super.container = false, + super.explicitChildNodes = false, + super.excludeSemantics = false, + super.blockUserActions = false, + super.enabled, + super.checked, + super.mixed, + super.selected, + super.toggled, + super.button, + super.slider, + super.keyboardKey, + super.link, + super.linkUrl, + super.header, + super.headingLevel, + super.textField, + super.readOnly, + super.focusable, + super.focused, + super.inMutuallyExclusiveGroup, + super.obscured, + super.multiline, + super.scopesRoute, + super.namesRoute, + super.hidden, + super.image, + super.liveRegion, + super.expanded, + super.isRequired, + super.maxValueLength, + super.currentValueLength, + super.identifier, + super.label, + super.attributedLabel, + super.value, + super.attributedValue, + super.increasedValue, + super.attributedIncreasedValue, + super.decreasedValue, + super.attributedDecreasedValue, + super.hint, + super.attributedHint, + super.tooltip, + super.onTapHint, + super.onLongPressHint, + super.textDirection, + super.sortKey, + super.tagForChildren, + super.onTap, + super.onLongPress, + super.onScrollLeft, + super.onScrollRight, + super.onScrollUp, + super.onScrollDown, + super.onIncrease, + super.onDecrease, + super.onCopy, + super.onCut, + super.onPaste, + super.onDismiss, + super.onMoveCursorForwardByCharacter, + super.onMoveCursorBackwardByCharacter, + super.onSetSelection, + super.onSetText, + super.onDidGainAccessibilityFocus, + super.onDidLoseAccessibilityFocus, + super.onFocus, + super.customSemanticsActions, + super.role, + super.controlsNodes, + super.validationResult = SemanticsValidationResult.none, + super.inputType, + super.localeForSubtree, + }) : super(child: sliver); + + /// {@macro flutter.widgets.SemanticsBase.fromProperties} + const SliverSemantics.fromProperties({ + super.key, + super.child, + super.container = false, + super.explicitChildNodes = false, + super.excludeSemantics = false, + super.blockUserActions = false, + super.localeForSubtree, + required super.properties, + }) : super.fromProperties(); + + @override + RenderSliverSemanticsAnnotations createRenderObject(BuildContext context) { + return RenderSliverSemanticsAnnotations( + container: container, + explicitChildNodes: explicitChildNodes, + excludeSemantics: excludeSemantics, + blockUserActions: blockUserActions, + properties: properties, + localeForSubtree: localeForSubtree, + textDirection: _getTextDirection(context), + ); + } + + @override + void updateRenderObject(BuildContext context, RenderSliverSemanticsAnnotations renderObject) { + renderObject + ..container = container + ..explicitChildNodes = explicitChildNodes + ..excludeSemantics = excludeSemantics + ..blockUserActions = blockUserActions + ..properties = properties + ..textDirection = _getTextDirection(context) + ..localeForSubtree = localeForSubtree; + } +} + // LAYOUT NODES /// Returns the [AxisDirection] in the given [Axis] in the current @@ -7289,294 +7772,108 @@ class MetaData extends SingleChildRenderObjectWidget { /// A widget that annotates the widget tree with a description of the meaning of /// the widgets. /// -/// Used by assistive technologies, search engines, and other semantic analysis -/// software to determine the meaning of the application. -/// /// {@youtube 560 315 https://www.youtube.com/watch?v=NvtMt_DtFrQ} /// -/// See also: -/// -/// * [SemanticsProperties], which contains a complete documentation for each -/// of the constructor parameters that belongs to semantics properties. -/// * [MergeSemantics], which marks a subtree as being a single node for -/// accessibility purposes. -/// * [ExcludeSemantics], which excludes a subtree from the semantics tree -/// (which might be useful if it is, e.g., totally decorative and not -/// important to the user). -/// * [RenderObject.describeSemanticsConfiguration], the rendering library API -/// through which the [Semantics] widget is actually implemented. -/// * [SemanticsNode], the object used by the rendering library to represent -/// semantics in the semantics tree. -/// * [SemanticsDebugger], an overlay to help visualize the semantics tree. Can -/// be enabled using [WidgetsApp.showSemanticsDebugger], -/// [MaterialApp.showSemanticsDebugger], or [CupertinoApp.showSemanticsDebugger]. +/// {@macro flutter.widgets.SemanticsBase} +/// * [SliverSemantics], the sliver variant of this widget. @immutable -class Semantics extends SingleChildRenderObjectWidget { +class Semantics extends _SemanticsBase { /// Creates a semantic annotation. /// /// To create a `const` instance of [Semantics], use the /// [Semantics.fromProperties] constructor. /// - /// See also: - /// - /// * [SemanticsProperties], which contains a complete documentation for each - /// of the constructor parameters that belongs to semantics properties. - /// * [SemanticsSortKey] for a class that determines accessibility traversal - /// order. + /// {@macro flutter.widgets.SemanticsBase} Semantics({ - Key? key, - Widget? child, - bool container = false, - bool explicitChildNodes = false, - bool excludeSemantics = false, - bool blockUserActions = false, - bool? enabled, - bool? checked, - bool? mixed, - bool? selected, - bool? toggled, - bool? button, - bool? slider, - bool? keyboardKey, - bool? link, - Uri? linkUrl, - bool? header, - int? headingLevel, - bool? textField, - bool? readOnly, - bool? focusable, - bool? focused, - bool? inMutuallyExclusiveGroup, - bool? obscured, - bool? multiline, - bool? scopesRoute, - bool? namesRoute, - bool? hidden, - bool? image, - bool? liveRegion, - bool? expanded, - bool? isRequired, - int? maxValueLength, - int? currentValueLength, - String? identifier, - String? label, - AttributedString? attributedLabel, - String? value, - AttributedString? attributedValue, - String? increasedValue, - AttributedString? attributedIncreasedValue, - String? decreasedValue, - AttributedString? attributedDecreasedValue, - String? hint, - AttributedString? attributedHint, - String? tooltip, - String? onTapHint, - String? onLongPressHint, - TextDirection? textDirection, - SemanticsSortKey? sortKey, - SemanticsTag? tagForChildren, - VoidCallback? onTap, - VoidCallback? onLongPress, - VoidCallback? onScrollLeft, - VoidCallback? onScrollRight, - VoidCallback? onScrollUp, - VoidCallback? onScrollDown, - VoidCallback? onIncrease, - VoidCallback? onDecrease, - VoidCallback? onCopy, - VoidCallback? onCut, - VoidCallback? onPaste, - VoidCallback? onDismiss, - MoveCursorHandler? onMoveCursorForwardByCharacter, - MoveCursorHandler? onMoveCursorBackwardByCharacter, - SetSelectionHandler? onSetSelection, - SetTextHandler? onSetText, - VoidCallback? onDidGainAccessibilityFocus, - VoidCallback? onDidLoseAccessibilityFocus, - VoidCallback? onFocus, - Map? customSemanticsActions, - SemanticsRole? role, - Set? controlsNodes, - SemanticsValidationResult validationResult = SemanticsValidationResult.none, - ui.SemanticsInputType? inputType, - Locale? localeForSubtree, - }) : this.fromProperties( - key: key, - child: child, - container: container, - explicitChildNodes: explicitChildNodes, - excludeSemantics: excludeSemantics, - blockUserActions: blockUserActions, - localeForSubtree: localeForSubtree, - properties: SemanticsProperties( - enabled: enabled, - checked: checked, - mixed: mixed, - expanded: expanded, - toggled: toggled, - selected: selected, - button: button, - slider: slider, - keyboardKey: keyboardKey, - link: link, - linkUrl: linkUrl, - header: header, - headingLevel: headingLevel, - textField: textField, - readOnly: readOnly, - focusable: focusable, - focused: focused, - inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, - obscured: obscured, - multiline: multiline, - scopesRoute: scopesRoute, - namesRoute: namesRoute, - hidden: hidden, - image: image, - liveRegion: liveRegion, - isRequired: isRequired, - maxValueLength: maxValueLength, - currentValueLength: currentValueLength, - identifier: identifier, - label: label, - attributedLabel: attributedLabel, - value: value, - attributedValue: attributedValue, - increasedValue: increasedValue, - attributedIncreasedValue: attributedIncreasedValue, - decreasedValue: decreasedValue, - attributedDecreasedValue: attributedDecreasedValue, - hint: hint, - attributedHint: attributedHint, - tooltip: tooltip, - textDirection: textDirection, - sortKey: sortKey, - tagForChildren: tagForChildren, - onTap: onTap, - onLongPress: onLongPress, - onScrollLeft: onScrollLeft, - onScrollRight: onScrollRight, - onScrollUp: onScrollUp, - onScrollDown: onScrollDown, - onIncrease: onIncrease, - onDecrease: onDecrease, - onCopy: onCopy, - onCut: onCut, - onPaste: onPaste, - onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter, - onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter, - onDidGainAccessibilityFocus: onDidGainAccessibilityFocus, - onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus, - onFocus: onFocus, - onDismiss: onDismiss, - onSetSelection: onSetSelection, - onSetText: onSetText, - customSemanticsActions: customSemanticsActions, - hintOverrides: onTapHint != null || onLongPressHint != null - ? SemanticsHintOverrides(onTapHint: onTapHint, onLongPressHint: onLongPressHint) - : null, - role: role, - controlsNodes: controlsNodes, - validationResult: validationResult, - inputType: inputType, - ), - ); + super.key, + super.child, + super.container = false, + super.explicitChildNodes = false, + super.excludeSemantics = false, + super.blockUserActions = false, + super.enabled, + super.checked, + super.mixed, + super.selected, + super.toggled, + super.button, + super.slider, + super.keyboardKey, + super.link, + super.linkUrl, + super.header, + super.headingLevel, + super.textField, + super.readOnly, + super.focusable, + super.focused, + super.inMutuallyExclusiveGroup, + super.obscured, + super.multiline, + super.scopesRoute, + super.namesRoute, + super.hidden, + super.image, + super.liveRegion, + super.expanded, + super.isRequired, + super.maxValueLength, + super.currentValueLength, + super.identifier, + super.label, + super.attributedLabel, + super.value, + super.attributedValue, + super.increasedValue, + super.attributedIncreasedValue, + super.decreasedValue, + super.attributedDecreasedValue, + super.hint, + super.attributedHint, + super.tooltip, + super.onTapHint, + super.onLongPressHint, + super.textDirection, + super.sortKey, + super.tagForChildren, + super.onTap, + super.onLongPress, + super.onScrollLeft, + super.onScrollRight, + super.onScrollUp, + super.onScrollDown, + super.onIncrease, + super.onDecrease, + super.onCopy, + super.onCut, + super.onPaste, + super.onDismiss, + super.onMoveCursorForwardByCharacter, + super.onMoveCursorBackwardByCharacter, + super.onSetSelection, + super.onSetText, + super.onDidGainAccessibilityFocus, + super.onDidLoseAccessibilityFocus, + super.onFocus, + super.customSemanticsActions, + super.role, + super.controlsNodes, + super.validationResult = SemanticsValidationResult.none, + super.inputType, + super.localeForSubtree, + }); - /// Creates a semantic annotation using [SemanticsProperties]. + /// {@macro flutter.widgets.SemanticsBase.fromProperties} const Semantics.fromProperties({ super.key, super.child, - this.container = false, - this.explicitChildNodes = false, - this.excludeSemantics = false, - this.blockUserActions = false, - this.localeForSubtree, - required this.properties, - }) : assert( - localeForSubtree == null || container, - 'To assign locale for subtree, this widget needs to be a ' - 'container', - ); - - /// Contains properties used by assistive technologies to make the application - /// more accessible. - final SemanticsProperties properties; - - /// If [container] is true, this widget will introduce a new - /// node in the semantics tree. Otherwise, the semantics will be - /// merged with the semantics of any ancestors (if the ancestor allows that). - /// - /// Whether descendants of this widget can add their semantic information to the - /// [SemanticsNode] introduced by this configuration is controlled by - /// [explicitChildNodes]. - final bool container; - - /// Whether descendants of this widget are allowed to add semantic information - /// to the [SemanticsNode] annotated by this widget. - /// - /// When set to false descendants are allowed to annotate [SemanticsNode]s of - /// their parent with the semantic information they want to contribute to the - /// semantic tree. - /// When set to true the only way for descendants to contribute semantic - /// information to the semantic tree is to introduce new explicit - /// [SemanticsNode]s to the tree. - /// - /// If the semantics properties of this node include - /// [SemanticsProperties.scopesRoute] set to true, then [explicitChildNodes] - /// must be true also. - /// - /// This setting is often used in combination with [SemanticsConfiguration.isSemanticBoundary] - /// to create semantic boundaries that are either writable or not for children. - final bool explicitChildNodes; - - /// The [Locale] for widgets in the subtree. - /// - /// If null, the subtree will inherit the locale form ancestor widget. - final Locale? localeForSubtree; - - /// Whether to replace all child semantics with this node. - /// - /// Defaults to false. - /// - /// When this flag is set to true, all child semantics nodes are ignored. - /// This can be used as a convenience for cases where a child is wrapped in - /// an [ExcludeSemantics] widget and then another [Semantics] widget. - final bool excludeSemantics; - - /// Whether to block user interactions for the rendering subtree. - /// - /// Setting this to true will prevent users from interacting with The - /// rendering object configured by this widget and its subtree through - /// pointer-related [SemanticsAction]s in assistive technologies. - /// - /// The [SemanticsNode] created from this widget is still focusable by - /// assistive technologies. Only pointer-related [SemanticsAction]s, such as - /// [SemanticsAction.tap] or its friends, are blocked. - /// - /// If this widget is merged into a parent semantics node, only the - /// [SemanticsAction]s of this widget and the widgets in the subtree are - /// blocked. - /// - /// For example: - /// ```dart - /// void _myTap() { } - /// void _myLongPress() { } - /// - /// Widget build(BuildContext context) { - /// return Semantics( - /// onTap: _myTap, - /// child: Semantics( - /// blockUserActions: true, - /// onLongPress: _myLongPress, - /// child: const Text('label'), - /// ), - /// ); - /// } - /// ``` - /// - /// The result semantics node will still have `_myTap`, but the `_myLongPress` - /// will be blocked. - final bool blockUserActions; + super.container = false, + super.explicitChildNodes = false, + super.excludeSemantics = false, + super.blockUserActions = false, + super.localeForSubtree, + required super.properties, + }) : super.fromProperties(); @override RenderSemanticsAnnotations createRenderObject(BuildContext context) { @@ -7591,31 +7888,6 @@ class Semantics extends SingleChildRenderObjectWidget { ); } - TextDirection? _getTextDirection(BuildContext context) { - if (properties.textDirection != null) { - return properties.textDirection; - } - - final bool containsText = - properties.label != null || - properties.attributedLabel != null || - properties.value != null || - properties.attributedValue != null || - properties.increasedValue != null || - properties.attributedIncreasedValue != null || - properties.decreasedValue != null || - properties.attributedDecreasedValue != null || - properties.hint != null || - properties.attributedHint != null || - properties.tooltip != null; - - if (!containsText) { - return null; - } - - return Directionality.maybeOf(context); - } - @override void updateRenderObject(BuildContext context, RenderSemanticsAnnotations renderObject) { renderObject diff --git a/packages/flutter/test/widgets/sliversemantics_test.dart b/packages/flutter/test/widgets/sliversemantics_test.dart new file mode 100644 index 00000000000..84088b08178 --- /dev/null +++ b/packages/flutter/test/widgets/sliversemantics_test.dart @@ -0,0 +1,2931 @@ +// 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 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'semantics_tester.dart'; + +void main() { + group('SliverSemantics', () { + setUp(() { + debugResetSemanticsIdCounter(); + }); + + _tests(); + }); +} + +Widget boilerPlate({required List slivers, bool wrapWithDirectionality = true}) { + Widget child = MediaQuery( + data: const MediaQueryData(), + child: CustomScrollView(slivers: slivers), + ); + if (wrapWithDirectionality) { + child = Directionality(textDirection: TextDirection.ltr, child: child); + } + return child; +} + +void _tests() { + testWidgets('Semantics shutdown and restart', (WidgetTester tester) async { + SemanticsTester? semantics = SemanticsTester(tester); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + textDirection: TextDirection.ltr, + label: 'test1', + ), + ], + ), + ], + ), + ], + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + label: 'test1', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + semantics.dispose(); + semantics = null; + await tester.pump(); + + expect(tester.binding.hasScheduledFrame, isFalse); + semantics = SemanticsTester(tester); + expect(tester.binding.hasScheduledFrame, isTrue); + await tester.pump(); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }, semanticsEnabled: false); + + testWidgets('tag only applies to immediate child', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: Container(padding: const EdgeInsets.only(top: 20.0))), + const SliverToBoxAdapter(child: 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('Detach and reattach assert', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + label: 'test1', + sliver: SliverSemantics( + key: key, + container: true, + label: 'test2a', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ), + ], + ), + ); + + TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + label: 'test1', + children: [TestSemantics(label: 'test2a')], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + label: 'test1', + sliver: SliverSemantics( + container: true, + label: 'middle', + sliver: SliverSemantics( + key: key, + container: true, + label: 'test2b', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ), + ), + ], + ), + ); + + expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + label: 'test1', + children: [ + TestSemantics( + label: 'middle', + children: [TestSemantics(label: 'test2b')], + ), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + semantics.dispose(); + }); + + testWidgets('Semantics and Directionality - RTL', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: boilerPlate( + wrapWithDirectionality: false, + slivers: [ + SliverSemantics( + label: 'test1', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ); + + expect(semantics, includesNodeWith(label: 'test1', textDirection: TextDirection.rtl)); + semantics.dispose(); + }); + + testWidgets('Semantics and Directionality - LTR', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + // Wraps with a default Directionality widget with TextDirection.ltr. + slivers: [ + SliverSemantics( + label: 'test1', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect(semantics, includesNodeWith(label: 'test1', textDirection: TextDirection.ltr)); + semantics.dispose(); + }); + + testWidgets('Semantics and Directionality - cannot override RTL with LTR', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.rtl, + child: boilerPlate( + wrapWithDirectionality: false, + slivers: [ + SliverSemantics( + label: 'test1', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + textDirection: TextDirection.ltr, + label: 'test1', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('Semantics and Directionality - cannot override LTR with RTL', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + // Wraps with a default Directionality widget with TextDirection.ltr. + slivers: [ + SliverSemantics( + label: 'test1', + textDirection: TextDirection.rtl, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + textDirection: TextDirection.rtl, + label: 'test1', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('label and hint', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + label: 'label', + hint: 'hint', + value: 'value', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + label: 'label', + hint: 'hint', + value: 'value', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('hints can merge', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + hint: 'hint one', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + hint: 'hint two', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + textDirection: TextDirection.ltr, + hint: 'hint one\nhint two', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('hints can merge with Semantics widget', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics(hint: 'hint one', child: const SizedBox(height: 10.0)), + Semantics(hint: 'hint two', child: const SizedBox(height: 10.0)), + ]), + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + textDirection: TextDirection.ltr, + hint: 'hint one\nhint two', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('values do not merge', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + value: 'value one', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + value: 'value two', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + children: [ + TestSemantics(value: 'value one', textDirection: TextDirection.ltr), + TestSemantics(value: 'value two', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('values do not merge with Semantics widget', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics(value: 'value one', child: const SizedBox(height: 10.0)), + Semantics(value: 'value two', child: const SizedBox(height: 10.0)), + ]), + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + children: [ + TestSemantics(value: 'value one', textDirection: TextDirection.ltr), + TestSemantics(value: 'value two', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('value and hint can merge', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + hint: 'hint', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + value: 'value', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + hint: 'hint', + value: 'value', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('value and hint can merge with Semantics widget', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics(hint: 'hint', child: const SizedBox(height: 10.0)), + Semantics(value: 'value', child: const SizedBox(height: 10.0)), + ]), + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + hint: 'hint', + value: 'value', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('tagForChildren works', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + container: true, + sliver: const SliverToBoxAdapter(child: Text('child 1')), + ), + SliverSemantics( + container: true, + sliver: const SliverToBoxAdapter(child: Text('child 2')), + ), + ], + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + children: [ + TestSemantics(label: 'child 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'child 2', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('tagForChildren works with Semantics widget', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics(container: true, child: const Text('child 1')), + Semantics(container: true, child: const Text('child 2')), + ]), + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + children: [ + TestSemantics(label: 'child 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'child 2', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('supports all actions', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + final List performedActions = []; + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onDismiss: () => performedActions.add(SemanticsAction.dismiss), + onTap: () => performedActions.add(SemanticsAction.tap), + onLongPress: () => performedActions.add(SemanticsAction.longPress), + onScrollLeft: () => performedActions.add(SemanticsAction.scrollLeft), + onScrollRight: () => performedActions.add(SemanticsAction.scrollRight), + onScrollUp: () => performedActions.add(SemanticsAction.scrollUp), + onScrollDown: () => performedActions.add(SemanticsAction.scrollDown), + onIncrease: () => performedActions.add(SemanticsAction.increase), + onDecrease: () => performedActions.add(SemanticsAction.decrease), + onCopy: () => performedActions.add(SemanticsAction.copy), + onCut: () => performedActions.add(SemanticsAction.cut), + onPaste: () => performedActions.add(SemanticsAction.paste), + onMoveCursorForwardByCharacter: (bool _) => + performedActions.add(SemanticsAction.moveCursorForwardByCharacter), + onMoveCursorBackwardByCharacter: (bool _) => + performedActions.add(SemanticsAction.moveCursorBackwardByCharacter), + onSetSelection: (TextSelection _) => performedActions.add(SemanticsAction.setSelection), + onSetText: (String _) => performedActions.add(SemanticsAction.setText), + onDidGainAccessibilityFocus: () => + performedActions.add(SemanticsAction.didGainAccessibilityFocus), + onDidLoseAccessibilityFocus: () => + performedActions.add(SemanticsAction.didLoseAccessibilityFocus), + onFocus: () => performedActions.add(SemanticsAction.focus), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Lorem Ipsum $index'), + ), + ); + }, childCount: 1), + ), + ), + ], + ), + ); + + final Set allActions = SemanticsAction.values.toSet() + ..remove(SemanticsAction.moveCursorForwardByWord) + ..remove(SemanticsAction.moveCursorBackwardByWord) + ..remove(SemanticsAction.customAction) // customAction is not user-exposed. + ..remove(SemanticsAction.showOnScreen) // showOnScreen is not user-exposed. + ..remove(SemanticsAction.scrollToOffset); // scrollToOffset is not user-exposed. + + const int expectedId = 2; + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: expectedId, + tags: [const SemanticsTag('RenderViewport.twoPane')], + actions: [ + SemanticsAction.tap, + SemanticsAction.longPress, + SemanticsAction.scrollLeft, + SemanticsAction.scrollRight, + SemanticsAction.scrollUp, + SemanticsAction.scrollDown, + SemanticsAction.increase, + SemanticsAction.decrease, + SemanticsAction.moveCursorForwardByCharacter, + SemanticsAction.moveCursorBackwardByCharacter, + SemanticsAction.setSelection, + SemanticsAction.copy, + SemanticsAction.cut, + SemanticsAction.paste, + SemanticsAction.didGainAccessibilityFocus, + SemanticsAction.didLoseAccessibilityFocus, + SemanticsAction.dismiss, + SemanticsAction.setText, + SemanticsAction.focus, + ], + children: [ + TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + // Do the actions work? + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + int expectedLength = 1; + for (final SemanticsAction action in allActions) { + switch (action) { + case SemanticsAction.moveCursorBackwardByCharacter: + case SemanticsAction.moveCursorForwardByCharacter: + semanticsOwner.performAction(expectedId, action, true); + case SemanticsAction.setSelection: + semanticsOwner.performAction(expectedId, action, { + 'base': 4, + 'extent': 5, + }); + case SemanticsAction.setText: + semanticsOwner.performAction(expectedId, action, 'text'); + case SemanticsAction.copy: + case SemanticsAction.customAction: + case SemanticsAction.cut: + case SemanticsAction.decrease: + case SemanticsAction.didGainAccessibilityFocus: + case SemanticsAction.didLoseAccessibilityFocus: + case SemanticsAction.dismiss: + case SemanticsAction.increase: + case SemanticsAction.longPress: + case SemanticsAction.moveCursorBackwardByWord: + case SemanticsAction.moveCursorForwardByWord: + case SemanticsAction.paste: + case SemanticsAction.scrollDown: + case SemanticsAction.scrollLeft: + case SemanticsAction.scrollRight: + case SemanticsAction.scrollUp: + case SemanticsAction.scrollToOffset: + case SemanticsAction.showOnScreen: + case SemanticsAction.tap: + case SemanticsAction.focus: + semanticsOwner.performAction(expectedId, action); + } + expect(performedActions.length, expectedLength); + expect(performedActions.last, action); + expectedLength += 1; + } + + semantics.dispose(); + }); + + testWidgets('supports all flags', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + // Checked state and toggled state are mutually exclusive. + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: const Key('a'), + container: true, + explicitChildNodes: true, + // flags. + enabled: true, + hidden: true, + checked: true, + selected: true, + button: true, + slider: true, + keyboardKey: true, + link: true, + textField: true, + readOnly: true, + focused: true, + focusable: true, + inMutuallyExclusiveGroup: true, + header: true, + obscured: true, + multiline: true, + scopesRoute: true, + namesRoute: true, + image: true, + liveRegion: true, + expanded: true, + isRequired: true, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Lorem Ipsum $index'), + ), + ); + }, childCount: 1), + ), + ), + ], + ), + ); + final List flags = SemanticsFlag.values.toList(); + flags + ..remove(SemanticsFlag.hasToggledState) + ..remove(SemanticsFlag.isToggled) + ..remove(SemanticsFlag.hasImplicitScrolling) + ..remove(SemanticsFlag.isCheckStateMixed); + + TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + flags: flags, + children: [ + TestSemantics( + children: [ + TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: const Key('b'), + container: true, + scopesRoute: false, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Lorem Ipsum $index'), + ), + ); + }, childCount: 1), + ), + ), + ], + ), + ); + expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + flags: [], + children: [ + TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: const Key('c'), + toggled: true, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Lorem Ipsum $index'), + ), + ); + }, childCount: 1), + ), + ), + ], + ), + ); + + expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + flags: [SemanticsFlag.hasToggledState, SemanticsFlag.isToggled], + children: [ + TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: const Key('a'), + container: true, + explicitChildNodes: true, + // flags. + enabled: true, + hidden: true, + checked: false, + mixed: true, + selected: true, + button: true, + slider: true, + keyboardKey: true, + link: true, + textField: true, + readOnly: true, + focused: true, + focusable: true, + inMutuallyExclusiveGroup: true, + header: true, + obscured: true, + multiline: true, + scopesRoute: true, + namesRoute: true, + image: true, + liveRegion: true, + expanded: true, + isRequired: true, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((BuildContext context, int index) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text('Lorem Ipsum $index'), + ), + ); + }, childCount: 1), + ), + ), + ], + ), + ); + flags + ..remove(SemanticsFlag.isChecked) + ..add(SemanticsFlag.isCheckStateMixed); + semantics.dispose(); + expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + flags: flags, + children: [ + TestSemantics( + children: [ + TestSemantics(label: 'Lorem Ipsum 0', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + }); + + testWidgets('supports tooltip', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + tooltip: 'test1', + ), + ], + ), + ], + ), + ], + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + tooltip: 'test1', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('actions can be replaced without triggering semantics update', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + int semanticsUpdateCount = 0; + tester.binding.pipelineOwner.semanticsOwner!.addListener(() { + semanticsUpdateCount += 1; + }); + + final List performedActions = []; + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onTap: () => performedActions.add('first'), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + const int expectedId = 2; + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: expectedId, + tags: [const SemanticsTag('RenderViewport.twoPane')], + actions: SemanticsAction.tap.index, + ), + ], + ), + ], + ), + ], + ); + + final SemanticsOwner semanticsOwner = tester.binding.pipelineOwner.semanticsOwner!; + + expect(semantics, hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true)); + semanticsOwner.performAction(expectedId, SemanticsAction.tap); + expect(semanticsUpdateCount, 1); + expect(performedActions, ['first']); + + semanticsUpdateCount = 0; + performedActions.clear(); + + // Updating existing handler should not trigger semantics update. + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onTap: () => performedActions.add('second'), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true)); + semanticsOwner.performAction(expectedId, SemanticsAction.tap); + expect(semanticsUpdateCount, 0); + expect(performedActions, ['second']); + + semanticsUpdateCount = 0; + performedActions.clear(); + + // Adding a handler works. + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onTap: () => performedActions.add('second'), + onLongPress: () => performedActions.add('longPress'), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + final TestSemantics expectedSemanticsWithLongPress = TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 3, + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: expectedId, + tags: [const SemanticsTag('RenderViewport.twoPane')], + actions: SemanticsAction.tap.index | SemanticsAction.longPress.index, + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemanticsWithLongPress, ignoreRect: true, ignoreTransform: true), + ); + semanticsOwner.performAction(expectedId, SemanticsAction.longPress); + expect(semanticsUpdateCount, 1); + expect(performedActions, ['longPress']); + + semanticsUpdateCount = 0; + performedActions.clear(); + + // Removing a handler works. + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onTap: () => performedActions.add('second'), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect(semantics, hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true)); + expect(semanticsUpdateCount, 1); + + semantics.dispose(); + }); + + testWidgets('onTapHint and onLongPressHint create custom actions', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onTap: () {}, + onTapHint: 'test', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.byType(SliverSemantics)), + matchesSemantics(hasTapAction: true, onTapHint: 'test'), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + onLongPress: () {}, + onLongPressHint: 'foo', + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.byType(SliverSemantics)), + matchesSemantics(hasLongPressAction: true, onLongPressHint: 'foo'), + ); + semantics.dispose(); + }); + + testWidgets('supports CustomSemanticsActions', (WidgetTester tester) async { + final SemanticsHandle semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + customSemanticsActions: { + const CustomSemanticsAction(label: 'foo'): () {}, + const CustomSemanticsAction(label: 'bar'): () {}, + }, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.byType(SliverSemantics)), + matchesSemantics( + customActions: [ + const CustomSemanticsAction(label: 'bar'), + const CustomSemanticsAction(label: 'foo'), + ], + ), + ); + semantics.dispose(); + }); + + testWidgets('increased/decreased values are annotated', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + value: '10s', + increasedValue: '11s', + decreasedValue: '9s', + onIncrease: () => () {}, + onDecrease: () => () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + actions: SemanticsAction.increase.index | SemanticsAction.decrease.index, + textDirection: TextDirection.ltr, + value: '10s', + increasedValue: '11s', + decreasedValue: '9s', + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + semantics.dispose(); + }); + + testWidgets('excludeSemantics ignores children', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + label: 'label', + excludeSemantics: true, + textDirection: TextDirection.ltr, + sliver: SliverSemantics( + label: 'other label', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + tags: [const SemanticsTag('RenderViewport.twoPane')], + label: 'label', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ); + + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + semantics.dispose(); + }); + + testWidgets('slivers built in a widget tree are sorted properly', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + int semanticsUpdateCount = 0; + tester.binding.pipelineOwner.semanticsOwner!.addListener(() { + semanticsUpdateCount += 1; + }); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + sortKey: const CustomSortKey(0.0), + explicitChildNodes: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + sortKey: const CustomSortKey(3.0), + sliver: const SliverToBoxAdapter(child: Text('Label 1')), + ), + SliverSemantics( + sortKey: const CustomSortKey(2.0), + sliver: const SliverToBoxAdapter(child: Text('Label 2')), + ), + SliverSemantics( + sortKey: const CustomSortKey(1.0), + explicitChildNodes: true, + sliver: SliverCrossAxisGroup( + slivers: [ + SliverSemantics( + sortKey: const OrdinalSortKey(3.0), + sliver: const SliverToBoxAdapter(child: Text('Label 3')), + ), + SliverSemantics( + sortKey: const OrdinalSortKey(2.0), + sliver: const SliverToBoxAdapter(child: Text('Label 4')), + ), + SliverSemantics( + sortKey: const OrdinalSortKey(1.0), + sliver: const SliverToBoxAdapter(child: Text('Label 5')), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + expect(semanticsUpdateCount, 1); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 9, + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics( + id: 2, + tags: [const SemanticsTag('RenderViewport.twoPane')], + children: [ + TestSemantics( + id: 5, + children: [ + TestSemantics(id: 8, label: 'Label 5', textDirection: TextDirection.ltr), + TestSemantics(id: 7, label: 'Label 4', textDirection: TextDirection.ltr), + TestSemantics(id: 6, label: 'Label 3', textDirection: TextDirection.ltr), + ], + ), + TestSemantics(id: 4, label: 'Label 2', textDirection: TextDirection.ltr), + TestSemantics(id: 3, label: 'Label 1', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreRect: true, ignoreTransform: true)); + + semantics.dispose(); + }); + + testWidgets('Semantics widgets built with explicit sort orders are sorted properly', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + int semanticsUpdateCount = 0; + tester.binding.pipelineOwner.semanticsOwner!.addListener(() { + semanticsUpdateCount += 1; + }); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverCrossAxisGroup( + slivers: [ + SliverSemantics( + sortKey: const CustomSortKey(3.0), + sliver: const SliverToBoxAdapter(child: Text('Label 1')), + ), + SliverSemantics( + sortKey: const CustomSortKey(1.0), + sliver: const SliverToBoxAdapter(child: Text('Label 2')), + ), + SliverSemantics( + sortKey: const CustomSortKey(2.0), + sliver: const SliverToBoxAdapter(child: Text('Label 3')), + ), + ], + ), + ], + ), + ); + expect(semanticsUpdateCount, 1); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + id: 1, + children: [ + TestSemantics( + id: 5, + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics(id: 3, label: 'Label 2', textDirection: TextDirection.ltr), + TestSemantics(id: 4, label: 'Label 3', textDirection: TextDirection.ltr), + TestSemantics(id: 2, label: 'Label 1', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ); + expect(semantics, hasSemantics(expectedSemantics, ignoreTransform: true, ignoreRect: true)); + + semantics.dispose(); + }); + + testWidgets('slivers without sort orders are sorted properly', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + int semanticsUpdateCount = 0; + tester.binding.pipelineOwner.semanticsOwner!.addListener(() { + semanticsUpdateCount += 1; + }); + await tester.pumpWidget( + boilerPlate( + slivers: const [ + SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter(child: Text('Label 1')), + SliverToBoxAdapter(child: Text('Label 2')), + SliverCrossAxisGroup( + slivers: [ + SliverToBoxAdapter(child: Text('Label 3')), + SliverToBoxAdapter(child: Text('Label 4')), + SliverToBoxAdapter(child: Text('Label 5')), + ], + ), + ], + ), + ], + ), + ); + expect(semanticsUpdateCount, 1); + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics(label: 'Label 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'Label 2', textDirection: TextDirection.ltr), + TestSemantics(label: 'Label 3', textDirection: TextDirection.ltr), + TestSemantics(label: 'Label 4', textDirection: TextDirection.ltr), + TestSemantics(label: 'Label 5', textDirection: TextDirection.ltr), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + semantics.dispose(); + }); + + testWidgets('Can change handlers', (WidgetTester tester) async { + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasTapAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onDismiss: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasDismissAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onLongPress: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasLongPressAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onScrollLeft: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasScrollLeftAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onScrollRight: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasScrollRightAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onScrollUp: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasScrollUpAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onScrollDown: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasScrollDownAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onIncrease: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasIncreaseAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onDecrease: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasDecreaseAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onCopy: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasCopyAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onCut: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasCutAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onPaste: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasPasteAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onSetSelection: (TextSelection _) {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', hasSetSelectionAction: true, textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onDidGainAccessibilityFocus: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics( + label: 'foo', + hasDidGainAccessibilityFocusAction: true, + textDirection: TextDirection.ltr, + ), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + onDidLoseAccessibilityFocus: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics( + label: 'foo', + hasDidLoseAccessibilityFocusAction: true, + textDirection: TextDirection.ltr, + ), + ); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + container: true, + label: 'foo', + textDirection: TextDirection.ltr, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ); + + expect( + tester.getSemantics(find.bySemanticsLabel('foo')), + matchesSemantics(label: 'foo', textDirection: TextDirection.ltr), + ); + }); + + testWidgets('blocking user interaction works on explicit child node', ( + WidgetTester tester, + ) async { + final UniqueKey key1 = UniqueKey(); + final UniqueKey key2 = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + blockUserActions: true, + explicitChildNodes: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + key: key1, + label: 'label1', + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + key: key2, + label: 'label2', + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + expect( + tester.getSemantics(find.byKey(key1)), + // Tap action is blocked. + matchesSemantics(label: 'label1'), + ); + expect( + tester.getSemantics(find.byKey(key2)), + // Tap action is blocked. + matchesSemantics(label: 'label2'), + ); + }); + + testWidgets('blocking user interaction works on explicit child node with Semantics widget', ( + WidgetTester tester, + ) async { + final UniqueKey key1 = UniqueKey(); + final UniqueKey key2 = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + blockUserActions: true, + explicitChildNodes: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics( + key: key1, + label: 'label1', + onTap: () {}, + child: const SizedBox(height: 10), + ), + Semantics( + key: key2, + label: 'label2', + onTap: () {}, + child: const SizedBox(height: 10), + ), + ]), + ), + ), + ], + ), + ); + expect( + tester.getSemantics(find.byKey(key1)), + // Tap action is blocked. + matchesSemantics(label: 'label1'), + ); + expect( + tester.getSemantics(find.byKey(key2)), + // Tap action is blocked. + matchesSemantics(label: 'label2'), + ); + }); + + testWidgets('blocking user interaction on a merged child', (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + blockUserActions: true, + label: 'label1', + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + label: 'label2', + onLongPress: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + expect( + tester.getSemantics(find.byKey(key)), + // Tap action in label1 is blocked. + matchesSemantics(label: 'label1\nlabel2', hasLongPressAction: true), + ); + }); + + testWidgets('blocking user interaction on a merged child with Semantics widget', ( + WidgetTester tester, + ) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + container: true, + sliver: SliverList( + delegate: SliverChildListDelegate([ + Semantics( + blockUserActions: true, + label: 'label1', + onTap: () {}, + child: const SizedBox(height: 10), + ), + Semantics(label: 'label2', onLongPress: () {}, child: const SizedBox(height: 10)), + ]), + ), + ), + ], + ), + ); + expect( + tester.getSemantics(find.byKey(key)), + // Tap action in label1 is blocked. + matchesSemantics(label: 'label1\nlabel2', hasLongPressAction: true), + ); + }); + + testWidgets('does not merge conflicting actions even if one of them is blocked', ( + WidgetTester tester, + ) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + container: true, + sliver: SliverMainAxisGroup( + slivers: [ + SliverSemantics( + blockUserActions: true, + label: 'label1', + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + SliverSemantics( + label: 'label2', + onTap: () {}, + sliver: const SliverToBoxAdapter(child: SizedBox(height: 10.0)), + ), + ], + ), + ), + ], + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + expect( + node, + matchesSemantics( + children: [ + containsSemantics(label: 'label1'), + containsSemantics(label: 'label2'), + ], + ), + ); + }); + + testWidgets( + 'does not merge conflicting actions even if one of them is blocked with Semantics widget', + (WidgetTester tester) async { + final UniqueKey key = UniqueKey(); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + container: true, + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Semantics( + blockUserActions: true, + label: 'label1', + onTap: () {}, + child: const SizedBox(width: 10, height: 10), + ), + Semantics( + label: 'label2', + onTap: () {}, + child: const SizedBox(width: 10, height: 10), + ), + ], + ), + ), + ), + ], + ), + ); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + expect( + node, + matchesSemantics( + children: [ + containsSemantics(label: 'label1'), + containsSemantics(label: 'label2'), + ], + ), + ); + }, + ); + + testWidgets('RenderSliverSemanticsAnnotations provides validation result', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future pumpValidationResult(SemanticsValidationResult result) async { + final ValueKey key = ValueKey('validation-$result'); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + validationResult: result, + sliver: SliverToBoxAdapter( + child: Text('Validation result $result', textDirection: TextDirection.ltr), + ), + ), + ], + ), + ); + final RenderSliverSemanticsAnnotations object = tester + .renderObject(find.byKey(key)); + final SemanticsConfiguration config = SemanticsConfiguration(); + object.describeSemanticsConfiguration(config); + return config; + } + + final SemanticsConfiguration noneResult = await pumpValidationResult( + SemanticsValidationResult.none, + ); + expect(noneResult.validationResult, SemanticsValidationResult.none); + + final SemanticsConfiguration validResult = await pumpValidationResult( + SemanticsValidationResult.valid, + ); + expect(validResult.validationResult, SemanticsValidationResult.valid); + + final SemanticsConfiguration invalidResult = await pumpValidationResult( + SemanticsValidationResult.invalid, + ); + expect(invalidResult.validationResult, SemanticsValidationResult.invalid); + + semantics.dispose(); + }); + + testWidgets('validation result precedence', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future expectValidationResult({ + required SemanticsValidationResult outer, + required SemanticsValidationResult inner, + required SemanticsValidationResult expected, + }) async { + const ValueKey key = ValueKey('validated-widget'); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + validationResult: outer, + sliver: SliverSemantics( + validationResult: inner, + sliver: SliverToBoxAdapter( + child: Text( + key: key, + 'Outer = $outer; inner = $inner', + textDirection: TextDirection.ltr, + ), + ), + ), + ), + ], + ), + ); + final SemanticsNode result = tester.getSemantics(find.byKey(key)); + expect( + result, + containsSemantics(label: 'Outer = $outer; inner = $inner', validationResult: expected), + ); + } + + // Outer is none. + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.none, + ); + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.none, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + // Outer is valid. + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.valid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.valid, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + // Outer is invalid. + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.none, + expected: SemanticsValidationResult.invalid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.valid, + expected: SemanticsValidationResult.invalid, + ); + await expectValidationResult( + outer: SemanticsValidationResult.invalid, + inner: SemanticsValidationResult.invalid, + expected: SemanticsValidationResult.invalid, + ); + + semantics.dispose(); + }); + + testWidgets('supports heading levels', (WidgetTester tester) async { + // Default: not a heading. + expect( + SliverSemantics( + sliver: const SliverToBoxAdapter(child: Text('dummy text')), + ).properties.headingLevel, + isNull, + ); + + // Headings level 1-6. + for (int level = 1; level <= 6; level++) { + final SliverSemantics semantics = SliverSemantics( + headingLevel: level, + sliver: const SliverToBoxAdapter(child: Text('dummy text')), + ); + expect(semantics.properties.headingLevel, level); + } + + // Invalid heading levels. + for (final int badLevel in const [-1, 0, 7, 8, 9]) { + expect( + () => SliverSemantics( + headingLevel: badLevel, + sliver: const SliverToBoxAdapter(child: Text('dummy text')), + ), + throwsAssertionError, + ); + } + }); + + testWidgets('parent heading level takes precedence when it absorbs a child', ( + WidgetTester tester, + ) async { + final SemanticsTester semantics = SemanticsTester(tester); + + Future pumpHeading(int? level) async { + final ValueKey key = ValueKey('heading-$level'); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverSemantics( + key: key, + headingLevel: level, + sliver: SliverToBoxAdapter( + child: Text('Heading level $level', textDirection: TextDirection.ltr), + ), + ), + ], + ), + ); + final RenderSliverSemanticsAnnotations object = tester + .renderObject(find.byKey(key)); + final SemanticsConfiguration config = SemanticsConfiguration(); + object.describeSemanticsConfiguration(config); + return config; + } + + // Tuples contain (parent level, child level, expected combined level). + final List<(int, int, int)> scenarios = <(int, int, int)>[ + // Case: neither are headings + (0, 0, 0), // expect not a heading + // Case: parent not a heading, child always wins. + (0, 1, 1), + (0, 2, 2), + + // Case: child not a heading, parent always wins. + (1, 0, 1), + (2, 0, 2), + + // Case: child heading level higher, parent still wins. + (3, 2, 3), + (4, 1, 4), + + // Case: parent heading level higher, parent still wins. + (2, 3, 2), + (1, 5, 1), + ]; + + for (final (int, int, int) scenario in scenarios) { + final int parentLevel = scenario.$1; + final int childLevel = scenario.$2; + final int resultLevel = scenario.$3; + + final SemanticsConfiguration parent = await pumpHeading( + parentLevel == 0 ? null : parentLevel, + ); + final SemanticsConfiguration child = SemanticsConfiguration()..headingLevel = childLevel; + parent.absorb(child); + expect( + reason: + 'parent heading level is $parentLevel, ' + 'child heading level is $childLevel, ' + 'expecting $resultLevel.', + parent.headingLevel, + resultLevel, + ); + } + + semantics.dispose(); + }); + + testWidgets('applies heading semantics to semantics tree', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverMainAxisGroup( + slivers: [ + for (int level = 1; level <= 6; level++) + SliverSemantics( + key: ValueKey('heading-$level'), + headingLevel: level, + sliver: SliverToBoxAdapter(child: Text('Heading level $level')), + ), + const SliverToBoxAdapter(child: Text('This is not a heading')), + ], + ), + ], + ), + ); + + for (int level = 1; level <= 6; level++) { + final ValueKey key = ValueKey('heading-$level'); + final SemanticsNode node = tester.getSemantics(find.byKey(key)); + expect('$node', contains('headingLevel: $level')); + } + + final SemanticsNode notHeading = tester.getSemantics(find.text('This is not a heading')); + expect(notHeading, isNot(contains('headingLevel'))); + + semantics.dispose(); + }); + + testWidgets('sliver with zero transform gets dropped', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/110671. + // Construct a widget tree that will end up with a fitted box that applies + // a zero transform because it does not actually draw its children. + // Assert that this subtree gets dropped (the root node has no children). + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + boilerPlate( + slivers: [ + const SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + height: 0, + width: 500, + child: FittedBox( + child: SizedBox( + height: 55, + width: 266, + child: SingleChildScrollView(child: Column()), + ), + ), + ), + ), + ], + ), + ], + ), + ); + + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics(flags: [SemanticsFlag.hasImplicitScrolling]), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + final SemanticsNode node = RendererBinding.instance.renderView.debugSemantics!; + + expect(node.transform, null); // Make sure the zero transform didn't end up on the root somehow. + expect(node.childrenCount, 1); + semantics.dispose(); + }); + + testWidgets('slivers that are transformed are sorted properly', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + int semanticsUpdateCount = 0; + tester.binding.pipelineOwner.semanticsOwner!.addListener(() { + semanticsUpdateCount += 1; + }); + await tester.pumpWidget( + boilerPlate( + slivers: [ + SliverMainAxisGroup( + slivers: [ + const SliverToBoxAdapter(child: Text('Label 1')), + const SliverToBoxAdapter(child: Text('Label 2')), + SliverToBoxAdapter( + child: Transform.rotate( + angle: pi / 2.0, + child: const Row( + children: [Text('Label 3'), Text('Label 4'), Text('Label 5')], + ), + ), + ), + ], + ), + ], + ), + ); + expect(semanticsUpdateCount, 1); + // Label 3 is off-screen so it gets dropped. + final TestSemantics expectedSemantics = TestSemantics.root( + children: [ + TestSemantics( + children: [ + TestSemantics( + flags: [SemanticsFlag.hasImplicitScrolling], + children: [ + TestSemantics(label: 'Label 1', textDirection: TextDirection.ltr), + TestSemantics(label: 'Label 2', textDirection: TextDirection.ltr), + TestSemantics( + flags: [SemanticsFlag.isHidden], + label: 'Label 4', + textDirection: TextDirection.ltr, + ), + TestSemantics( + flags: [SemanticsFlag.isHidden], + label: 'Label 5', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + ], + ); + expect( + semantics, + hasSemantics(expectedSemantics, ignoreId: true, ignoreRect: true, ignoreTransform: true), + ); + + semantics.dispose(); + }); +} + +class CustomSortKey extends OrdinalSortKey { + const CustomSortKey(super.order, {super.name}); +}