diff --git a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt index 81d2da7b2375b..5c0a2ab3a0a1b 100644 --- a/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt +++ b/compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getAllUncoveredSemanticsNodesToIntObjectMap import androidx.compose.ui.semantics.getOrNull -import androidx.compose.ui.semantics.isImportantForAccessibility import androidx.compose.ui.semantics.sortByGeometryGroupings import androidx.compose.ui.uikit.density import androidx.compose.ui.uikit.toNanoSeconds @@ -200,10 +199,10 @@ private sealed interface AccessibilityNode { * @mediator reference to the containing AccessibilityMediator */ class Semantics( - override val semanticsNode: SemanticsNode, + semanticsNode: SemanticsNode, private val mediator: AccessibilityMediator, - private val isBeyondBounds: Boolean - ) : AccessibilityNode { + private val isBeyondBounds: Boolean, + ) : Container(semanticsNode) { private val cachedConfig = semanticsNode.config private val scrollableParentNodeIds by lazy { semanticsNode.allScrollableParentNodeIds } @@ -217,6 +216,13 @@ private sealed interface AccessibilityNode { } } + override val accessibilityContainerType: UIAccessibilityContainerType + get() = when { + semanticsNode.canBeAccessibilityElement() -> UIAccessibilityContainerTypeNone + semanticsNode.isTraversalGroup -> UIAccessibilityContainerTypeSemanticGroup + else -> UIAccessibilityContainerTypeNone + } + override val accessibilityInteropView: InteropWrappingView? get() = cachedConfig.getOrNull(NativeAccessibilityViewSemanticsKey)?.also { it.isAccessibilityFocusable = ::isBeyondBoundsOrFocusable @@ -357,7 +363,7 @@ private sealed interface AccessibilityNode { * with all its children. [Container] is used to indicate element that contains container * semantic node with all its children. */ - class Container( + open class Container( override val semanticsNode: SemanticsNode ) : AccessibilityNode { override val key: AccessibilityElementKey = semanticsNode.containerKey @@ -1541,7 +1547,7 @@ internal class AccessibilityMediator( node = AccessibilityNode.Semantics( semanticsNode = node, mediator = this, - isBeyondBounds = isBeyondBounds + isBeyondBounds = isBeyondBounds, ), container = container, children = children, @@ -1576,25 +1582,29 @@ internal class AccessibilityMediator( traverseChildren(it, isBeyondBounds = true, flatten = flattenChildren, container = node) } - val allElements = beforeElements + visibleElements + afterElements if (node.isTraversalGroup || node.id == rootNode.id) { - val hasSemanticsNode = node.isImportantForAccessibility() || - node.config.contains(SemanticsProperties.TestTag) - - val containerChildren = if (hasSemanticsNode) { - listOf(makeSemanticsNode(allElements)) + if (node.canBeAccessibilityElement()) { + val containerElement = listOf(makeSemanticsNode(emptyList())) + createOrUpdateAccessibilityElement( + node = AccessibilityNode.Container(semanticsNode = node), + container = container, + children = beforeElements + visibleElements + containerElement + afterElements, + frame = frame + ) } else { - allElements + createOrUpdateAccessibilityElement( + node = AccessibilityNode.Semantics( + semanticsNode = node, + mediator = this, + isBeyondBounds = isBeyondBounds + ), + container = container, + children = beforeElements + visibleElements + afterElements, + frame = frame + ) } - - createOrUpdateAccessibilityElement( - node = AccessibilityNode.Container(semanticsNode = node), - container = container, - children = containerChildren, - frame = frame - ) } else { - makeSemanticsNode(allElements) + makeSemanticsNode(beforeElements + visibleElements + afterElements) } } else { makeSemanticsNode(emptyList()) diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt index 1e398d1b743a3..726cca1c2ab51 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt @@ -78,6 +78,8 @@ import kotlin.test.assertTrue import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available +import platform.UIKit.UIAccessibilityContainerTypeNone +import platform.UIKit.UIAccessibilityContainerTypeSemanticGroup import platform.UIKit.UIAccessibilityTraitAdjustable import platform.UIKit.UIAccessibilityTraitButton import platform.UIKit.UIAccessibilityTraitHeader @@ -1143,6 +1145,44 @@ class ComponentsAccessibilitySemanticTest { } } + @Test + fun testTraversalGroupWithSemantics() = runUIKitInstrumentedTest { + setContent { + Column( + modifier = Modifier.semantics { + testTag = "group_column" + isTraversalGroup = true + // Should be added as a separate accessibility element + contentDescription = "Group Column" + } + ) { + Button( + onClick = {}, + modifier = Modifier.semantics { + testTag = "button" + } + ) { + Text("Button text") + } + } + } + assertAccessibilityTree { + isAccessibilityElement = false + containerType = UIAccessibilityContainerTypeSemanticGroup + node { + identifier = "button" + isAccessibilityElement = true + containerType = UIAccessibilityContainerTypeNone + } + node { + identifier = "group_column" + label = "Group Column" + isAccessibilityElement = true + containerType = UIAccessibilityContainerTypeNone + } + } + } + @Test fun testMergeDescendantsWithButton() = runUIKitInstrumentedTest { setContent { diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt index 168c8529583aa..956c1ceb9282a 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt @@ -32,6 +32,12 @@ import kotlinx.cinterop.ExperimentalForeignApi import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available +import platform.UIKit.UIAccessibilityContainerType +import platform.UIKit.UIAccessibilityContainerTypeDataTable +import platform.UIKit.UIAccessibilityContainerTypeLandmark +import platform.UIKit.UIAccessibilityContainerTypeList +import platform.UIKit.UIAccessibilityContainerTypeNone +import platform.UIKit.UIAccessibilityContainerTypeSemanticGroup import platform.UIKit.UIAccessibilityElement import platform.UIKit.UIAccessibilityTraitAdjustable import platform.UIKit.UIAccessibilityTraitAllowsDirectInteraction @@ -57,6 +63,7 @@ import platform.UIKit.UIAccessibilityTraits import platform.UIKit.UIView import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene +import platform.UIKit.accessibilityContainerType import platform.UIKit.accessibilityCustomActions import platform.UIKit.accessibilityElementAtIndex import platform.UIKit.accessibilityElementCount @@ -142,6 +149,7 @@ internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode label = element.accessibilityLabel, value = element.accessibilityValue, frame = element.accessibilityFrame.asDpRect(), + containerType = element.accessibilityContainerType, children = children, traits = allAccessibilityTraits.keys.filter { element.accessibilityTraits and it != 0.toULong() @@ -184,6 +192,14 @@ private val allAccessibilityTraits = mutableMapOf( it as Map } +private val allContainerTypes = mapOf( + UIAccessibilityContainerTypeNone to "UIAccessibilityContainerTypeNone", + UIAccessibilityContainerTypeDataTable to "UIAccessibilityContainerTypeDataTable", + UIAccessibilityContainerTypeList to "UIAccessibilityContainerTypeList", + UIAccessibilityContainerTypeLandmark to "UIAccessibilityContainerTypeLandmark", + UIAccessibilityContainerTypeSemanticGroup to "UIAccessibilityContainerTypeSemanticGroup", +) + /** * Represents a node in an accessibility tree, which is used for testing accessibility features * within a UI hierarchy. This class captures various accessibility properties of UI components @@ -195,6 +211,7 @@ internal data class AccessibilityTestNode( var label: String? = null, var value: String? = null, var frame: DpRect? = null, + var containerType: UIAccessibilityContainerType? = null, var children: List? = null, var traits: List? = null, var element: NSObject? = null, @@ -227,6 +244,9 @@ internal data class AccessibilityTestNode( traits?.let { assertEquals(it.toSet(), actualNode?.traits?.toSet()) } + containerType?.let { + assertEquals(it, actualNode?.containerType) + } children?.let { assertEquals(it.count(), actualNode?.children?.count()) it.zip(actualNode?.children ?: emptyList()) { validator, child -> @@ -268,6 +288,10 @@ internal data class AccessibilityTestNode( builder.appendLine("$fieldIndent - ${allAccessibilityTraits.getValue(it)}") } } + node.containerType?.takeIf { it != UIAccessibilityContainerTypeNone }?.let { + val typeString = allContainerTypes[it] ?: "Unknown: $it" + builder.appendLine("$fieldIndent accessibilityContainerType: $typeString") + } node.value?.let { builder.appendLine("$fieldIndent accessibilityValue: $it") } node.element?.accessibilityCustomActions?.takeIf { it.isNotEmpty() }?.let { builder.appendLine("$fieldIndent accessibilityCustomActions: $it")