Skip to content

Commit 337deda

Browse files
authored
Fix traversal order of semantic descendants (JetBrains#2643)
As the TalkBack app on Android also adjusts the traversal order and semantics, the actual order differs from that returned by the function `sortByGeometryGroupings`. The change fixes some cases where the parent semantics node may go after the child node in the result list. Fixes https://youtrack.jetbrains.com/issue/CMP-9417/Incorrect-traversal-order-of-semantic-descendants ## Release Notes ### Fixes - iOS - Fix the traversal order of accessibility nodes where a parent node may follow its child node
1 parent 6236b82 commit 337deda

3 files changed

Lines changed: 139 additions & 16 deletions

File tree

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/Accessibility.ios.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import androidx.compose.ui.platform.accessibility.isRTL
3333
import androidx.compose.ui.platform.accessibility.isScreenReaderFocusable
3434
import androidx.compose.ui.platform.accessibility.scrollIfPossible
3535
import androidx.compose.ui.platform.accessibility.scrollToCenterRectIfNeeded
36+
import androidx.compose.ui.platform.accessibility.sortFlattenChildren
3637
import androidx.compose.ui.platform.accessibility.unclippedBoundsInWindow
3738
import androidx.compose.ui.semantics.ScrollAxisRange
3839
import androidx.compose.ui.semantics.SemanticsActions
@@ -1485,7 +1486,7 @@ internal class AccessibilityMediator(
14851486
flatten = flattenChildren
14861487
)
14871488

1488-
val sortedChildren = node.sortByGeometryGroupings(visibleChildren)
1489+
val sortedChildren = node.sortFlattenChildren(visibleChildren)
14891490
beforeChildren.sortWith(BeyondBoundsComparator(node.isRTL))
14901491
afterChildren.sortWith(BeyondBoundsComparator(node.isRTL))
14911492

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/platform/accessibility/SemanticsNodeUtils.ios.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import androidx.compose.ui.semantics.SemanticsProperties.InvisibleToUser
3030
import androidx.compose.ui.semantics.SemanticsPropertyKey
3131
import androidx.compose.ui.semantics.findClosestParentNode
3232
import androidx.compose.ui.semantics.getOrNull
33+
import androidx.compose.ui.semantics.sortByGeometryGroupings
3334
import androidx.compose.ui.unit.LayoutDirection
3435
import androidx.compose.ui.unit.toSize
3536
import platform.UIKit.UIAccessibilityScrollDirection
@@ -293,4 +294,26 @@ internal val SemanticsNode.contentDescription: String? get() {
293294
} else {
294295
config.getOrNull(SemanticsProperties.Text)?.joinToString(", ") { it.text }
295296
}
296-
}
297+
}
298+
299+
internal fun SemanticsNode.sortFlattenChildren(children: List<SemanticsNode>): List<SemanticsNode> {
300+
val sortedChildren = sortByGeometryGroupings(children) as MutableList<SemanticsNode>
301+
302+
// Fix the specifics of nodes sorting where a parent node may go after a child in the sorted list.
303+
// Swapping them if the order is not specified by other criteria as TraversalIndex.
304+
// In case of other sort issues, consider copy and re-implementing the `sortByGeometryGroupings`
305+
// method to match TalkBack application traversal order.
306+
repeat(sortedChildren.count() - 1) { index ->
307+
val first = sortedChildren[index]
308+
val second = sortedChildren[index + 1]
309+
if (!first.unmergedConfig.contains(SemanticsProperties.TraversalIndex) &&
310+
!second.unmergedConfig.contains(SemanticsProperties.TraversalIndex) &&
311+
first.layoutNode.parent != second.layoutNode.parent &&
312+
first.layoutNode.findClosestParentNode({ it == second.layoutNode }) != null
313+
) {
314+
sortedChildren[index] = second
315+
sortedChildren[index + 1] = first
316+
}
317+
}
318+
return sortedChildren
319+
}

compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import androidx.compose.foundation.clickable
2121
import androidx.compose.foundation.layout.Box
2222
import androidx.compose.foundation.layout.Column
2323
import androidx.compose.foundation.layout.Row
24+
import androidx.compose.foundation.layout.padding
2425
import androidx.compose.foundation.layout.size
2526
import androidx.compose.foundation.text.selection.SelectionContainer
2627
import androidx.compose.material.Button
@@ -52,6 +53,7 @@ import androidx.compose.ui.semantics.heading
5253
import androidx.compose.ui.semantics.isTraversalGroup
5354
import androidx.compose.ui.semantics.role
5455
import androidx.compose.ui.semantics.semantics
56+
import androidx.compose.ui.semantics.stateDescription
5557
import androidx.compose.ui.semantics.testTag
5658
import androidx.compose.ui.semantics.text
5759
import androidx.compose.ui.state.ToggleableState
@@ -851,14 +853,14 @@ class ComponentsAccessibilitySemanticTest {
851853
label = "Title 1"
852854
isAccessibilityElement = true
853855
}
854-
node {
855-
label = "Description 1"
856-
isAccessibilityElement = true
857-
}
858856
node {
859857
identifier = "Tag 1"
860858
isAccessibilityElement = false
861859
}
860+
node {
861+
label = "Description 1"
862+
isAccessibilityElement = true
863+
}
862864
node {
863865
label = "Details 1"
864866
isAccessibilityElement = true
@@ -917,11 +919,11 @@ class ComponentsAccessibilitySemanticTest {
917919

918920
assertAccessibilityTree {
919921
node {
920-
label = "Text"
922+
label = "Description"
921923
isAccessibilityElement = true
922924
}
923925
node {
924-
label = "Description"
926+
label = "Text"
925927
isAccessibilityElement = true
926928
}
927929
}
@@ -947,6 +949,103 @@ class ComponentsAccessibilitySemanticTest {
947949
}
948950
}
949951

952+
@Test
953+
fun testEnclosedSemanticsContainersOrder() = runUIKitInstrumentedTest {
954+
setContent {
955+
Box(modifier = Modifier.semantics { contentDescription = "Box 1" }) {
956+
Box(modifier = Modifier.semantics { contentDescription = "Box 2" }) {
957+
Column(modifier = Modifier.padding(1.dp).semantics { contentDescription = "Column 3" }) {
958+
Text("Text 1")
959+
Text("Text 2")
960+
}
961+
}
962+
}
963+
}
964+
965+
assertAccessibilityTree {
966+
node {
967+
label = "Box 1"
968+
isAccessibilityElement = true
969+
}
970+
node {
971+
label = "Box 2"
972+
isAccessibilityElement = true
973+
}
974+
node {
975+
label = "Column 3"
976+
isAccessibilityElement = true
977+
}
978+
node {
979+
label = "Text 1"
980+
isAccessibilityElement = true
981+
}
982+
node {
983+
label = "Text 2"
984+
isAccessibilityElement = true
985+
}
986+
}
987+
}
988+
989+
@Test
990+
fun testMergedTextContentWithMergeDescendants() = runUIKitInstrumentedTest {
991+
setContent {
992+
Column(
993+
modifier = Modifier.semantics(mergeDescendants = true) {
994+
contentDescription = "Content description"
995+
stateDescription = "State description"
996+
}
997+
) {
998+
Text("Text 1")
999+
Text("Text 2")
1000+
}
1001+
}
1002+
1003+
assertAccessibilityTree {
1004+
label = "Content description, Text 1, Text 2"
1005+
value = "State description"
1006+
isAccessibilityElement = true
1007+
node {
1008+
label = "Text 1"
1009+
isAccessibilityElement = false
1010+
}
1011+
node {
1012+
label = "Text 2"
1013+
isAccessibilityElement = false
1014+
}
1015+
}
1016+
}
1017+
1018+
@Test
1019+
fun testMergedTextContentWithoutMergeDescendants() = runUIKitInstrumentedTest {
1020+
setContent {
1021+
Column(
1022+
modifier = Modifier.semantics(mergeDescendants = false) {
1023+
contentDescription = "Content description"
1024+
stateDescription = "State description"
1025+
}
1026+
) {
1027+
Text("Text 1")
1028+
Text("Text 2")
1029+
}
1030+
}
1031+
1032+
assertAccessibilityTree {
1033+
node {
1034+
label = "Content description"
1035+
value = "State description"
1036+
isAccessibilityElement = true
1037+
}
1038+
node {
1039+
label = "Text 1"
1040+
isAccessibilityElement = true
1041+
}
1042+
node {
1043+
label = "Text 2"
1044+
isAccessibilityElement = true
1045+
}
1046+
}
1047+
}
1048+
9501049
@Test
9511050
fun testEnclosedComplexContentWithMergedSemantics() = runUIKitInstrumentedTest {
9521051
setContent {
@@ -973,14 +1072,6 @@ class ComponentsAccessibilitySemanticTest {
9731072
}
9741073

9751074
assertAccessibilityTree {
976-
node {
977-
isAccessibilityElement = true
978-
label = "Label"
979-
node {
980-
label = "Label"
981-
isAccessibilityElement = false
982-
}
983-
}
9841075
node {
9851076
label = "Description, Inner, Text"
9861077
isAccessibilityElement = true
@@ -993,6 +1084,14 @@ class ComponentsAccessibilitySemanticTest {
9931084
isAccessibilityElement = false
9941085
}
9951086
}
1087+
node {
1088+
isAccessibilityElement = true
1089+
label = "Label"
1090+
node {
1091+
label = "Label"
1092+
isAccessibilityElement = false
1093+
}
1094+
}
9961095
node {
9971096
label = "First Text, Second Text"
9981097
isAccessibilityElement = true

0 commit comments

Comments
 (0)