Skip to content

Commit

Permalink
Move getAllUncoveredSemanticsNodesToIntObjectMap to common
Browse files Browse the repository at this point in the history
Move the semantic tree traversal function, including helper utils, to common code.
Introduce SemanticRegion as a wrapper around the platform's Region class.

Test: AndroidComposeViewAccessibilityDelegateCompatTest, ScrollCaptureTest
Change-Id: Ibb25e7eac48ed0b61a9318b004669f0d7d0c8b97
  • Loading branch information
ASalavei committed Jan 17, 2025
1 parent 39e3e52 commit 65008b1
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 141 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,22 @@ import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.node.LayoutNode
import androidx.compose.ui.platform.AndroidComposeView
import androidx.compose.ui.platform.SemanticsNodeCopy
import androidx.compose.ui.platform.SemanticsNodeWithAdjustedBounds
import androidx.compose.ui.platform.coreshims.ContentCaptureSessionCompat
import androidx.compose.ui.platform.coreshims.ViewCompatShims
import androidx.compose.ui.platform.coreshims.ViewStructureCompat
import androidx.compose.ui.platform.getAllUncoveredSemanticsNodesToIntObjectMap
import androidx.compose.ui.platform.getTextLayoutResult
import androidx.compose.ui.platform.toLegacyClassName
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsNodeWithAdjustedBounds
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getAllUncoveredSemanticsNodesToIntObjectMap
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastJoinToString
import androidx.compose.ui.util.fastMap
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import java.util.function.Consumer
Expand Down Expand Up @@ -109,7 +110,10 @@ internal class AndroidContentCaptureManager(
get() {
if (currentSemanticsNodesInvalidated) { // first instance of retrieving all nodes
currentSemanticsNodesInvalidated = false
field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap()
field =
view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap(
customRootNodeId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
)
currentSemanticsNodesSnapshotTimestampMillis = System.currentTimeMillis()
}
return field
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ import androidx.compose.ui.contentcapture.ContentCaptureManager
import androidx.compose.ui.focus.FocusDirection.Companion.Exit
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.toComposeRect
import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.layout.boundsInParent
import androidx.compose.ui.layout.positionInRoot
Expand All @@ -84,9 +83,13 @@ import androidx.compose.ui.semantics.SemanticsActions.PageRight
import androidx.compose.ui.semantics.SemanticsActions.PageUp
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsNodeWithAdjustedBounds
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.SemanticsPropertiesAndroid
import androidx.compose.ui.semantics.getAllUncoveredSemanticsNodesToIntObjectMap
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.semantics.isHidden
import androidx.compose.ui.semantics.isImportantForAccessibility
import androidx.compose.ui.semantics.subtreeSortedByGeometryGrouping
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.AnnotatedString
Expand All @@ -95,6 +98,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.platform.URLSpanCache
import androidx.compose.ui.text.platform.toAccessibilitySpannableString
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.toRect
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastForEach
Expand Down Expand Up @@ -310,7 +314,10 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo
get() {
if (currentSemanticsNodesInvalidated) { // first instance of retrieving all nodes
currentSemanticsNodesInvalidated = false
field = view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap()
field =
view.semanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap(
customRootNodeId = AccessibilityNodeProviderCompat.HOST_VIEW_ID
)
if (isEnabled) {
setTraversalValues(field, idToBeforeMap, idToAfterMap, view.context.resources)
}
Expand Down Expand Up @@ -402,7 +409,7 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo
// avoid overlapping siblings. Because position is a float (touch event can happen in-
// between pixels), convert the int-based Android Rect to a float-based Compose Rect
// before doing the comparison.
if (!node.adjustedBounds.toComposeRect().contains(position)) {
if (!node.adjustedBounds.toRect().contains(position)) {
return@forEachValue
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,19 @@
package androidx.compose.ui.platform

import android.annotation.SuppressLint
import android.graphics.Region
import android.view.View
import androidx.collection.IntObjectMap
import androidx.collection.MutableIntObjectMap
import androidx.collection.MutableIntSet
import androidx.collection.emptyIntObjectMap
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.node.OwnerScope
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.ScrollAxisRange
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsConfiguration
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
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.SemanticsNodeWithAdjustedBounds
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastRoundToInt
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat

/**
* A snapshot of the semantics node. The children here is fixed and are taken from the time this
Expand Down Expand Up @@ -128,130 +119,6 @@ internal fun Role.toLegacyClassName(): String? =
else -> null
}

internal fun SemanticsNode.isImportantForAccessibility() =
!isHidden &&
(unmergedConfig.isMergingSemanticsOfDescendants ||
unmergedConfig.containsImportantForAccessibility())

@Suppress("DEPRECATION")
internal val SemanticsNode.isHidden: Boolean
// A node is considered hidden if it is transparent, or explicitly is hidden from accessibility.
// This also checks if the node has been marked as `invisibleToUser`, which is what the
// `hiddenFromAccessibility` API used to be named.
get() =
isTransparent ||
(unmergedConfig.contains(HideFromAccessibility) ||
unmergedConfig.contains(InvisibleToUser))

internal val DefaultFakeNodeBounds = Rect(0f, 0f, 10f, 10f)

/** Semantics node with adjusted bounds for the uncovered(by siblings) part. */
internal class SemanticsNodeWithAdjustedBounds(
val semanticsNode: SemanticsNode,
val adjustedBounds: android.graphics.Rect
)

/** This function retrieves the View corresponding to a semanticsId, if it exists. */
internal fun AndroidViewsHandler.semanticsIdToView(id: Int): View? =
layoutNodeToHolder.entries.firstOrNull { it.key.semanticsId == id }?.value

/**
* Finds pruned [SemanticsNode]s in the tree owned by this [SemanticsOwner]. A semantics node
* completely covered by siblings drawn on top of it will be pruned. Return the results in a map.
*/
internal fun SemanticsOwner.getAllUncoveredSemanticsNodesToIntObjectMap():
IntObjectMap<SemanticsNodeWithAdjustedBounds> {
val root = unmergedRootSemanticsNode
if (!root.layoutNode.isPlaced || !root.layoutNode.isAttached) {
return emptyIntObjectMap()
}

// Default capacity chosen to accommodate common scenarios
val nodes = MutableIntObjectMap<SemanticsNodeWithAdjustedBounds>(48)

val unaccountedSpace =
with(root.boundsInRoot) {
Region(
left.fastRoundToInt(),
top.fastRoundToInt(),
right.fastRoundToInt(),
bottom.fastRoundToInt()
)
}

fun findAllSemanticNodesRecursive(currentNode: SemanticsNode, region: Region) {
val notAttachedOrPlaced =
!currentNode.layoutNode.isPlaced || !currentNode.layoutNode.isAttached
if (
(unaccountedSpace.isEmpty && currentNode.id != root.id) ||
(notAttachedOrPlaced && !currentNode.isFake)
) {
return
}
val touchBoundsInRoot = currentNode.touchBoundsInRoot
val left = touchBoundsInRoot.left.fastRoundToInt()
val top = touchBoundsInRoot.top.fastRoundToInt()
val right = touchBoundsInRoot.right.fastRoundToInt()
val bottom = touchBoundsInRoot.bottom.fastRoundToInt()

region.set(left, top, right, bottom)

val virtualViewId =
if (currentNode.id == root.id) {
AccessibilityNodeProviderCompat.HOST_VIEW_ID
} else {
currentNode.id
}
if (region.op(unaccountedSpace, Region.Op.INTERSECT)) {
nodes[virtualViewId] = SemanticsNodeWithAdjustedBounds(currentNode, region.bounds)
// Children could be drawn outside of parent, but we are using clipped bounds for
// accessibility now, so let's put the children recursion inside of this if. If later
// we decide to support children drawn outside of parent, we can move it out of the
// if block.
val children = currentNode.replacedChildren
for (i in children.size - 1 downTo 0) {
// Links in text nodes are semantics children. But for Android accessibility support
// we don't publish them to the accessibility services because they are exposed
// as UrlSpan/ClickableSpan spans instead
if (children[i].config.contains(SemanticsProperties.LinkTestMarker)) {
continue
}
findAllSemanticNodesRecursive(children[i], region)
}
if (currentNode.isImportantForAccessibility()) {
unaccountedSpace.op(left, top, right, bottom, Region.Op.DIFFERENCE)
}
} else {
if (currentNode.isFake) {
val parentNode = currentNode.parent
// use parent bounds for fake node
val boundsForFakeNode =
if (parentNode?.layoutInfo?.isPlaced == true) {
parentNode.boundsInRoot
} else {
DefaultFakeNodeBounds
}
nodes[virtualViewId] =
SemanticsNodeWithAdjustedBounds(
currentNode,
android.graphics.Rect(
boundsForFakeNode.left.fastRoundToInt(),
boundsForFakeNode.top.fastRoundToInt(),
boundsForFakeNode.right.fastRoundToInt(),
boundsForFakeNode.bottom.fastRoundToInt(),
)
)
} else if (virtualViewId == AccessibilityNodeProviderCompat.HOST_VIEW_ID) {
// Root view might have WRAP_CONTENT layout params in which case it will have zero
// bounds if there is no other content with semantics. But we need to always send
// the
// root view info as there are some other apps (e.g. Google Assistant) that depend
// on accessibility info
nodes[virtualViewId] = SemanticsNodeWithAdjustedBounds(currentNode, region.bounds)
}
}
}

findAllSemanticNodesRecursive(root, Region())
return nodes
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,13 @@ import androidx.compose.ui.internal.checkPreconditionNotNull
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.platform.isHidden
import androidx.compose.ui.semantics.SemanticsActions.ScrollByOffset
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties.Disabled
import androidx.compose.ui.semantics.SemanticsProperties.VerticalScrollAxisRange
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.semantics.isHidden
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.roundToIntRect
import java.util.function.Consumer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package androidx.compose.ui.semantics

import android.graphics.Region
import androidx.compose.ui.graphics.toComposeIntRect
import androidx.compose.ui.unit.IntRect

/** Wrapper around platform-specific [android.graphics.Region] class */
private class SemanticRegionImpl : SemanticsRegion {
val region = Region()

override fun set(rect: IntRect) {
region.set(rect.left, rect.top, rect.right, rect.bottom)
}

override val bounds: IntRect
get() = region.bounds.toComposeIntRect()

override val isEmpty: Boolean
get() = region.isEmpty

override fun intersect(region: SemanticsRegion): Boolean {
return this.region.op((region as SemanticRegionImpl).region, Region.Op.INTERSECT)
}

override fun difference(rect: IntRect): Boolean {
return region.op(rect.left, rect.top, rect.right, rect.bottom, Region.Op.DIFFERENCE)
}
}

/** Builder that creates wrapper around platform-specific Region class */
internal actual fun SemanticsRegion(): SemanticsRegion = SemanticRegionImpl()
Loading

0 comments on commit 65008b1

Please sign in to comment.