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.compose.ui.Modifier
20 import androidx.compose.ui.geometry.Offset
21 import androidx.compose.ui.geometry.Rect
22 import androidx.compose.ui.layout.AlignmentLine
23 import androidx.compose.ui.layout.LayoutInfo
24 import androidx.compose.ui.layout.boundsInRoot
25 import androidx.compose.ui.layout.boundsInWindow
26 import androidx.compose.ui.layout.positionInRoot
27 import androidx.compose.ui.layout.positionInWindow
28 import androidx.compose.ui.layout.positionOnScreen
29 import androidx.compose.ui.node.LayoutNode
30 import androidx.compose.ui.node.NodeCoordinator
31 import androidx.compose.ui.node.Nodes
32 import androidx.compose.ui.node.RootForTest
33 import androidx.compose.ui.node.SemanticsModifierNode
34 import androidx.compose.ui.node.requireCoordinator
35 import androidx.compose.ui.node.requireLayoutNode
36 import androidx.compose.ui.node.touchBoundsInRoot
37 import androidx.compose.ui.node.useMinimumTouchTarget
38 import androidx.compose.ui.platform.ViewConfiguration
39 import androidx.compose.ui.unit.IntSize
40 import kotlin.contracts.ExperimentalContracts
41 import kotlin.contracts.contract
42
43 internal fun SemanticsNode(layoutNode: LayoutNode, mergingEnabled: Boolean) =
44 SemanticsNode(
45 layoutNode.nodes.head(Nodes.Semantics)!!.node,
46 mergingEnabled,
47 layoutNode,
48 layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
49 )
50
51 internal fun SemanticsNode(
52 /*
53 * This is expected to be the outermost semantics modifier on a layout node.
54 */
55 outerSemanticsNode: SemanticsModifierNode,
56 /**
57 * mergingEnabled specifies whether mergeDescendants config has any effect.
58 *
59 * If true, then mergeDescendants nodes will merge up all properties from child semantics nodes
60 * and remove those children from "children", with the exception of nodes that themselves have
61 * mergeDescendants. If false, then mergeDescendants has no effect.
62 *
63 * mergingEnabled is typically true or false consistently on every node of a SemanticsNode tree.
64 */
65 mergingEnabled: Boolean,
66 /** The [LayoutNode] that this is associated with. */
67 layoutNode: LayoutNode = outerSemanticsNode.requireLayoutNode()
68 ) =
69 SemanticsNode(
70 outerSemanticsNode.node,
71 mergingEnabled,
72 layoutNode,
73 layoutNode.semanticsConfiguration ?: SemanticsConfiguration()
74 )
75
76 /**
77 * A list of key/value pairs associated with a layout node or its subtree.
78 *
79 * Each SemanticsNode takes its id and initial key/value list from the outermost modifier on one
80 * layout node. It also contains the "collapsed" configuration of any other semantics modifiers on
81 * the same layout node, and if "mergeDescendants" is specified and enabled, also the "merged"
82 * configuration of its subtree.
83 */
84 class SemanticsNode
85 internal constructor(
86 internal val outerSemanticsNode: Modifier.Node,
87 val mergingEnabled: Boolean,
88 internal val layoutNode: LayoutNode,
89 internal val unmergedConfig: SemanticsConfiguration,
90 ) {
91 // We emit fake nodes for several cases. One is to prevent the content description clobbering
92 // issue. Another case is temporary workaround to retrieve default role ordering for Button
93 // and other selection controls.
94 internal var isFake = false
95 private var fakeNodeParent: SemanticsNode? = null
96
97 internal val isUnmergedLeafNode
98 get() =
99 !isFake &&
100 replacedChildren.isEmpty() &&
101 layoutNode.findClosestParentNode {
102 it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
103 } == null
104
105 /** The [LayoutInfo] that this is associated with. */
106 val layoutInfo: LayoutInfo
107 get() = layoutNode
108
109 /** The [root][RootForTest] this node is attached to. */
110 val root: RootForTest?
111 get() = layoutNode.owner?.rootForTest
112
113 /**
114 * For newer AccessibilityNodeInfo-based integration test frameworks, it can be matched in the
115 * extras with key "androidx.compose.ui.semantics.id"
116 */
117 val id: Int = layoutNode.semanticsId
118
119 // GEOMETRY
120
121 /**
122 * The rectangle of the touchable area.
123 *
124 * If this is a clickable region, this is the rectangle that accepts touch input. This can be
125 * larger than [size] when the layout is less than [ViewConfiguration.minimumTouchTargetSize]
126 */
127 val touchBoundsInRoot: Rect
128 get() {
129 val entity =
130 if (unmergedConfig.isMergingSemanticsOfDescendants) {
131 (layoutNode.outerMergingSemantics ?: outerSemanticsNode)
132 } else {
133 outerSemanticsNode
134 }
135 return entity.node.touchBoundsInRoot(unmergedConfig.useMinimumTouchTarget)
136 }
137
138 /** The size of the bounding box for this node, with no clipping applied */
139 val size: IntSize
140 get() = findCoordinatorToGetBounds()?.size ?: IntSize.Zero
141
142 /**
143 * The bounding box for this node relative to the root of this Compose hierarchy, with clipping
144 * applied. To get the bounds with no clipping applied, use Rect([positionInRoot],
145 * [size].toSize())
146 */
147 val boundsInRoot: Rect
148 get() = findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.boundsInRoot() ?: Rect.Zero
149
150 /**
151 * The position of this node relative to the root of this Compose hierarchy, with no clipping
152 * applied
153 */
154 val positionInRoot: Offset
155 get() =
156 findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.positionInRoot() ?: Offset.Zero
157
158 /**
159 * The bounding box for this node relative to the window, with clipping applied. To get the
160 * bounds with no clipping applied, use PxBounds([positionInWindow], [size].toSize())
161 */
162 val boundsInWindow: Rect
163 get() =
164 findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.boundsInWindow() ?: Rect.Zero
165
166 /** The position of this node relative to the window, with no clipping applied */
167 val positionInWindow: Offset
168 get() =
169 findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.positionInWindow()
170 ?: Offset.Zero
171
172 /** The position of this node relative to the screen, with no clipping applied */
173 val positionOnScreen: Offset
174 get() =
175 findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.positionOnScreen()
176 ?: Offset.Zero
177
178 /**
179 * The bounding box for this node relative to the parent semantics node, with clipping applied.
180 */
181 internal val boundsInParent: Rect
182 get() {
183 val parent = this.parent ?: return Rect.Zero
184 val currentCoordinates =
185 findCoordinatorToGetBounds()?.takeIf { it.isAttached }?.coordinates
186 ?: return Rect.Zero
187 return parent.outerSemanticsNode
188 .requireCoordinator(Nodes.Semantics)
189 .localBoundingBoxOf(currentCoordinates)
190 }
191
192 /** Whether this node is transparent. */
193 internal val isTransparent: Boolean
194 get() = findCoordinatorToGetBounds()?.isTransparent() ?: false
195
196 /**
197 * Returns the position of an [alignment line][AlignmentLine], or [AlignmentLine.Unspecified] if
198 * the line is not provided.
199 */
200 fun getAlignmentLinePosition(alignmentLine: AlignmentLine): Int {
201 return findCoordinatorToGetBounds()?.get(alignmentLine) ?: AlignmentLine.Unspecified
202 }
203
204 // CHILDREN
205
206 /**
207 * The list of semantics properties of this node.
208 *
209 * This includes all properties attached as modifiers to the current layout node. In addition,
210 * if mergeDescendants and mergingEnabled are both true, then it also includes the semantics
211 * properties of descendant nodes.
212 */
213 // TODO(b/184376083): This is too expensive for a val (full subtree recreation every call);
214 // optimize this when the merging algorithm is improved.
215 val config: SemanticsConfiguration
216 get() {
217 if (isMergingSemanticsOfDescendants) {
218 val mergedConfig = unmergedConfig.copy()
219 mergeConfig(
220 // TODO(b/384549982): Pass in the unmerged children instead of an empty list.
221 mutableListOf(),
222 mergedConfig
223 )
224 return mergedConfig
225 } else {
226 return unmergedConfig
227 }
228 }
229
230 private fun mergeConfig(
231 unmergedChildren: MutableList<SemanticsNode>,
232 mergedConfig: SemanticsConfiguration
233 ) {
234 if (!unmergedConfig.isClearingSemantics) {
235 unmergedChildren.forEachUnmergedChild { child ->
236 // Don't merge children that themselves merge all their descendants (because that
237 // indicates they're independently screen-reader-focusable).
238 if (!child.isMergingSemanticsOfDescendants) {
239 mergedConfig.mergeChild(child.unmergedConfig)
240 child.mergeConfig(unmergedChildren, mergedConfig)
241 }
242 }
243 }
244 }
245
246 private val isMergingSemanticsOfDescendants: Boolean
247 get() = mergingEnabled && unmergedConfig.isMergingSemanticsOfDescendants
248
249 internal fun unmergedChildren(
250 unmergedChildren: MutableList<SemanticsNode> = mutableListOf(),
251 includeFakeNodes: Boolean = false,
252 includeDeactivatedNodes: Boolean = false
253 ): List<SemanticsNode> {
254 // TODO(lmr): we should be able to do this more efficiently using visitSubtree
255 if (isFake) return emptyList()
256
257 layoutNode.fillOneLayerOfSemanticsWrappers(unmergedChildren, includeDeactivatedNodes)
258
259 if (includeFakeNodes) {
260 emitFakeNodes(unmergedChildren)
261 }
262
263 return unmergedChildren
264 }
265
266 private fun LayoutNode.fillOneLayerOfSemanticsWrappers(
267 list: MutableList<SemanticsNode>,
268 includeDeactivatedNodes: Boolean
269 ) {
270 // TODO(lmr): visitChildren would be great for this but we would lose the zSorted bit...
271 // i wonder if we can optimize this for the common case of no z-sortedness going on.
272 zSortedChildren.forEach { child ->
273 // TODO(b/290936195): In some conditions it appears that children here can be
274 // unattached. We just guard against that here as a "quick fix" but we need to
275 // understand why this is happening and followup with a proper fix.
276 if (child.isAttached && (includeDeactivatedNodes || !child.isDeactivated)) {
277 if (child.nodes.has(Nodes.Semantics)) {
278 list.add(SemanticsNode(child, mergingEnabled))
279 } else {
280 child.fillOneLayerOfSemanticsWrappers(list, includeDeactivatedNodes)
281 }
282 }
283 }
284 }
285
286 /**
287 * Contains the children in inverse hit test order (i.e. paint order).
288 *
289 * Note that if mergingEnabled and mergeDescendants are both true, then there are no children
290 * (except those that are themselves mergeDescendants).
291 */
292 // TODO(b/184376083): This is too expensive for a val (full subtree recreation every call);
293 // optimize this when the merging algorithm is improved.
294 val children: List<SemanticsNode>
295 get() = getChildren()
296
297 /**
298 * Contains the children in inverse hit test order (i.e. paint order).
299 *
300 * Unlike [children] property that includes replaced semantics nodes in unmerged tree, here node
301 * marked as [clearAndSetSemantics] will not have children. This property is primarily used in
302 * Accessibility delegate.
303 */
304 internal val replacedChildren: List<SemanticsNode>
305 get() = getChildren(includeReplacedSemantics = false, includeFakeNodes = true)
306
307 /**
308 * @param includeReplacedSemantics if true, the result will contain children of nodes marked as
309 * [clearAndSetSemantics]. For accessibility we always use false, but in testing and debugging
310 * we should be able to investigate both
311 * @param includeFakeNodes if true, the tree will include fake nodes. For accessibility we set
312 * to true, but for testing purposes we don't want to expose the fake nodes and therefore set
313 * to false. When Talkback can properly handle unmerged tree, fake nodes will be removed and
314 * so will be this parameter.
315 * @param includeDeactivatedNodes set to true if you want to collect the nodes which are
316 * deactivated. For example, the children of [androidx.compose.ui.layout.SubcomposeLayout]
317 * which are retained to be reused in future are considered deactivated.
318 */
319 internal fun getChildren(
320 includeReplacedSemantics: Boolean = !mergingEnabled,
321 includeFakeNodes: Boolean = false,
322 includeDeactivatedNodes: Boolean = false
323 ): List<SemanticsNode> {
324 if (!includeReplacedSemantics && unmergedConfig.isClearingSemantics) {
325 return emptyList()
326 }
327
328 val unmergedChildren = mutableListOf<SemanticsNode>()
329
330 if (isMergingSemanticsOfDescendants) {
331 // In most common merging scenarios like Buttons, this will return nothing.
332 // In cases like a clickable Row itself containing a Button, this will
333 // return the Button as a child.
334 return findOneLayerOfMergingSemanticsNodes(unmergedChildren)
335 }
336
337 return unmergedChildren(unmergedChildren, includeFakeNodes, includeDeactivatedNodes)
338 }
339
340 /** Whether this SemanticNode is the root of a tree or not */
341 val isRoot: Boolean
342 get() = parent == null
343
344 /** The parent of this node in the tree. */
345 val parent: SemanticsNode?
346 get() {
347 if (fakeNodeParent != null) return fakeNodeParent
348 var node: LayoutNode? = null
349 if (mergingEnabled) {
350 node =
351 this.layoutNode.findClosestParentNode {
352 it.semanticsConfiguration?.isMergingSemanticsOfDescendants == true
353 }
354 }
355
356 if (node == null) {
357 node = this.layoutNode.findClosestParentNode { it.nodes.has(Nodes.Semantics) }
358 }
359
360 if (node == null) return null
361
362 return SemanticsNode(node, mergingEnabled)
363 }
364
365 private fun findOneLayerOfMergingSemanticsNodes(
366 unmergedChildren: MutableList<SemanticsNode>,
367 list: MutableList<SemanticsNode> = mutableListOf()
368 ): List<SemanticsNode> {
369 unmergedChildren.forEachUnmergedChild { child ->
370 if (child.isMergingSemanticsOfDescendants) {
371 list.add(child)
372 } else {
373 if (!child.unmergedConfig.isClearingSemantics) {
374 child.findOneLayerOfMergingSemanticsNodes(unmergedChildren, list)
375 }
376 }
377 }
378 return list
379 }
380
381 @Suppress("BanInlineOptIn")
382 @OptIn(ExperimentalContracts::class)
383 private inline fun MutableList<SemanticsNode>.forEachUnmergedChild(
384 block: (SemanticsNode) -> Unit
385 ) {
386 contract { callsInPlace(block) }
387
388 // This allows block() to invoke unmergedChildren() recursively
389 val start = size
390 unmergedChildren(this)
391 val end = size
392 for (i in start until end) {
393 block(this[i])
394 }
395 }
396
397 /**
398 * If the node is merging the descendants, we'll use the outermost semantics modifier that has
399 * mergeDescendants == true to report the bounds, size and position of the node. For majority of
400 * use cases it means that accessibility bounds will be equal to the clickable area. Otherwise
401 * the outermost semantics will be used to report bounds, size and position.
402 */
403 internal fun findCoordinatorToGetBounds(): NodeCoordinator? {
404 if (isFake) return parent?.findCoordinatorToGetBounds()
405 val semanticsModifierNode = layoutNode.outerMergingSemantics ?: outerSemanticsNode
406 return semanticsModifierNode.requireCoordinator(Nodes.Semantics)
407 }
408
409 // Fake nodes
410 private fun emitFakeNodes(unmergedChildren: MutableList<SemanticsNode>) {
411 val nodeRole = this.role
412 if (
413 nodeRole != null &&
414 unmergedConfig.isMergingSemanticsOfDescendants &&
415 unmergedChildren.isNotEmpty()
416 ) {
417 val fakeNode = fakeSemanticsNode(nodeRole) { this.role = nodeRole }
418 unmergedChildren.add(fakeNode)
419 }
420
421 // Fake node for contentDescription clobbering issue
422 if (
423 unmergedConfig.contains(SemanticsProperties.ContentDescription) &&
424 unmergedChildren.isNotEmpty() &&
425 unmergedConfig.isMergingSemanticsOfDescendants
426 ) {
427 val contentDescription =
428 this.unmergedConfig.getOrNull(SemanticsProperties.ContentDescription)?.firstOrNull()
429 if (contentDescription != null) {
430 val fakeNode =
431 fakeSemanticsNode(null) { this.contentDescription = contentDescription }
432 unmergedChildren.add(0, fakeNode)
433 }
434 }
435 }
436
437 private fun fakeSemanticsNode(
438 role: Role?,
439 properties: SemanticsPropertyReceiver.() -> Unit
440 ): SemanticsNode {
441 val configuration =
442 SemanticsConfiguration().also {
443 it.isMergingSemanticsOfDescendants = false
444 it.isClearingSemantics = false
445 it.properties()
446 }
447 val fakeNode =
448 SemanticsNode(
449 outerSemanticsNode =
450 object : SemanticsModifierNode, Modifier.Node() {
451 override fun SemanticsPropertyReceiver.applySemantics() {
452 properties()
453 }
454 },
455 mergingEnabled = false,
456 layoutNode =
457 LayoutNode(
458 isVirtual = true,
459 semanticsId =
460 if (role != null) roleFakeNodeId() else contentDescriptionFakeNodeId()
461 ),
462 unmergedConfig = configuration
463 )
464 fakeNode.isFake = true
465 fakeNode.fakeNodeParent = this
466 return fakeNode
467 }
468
469 internal fun copyWithMergingEnabled(): SemanticsNode {
470 return SemanticsNode(outerSemanticsNode, true, layoutNode, unmergedConfig)
471 }
472 }
473
474 internal val LayoutNode.outerMergingSemantics: SemanticsModifierNode?
<lambda>null475 get() = nodes.firstFromHead(Nodes.Semantics) { it.shouldMergeDescendantSemantics }
476
477 /**
478 * Executes [selector] on every parent of this [LayoutNode] and returns the closest [LayoutNode] to
479 * return `true` from [selector] or null if [selector] returns false for all ancestors.
480 */
findClosestParentNodenull481 internal inline fun LayoutNode.findClosestParentNode(
482 selector: (LayoutNode) -> Boolean
483 ): LayoutNode? {
484 var currentParent = this.parent
485 while (currentParent != null) {
486 if (selector(currentParent)) {
487 return currentParent
488 } else {
489 currentParent = currentParent.parent
490 }
491 }
492
493 return null
494 }
495
496 private val SemanticsNode.role
497 get() = this.unmergedConfig.getOrNull(SemanticsProperties.Role)
498
SemanticsNodenull499 private fun SemanticsNode.contentDescriptionFakeNodeId() = this.id + 2_000_000_000
500
501 private fun SemanticsNode.roleFakeNodeId() = this.id + 1_000_000_000
502