1 /*
<lambda>null2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.semantics
18
19 import androidx.collection.IntObjectMap
20 import androidx.collection.MutableIntObjectMap
21 import androidx.collection.MutableObjectList
22 import androidx.collection.emptyIntObjectMap
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.node.LayoutNode
25 import androidx.compose.ui.semantics.SemanticsProperties.HideFromAccessibility
26 import androidx.compose.ui.semantics.SemanticsProperties.InvisibleToUser
27 import androidx.compose.ui.unit.IntRect
28 import androidx.compose.ui.unit.roundToIntRect
29 import androidx.compose.ui.util.fastForEach
30
31 /** Owns [SemanticsNode] objects and notifies listeners of changes to the semantics tree */
32 class SemanticsOwner
33 internal constructor(
34 private val rootNode: LayoutNode,
35 private val outerSemanticsNode: EmptySemanticsModifier,
36 private val nodes: IntObjectMap<LayoutNode>
37 ) {
38 /**
39 * The root node of the semantics tree. Does not contain any unmerged data. May contain merged
40 * data.
41 */
42 val rootSemanticsNode: SemanticsNode
43 get() {
44 return SemanticsNode(rootNode, mergingEnabled = true)
45 }
46
47 val unmergedRootSemanticsNode: SemanticsNode
48 get() {
49 return SemanticsNode(
50 outerSemanticsNode = outerSemanticsNode,
51 layoutNode = rootNode,
52 mergingEnabled = false,
53 // Forcing an empty SemanticsConfiguration here since the root node will always
54 // have an empty config, but if we don't pass this in explicitly here it will try
55 // to call `rootNode.collapsedSemantics` which will fail because the LayoutNode
56 // is not yet attached when this getter is first called.
57 unmergedConfig = SemanticsConfiguration()
58 )
59 }
60
61 internal val listeners = MutableObjectList<SemanticsListener>(2)
62
63 internal val rootInfo: SemanticsInfo
64 get() = rootNode
65
66 internal operator fun get(semanticsId: Int): SemanticsInfo? {
67 return nodes[semanticsId]
68 }
69
70 internal fun notifySemanticsChange(
71 semanticsInfo: SemanticsInfo,
72 previousSemanticsConfiguration: SemanticsConfiguration?
73 ) {
74 listeners.forEach { it.onSemanticsChanged(semanticsInfo, previousSemanticsConfiguration) }
75 }
76 }
77
78 /**
79 * Finds all [SemanticsNode]s in the tree owned by this [SemanticsOwner]. Return the results in a
80 * list.
81 *
82 * @param mergingEnabled set to true if you want the data to be merged.
83 * @param skipDeactivatedNodes set to false if you want to collect the nodes which are deactivated.
84 * For example, the children of [androidx.compose.ui.layout.SubcomposeLayout] which are retained
85 * to be reused in future are considered deactivated.
86 */
getAllSemanticsNodesnull87 fun SemanticsOwner.getAllSemanticsNodes(
88 mergingEnabled: Boolean,
89 skipDeactivatedNodes: Boolean = true
90 ): List<SemanticsNode> {
91 return getAllSemanticsNodesToMap(
92 useUnmergedTree = !mergingEnabled,
93 skipDeactivatedNodes = skipDeactivatedNodes
94 )
95 .values
96 .toList()
97 }
98
99 @Suppress("unused")
100 @Deprecated(message = "Use a new overload instead", level = DeprecationLevel.HIDDEN)
getAllSemanticsNodesnull101 fun SemanticsOwner.getAllSemanticsNodes(mergingEnabled: Boolean) =
102 getAllSemanticsNodes(mergingEnabled, true)
103
104 /**
105 * Finds all [SemanticsNode]s in the tree owned by this [SemanticsOwner]. Return the results in a
106 * map.
107 */
108 internal fun SemanticsOwner.getAllSemanticsNodesToMap(
109 useUnmergedTree: Boolean = false,
110 skipDeactivatedNodes: Boolean = true
111 ): Map<Int, SemanticsNode> {
112 val nodes = mutableMapOf<Int, SemanticsNode>()
113
114 fun findAllSemanticNodesRecursive(currentNode: SemanticsNode) {
115 nodes[currentNode.id] = currentNode
116 currentNode.getChildren(includeDeactivatedNodes = !skipDeactivatedNodes).fastForEach { child
117 ->
118 findAllSemanticNodesRecursive(child)
119 }
120 }
121
122 val root = if (useUnmergedTree) unmergedRootSemanticsNode else rootSemanticsNode
123 if (!skipDeactivatedNodes || !root.layoutNode.isDeactivated) {
124 findAllSemanticNodesRecursive(root)
125 }
126 return nodes
127 }
128
isImportantForAccessibilitynull129 internal fun SemanticsNode.isImportantForAccessibility() =
130 !isHidden &&
131 (unmergedConfig.isMergingSemanticsOfDescendants ||
132 unmergedConfig.containsImportantForAccessibility())
133
134 @Suppress("DEPRECATION")
135 internal val SemanticsNode.isHidden: Boolean
136 // A node is considered hidden if it is transparent, or explicitly is hidden from accessibility.
137 // This also checks if the node has been marked as `invisibleToUser`, which is what the
138 // `hiddenFromAccessibility` API used to be named.
139 get() =
140 isTransparent ||
141 (unmergedConfig.contains(HideFromAccessibility) ||
142 unmergedConfig.contains(InvisibleToUser))
143
144 private val DefaultFakeNodeBounds = Rect(0f, 0f, 10f, 10f)
145
146 /** Semantics node with adjusted bounds for the uncovered(by siblings) part. */
147 internal class SemanticsNodeWithAdjustedBounds(
148 val semanticsNode: SemanticsNode,
149 val adjustedBounds: IntRect
150 )
151
152 /**
153 * Finds pruned [SemanticsNode]s in the tree owned by this [SemanticsOwner]. A semantics node
154 * completely covered by siblings drawn on top of it will be pruned. Return the results in a map.
155 */
156 internal fun SemanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap(
157 customRootNodeId: Int
158 ): IntObjectMap<SemanticsNodeWithAdjustedBounds> {
159 val root = unmergedRootSemanticsNode
160 if (!root.layoutNode.isPlaced || !root.layoutNode.isAttached) {
161 return emptyIntObjectMap()
162 }
163
164 // Default capacity chosen to accommodate common scenarios
165 val nodes = MutableIntObjectMap<SemanticsNodeWithAdjustedBounds>(48)
166
167 val unaccountedSpace = SemanticsRegion()
168 unaccountedSpace.set(root.boundsInRoot.roundToIntRect())
169
170 fun findAllSemanticNodesRecursive(currentNode: SemanticsNode, region: SemanticsRegion) {
171 val notAttachedOrPlaced =
172 !currentNode.layoutNode.isPlaced || !currentNode.layoutNode.isAttached
173 if (
174 (unaccountedSpace.isEmpty && currentNode.id != root.id) ||
175 (notAttachedOrPlaced && !currentNode.isFake)
176 ) {
177 return
178 }
179 val touchBoundsInRoot = currentNode.touchBoundsInRoot.roundToIntRect()
180
181 region.set(touchBoundsInRoot)
182
183 val virtualViewId =
184 if (currentNode.id == root.id) {
185 customRootNodeId
186 } else {
187 currentNode.id
188 }
189 if (region.intersect(unaccountedSpace)) {
190 nodes[virtualViewId] = SemanticsNodeWithAdjustedBounds(currentNode, region.bounds)
191 // Children could be drawn outside of parent, but we are using clipped bounds for
192 // accessibility now, so let's put the children recursion inside of this if. If later
193 // we decide to support children drawn outside of parent, we can move it out of the
194 // if block.
195 val children = currentNode.replacedChildren
196 for (i in children.size - 1 downTo 0) {
197 // Links in text nodes are semantics children. But for Android accessibility support
198 // we don't publish them to the accessibility services because they are exposed
199 // as UrlSpan/ClickableSpan spans instead
200 if (children[i].config.contains(SemanticsProperties.LinkTestMarker)) {
201 continue
202 }
203 findAllSemanticNodesRecursive(children[i], region)
204 }
205 if (currentNode.isImportantForAccessibility()) {
206 unaccountedSpace.difference(touchBoundsInRoot)
207 }
208 } else {
209 if (currentNode.isFake) {
210 val parentNode = currentNode.parent
211 // use parent bounds for fake node
212 val boundsForFakeNode =
213 if (parentNode?.layoutInfo?.isPlaced == true) {
214 parentNode.boundsInRoot
215 } else {
216 DefaultFakeNodeBounds
217 }
218 nodes[virtualViewId] =
219 SemanticsNodeWithAdjustedBounds(currentNode, boundsForFakeNode.roundToIntRect())
220 } else if (virtualViewId == customRootNodeId) {
221 // Root view might have WRAP_CONTENT layout params in which case it will have zero
222 // bounds if there is no other content with semantics. But we need to always send
223 // the
224 // root view info as there are some other apps (e.g. Google Assistant) that depend
225 // on accessibility info
226 nodes[virtualViewId] = SemanticsNodeWithAdjustedBounds(currentNode, region.bounds)
227 }
228 }
229 }
230
231 findAllSemanticNodesRecursive(root, SemanticsRegion())
232 return nodes
233 }
234