diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.h b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.h index 19de5fdc99ee3..f1f5490ef41b7 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.h +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.h @@ -41,12 +41,18 @@ NS_ASSUME_NONNULL_BEGIN - (NSString *__nullable)accessibilityValue CMP_ABSTRACT_FUNCTION; +- (__nullable id)accessibilityTextInputResponder CMP_ABSTRACT_FUNCTION; + - (CGRect)accessibilityFrame CMP_ABSTRACT_FUNCTION; - (BOOL)isAccessibilityElement CMP_ABSTRACT_FUNCTION; - (BOOL)accessibilityActivate CMP_ABSTRACT_FUNCTION; +- (void)accessibilityIncrement CMP_ABSTRACT_FUNCTION; + +- (void)accessibilityDecrement CMP_ABSTRACT_FUNCTION; + // Private SDK method. Calls when the item is swipe-to-focused in VoiceOver. - (BOOL)accessibilityScrollToVisible; diff --git a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.m b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.m index 7798a5f7973c7..18765181a3f85 100644 --- a/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.m +++ b/compose/ui/ui-uikit/src/uikitMain/objc/CMPUIKitUtils/CMPUIKitUtils/CMPAccessibilityElement.m @@ -80,6 +80,10 @@ - (NSString *__nullable)accessibilityLabel { CMP_ABSTRACT_FUNCTION_CALLED } +- (__nullable id)accessibilityTextInputResponder { + CMP_ABSTRACT_FUNCTION_CALLED +} + - (NSString *__nullable)accessibilityValue { CMP_ABSTRACT_FUNCTION_CALLED } @@ -96,6 +100,14 @@ - (BOOL)accessibilityActivate { CMP_ABSTRACT_FUNCTION_CALLED } +- (void)accessibilityIncrement { + CMP_ABSTRACT_FUNCTION_CALLED +} + +- (void)accessibilityDecrement { + CMP_ABSTRACT_FUNCTION_CALLED +} + - (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction { CMP_ABSTRACT_FUNCTION_CALLED } diff --git a/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt new file mode 100644 index 0000000000000..9fac53df0a492 --- /dev/null +++ b/compose/ui/ui/src/uikitInstrumentedTest/kotlin/androidx/compose/ui/accessibility/ComponentsAccessibilitySemanticTest.kt @@ -0,0 +1,604 @@ +/* + * 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.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Button +import androidx.compose.material.Checkbox +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.RadioButton +import androidx.compose.material.RangeSlider +import androidx.compose.material.Scaffold +import androidx.compose.material.Slider +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.assertAccessibilityTree +import androidx.compose.ui.test.findNode +import androidx.compose.ui.test.firstAccessibleNode +import androidx.compose.ui.test.runUIKitInstrumentedTest +import androidx.compose.ui.text.buildAnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available +import platform.UIKit.UIAccessibilityTraitAdjustable +import platform.UIKit.UIAccessibilityTraitButton +import platform.UIKit.UIAccessibilityTraitHeader +import platform.UIKit.UIAccessibilityTraitImage +import platform.UIKit.UIAccessibilityTraitNotEnabled +import platform.UIKit.UIAccessibilityTraitSelected +import platform.UIKit.UIAccessibilityTraitStaticText +import platform.UIKit.UIAccessibilityTraitToggleButton +import platform.UIKit.accessibilityActivate +import platform.UIKit.accessibilityDecrement +import platform.UIKit.accessibilityIncrement + +class ComponentsAccessibilitySemanticTest { + + @Test + fun testButtonNodeActionAndSemantic() = runUIKitInstrumentedTest { + var tapped = false + setContentWithAccessibilityEnabled { + Button({ tapped = true }) { + Text("Content") + } + Button({ }) {} + } + + assertAccessibilityTree { + node { + isAccessibilityElement = true + label = "Content" + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + } + + val node = firstAccessibleNode() + node.element?.accessibilityActivate() + assertTrue(tapped) + } + + @OptIn(ExperimentalMaterialApi::class) + @Test + fun testProgressNodesSemantic() = runUIKitInstrumentedTest { + var sliderValue = 0.4f + setContentWithAccessibilityEnabled { + Column { + Slider( + value = sliderValue, + onValueChange = { sliderValue = it } + ) + LinearProgressIndicator(progress = 0.7f) + RangeSlider( + value = 30f..70f, + onValueChange = {}, + valueRange = 0f..100f + ) + } + } + + assertAccessibilityTree { + // Slider + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitAdjustable) + value = "40%" + } + + // LinearProgressIndicator + node { + isAccessibilityElement = true + value = "70%" + traits() + } + + // Range Slider + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitAdjustable) + value = "43%" + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitAdjustable) + value = "57%" + } + } + } + + @Test + fun testSliderAction() = runUIKitInstrumentedTest { + var sliderValue = 0.4f + setContentWithAccessibilityEnabled { + Slider( + value = sliderValue, + onValueChange = { sliderValue = it }, + modifier = Modifier.testTag("Slider") + ) + } + + var oldValue = sliderValue + val sliderNode = findNode("Slider") + sliderNode.element?.accessibilityIncrement() + assertTrue(oldValue < sliderValue) + + oldValue = sliderValue + sliderNode.element?.accessibilityDecrement() + assertTrue(oldValue > sliderValue) + } + + @Test + fun testToggleAndCheckboxSemantic() = runUIKitInstrumentedTest { + setContentWithAccessibilityEnabled { + Column { + Switch(false, {}) + Checkbox(false, {}) + TriStateCheckbox(ToggleableState.On, {}) + TriStateCheckbox(ToggleableState.Off, {}) + TriStateCheckbox(ToggleableState.Indeterminate, {}) + } + } + + assertAccessibilityTree { + // Switch + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + if (available(OS.Ios to OSVersion(major = 17))) { + traits(UIAccessibilityTraitToggleButton) + } + } + // Checkbox + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + // ToggleableState + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitSelected + ) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + } + } + + @Test + fun testToggleAndCheckboxAction() = runUIKitInstrumentedTest { + var switch by mutableStateOf(false) + var checkbox by mutableStateOf(false) + var triStateCheckbox by mutableStateOf(ToggleableState.Off) + + setContentWithAccessibilityEnabled { + Column { + Switch( + checked = switch, + onCheckedChange = { switch = it }, + modifier = Modifier.testTag("Switch") + ) + Checkbox( + checked = checkbox, + onCheckedChange = { checkbox = it }, + modifier = Modifier.testTag("Checkbox") + ) + TriStateCheckbox( + state = triStateCheckbox, + onClick = { triStateCheckbox = ToggleableState.On }, + modifier = Modifier.testTag("TriStateCheckbox") + ) + } + } + + findNode("Switch").element?.accessibilityActivate() + assertTrue(switch) + waitForIdle() + findNode("Switch").element?.accessibilityActivate() + assertFalse(switch) + + findNode("Checkbox").element?.accessibilityActivate() + assertTrue(checkbox) + waitForIdle() + findNode("Checkbox").element?.accessibilityActivate() + assertFalse(checkbox) + + findNode("TriStateCheckbox").element?.accessibilityActivate() + assertEquals(ToggleableState.On, triStateCheckbox) + } + + @Test + fun testRadioButtonSelection() = runUIKitInstrumentedTest { + var selectedIndex by mutableStateOf(0) + + setContentWithAccessibilityEnabled { + Column { + RadioButton(selected = selectedIndex == 0, onClick = { selectedIndex = 0 }) + RadioButton(selected = selectedIndex == 1, onClick = { selectedIndex = 1 }) + RadioButton( + selected = selectedIndex == 2, + onClick = { selectedIndex = 2 }, + Modifier.testTag("RadioButton") + ) + } + } + + assertAccessibilityTree { + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitSelected + ) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + } + + findNode("RadioButton").element?.accessibilityActivate() + assertAccessibilityTree { + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitSelected + ) + } + } + + selectedIndex = 0 + assertAccessibilityTree { + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitSelected + ) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + node { + isAccessibilityElement = true + traits(UIAccessibilityTraitButton) + } + } + } + + @Test + fun testImageSemantics() = runUIKitInstrumentedTest { + setContentWithAccessibilityEnabled { + Column { + Image( + ImageBitmap(10, 10), + contentDescription = null, + modifier = Modifier.testTag("Image 1") + ) + Image( + ImageBitmap(10, 10), + contentDescription = null, + modifier = Modifier.testTag("Image 2").semantics { role = Role.Image } + ) + Image( + ImageBitmap(10, 10), + contentDescription = "Abstract Picture", + modifier = Modifier.testTag("Image 3") + ) + } + } + + assertAccessibilityTree { + node { + isAccessibilityElement = false + identifier = "Image 1" + traits() + } + node { + isAccessibilityElement = false + identifier = "Image 2" + traits(UIAccessibilityTraitImage) + } + node { + isAccessibilityElement = true + identifier = "Image 3" + label = "Abstract Picture" + traits(UIAccessibilityTraitImage) + } + } + } + + @Test + fun testTextSemantics() = runUIKitInstrumentedTest { + setContentWithAccessibilityEnabled { + Column { + Text("Static Text", modifier = Modifier.testTag("Text 1")) + Text("Custom Button", modifier = Modifier.testTag("Text 2").clickable { }) + } + } + + assertAccessibilityTree { + node { + isAccessibilityElement = true + identifier = "Text 1" + label = "Static Text" + traits(UIAccessibilityTraitStaticText) + } + node { + isAccessibilityElement = true + identifier = "Text 2" + label = "Custom Button" + traits(UIAccessibilityTraitButton) + } + } + } + + @Test + fun testDisabledSemantics() = runUIKitInstrumentedTest { + setContentWithAccessibilityEnabled { + Column { + Button({}, enabled = false) {} + TextField("", {}, enabled = false) + Slider(value = 0f, onValueChange = {}, enabled = false) + Switch(checked = false, onCheckedChange = {}, enabled = false) + Checkbox(checked = false, onCheckedChange = {}, enabled = false) + TriStateCheckbox(state = ToggleableState.Off, onClick = {}, enabled = false) + } + } + + assertAccessibilityTree { + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitNotEnabled + ) + } + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitNotEnabled + ) + } + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitAdjustable, + UIAccessibilityTraitNotEnabled + ) + } + node { + isAccessibilityElement = true + if (available(OS.Ios to OSVersion(major = 17))) { + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitToggleButton, + UIAccessibilityTraitNotEnabled + ) + } else { + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitNotEnabled + ) + } + } + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitNotEnabled + ) + } + node { + isAccessibilityElement = true + traits( + UIAccessibilityTraitButton, + UIAccessibilityTraitNotEnabled + ) + } + } + } + + @Test + fun testHeadingSemantics() = runUIKitInstrumentedTest { + setContentWithAccessibilityEnabled { + Scaffold(topBar = { + TopAppBar { + Text("Header", modifier = Modifier.semantics { heading() }) + } + }) { + Column { + Text("Content") + } + } + } + + assertAccessibilityTree { + node { + label = "Header" + isAccessibilityElement = true + traits(UIAccessibilityTraitHeader) + } + node { + label = "Content" + isAccessibilityElement = true + traits(UIAccessibilityTraitStaticText) + } + } + } + + @Test + fun testSelectionContainer() = runUIKitInstrumentedTest { + @Composable + fun LabeledInfo(label: String, data: String) { + Text( + buildAnnotatedString { + append("$label: ") + append(data) + } + ) + } + + setContentWithAccessibilityEnabled { + SelectionContainer { + Column { + Text("Title") + LabeledInfo("Subtitle", "subtitle") + LabeledInfo("Details", "details") + } + } + } + + assertAccessibilityTree { + node { + label = "Title" + isAccessibilityElement = true + traits(UIAccessibilityTraitStaticText) + } + node { + label = "Subtitle: subtitle" + isAccessibilityElement = true + traits(UIAccessibilityTraitStaticText) + } + node { + label = "Details: details" + isAccessibilityElement = true + traits(UIAccessibilityTraitStaticText) + } + } + } + + @Test + fun testVisibleNodes() = runUIKitInstrumentedTest { + var alpha by mutableStateOf(0f) + + setContentWithAccessibilityEnabled { + Text("Hidden", modifier = Modifier.graphicsLayer { + this.alpha = alpha + }) + } + + assertAccessibilityTree { + label = "Hidden" + isAccessibilityElement = false + } + + alpha = 1f + assertAccessibilityTree { + label = "Hidden" + isAccessibilityElement = true + } + } + + @Test + fun testVisibleNodeContainers() = runUIKitInstrumentedTest { + var alpha by mutableStateOf(0f) + + setContentWithAccessibilityEnabled { + Column { + Text("Text 1") + Row(modifier = Modifier.graphicsLayer { + this.alpha = alpha + }) { + Text("Text 2") + Text("Text 3") + } + } + } + + assertAccessibilityTree { + node { + label = "Text 1" + isAccessibilityElement = true + } + node { + label = "Text 2" + isAccessibilityElement = false + } + node { + label = "Text 3" + isAccessibilityElement = false + } + } + + alpha = 1f + assertAccessibilityTree { + node { + label = "Text 1" + isAccessibilityElement = true + } + node { + label = "Text 2" + isAccessibilityElement = true + } + node { + label = "Text 3" + isAccessibilityElement = true + } + } + } +} 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 index 36fbb2f0252dd..377d72850f0a4 100644 --- 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 @@ -19,6 +19,7 @@ package androidx.compose.ui.test import androidx.compose.ui.uikit.toDpRect import androidx.compose.ui.unit.DpRect import kotlin.test.assertEquals +import kotlin.test.fail import kotlinx.cinterop.ExperimentalForeignApi import platform.UIKit.UIAccessibilityElement import platform.UIKit.UIAccessibilityTraitAdjustable @@ -282,6 +283,35 @@ internal fun UIKitInstrumentedTest.assertAccessibilityTree( assertAccessibilityTree(validator) } +internal fun UIKitInstrumentedTest.findNode(identifier: String) = findNodeOrNull { + it.identifier == identifier +} ?: fail("Unable to find node with identifier: $identifier") + +internal fun UIKitInstrumentedTest.findNodeWithLabel(label: String) = findNodeOrNull { + it.label == label +} ?: fail("Unable to find node with label: $label") + +internal fun UIKitInstrumentedTest.firstAccessibleNode() = + findNodeOrNull { it.isAccessibilityElement == true } + ?: fail("Unable to find accessibility element") + +internal fun UIKitInstrumentedTest.findNodeOrNull( + isValid: (AccessibilityTestNode) -> Boolean +): AccessibilityTestNode? { + waitForIdle() + val actualTreeRoot = getAccessibilityTree() + + fun check(node: AccessibilityTestNode): AccessibilityTestNode? { + return if (isValid(node)) { + node + } else { + node.children?.firstNotNullOfOrNull(::check) + } + } + + return check(node = actualTreeRoot) +} + /** * Asserts that the current accessibility tree matches the expected structure defined in the * provided lambda. The expected structure is defined by configuring an `AccessibilityTestNode`, 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 340b77af03b04..1397d5430f94c 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.toSize import androidx.compose.ui.viewinterop.InteropWrappingView import androidx.compose.ui.viewinterop.NativeAccessibilityViewSemanticsKey import kotlin.coroutines.CoroutineContext +import kotlin.math.roundToInt import kotlin.time.measureTime import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.CValue @@ -53,6 +54,9 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import org.jetbrains.skiko.OS +import org.jetbrains.skiko.OSVersion +import org.jetbrains.skiko.available import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectMake import platform.CoreGraphics.CGRectZero @@ -77,8 +81,11 @@ import platform.UIKit.UIAccessibilityTraitImage import platform.UIKit.UIAccessibilityTraitNone import platform.UIKit.UIAccessibilityTraitNotEnabled import platform.UIKit.UIAccessibilityTraitSelected +import platform.UIKit.UIAccessibilityTraitStaticText +import platform.UIKit.UIAccessibilityTraitToggleButton import platform.UIKit.UIAccessibilityTraitUpdatesFrequently import platform.UIKit.UIAccessibilityTraits +import platform.UIKit.UITextInputProtocol import platform.UIKit.UIView import platform.UIKit.UIWindow import platform.UIKit.accessibilityCustomActions @@ -108,7 +115,6 @@ private class CachedAccessibilityPropertyKey private object CachedAccessibilityPropertyKeys { val accessibilityLabel = CachedAccessibilityPropertyKey() - val isAccessibilityElement = CachedAccessibilityPropertyKey() val accessibilityIdentifier = CachedAccessibilityPropertyKey() val accessibilityHint = CachedAccessibilityPropertyKey() val accessibilityCustomActions = CachedAccessibilityPropertyKey>() @@ -118,6 +124,10 @@ private object CachedAccessibilityPropertyKeys { val nativeAccessibilityView = CachedAccessibilityPropertyKey() } +// Private accessibility trait for text fields +internal val CMPAccessibilityTraitTextField: UIAccessibilityTraits = 1UL shl 18 +internal val CMPAccessibilityTraitIsEditing: UIAccessibilityTraits = 1UL shl 21 + /** * Represents a projection of the Compose semantics node to the iOS world. * @@ -345,6 +355,22 @@ private class AccessibilityElement( return action() } + override fun accessibilityIncrement() { + updateProgress(increment = true) + } + + override fun accessibilityDecrement() { + updateProgress(increment = false) + } + + private fun updateProgress(increment: Boolean) { + val progress = cachedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo) ?: return + val setProgress = cachedConfig.getOrNull(SemanticsActions.SetProgress) ?: return + val step = (progress.range.endInclusive - progress.range.start) / progress.steps + val value = progress.current + if (increment) step else -step + setProgress.action?.invoke(value) + } + /** * This function is the final one called during the accessibility tree resolution for iOS services * and is invoked from underlying Obj-C library. If this node has children, then we return its @@ -593,10 +619,11 @@ private class AccessibilityElement( } } - override fun isAccessibilityElement(): Boolean = - getOrElse(CachedAccessibilityPropertyKeys.isAccessibilityElement) { - semanticsNode.isAccessibilityElement - } + override fun isAccessibilityElement(): Boolean { + // Node visibility changes don't trigger accessibility semantic recalculation. + // This value should not be cached. See [SemanticsNode.isHidden] + return semanticsNode.isAccessibilityElement + } override fun accessibilityIdentifier(): String? = getOrElse(CachedAccessibilityPropertyKeys.accessibilityIdentifier) { @@ -647,6 +674,12 @@ private class AccessibilityElement( result = result or UIAccessibilityTraitNotEnabled } + config.getOrNull(SemanticsProperties.Selected)?.let { selected -> + if (selected) { + result = result or UIAccessibilityTraitSelected + } + } + if (config.contains(SemanticsProperties.Heading)) { result = result or UIAccessibilityTraitHeader } @@ -663,17 +696,23 @@ private class AccessibilityElement( } } - config.getOrNull(SemanticsProperties.LiveRegion)?.let { - result = result or UIAccessibilityTraitUpdatesFrequently + if (config.contains(SemanticsProperties.ProgressBarRangeInfo)) { + if (config.contains(SemanticsActions.SetProgress)) { + result = result or UIAccessibilityTraitAdjustable + } } - config.getOrNull(SemanticsActions.OnClick)?.let { + if (config.contains(SemanticsProperties.EditableText) && + config.contains(SemanticsActions.SetText) + ) { + result = result or CMPAccessibilityTraitTextField + } else if (config.contains(SemanticsActions.OnClick)) { result = result or UIAccessibilityTraitButton } config.getOrNull(SemanticsProperties.Role)?.let { role -> when (role) { - Role.Button, Role.RadioButton, Role.Checkbox, Role.Switch -> { + Role.Button -> { result = result or UIAccessibilityTraitButton } @@ -684,16 +723,45 @@ private class AccessibilityElement( Role.Image -> { result = result or UIAccessibilityTraitImage } + + Role.Switch -> { + if (available(OS.Ios to OSVersion(major = 17))) { + result = result or UIAccessibilityTraitToggleButton + } + } } } + if (result == UIAccessibilityTraitNone && + config.contains(SemanticsProperties.Text) && + config.contains(SemanticsActions.GetTextLayoutResult) && + config.contains(SemanticsActions.ShowTextSubstitution) + ) { + result = result or UIAccessibilityTraitStaticText + } + result } + override fun accessibilityTextInputResponder(): UITextInputProtocol? { + return null + } override fun accessibilityValue(): String? = getOrElse(CachedAccessibilityPropertyKeys.accessibilityValue) { - cachedConfig.getOrNull(SemanticsProperties.StateDescription) + cachedConfig.getOrNull(SemanticsProperties.StateDescription)?.let { + return@getOrElse it + } + + cachedConfig.getOrNull(SemanticsProperties.ProgressBarRangeInfo)?.let { + return@getOrElse if (!it.range.isEmpty()) { + val fraction = (it.current - it.range.start) / + (it.range.endInclusive - it.range.start) + "${(fraction * 100f).roundToInt()}%" + } else { + null + } + } } override fun accessibilityFrame(): CValue = @@ -704,7 +772,6 @@ private class AccessibilityElement( mediator.convertToAppWindowCGRect(semanticsNode.boundsInWindow) } - override fun accessibilityPerformEscape(): Boolean { if (!isAlive) { mediator.debugLogger?.log("accessibilityPerformEscape() called after $semanticsNodeId was removed from the tree") @@ -796,7 +863,6 @@ private class AccessibilityElement( logger.apply { log("${indent}AccessibilityElement_$semanticsNodeId") log("$indent containmentChain: ${debugContainmentChain()}") - log("$indent isAccessibilityElement: $isAccessibilityElement") log("$indent accessibilityLabel: $accessibilityLabel") log("$indent accessibilityValue: $accessibilityValue") log("$indent accessibilityTraits: $accessibilityTraits") @@ -812,7 +878,7 @@ private class AccessibilityElement( } val containsPoint = semanticsNode.boundsInWindow.contains(offsetInWindow) - if (containsPoint && isAccessibilityElement) { + if (containsPoint && semanticsNode.isAccessibilityElement) { return this } @@ -1095,6 +1161,7 @@ internal class AccessibilityMediator( accessibilityDebugLogger?.log("AccessibilityMediator for $view created") view.accessibilityElements = listOf() + var notificationName = UIAccessibilityScreenChangedNotification coroutineScope.launch { // The main loop that listens for invalidations and performs the tree syncing // Will exit on CancellationException from within await on `invalidationChannel.receive()` @@ -1118,8 +1185,10 @@ internal class AccessibilityMediator( debugLogger?.log("AccessibilityMediator.sync took $time") debugLogger?.log("LayoutChanged, newElementToFocus: ${result.newElementToFocus}") + UIAccessibilityPostNotification(notificationName, result.newElementToFocus) - UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, result.newElementToFocus) + // Post screen change notification only once + notificationName = UIAccessibilityLayoutChangedNotification } else { if (view.accessibilityElements?.isEmpty() != true) { view.accessibilityElements = listOf() @@ -1479,26 +1548,25 @@ 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() - } - } +private val SemanticsNode.isAccessibilityElement: Boolean + get() = isScreenReaderFocusable() + +// Simplified version of the isScreenReaderFocusable() from the +// AndroidComposeViewAccessibilityDelegateCompat.android.kt +private fun SemanticsNode.isScreenReaderFocusable(): Boolean { + return !isHidden && + (unmergedConfig.isMergingSemanticsOfDescendants || + isUnmergedLeafNode && isSpeakingNode) +} + +private val SemanticsNode.isSpeakingNode: Boolean get() { + return unmergedConfig.contains(SemanticsProperties.ContentDescription) || + unmergedConfig.contains(SemanticsProperties.EditableText) || + unmergedConfig.contains(SemanticsProperties.Text) || + unmergedConfig.contains(SemanticsProperties.StateDescription) || + unmergedConfig.contains(SemanticsProperties.ToggleableState) || + unmergedConfig.contains(SemanticsProperties.Selected) || + unmergedConfig.contains(SemanticsProperties.ProgressBarRangeInfo) } @OptIn(ExperimentalComposeUiApi::class)