Skip to content

Commit

Permalink
iOS support LiveRegion semantics in a11y (#1258)
Browse files Browse the repository at this point in the history
## Proposed Changes

Add `UIAccessibilityTraitUpdatesFrequently` to elements with associated
`SemanticsNode` having a `LiveRegion` property.
Don't return a non-null refocused element if it's the same one before
the sync unless it needs to happen (during the scroll to trigger private
API).
This prevents accessibility focus change (forcing it to previous
element) if the semantics tree is synced continuously due to bug in iOS
(UIAccessibilityFocusedElement returns older value)

## Testing

Test: When a focused accessibility element has a `LiveRegion`, it will
voice update in a given element.

## Issues Fixed
Incorrect refocusing when semantics are synced continuously.
  • Loading branch information
elijah-semyonov committed Apr 11, 2024
1 parent c1db186 commit f92948f
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 4 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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.mpp.demo

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Text
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import kotlinx.coroutines.delay

val AccessibilityLiveRegionExample = Screen.Example("Accessibility LiveRegion example") {
var number by remember { mutableStateOf(0) }

Column(modifier = Modifier.fillMaxSize()) {
Text("Polite:")
Text("Number $number", modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
})

Text("Assertive:")
Text("Number $number", modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Assertive
})
}

// Update the number every second
LaunchedEffect(Unit) {
while (true) {
number++
delay(1000)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,6 @@ val IosSpecificFeatures = Screen.Selection(
"iOS-specific features",
NativeModalWithNaviationExample,
HapticFeedbackExample,
LazyColumnWithInteropViewsExample,
AccessibilityLiveRegionExample
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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.mpp.demo

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.interop.UIKitView
import androidx.compose.ui.unit.dp
import platform.UIKit.UILabel

val LazyColumnWithInteropViewsExample = Screen.Example("LazyColumn with interop views") {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(100) {
if (it % 2 == 0) {
UIKitView(
factory = {
val view = UILabel()
view.text = "UILabel $it"
view
},
modifier = Modifier.fillMaxWidth().height(40.dp)
)
} else {
Text("Text $it", Modifier.height(40.dp))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,6 @@ private class AccessibilityElement(
private fun scrollIfPossible(direction: UIAccessibilityScrollDirection): AccessibilityElement? {
val config = cachedConfig

//val (width, height) = semanticsNode.size

when (direction) {
UIAccessibilityScrollDirectionUp -> {
var result = config.getOrNull(SemanticsActions.PageUp)?.action?.invoke()
Expand Down Expand Up @@ -601,6 +599,10 @@ private class AccessibilityElement(
val config = cachedConfig

if (config.contains(SemanticsProperties.LiveRegion)) {
// TODO: LiveRegionMode in the config is currently ignored.
// the default behavior due this flag set will actually do `Polite` announcements
// to do `Assertive` announcements, we need to post a notification explicitly on each change
// which we need to track manually
result = result or UIAccessibilityTraitUpdatesFrequently
}

Expand All @@ -624,6 +626,10 @@ private class AccessibilityElement(
}
}

config.getOrNull(SemanticsProperties.LiveRegion)?.let {
result = result or UIAccessibilityTraitUpdatesFrequently
}

config.getOrNull(SemanticsActions.OnClick)?.let {
result = result or UIAccessibilityTraitButton
}
Expand Down Expand Up @@ -992,6 +998,10 @@ internal class AccessibilityMediator(
private var needsInitialRefocusing = true
private var isAlive = true

private var inflightScrollsCount = 0
private val needsRedundantRefocusingOnSameElement: Boolean
get() = inflightScrollsCount > 0

/**
* 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
Expand Down Expand Up @@ -1066,6 +1076,7 @@ internal class AccessibilityMediator(
}

debugLogger?.log("AccessibilityMediator.sync took $time")
debugLogger?.log("LayoutChanged, newElementToFocus: ${result.newElementToFocus}")

UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, result.newElementToFocus)
}
Expand All @@ -1087,16 +1098,24 @@ internal class AccessibilityMediator(
focusedNode: SemanticsNode,
focusedRectInWindow: Rect
) {
inflightScrollsCount++

coroutineScope.launch {
delay(delay)

inflightScrollsCount--

UIAccessibilityPostNotification(
UIAccessibilityPageScrolledNotification,
null
)

debugLogger?.log("PageScrolled")

if (accessibilityElementsMap[focusedNode.id] == null) {
findElementInRect(rect = focusedRectInWindow)?.let {
debugLogger?.log("LayoutChanged, result: $it")

UIAccessibilityPostNotification(
UIAccessibilityLayoutChangedNotification,
it
Expand Down Expand Up @@ -1275,8 +1294,12 @@ internal class AccessibilityMediator(

refocusedElement
} else {
focusedElement?.semanticsNodeId?.let {
accessibilityElementsMap[it]
if (needsRedundantRefocusingOnSameElement) {
focusedElement?.semanticsNodeId?.let {
accessibilityElementsMap[it]
}
} else {
null // No need to refocus to anything
}
}

Expand Down

0 comments on commit f92948f

Please sign in to comment.