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 f5e94c9d53c5c..c7de3eb65fd33 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 @@ -18,6 +18,7 @@ package androidx.compose.ui.accessibility import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size @@ -46,15 +47,18 @@ import androidx.compose.ui.interop.runUIKitInstrumentedTestWithInterop import androidx.compose.ui.platform.accessibility.CMPAccessibilityTraitTextView import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.isTraversalGroup import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.semantics.text import androidx.compose.ui.state.ToggleableState import androidx.compose.ui.test.assertAccessibilityTree import androidx.compose.ui.test.findNodeWithTag import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.UIKitInteropProperties @@ -718,7 +722,7 @@ class ComponentsAccessibilitySemanticTest { } assertAccessibilityTree { - label = "Foo\nBar" + label = "Foo, Bar" identifier = "row" isAccessibilityElement = true traits(UIAccessibilityTraitButton) @@ -749,7 +753,7 @@ class ComponentsAccessibilitySemanticTest { } assertAccessibilityTree { - value = "Label" + label = "Label" isAccessibilityElement = true traits(CMPAccessibilityTraitTextView) } @@ -766,7 +770,7 @@ class ComponentsAccessibilitySemanticTest { } assertAccessibilityTree { - value = "Placeholder" + label = "Placeholder" isAccessibilityElement = true traits(CMPAccessibilityTraitTextView) } @@ -789,4 +793,218 @@ class ComponentsAccessibilitySemanticTest { traits(CMPAccessibilityTraitTextView) } } + + @Test + fun testNodeHierarchyInsideAccessibilityElementShouldNotFlatten() = runUIKitInstrumentedTest { + setContent { + Column(modifier = Modifier.clickable {}) { + Text("Title 1") + Row(modifier = Modifier.testTag("Tag 1")) { + Text("Description 1") + Text("Details 1") + } + } + } + + assertAccessibilityTree { + isAccessibilityElement = true + label = "Title 1, Description 1, Details 1" + node { + label = "Title 1" + isAccessibilityElement = false + } + node { + identifier = "Tag 1" + isAccessibilityElement = false + node { + label = "Description 1" + isAccessibilityElement = false + } + node { + label = "Details 1" + isAccessibilityElement = false + } + } + } + } + + @Test + fun testNodeHierarchyInsideTraversalGroupShouldFlatten() = runUIKitInstrumentedTest { + setContent { + Column { + Column(modifier = Modifier.semantics { isTraversalGroup = true }) { + Text("Title 1") + Row(modifier = Modifier.testTag("Tag 1")) { + Text("Description 1") + Text("Details 1") + } + } + Column(modifier = Modifier.semantics { isTraversalGroup = true }) { + Text("Title 2") + } + } + } + + assertAccessibilityTree { + node { + node { + label = "Title 1" + isAccessibilityElement = true + } + node { + label = "Description 1" + isAccessibilityElement = true + } + node { + identifier = "Tag 1" + isAccessibilityElement = false + } + node { + label = "Details 1" + isAccessibilityElement = true + } + } + node { + label = "Title 2" + isAccessibilityElement = true + } + } + } + + @Test + fun testReplacedTextContent() = runUIKitInstrumentedTest { + setContent { + Text("Text", modifier = Modifier.semantics { + text = AnnotatedString("Replaced") + }) + } + + assertAccessibilityTree { + label = "Replaced" + } + } + + @Test + fun testReplacedContentWithMergedSemantics() = runUIKitInstrumentedTest { + setContent { + Box(modifier = Modifier.size(50.dp).semantics(mergeDescendants = true) { + text = AnnotatedString("Text") + contentDescription = "Description" + }) { + Text("Text") + } + } + + assertAccessibilityTree { + label = "Description, Text" + isAccessibilityElement = true + node { + label = "Text" + isAccessibilityElement = false + } + } + } + + @Test + fun testReplacedContentWithoutMergedSemantics() = runUIKitInstrumentedTest { + setContent { + Box(modifier = Modifier.size(50.dp).semantics { + contentDescription = "Description" + }) { + Text("Text") + } + } + + assertAccessibilityTree { + node { + label = "Text" + isAccessibilityElement = true + } + node { + label = "Description" + isAccessibilityElement = true + } + } + } + + @Test + fun testContentReplacedSemanticsWithChildElement() = runUIKitInstrumentedTest { + setContent { + Box(modifier = Modifier.semantics(mergeDescendants = true) { + text = AnnotatedString("Text") + contentDescription = "Description" + }) { + Box(modifier = Modifier.size(50.dp).testTag("Child")) + } + } + + assertAccessibilityTree { + label = "Description" + isAccessibilityElement = true + node { + identifier = "Child" + } + } + } + + @Test + fun testEnclosedComplexContentWithMergedSemantics() = runUIKitInstrumentedTest { + setContent { + Column(modifier = Modifier.semantics(mergeDescendants = true) { + text = AnnotatedString("Text") + contentDescription = "Description" + }) { + Box(modifier = Modifier.size(50.dp).semantics { + contentDescription = "Inner" + }) { + TextField( + value = "", + onValueChange = {}, + label = { Text("Label") }, + placeholder = { Text("Placeholder") } + ) + } + Column(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text(text = "First Text") + Text(text = "Second Text") + } + Text(text = "Text") + } + } + + assertAccessibilityTree { + node { + isAccessibilityElement = true + label = "Label" + node { + label = "Label" + isAccessibilityElement = false + } + } + node { + label = "Description, Inner, Text" + isAccessibilityElement = true + node { + label = "Inner" + isAccessibilityElement = false + } + node { + label = "Text" + isAccessibilityElement = false + } + } + node { + label = "First Text, Second Text" + isAccessibilityElement = true + node { + label = "First Text" + isAccessibilityElement = false + } + node { + label = "Second Text" + isAccessibilityElement = false + } + } + } + } } diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt index 20fe3f095b0cf..25725e94846fc 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/Accessibility.uikit.kt @@ -16,18 +16,19 @@ package androidx.compose.ui.platform +import androidx.collection.MutableIntSet import androidx.compose.runtime.BroadcastFrameClock import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.node.LayoutNode import androidx.compose.ui.platform.accessibility.AccessibilityScrollEventResult import androidx.compose.ui.platform.accessibility.accessibilityCustomActions -import androidx.compose.ui.platform.accessibility.accessibilityLabel import androidx.compose.ui.platform.accessibility.accessibilityTraits import androidx.compose.ui.platform.accessibility.accessibilityValue import androidx.compose.ui.platform.accessibility.allScrollableParentNodeIds import androidx.compose.ui.platform.accessibility.canBeAccessibilityElement import androidx.compose.ui.platform.accessibility.canScroll +import androidx.compose.ui.platform.accessibility.contentDescription import androidx.compose.ui.platform.accessibility.isRTL import androidx.compose.ui.platform.accessibility.isScreenReaderFocusable import androidx.compose.ui.platform.accessibility.scrollIfPossible @@ -154,7 +155,8 @@ private sealed interface AccessibilityNode { val isAccessibilityElement: Boolean val semanticsNode: SemanticsNode - val accessibilityLabel: String? get() = null + val contentDescription: String? get() = null + val shouldMergeDescription: Boolean get() = false val accessibilityHint: String? get() = null val accessibilityValue: String? get() = null val accessibilityTraits: UIAccessibilityTraits get() = UIAccessibilityTraitNone @@ -194,7 +196,7 @@ private sealed interface AccessibilityNode { private val mediator: AccessibilityMediator, private val isBeyondBounds: Boolean ) : AccessibilityNode { - private val cachedConfig = semanticsNode.copyWithMergingEnabled().config + private val cachedConfig = semanticsNode.config private val scrollableParentNodeIds by lazy { semanticsNode.allScrollableParentNodeIds } override val key: AccessibilityElementKey get() = semanticsNode.semanticsKey @@ -212,8 +214,12 @@ private sealed interface AccessibilityNode { it.isAccessibilityFocusable = ::isBeyondBoundsOrFocusable } - override val accessibilityLabel: String? - get() = cachedConfig.accessibilityLabel() + override val contentDescription: String? + get() = semanticsNode.contentDescription + + override val shouldMergeDescription: Boolean + get() = semanticsNode.unmergedConfig.isMergingSemanticsOfDescendants && + semanticsNode.canBeAccessibilityElement() override val accessibilityIdentifier: String? get() = cachedConfig.getOrNull(SemanticsProperties.TestTag) @@ -572,7 +578,7 @@ private class AccessibilityElement( override fun accessibilityLabel(): String? = getOrElse(CachedAccessibilityPropertyKeys.accessibilityLabel) { - node.accessibilityLabel + makeAccessibilityLabel() } override fun accessibilityElementDidBecomeFocused() { @@ -1369,8 +1375,8 @@ internal class AccessibilityMediator( private fun traverseSemanticsTree( rootNode: SemanticsNode ): Pair { - val presentIds = mutableSetOf() - + val presentIds = MutableIntSet() + presentIds.add(rootNode.id) val nodes = owner.getAllUncoveredSemanticsNodesToIntObjectMap(rootNode.id) { it.config.contains(SemanticsProperties.LinkTestMarker) } @@ -1383,21 +1389,29 @@ internal class AccessibilityMediator( } var focusedKey: AccessibilityElementKey? = null - // 1. Get children except nodes inside traversal. Flattening is used to: + // 1. Get accessibility elements and traversal groups. + // Flattening of accessibility elements is used to: // - have the same traversal order as on Android // - allow navigation between semantic containers on iOS // 2. Split non-visible children beyond bounds to be located go before and after the group // of visible semantic children in the accessibility elements tree. // See [isBeforeBeyondBoundsItem] for more details. - fun SemanticsNode.getChildrenInsideTraversalGroup( + fun SemanticsNode.flattenAccessibilityChildren( node: SemanticsNode, semanticsChildren: ArrayList, beforeBeyondBoundsChildren: ArrayList, afterBeyondBoundsChildren: ArrayList, + collectOnlyAccessibilityElements: Boolean = false, flatten: Boolean ) { node.replacedChildren.fastForEach { child -> - if (child.isValid) { + if (presentIds.contains(child.id)) { + return@fastForEach + } + + val canBeAccessibilityElement = child.canBeAccessibilityElement() + if (child.isValid && (!collectOnlyAccessibilityElements || canBeAccessibilityElement)) { + presentIds.add(child.id) if (nodes.contains(child.id)) { semanticsChildren.add(child) } else if (child.size != IntSize.Zero && (child.isScreenReaderFocusable() || @@ -1410,12 +1424,13 @@ internal class AccessibilityMediator( } } } - if (!child.isTraversalGroup && flatten && !child.canBeAccessibilityElement()) { - getChildrenInsideTraversalGroup( + if (!child.isTraversalGroup && flatten) { + flattenAccessibilityChildren( child, semanticsChildren, beforeBeyondBoundsChildren, afterBeyondBoundsChildren, + collectOnlyAccessibilityElements || canBeAccessibilityElement, flatten ) } @@ -1428,8 +1443,6 @@ internal class AccessibilityMediator( isBeyondBounds: Boolean, flatten: Boolean ): AccessibilityElement { - presentIds.add(node.semanticsKey) - val frame = nodes[node.id]?.adjustedBounds?.toRect() ?: node.unclippedBoundsInWindow if (node.unmergedConfig.getOrNull(SemanticsProperties.Focused) == true) { @@ -1454,7 +1467,7 @@ internal class AccessibilityMediator( val beforeChildren = ArrayList() val afterChildren = ArrayList() - node.getChildrenInsideTraversalGroup( + node.flattenAccessibilityChildren( node = node, semanticsChildren = visibleChildren, beforeBeyondBoundsChildren = beforeChildren, @@ -1485,7 +1498,6 @@ internal class AccessibilityMediator( emptyList() } - presentIds.add(node.containerKey) createOrUpdateAccessibilityElement( node = AccessibilityNode.Container(semanticsNode = node), container = container, @@ -1509,7 +1521,7 @@ internal class AccessibilityMediator( // Filter out [AccessibilityElement] in [accessibilityElementsMap] that are not present in the tree anymore accessibilityElementsMap.keys.retainAll { - val isPresent = it in presentIds + val isPresent = it.id in presentIds if (!isPresent) { accessibilityDebugLogger?.log("$it removed") @@ -1850,3 +1862,63 @@ private class AccessibilityFocusedElementObserver( NSNotificationCenter.defaultCenter.removeObserver(this) } } + +private fun AccessibilityElement.makeAccessibilityLabel(): String? { + val contentDescription = if (node.shouldMergeDescription) { + val collector = NodeDescriptionCollector() + collectContentDescription(collector) + collector.getText().takeIf { it.isNotBlank() } + } else { + null + } + + return contentDescription ?: node.contentDescription +} + +/** + * Mimics the behavior of the 'NodeDescription' and 'LeafTextCollector' of the TalkBack application. + * Rather than using merged node semantics, TalkBack navigates through the node hierarchy and + * generates a description of the child nodes independently. + */ +private class NodeDescriptionCollector { + companion object { + private const val MAX_TEXT_COLLECT_NODES = 5 + } + private val text = StringBuilder() + private var numNodes = 0 + + fun collect(node: AccessibilityElement): Boolean { + if (numNodes >= MAX_TEXT_COLLECT_NODES) { + return false + } + node.node.contentDescription + ?.takeIf { it.isNotBlank() } + ?.let { + numNodes++ + if (text.isNotEmpty()) { + text.append(", ") + } + text.append(it) + } + + return true + } + + fun getText(): String { + return text.toString() + } +} + +private fun AccessibilityElement.collectContentDescription(collector: NodeDescriptionCollector): Boolean { + if (!collector.collect(this)) { + return false + } + for (element in accessibilityElements ?: emptyList()) { + if (element is AccessibilityElement) { + if (!element.collectContentDescription(collector)) { + return false + } + } + } + return true +} diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticConfigurationUtils.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticConfigurationUtils.kt index aab65a206f9ef..0e9cc937a82b1 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticConfigurationUtils.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticConfigurationUtils.kt @@ -128,18 +128,6 @@ internal fun SemanticsConfiguration.accessibilityTraits(): UIAccessibilityTraits return result } -internal fun SemanticsConfiguration.accessibilityLabel(): String? { - val contentDescription = getOrNull(SemanticsProperties.ContentDescription) - ?.joinToString("\n") - ?.takeIf { it.isNotBlank() } - - return contentDescription ?: if (contains(SemanticsProperties.EditableText)) { - null - } else { - getOrNull(SemanticsProperties.Text)?.joinToString("\n") { it.text } - } -} - internal fun SemanticsConfiguration.accessibilityValue(): String? { getOrNull(SemanticsProperties.StateDescription)?.takeIf { it.isNotBlank() }?.let { return it diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticsNodeUtils.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticsNodeUtils.kt index abb797ddd81e3..1988c6391d0c6 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticsNodeUtils.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticsNodeUtils.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsProperties.HideFromAccessibility import androidx.compose.ui.semantics.SemanticsProperties.InvisibleToUser import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.findClosestParentNode import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.toSize @@ -238,9 +239,14 @@ internal fun SemanticsNode.isScreenReaderFocusable(): Boolean { internal fun SemanticsNode.canBeAccessibilityElement(): Boolean { return !isHiddenFromAccessibility && (unmergedConfig.isMergingSemanticsOfDescendants || - isUnmergedLeafNode && isSpeakingNode) + isUnmergedNode && isSpeakingNode) } +private val SemanticsNode.isUnmergedNode get() = + !isFake && layoutNode.findClosestParentNode { + it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true + } == null + private val SemanticsNode.isSpeakingNode: Boolean get() { return unmergedConfig.contains(SemanticsProperties.ContentDescription) || unmergedConfig.contains(SemanticsProperties.EditableText) || @@ -276,3 +282,15 @@ internal val SemanticsNode.allScrollableParentNodeIds: Set get() { return result } + +internal val SemanticsNode.contentDescription: String? get() { + val contentDescription = config.getOrNull(SemanticsProperties.ContentDescription) + ?.joinToString(", ") + ?.takeIf { it.isNotBlank() } + + return contentDescription ?: if (config.contains(SemanticsProperties.EditableText)) { + null + } else { + config.getOrNull(SemanticsProperties.Text)?.joinToString(", ") { it.text } + } +} \ No newline at end of file