diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt new file mode 100644 index 0000000000000..08151a227c0e0 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/LayersAccessibilityTest.kt @@ -0,0 +1,179 @@ +/* + * 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.accessibility + +import androidx.compose.material.Text +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertAccessibilityTree +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import kotlin.test.Test + +class LayersAccessibilityTest { + + @Test + fun testNodesCoveredByPopup() = runUIKitInstrumentedTest { + val topPopup = mutableStateOf(false) + val bottomPopup = mutableStateOf(false) + val topPopupFocusable = mutableStateOf(false) + setContentWithAccessibilityEnabled { + Text("Root") + if (bottomPopup.value) { + Popup { + Text("Popup 1") + } + } + if (topPopup.value) { + Popup(properties = PopupProperties(focusable = topPopupFocusable.value)) { + Text("Popup 2") + } + } + } + + assertAccessibilityTree { + label = "Root" + } + + bottomPopup.value = true + // Non-focusable popup should not hide content under it for accessibility reader + assertAccessibilityTree { + node { + label = "Root" + } + node { + label = "Popup 1" + } + } + + topPopup.value = true + // Non-focusable popup should not hide content under it for accessibility reader + assertAccessibilityTree { + node { + label = "Root" + } + node { + label = "Popup 1" + } + node { + label = "Popup 2" + } + } + + topPopupFocusable.value = true + // Popup should react on focusable flag change + assertAccessibilityTree { + label = "Popup 2" + } + + topPopup.value = false + bottomPopup.value = false + assertAccessibilityTree { + label = "Root" + } + } + + @Test + fun testNodesCoveredByDialog() = runUIKitInstrumentedTest { + val showDialog = mutableStateOf(false) + setContentWithAccessibilityEnabled { + Text("Root") + Popup { + Text("Popup") + } + if (showDialog.value) { + Dialog(onDismissRequest = {}) { + Text("Dialog") + } + } + } + + assertAccessibilityTree { + node { + label = "Root" + } + node { + label = "Popup" + } + } + + showDialog.value = true + // Dialog popup should hide content under it for accessibility reader + assertAccessibilityTree { + label = "Dialog" + } + + showDialog.value = false + + assertAccessibilityTree { + node { + label = "Root" + } + node { + label = "Popup" + } + } + } + + @Test + fun testLayersAppearanceOrder() = runUIKitInstrumentedTest { + val bottomLayer = mutableStateOf(false) + val middleLayers = mutableStateOf(false) + setContentWithAccessibilityEnabled { + Text("Root") + if (bottomLayer.value) { + Popup(properties = PopupProperties(focusable = true)) { + Text("Bottom") + } + } + if (middleLayers.value) { + Popup(properties = PopupProperties(focusable = true)) { + Text("Middle 1") + } + // Non-focusable layer + Popup { + Text("Middle 2") + } + } + Popup(properties = PopupProperties(focusable = true)) { + Text("Top") + } + } + + assertAccessibilityTree { + label = "Top" + } + + bottomLayer.value = true + // The last added layer should be on top + assertAccessibilityTree { + label = "Bottom" + } + + middleLayers.value = true + // The last added layers should be on top + assertAccessibilityTree { + node { + label = "Middle 1" + } + node { + label = "Middle 2" + } + } + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt new file mode 100644 index 0000000000000..36fbb2f0252dd --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/AccessibilityTestNode.kt @@ -0,0 +1,311 @@ +/* + * 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.test + +import androidx.compose.ui.uikit.toDpRect +import androidx.compose.ui.unit.DpRect +import kotlin.test.assertEquals +import kotlinx.cinterop.ExperimentalForeignApi +import platform.UIKit.UIAccessibilityElement +import platform.UIKit.UIAccessibilityTraitAdjustable +import platform.UIKit.UIAccessibilityTraitAllowsDirectInteraction +import platform.UIKit.UIAccessibilityTraitButton +import platform.UIKit.UIAccessibilityTraitCausesPageTurn +import platform.UIKit.UIAccessibilityTraitHeader +import platform.UIKit.UIAccessibilityTraitImage +import platform.UIKit.UIAccessibilityTraitKeyboardKey +import platform.UIKit.UIAccessibilityTraitLink +import platform.UIKit.UIAccessibilityTraitNone +import platform.UIKit.UIAccessibilityTraitNotEnabled +import platform.UIKit.UIAccessibilityTraitPlaysSound +import platform.UIKit.UIAccessibilityTraitSearchField +import platform.UIKit.UIAccessibilityTraitSelected +import platform.UIKit.UIAccessibilityTraitStartsMediaSession +import platform.UIKit.UIAccessibilityTraitStaticText +import platform.UIKit.UIAccessibilityTraitSummaryElement +import platform.UIKit.UIAccessibilityTraitSupportsZoom +import platform.UIKit.UIAccessibilityTraitTabBar +import platform.UIKit.UIAccessibilityTraitToggleButton +import platform.UIKit.UIAccessibilityTraitUpdatesFrequently +import platform.UIKit.UIAccessibilityTraits +import platform.UIKit.UICollectionView +import platform.UIKit.UITableView +import platform.UIKit.UIView +import platform.UIKit.accessibilityCustomActions +import platform.UIKit.accessibilityElementAtIndex +import platform.UIKit.accessibilityElementCount +import platform.UIKit.accessibilityElements +import platform.UIKit.accessibilityFrame +import platform.UIKit.accessibilityLabel +import platform.UIKit.accessibilityTraits +import platform.UIKit.accessibilityValue +import platform.UIKit.isAccessibilityElement +import platform.darwin.NSIntegerMax +import platform.darwin.NSObject + +/** + * Constructs an accessibility tree representation of the UI hierarchy starting from the window. + * + * This function traverses the accessibility elements and their children to build a structured + * node tree with information about accessibility properties, allowing for analysis and testing + * of the accessibility features of the UI. + * + * @return The root node of the accessibility tree representing the current UI hierarchy, + * or null if the tree cannot be constructed. + */ +@OptIn(ExperimentalForeignApi::class) +internal fun UIKitInstrumentedTest.getAccessibilityTree(): AccessibilityTestNode { + fun buildNode(element: NSObject, level: Int): AccessibilityTestNode { + val children = mutableListOf() + val elements = element.accessibilityElements() + + if (elements != null) { + elements.forEach { + children.add(buildNode(it as NSObject, level = level + 1)) + } + } else { + val count = element.accessibilityElementCount() + if (count == NSIntegerMax) { + when (element) { + is UITableView -> { + TODO("Unused in tests. Implement correct table view traversal.") + } + + is UICollectionView -> { + TODO("Unused in tests. Implement correct collection view traversal.") + } + + else -> { + error("Unsupported element: $element of type ${element::class}") + } + } + } else if (count > 0) { + (0 until count).mapNotNull { + val child = element.accessibilityElementAtIndex(it) as NSObject + children.add(buildNode(child, level = level + 1)) + } + } else if (element is UIView) { + element.subviews.mapNotNull { + children.add(buildNode(it as UIView, level = level + 1)) + } + } + } + + return AccessibilityTestNode( + isAccessibilityElement = element.isAccessibilityElement, + identifier = (element as? UIAccessibilityElement)?.accessibilityIdentifier, + label = element.accessibilityLabel, + value = element.accessibilityValue, + frame = element.accessibilityFrame.toDpRect(), + children = children, + traits = allAccessibilityTraits.keys.filter { + element.accessibilityTraits and it != 0.toULong() + }, + element = element + ) + } + + return buildNode(window, 0) +} + +private val allAccessibilityTraits = mapOf( + UIAccessibilityTraitNone to "UIAccessibilityTraitNone", + UIAccessibilityTraitButton to "UIAccessibilityTraitButton", + UIAccessibilityTraitLink to "UIAccessibilityTraitLink", + UIAccessibilityTraitHeader to "UIAccessibilityTraitHeader", + UIAccessibilityTraitSearchField to "UIAccessibilityTraitSearchField", + UIAccessibilityTraitImage to "UIAccessibilityTraitImage", + UIAccessibilityTraitSelected to "UIAccessibilityTraitSelected", + UIAccessibilityTraitPlaysSound to "UIAccessibilityTraitPlaysSound", + UIAccessibilityTraitKeyboardKey to "UIAccessibilityTraitKeyboardKey", + UIAccessibilityTraitStaticText to "UIAccessibilityTraitStaticText", + UIAccessibilityTraitSummaryElement to "UIAccessibilityTraitSummaryElement", + UIAccessibilityTraitNotEnabled to "UIAccessibilityTraitNotEnabled", + UIAccessibilityTraitUpdatesFrequently to "UIAccessibilityTraitUpdatesFrequently", + UIAccessibilityTraitStartsMediaSession to "UIAccessibilityTraitStartsMediaSession", + UIAccessibilityTraitAdjustable to "UIAccessibilityTraitAdjustable", + UIAccessibilityTraitAllowsDirectInteraction to "UIAccessibilityTraitAllowsDirectInteraction", + UIAccessibilityTraitCausesPageTurn to "UIAccessibilityTraitCausesPageTurn", + UIAccessibilityTraitTabBar to "UIAccessibilityTraitTabBar", + UIAccessibilityTraitToggleButton to "UIAccessibilityTraitToggleButton", + UIAccessibilityTraitSupportsZoom to "UIAccessibilityTraitSupportsZoom" +) + +/** + * Represents a node in an accessibility tree, which is used for testing accessibility features + * within a UI hierarchy. This class captures various accessibility properties of UI components + * and structures them into a tree. + */ +data class AccessibilityTestNode( + var isAccessibilityElement: Boolean? = null, + var identifier: String? = null, + var label: String? = null, + var value: String? = null, + var frame: DpRect? = null, + var children: List? = null, + var traits: List? = null, + var element: NSObject? = null +) { + fun node(builder: AccessibilityTestNode.() -> Unit) { + children = (children ?: emptyList()) + AccessibilityTestNode().apply(builder) + } + + fun traits(vararg trait: UIAccessibilityTraits) { + traits = (traits ?: emptyList()) + trait + } + + fun validate(actualNode: AccessibilityTestNode?) { + isAccessibilityElement?.let { + assertEquals(it, actualNode?.isAccessibilityElement) + } + identifier?.let { + assertEquals(it, actualNode?.identifier) + } + label?.let { + assertEquals(it, actualNode?.label) + } + value?.let { + assertEquals(it, actualNode?.value) + } + frame?.let { + assertEquals(it, actualNode?.frame) + } + traits?.let { + assertEquals(it.toSet(), actualNode?.traits?.toSet()) + } + children?.let { + assertEquals(it.count(), actualNode?.children?.count()) + it.zip(actualNode?.children ?: emptyList()) { validator, child -> + validator.validate(child) + } + } + } + + val hasAccessibilityComponents: Boolean = identifier != null || + isAccessibilityElement == true || + label != null || + value != null || + traits?.isNotEmpty() == true + + fun printTree(): String { + val builder = StringBuilder() + + fun print(node: AccessibilityTestNode, level: Int) { + val indent = " ".repeat(level) + builder.append(indent) + builder.append(node.label ?: node.identifier ?: "other") + builder.append(" - ${node.frame}") + node.element?.let { + builder.append(" - <${it::class}>") + } + builder.appendLine() + + val fieldIndent = "$indent |" + if (node.isAccessibilityElement == true) { + builder.appendLine("$fieldIndent isAccessibilityElement: true") + } + node.identifier?.let { + builder.appendLine("$fieldIndent accessibilityIdentifier: $it") + } + node.label?.let { builder.appendLine("$fieldIndent accessibilityLabel: $it") } + if (node.traits?.isNotEmpty() == true) { + builder.appendLine("$fieldIndent accessibilityTraits:") + node.traits?.forEach { + builder.appendLine("$fieldIndent - ${allAccessibilityTraits.getValue(it)}") + } + } + node.value?.let { builder.appendLine("$fieldIndent accessibilityValue: $it") } + node.element?.accessibilityCustomActions?.takeIf { it.isNotEmpty() }?.let { + builder.appendLine("$fieldIndent accessibilityCustomActions: $it") + } + + node.children?.forEach { print(it, level + 1) } + } + print(this, level = 0) + + return builder.toString() + } +} + +/** + * Normalizes the accessibility nodes tree by analyzing its properties and children. + * Removes all element that are not accessibility elements or does not work as elements containers. + */ +internal fun AccessibilityTestNode.normalized(): AccessibilityTestNode? { + val normalizedChildren = children?.flatMap { + it.normalized()?.let { + if (it.hasAccessibilityComponents) { + listOf(it) + } else { + it.children + } + } ?: emptyList() + } ?: emptyList() + + return if (hasAccessibilityComponents || normalizedChildren.count() > 1) { + this.copy(children = normalizedChildren) + } else if (normalizedChildren.count() == 1) { + normalizedChildren.single() + } else { + null + } +} + +/** + * Asserts that the current accessibility tree matches the expected structure defined in the + * provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`, + * which is then validated against the actual normalized accessibility tree. This function waits + * for the UI to be idle before performing the validation. + * + * @param expected A lambda that allows the caller to specify the expected structure and properties + * of the accessibility tree. + */ +internal fun UIKitInstrumentedTest.assertAccessibilityTree( + expected: AccessibilityTestNode.() -> Unit +) { + val validator = AccessibilityTestNode() + with(validator, expected) + assertAccessibilityTree(validator) +} + +/** + * Asserts that the current accessibility tree matches the expected structure defined in the + * provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`, + * which is then validated against the actual normalized accessibility tree. This function waits + * for the UI to be idle before performing the validation. + * + * @param expected The expected accessibility tree structure represented by an instance of + * `AccessibilityTestNode`. + */ +internal fun UIKitInstrumentedTest.assertAccessibilityTree(expected: AccessibilityTestNode) { + waitForIdle() + + val actualTreeRoot = getAccessibilityTree() + val normalizedTree = actualTreeRoot.normalized() + + try { + expected.validate(normalizedTree) + } catch (e: Throwable) { + val message = "Unable to validate accessibility tree. Expected normalized tree:\n\n" + + "${expected.printTree()}\n" + + "Normalized tree:\n\n${normalizedTree?.printTree()}\n" + + "Actual tree:\n\n${actualTreeRoot.printTree()}\n" + println(message) + + throw e + } +} diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt index 2c81f2056f643..23c48fbdd2d4d 100644 --- a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/test/UIKitInstrumentedTest.kt @@ -17,7 +17,9 @@ package androidx.compose.ui.test import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.platform.AccessibilitySyncOptions import androidx.compose.ui.platform.InfiniteAnimationPolicy import androidx.compose.ui.scene.ComposeHostingViewController import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration @@ -94,6 +96,11 @@ internal class UIKitInstrumentedTest { private val coroutineContext = Dispatchers.Main + infiniteAnimationPolicy + @OptIn(ExperimentalComposeApi::class) + fun setContentWithAccessibilityEnabled(content: @Composable () -> Unit) { + setContent({ accessibilitySyncOptions = AccessibilitySyncOptions.Always }, content) + } + fun setContent( configure: ComposeUIViewControllerConfiguration.() -> Unit = {}, content: @Composable () -> Unit 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 5caa6cc842aa2..340b77af03b04 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 @@ -17,6 +17,7 @@ package androidx.compose.ui.platform import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.node.LayoutNode @@ -29,6 +30,7 @@ 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.SemanticsPropertyKey import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.state.ToggleableState @@ -43,9 +45,9 @@ import kotlin.time.measureTime import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass -import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.readValue import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel @@ -55,8 +57,6 @@ import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero import platform.Foundation.NSNotFound -import platform.Foundation.NSNotificationCenter -import platform.Foundation.NSSelectorFromString import platform.UIKit.NSStringFromCGRect import platform.UIKit.UIAccessibilityCustomAction import platform.UIKit.UIAccessibilityFocusedElement @@ -79,8 +79,6 @@ import platform.UIKit.UIAccessibilityTraitNotEnabled import platform.UIKit.UIAccessibilityTraitSelected import platform.UIKit.UIAccessibilityTraitUpdatesFrequently import platform.UIKit.UIAccessibilityTraits -import platform.UIKit.UIAccessibilityVoiceOverStatusChanged -import platform.UIKit.UIAccessibilityVoiceOverStatusDidChangeNotification import platform.UIKit.UIView import platform.UIKit.UIWindow import platform.UIKit.accessibilityCustomActions @@ -111,7 +109,7 @@ private class CachedAccessibilityPropertyKey private object CachedAccessibilityPropertyKeys { val accessibilityLabel = CachedAccessibilityPropertyKey() val isAccessibilityElement = CachedAccessibilityPropertyKey() - val accessibilityIdentifier = CachedAccessibilityPropertyKey() + val accessibilityIdentifier = CachedAccessibilityPropertyKey() val accessibilityHint = CachedAccessibilityPropertyKey() val accessibilityCustomActions = CachedAccessibilityPropertyKey>() val accessibilityTraits = CachedAccessibilityPropertyKey() @@ -597,30 +595,12 @@ private class AccessibilityElement( override fun isAccessibilityElement(): Boolean = getOrElse(CachedAccessibilityPropertyKeys.isAccessibilityElement) { - val config = cachedConfig - - if (config.contains(SemanticsProperties.InvisibleToUser) || - config.contains(HideFromAccessibility) - ) { - false - } else { - // TODO: investigate if it can it be one of those _and_ contain properties that should - // be communicated to iOS? - if (config.getOrNull(SemanticsProperties.IsTraversalGroup) == true - || config.contains(SemanticsProperties.IsPopup) - || config.contains(SemanticsProperties.IsDialog) - ) { - false - } else { - config.containsImportantForAccessibility() - } - } + semanticsNode.isAccessibilityElement } - override fun accessibilityIdentifier(): String = + override fun accessibilityIdentifier(): String? = getOrElse(CachedAccessibilityPropertyKeys.accessibilityIdentifier) { cachedConfig.getOrNull(SemanticsProperties.TestTag) - ?: "AccessibilityElement for SemanticsNode(id=$semanticsNodeId)" } override fun accessibilityHint(): String? = @@ -1029,7 +1009,7 @@ private val accessibilityDebugLogger: AccessibilityDebugLogger? = null // } @OptIn(ExperimentalComposeApi::class) -private val AccessibilitySyncOptions.shouldPerformSync +internal val AccessibilitySyncOptions.isGlobalAccessibilityEnabled get() = when (this) { AccessibilitySyncOptions.Never -> false @@ -1040,13 +1020,10 @@ private val AccessibilitySyncOptions.shouldPerformSync /** * A class responsible for mediating between the tree of specific SemanticsOwner and the iOS accessibility tree. */ -@OptIn(ExperimentalComposeApi::class) internal class AccessibilityMediator( val view: UIView, val owner: SemanticsOwner, coroutineContext: CoroutineContext, - private val getAccessibilitySyncOptions: () -> AccessibilitySyncOptions, - /** * A function that converts the given [Rect] from the semantics tree coordinate space (window container for layers) * to the [CGRect] in coordinate space of the app window. @@ -1065,8 +1042,6 @@ internal class AccessibilityMediator( private val needsRedundantRefocusingOnSameElement: Boolean get() = inflightScrollsCount > 0 - private val notificationCenter = NSNotificationCenter.defaultCenter - /** * The kind of invalidation that determines what kind of logic will be executed in the next sync. * `COMPLETE` invalidation means that the whole tree should be recomputed, `BOUNDS` means that only @@ -1108,16 +1083,18 @@ internal class AccessibilityMediator( */ private val accessibilityElementsMap = mutableMapOf() + var isEnabled: Boolean = false + set(value) { + if (field != value) { + field = value + onSemanticsChange() + } + } + init { accessibilityDebugLogger?.log("AccessibilityMediator for $view created") - notificationCenter.addObserver( - observer = this, - selector = NSSelectorFromString(::voiceOverStatusDidChange.name), - name = UIAccessibilityVoiceOverStatusDidChangeNotification, - `object` = null - ) - + view.accessibilityElements = listOf() coroutineScope.launch { // The main loop that listens for invalidations and performs the tree syncing // Will exit on CancellationException from within await on `invalidationChannel.receive()` @@ -1130,13 +1107,9 @@ internal class AccessibilityMediator( // Workaround for the channel buffering two invalidations despite the capacity of 1 } - val syncOptions = getAccessibilitySyncOptions() - - val shouldPerformSync = syncOptions.shouldPerformSync - - debugLogger = accessibilityDebugLogger.takeIf { shouldPerformSync } + debugLogger = accessibilityDebugLogger.takeIf { isEnabled } - if (shouldPerformSync) { + if (isEnabled) { var result: NodesSyncResult val time = measureTime { @@ -1147,6 +1120,11 @@ internal class AccessibilityMediator( debugLogger?.log("LayoutChanged, newElementToFocus: ${result.newElementToFocus}") UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, result.newElementToFocus) + } else { + if (view.accessibilityElements?.isEmpty() != true) { + view.accessibilityElements = listOf() + UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, null) + } } invalidationKind = SemanticsTreeInvalidationKind.BOUNDS @@ -1155,12 +1133,8 @@ internal class AccessibilityMediator( } } - @OptIn(BetaInteropApi::class) - @ObjCAction - private fun voiceOverStatusDidChange() { - invalidationKind = SemanticsTreeInvalidationKind.COMPLETE - invalidationChannel.trySend(Unit) - } + @OptIn(ExperimentalCoroutinesApi::class) + val hasPendingInvalidations: Boolean get() = !invalidationChannel.isEmpty fun convertToAppWindowCGRect(rect: Rect): CValue { val window = view.window ?: return CGRectZero.readValue() @@ -1228,17 +1202,11 @@ internal class AccessibilityMediator( job.cancel() isAlive = false - view.accessibilityElements = null + view.accessibilityElements = listOf() for (element in accessibilityElementsMap.values) { element.dispose() } - - notificationCenter.removeObserver( - observer = this, - name = UIAccessibilityVoiceOverStatusChanged, - `object` = null - ) } private fun createOrUpdateAccessibilityElementForSemanticsNode(node: SemanticsNode): AccessibilityElement { @@ -1511,6 +1479,39 @@ private val SemanticsNode.isValid: Boolean private val SemanticsNode.isRTL: Boolean get() = layoutInfo.layoutDirection == LayoutDirection.Rtl +private val SemanticsNode.isAccessibilityElement: Boolean get() { + val config = this.config + + @Suppress("DEPRECATION") + return if (isHidden) { + false + } else { + if (config.getOrNull(SemanticsProperties.IsTraversalGroup) == true + || config.contains(SemanticsProperties.IsPopup) + || config.contains(SemanticsProperties.IsDialog) + ) { + false + } else if (config.getOrNull(SemanticsProperties.IsContainer) == true && + config.props.size == 1 + ) { + false + } else { + config.containsImportantForAccessibility() + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@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)) + /** * Closest ancestor that has [SemanticsActions.ScrollBy] action */ diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt index 816f53e5d1b88..ac7f211d32deb 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeHostingViewController.uikit.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalInternalViewModelStoreOwner import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext +import androidx.compose.ui.platform.isGlobalAccessibilityEnabled import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration import androidx.compose.ui.uikit.InterfaceOrientation import androidx.compose.ui.uikit.LocalInterfaceOrientation @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.asDpRect import androidx.compose.ui.unit.roundToIntRect +import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.viewinterop.UIKitInteropAction import androidx.compose.ui.viewinterop.UIKitInteropTransaction import androidx.compose.ui.window.ComposeView @@ -58,6 +60,7 @@ import kotlin.time.toDuration import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue import kotlinx.cinterop.ExportObjCClass +import kotlinx.cinterop.ObjCAction import kotlinx.cinterop.useContents import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -66,6 +69,10 @@ import org.jetbrains.skiko.OS import org.jetbrains.skiko.OSVersion import org.jetbrains.skiko.available import platform.CoreGraphics.CGSize +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSSelectorFromString +import platform.UIKit.UIAccessibilityVoiceOverStatusChanged +import platform.UIKit.UIAccessibilityVoiceOverStatusDidChangeNotification import platform.UIKit.UIApplication import platform.UIKit.UIEvent import platform.UIKit.UIStatusBarAnimation @@ -179,6 +186,13 @@ internal class ComposeHostingViewController( configuration.delegate.viewDidLoad() systemThemeState.value = traitCollection.userInterfaceStyle.asComposeSystemTheme() + + NSNotificationCenter.defaultCenter.addObserver( + observer = this, + selector = NSSelectorFromString(::onAccessibilityChanged.name), + name = UIAccessibilityVoiceOverStatusDidChangeNotification, + `object` = null + ) } override fun viewDidLayoutSubviews() { @@ -330,7 +344,8 @@ internal class ComposeHostingViewController( onGestureEvent = layers::onGestureEvent, initDensity = density, initLayoutDirection = layoutDirection, - configuration = configuration, + onFocusBehavior = configuration.onFocusBehavior, + onAccessibilityChanged = ::onAccessibilityChanged, focusStack = if (focusable) focusStack else null, windowContext = windowContext, compositionContext = compositionContext, @@ -360,12 +375,13 @@ internal class ComposeHostingViewController( private fun createMediatorIfNeeded() { if (mediator == null) { mediator = createMediator() + onAccessibilityChanged() } } private fun createMediator() = ComposeSceneMediator( parentView = rootView, - configuration = configuration, + onFocusBehavior = configuration.onFocusBehavior, focusStack = focusStack, windowContext = windowContext, coroutineContext = coroutineContext, @@ -381,6 +397,23 @@ internal class ComposeHostingViewController( rootView.bringSubviewToFront(rootMetalView) } + /** + * Enables or disables accessibility for each layer, as well as the root mediator, taking into + * account layer order and ability to overlay underlying content. + */ + @ObjCAction + private fun onAccessibilityChanged() { + var isAccessibilityEnabled = + configuration.accessibilitySyncOptions.isGlobalAccessibilityEnabled + layers.withLayers { + it.fastForEachReversed { layer -> + layer.isAccessibilityEnabled = isAccessibilityEnabled + isAccessibilityEnabled = isAccessibilityEnabled && !layer.focusable + } + } + mediator?.isAccessibilityEnabled = isAccessibilityEnabled + } + /** * When there is an ongoing gesture, we need notify redrawer about it. It should unconditionally * unpause CADisplayLink which affects frequency of polling UITouch events on high frequency @@ -403,6 +436,12 @@ internal class ComposeHostingViewController( mediator = null layers.dispose(hasViewAppeared) + + NSNotificationCenter.defaultCenter.removeObserver( + observer = this, + name = UIAccessibilityVoiceOverStatusChanged, + `object` = null + ) } private fun attachLayer(layer: UIKitComposeSceneLayer) { @@ -411,10 +450,12 @@ internal class ComposeHostingViewController( } layers.attach(window, layer, hasViewAppeared) + onAccessibilityChanged() } private fun detachLayer(layer: UIKitComposeSceneLayer) { layers.detach(layer, hasViewAppeared) + onAccessibilityChanged() } @Composable diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt index d7c99617c98b3..8f5e4155225d4 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/ComposeSceneMediator.uikit.kt @@ -19,7 +19,6 @@ package androidx.compose.ui.scene import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.ExperimentalComposeApi import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -42,7 +41,6 @@ import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.layout.OffsetToFocusedRect import androidx.compose.ui.platform.AccessibilityMediator -import androidx.compose.ui.platform.AccessibilitySyncOptions import androidx.compose.ui.platform.CUPERTINO_TOUCH_SLOP import androidx.compose.ui.platform.DefaultInputModeManager import androidx.compose.ui.platform.EmptyViewConfiguration @@ -59,7 +57,6 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.platform.lerp import androidx.compose.ui.semantics.SemanticsOwner import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration import androidx.compose.ui.uikit.LocalKeyboardOverlapHeight import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density @@ -112,60 +109,67 @@ import platform.UIKit.UIWindow * * @property rootView The UI container associated with the semantics owner. * @property coroutineContext The coroutine context to use for handling semantics changes. - * @property getAccessibilitySyncOptions A lambda function to retrieve the latest accessibility synchronization options. * @property performEscape A lambda to delegate accessibility escape operation. Returns true if the escape was handled, false otherwise. */ -@OptIn(ExperimentalComposeApi::class) private class SemanticsOwnerListenerImpl( private val rootView: UIView, private val coroutineContext: CoroutineContext, - private val getAccessibilitySyncOptions: () -> AccessibilitySyncOptions, private val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue, private val performEscape: () -> Boolean ) : PlatformContext.SemanticsOwnerListener { - private var mediator: AccessibilityMediator? = null + + private var accessibilityMediator: AccessibilityMediator? = null + + var isEnabled: Boolean = false + set(value) { + field = value + accessibilityMediator?.isEnabled = value + } override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) { - if (mediator == null) { - mediator = AccessibilityMediator( + if (accessibilityMediator == null) { + accessibilityMediator = AccessibilityMediator( rootView, semanticsOwner, coroutineContext, - getAccessibilitySyncOptions, convertToAppWindowCGRect, performEscape - ) + ).also { + it.isEnabled = isEnabled + } } } override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) { - if (mediator?.owner == semanticsOwner) { - mediator?.dispose() - mediator = null + if (accessibilityMediator?.owner == semanticsOwner) { + accessibilityMediator?.dispose() + accessibilityMediator = null } } override fun onSemanticsChange(semanticsOwner: SemanticsOwner) { - if (mediator?.owner == semanticsOwner) { - mediator?.onSemanticsChange() + if (accessibilityMediator?.owner == semanticsOwner) { + accessibilityMediator?.onSemanticsChange() } } override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) { - if (mediator?.owner == semanticsOwner) { - mediator?.onLayoutChange(nodeId = semanticsNodeId) + if (accessibilityMediator?.owner == semanticsOwner) { + accessibilityMediator?.onLayoutChange(nodeId = semanticsNodeId) } } + val hasInvalidations: Boolean get() = accessibilityMediator?.hasPendingInvalidations ?: false + fun dispose() { - mediator?.dispose() - mediator = null + accessibilityMediator?.dispose() + accessibilityMediator = null } } internal class ComposeSceneMediator( parentView: UIView, - private val configuration: ComposeUIViewControllerConfiguration, + private val onFocusBehavior: OnFocusBehavior, private val focusStack: FocusStack?, private val windowContext: PlatformWindowContext, private val coroutineContext: CoroutineContext, @@ -270,14 +274,10 @@ internal class ComposeSceneMediator( private fun isPointInsideInteractionBounds(point: CValue) = interactionBounds.contains(point.asDpOffset().toOffset(view.density).round()) - @OptIn(ExperimentalComposeApi::class) private val semanticsOwnerListener by lazy { SemanticsOwnerListenerImpl( rootView = view, coroutineContext = coroutineContext, - getAccessibilitySyncOptions = { - configuration.accessibilitySyncOptions - }, convertToAppWindowCGRect = { rect, window -> windowContext.convertWindowRect(rect, window) .toDpRect(Density(window.screen.scale.toFloat())) @@ -292,6 +292,8 @@ internal class ComposeSceneMediator( ) } + var isAccessibilityEnabled by semanticsOwnerListener::isEnabled + private val keyboardManager by lazy { ComposeSceneKeyboardOffsetManager( view = view, @@ -322,8 +324,11 @@ internal class ComposeSceneMediator( } } - val hasInvalidations: Boolean get() = - scene.hasInvalidations() || keyboardManager.isAnimating || isLayoutTransitionAnimating + val hasInvalidations: Boolean + get() = scene.hasInvalidations() || + keyboardManager.isAnimating || + isLayoutTransitionAnimating || + semanticsOwnerListener.hasInvalidations private fun hitTestInteropView(point: CValue, event: UIEvent?): UIView? = point.useContents { @@ -472,7 +477,7 @@ internal class ComposeSceneMediator( @Composable private fun FocusAboveKeyboardIfNeeded(content: @Composable () -> Unit) { - if (configuration.onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { + if (onFocusBehavior == OnFocusBehavior.FocusableAboveKeyboard) { OffsetToFocusedRect( insets = PlatformInsets(bottom = keyboardOverlapHeight), getFocusedRect = ::getFocusedRect, diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt index 3d0922effcae2..65b4812f43601 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayer.uikit.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.platform.PlatformContext import androidx.compose.ui.platform.PlatformWindowContext -import androidx.compose.ui.uikit.ComposeUIViewControllerConfiguration +import androidx.compose.ui.uikit.OnFocusBehavior import androidx.compose.ui.uikit.density import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset @@ -51,13 +51,20 @@ internal class UIKitComposeSceneLayer( onGestureEvent: (GestureEvent) -> Unit, private val initDensity: Density, private val initLayoutDirection: LayoutDirection, - configuration: ComposeUIViewControllerConfiguration, + private val onAccessibilityChanged: () -> Unit, + onFocusBehavior: OnFocusBehavior, focusStack: FocusStack?, windowContext: PlatformWindowContext, compositionContext: CompositionContext, ) : ComposeSceneLayer { override var focusable: Boolean = focusStack != null + set(value) { + if (field != value) { + field = value + onAccessibilityChanged() + } + } val view = UIKitComposeSceneLayerView( ::isInsideInteractionBounds, @@ -65,12 +72,12 @@ internal class UIKitComposeSceneLayer( ) private val mediator = ComposeSceneMediator( - view, - configuration, - focusStack, - windowContext, + parentView = view, + onFocusBehavior = onFocusBehavior, + focusStack = focusStack, + windowContext = windowContext, coroutineContext = compositionContext.effectCoroutineContext, - metalView.redrawer, + redrawer = metalView.redrawer, onGestureEvent = onGestureEvent, composeSceneFactory = ::createComposeScene ) @@ -93,6 +100,8 @@ internal class UIKitComposeSceneLayer( val hasInvalidations by mediator::hasInvalidations + var isAccessibilityEnabled by mediator::isAccessibilityEnabled + override var density by mediator::density override var layoutDirection by mediator::layoutDirection diff --git a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt index bddbad8e93baa..50ea2f6c90e19 100644 --- a/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt +++ b/compose/ui/ui/src/uikitMain/kotlin/androidx/compose/ui/scene/UIKitComposeSceneLayersHolder.uikit.kt @@ -40,12 +40,16 @@ internal class UIKitComposeSceneLayersHolder( useSeparateRenderThreadWhenPossible: Boolean ) { val hasInvalidations: Boolean - get() = layers.any { it.hasInvalidations } + get() = this.layers.any { it.hasInvalidations } private val layers = mutableListOf() + private val layersCache = CopiedList { - it.addAll(layers) + it.addAll(this.layers) } + + fun withLayers(block: (List) -> Unit) = layersCache.withCopy(block) + private var ongoingGesturesCount = 0 /** @@ -70,12 +74,12 @@ internal class UIKitComposeSceneLayersHolder( ) fun animateSizeTransition(scope: CoroutineScope, duration: Duration) { - if (layers.isEmpty()) { + if (this.layers.isEmpty()) { return } val animations = listOf( windowContext.prepareAndGetSizeTransitionAnimation() - ) + layers.map { + ) + this.layers.map { it.prepareAndGetSizeTransitionAnimation() } @@ -119,8 +123,8 @@ internal class UIKitComposeSceneLayersHolder( // `dispose` is called instead of `close`, because `close` is also used imperatively // to remove the layer from the array based on user interaction. - while (layers.isNotEmpty()) { - val layer = layers.removeLast() + while (this.layers.isNotEmpty()) { + val layer = this.layers.removeLast() if (hasViewAppeared) { layer.sceneWillDisappear() @@ -134,9 +138,9 @@ internal class UIKitComposeSceneLayersHolder( } fun attach(window: UIWindow, layer: UIKitComposeSceneLayer, hasViewAppeared: Boolean) { - val isFirstLayer = layers.isEmpty() + val isFirstLayer = this.layers.isEmpty() - layers.add(layer) + this.layers.add(layer) view.embedSubview(layer.view) view.bringSubviewToFront(metalView) @@ -161,12 +165,12 @@ internal class UIKitComposeSceneLayersHolder( layer.sceneWillDisappear() } - layers.remove(layer) + this.layers.remove(layer) // Intercept the actions UIKitInteropTransaction from the layer val transaction = layer.retrieveInteropTransaction() - if (layers.isEmpty()) { + if (this.layers.isEmpty()) { // It was the last layer, remove the view and executed the actions immediately view.removeFromSuperview() @@ -182,13 +186,13 @@ internal class UIKitComposeSceneLayersHolder( } fun viewDidAppear() { - layers.fastForEach { + this.layers.fastForEach { it.sceneDidAppear() } } fun viewWillDisappear() { - layers.fastForEach { + this.layers.fastForEach { it.sceneWillDisappear() } } @@ -202,7 +206,7 @@ internal class UIKitComposeSceneLayersHolder( val removedLayersTransactionsCopy = removedLayersTransactions.toList() removedLayersTransactions.clear() - val transactions = layers.map { + val transactions = this.layers.map { it.retrieveInteropTransaction() } + removedLayersTransactionsCopy return UIKitInteropTransaction.merge(