Skip to content

Commit

Permalink
Reload accessibility node tree when VoiceOver status changes (#1656)
Browse files Browse the repository at this point in the history
Fixes
https://youtrack.jetbrains.com/issue/CMP-6874/VoiceOver-cannot-be-enabled-when-app-is-running

## Release Notes
### Fixes - iOS
- Fix a bug where the accessibility tree did not reload when VoiceOver
was enabled
  • Loading branch information
ASalavei authored Oct 28, 2024
1 parent 1e41171 commit 82a861c
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,20 @@ 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.getOrNull
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.uikit.utils.CMPAccessibilityContainer
import androidx.compose.ui.uikit.utils.CMPAccessibilityElement
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.viewinterop.NativeAccessibilityViewSemanticsKey
import androidx.compose.ui.viewinterop.InteropWrappingView
import androidx.compose.ui.viewinterop.NativeAccessibilityViewSemanticsKey
import kotlin.coroutines.CoroutineContext
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.Job
Expand All @@ -48,6 +51,8 @@ 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
Expand All @@ -70,6 +75,8 @@ 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
Expand Down Expand Up @@ -133,7 +140,7 @@ private object CachedAccessibilityPropertyKeys {
* resides.
*
*/
@OptIn(ExperimentalComposeApi::class)
@OptIn(ExperimentalComposeApi::class, BetaInteropApi::class)
@ExportObjCClass
private class AccessibilityElement(
private var semanticsNode: SemanticsNode,
Expand Down Expand Up @@ -571,7 +578,9 @@ private class AccessibilityElement(
getOrElse(CachedAccessibilityPropertyKeys.isAccessibilityElement) {
val config = cachedConfig

if (config.contains(SemanticsProperties.InvisibleToUser)) {
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
Expand Down Expand Up @@ -851,7 +860,7 @@ private class AccessibilityElement(
* /~https://github.com/flutter/engine/blob/main/shell/platform/darwin/ios/framework/Source/SemanticsObject.h
*
*/
@OptIn(ExperimentalComposeApi::class)
@OptIn(ExperimentalComposeApi::class, BetaInteropApi::class)
@ExportObjCClass
private class AccessibilityContainer(
/**
Expand Down Expand Up @@ -1007,7 +1016,7 @@ private val AccessibilitySyncOptions.debugLoggerIfEnabled: AccessibilityDebugLog
@OptIn(ExperimentalComposeApi::class)
internal class AccessibilityMediator(
val view: UIView,
private val owner: SemanticsOwner,
val owner: SemanticsOwner,
coroutineContext: CoroutineContext,
private val getAccessibilitySyncOptions: () -> AccessibilitySyncOptions,

Expand All @@ -1017,7 +1026,7 @@ internal class AccessibilityMediator(
*/
val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue<CGRect>,
val performEscape: () -> Boolean
) {
): NSObject() {
/**
* Indicates that this mediator was just created and the accessibility focus should be set on the
* first eligible element.
Expand All @@ -1029,6 +1038,8 @@ 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
Expand Down Expand Up @@ -1073,6 +1084,13 @@ internal class AccessibilityMediator(
init {
getAccessibilitySyncOptions().debugLoggerIfEnabled?.log("AccessibilityMediator for ${view} created")

notificationCenter.addObserver(
observer = this,
selector = NSSelectorFromString(::voiceOverStatusDidChange.name),
name = UIAccessibilityVoiceOverStatusDidChangeNotification,
`object` = null
)

coroutineScope.launch {
// The main loop that listens for invalidations and performs the tree syncing
// Will exit on CancellationException from within await on `invalidationChannel.receive()`
Expand Down Expand Up @@ -1114,6 +1132,13 @@ internal class AccessibilityMediator(
}
}

@OptIn(BetaInteropApi::class)
@ObjCAction
private fun voiceOverStatusDidChange() {
invalidationKind = SemanticsTreeInvalidationKind.COMPLETE
invalidationChannel.trySend(Unit)
}

fun convertToAppWindowCGRect(rect: Rect): CValue<CGRect> {
val window = view.window ?: return CGRectZero.readValue()

Expand Down Expand Up @@ -1178,6 +1203,12 @@ internal class AccessibilityMediator(
for (element in accessibilityElementsMap.values) {
element.dispose()
}

notificationCenter.removeObserver(
observer = this,
name = UIAccessibilityVoiceOverStatusChanged,
`object` = null
)
}

private fun createOrUpdateAccessibilityElementForSemanticsNode(node: SemanticsNode): AccessibilityElement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,11 +134,11 @@ private class SemanticsOwnerListenerImpl(
private val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue<CGRect>,
private val performEscape: () -> Boolean
) : PlatformContext.SemanticsOwnerListener {
var current: Pair<SemanticsOwner, AccessibilityMediator>? = null
private var mediator: AccessibilityMediator? = null

override fun onSemanticsOwnerAppended(semanticsOwner: SemanticsOwner) {
if (current == null) {
current = semanticsOwner to AccessibilityMediator(
if (mediator == null) {
mediator = AccessibilityMediator(
rootView,
semanticsOwner,
coroutineContext,
Expand All @@ -150,29 +150,28 @@ private class SemanticsOwnerListenerImpl(
}

override fun onSemanticsOwnerRemoved(semanticsOwner: SemanticsOwner) {
val current = current ?: return

if (current.first == semanticsOwner) {
current.second.dispose()
this.current = null
if (mediator?.owner == semanticsOwner) {
mediator?.dispose()
mediator = null
}
}

override fun onSemanticsChange(semanticsOwner: SemanticsOwner) {
val current = current ?: return

if (current.first == semanticsOwner) {
current.second.onSemanticsChange()
if (mediator?.owner == semanticsOwner) {
mediator?.onSemanticsChange()
}
}

override fun onLayoutChange(semanticsOwner: SemanticsOwner, semanticsNodeId: Int) {
val current = current ?: return

if (current.first == semanticsOwner) {
current.second.onLayoutChange(nodeId = semanticsNodeId)
if (mediator?.owner == semanticsOwner) {
mediator?.onLayoutChange(nodeId = semanticsNodeId)
}
}

fun dispose() {
mediator?.dispose()
mediator = null
}
}

internal sealed interface ComposeSceneMediatorLayout {
Expand Down Expand Up @@ -588,6 +587,7 @@ internal class ComposeSceneMediator(

scene.close()
interopContainer.dispose()
semanticsOwnerListener.dispose()
}

private fun setNeedsRedraw() {
Expand Down

0 comments on commit 82a861c

Please sign in to comment.