Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reload accessibility node tree when VoiceOver status changes #1656

Merged
merged 1 commit into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading