From a3d0f1763d0523682d7e89b3e239bbf1729c583f Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Tue, 24 Jun 2025 08:44:42 -0700 Subject: [PATCH 1/2] Fix View Coopting View edge case on Android (#52066) Summary: Before, to disable views that were excluded from the order we were setting them to be not important for accessibility. This however breaks coopting behavior of parent views, because parent views will not announce content descriptions of children that are not important for accessibility. Instead of disabling by setting `important for accessibility = no` now we just set `isFocusable = false` which disables focusing but still allows parent views to coopt We also add functionality to restore view focusability when enabling disabling screen readers since `isFocusable` changes keyboard focusability and when screen readers are disabled we don't want to change it. Changelog: [Internal] Reviewed By: joevilches Differential Revision: D76745057 --- .../ReactAndroid/api/ReactAndroid.api | 1 + .../react/uimanager/BaseViewManager.java | 11 ++++++ .../uimanager/ReactAccessibilityDelegate.java | 34 +++++++++++++++++++ .../react/uimanager/ReactAxOrderHelper.kt | 29 +++++++++++++--- .../main/res/views/uimanager/values/ids.xml | 3 ++ 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 4e84da68b656..88e9c1ce3c98 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -3659,6 +3659,7 @@ public class com/facebook/react/uimanager/ReactAccessibilityDelegate : androidx/ public static final field TOP_ACCESSIBILITY_ACTION_EVENT Ljava/lang/String; public static final field sActionIdMap Ljava/util/HashMap; public fun (Landroid/view/View;ZI)V + public fun cleanUp ()V public static fun createNodeInfoFromView (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeInfoCompat; public fun getAccessibilityNodeProvider (Landroid/view/View;)Landroidx/core/view/accessibility/AccessibilityNodeProviderCompat; protected fun getHostView ()Landroid/view/View; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index ff335564809a..6fccc44a85e3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -19,6 +19,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; import com.facebook.common.logging.FLog; import com.facebook.react.R; @@ -186,6 +187,16 @@ public void onDropViewInstance(@NonNull T view) { if (focusChangeListener instanceof BaseVMFocusChangeListener) { ((BaseVMFocusChangeListener) focusChangeListener).detach(view); } + + AccessibilityDelegateCompat axDelegate = ViewCompat.getAccessibilityDelegate(view); + + if (axDelegate instanceof ReactAccessibilityDelegate) { + ((ReactAccessibilityDelegate) axDelegate).cleanUp(); + } + + if (view instanceof ViewGroup) { + ((ViewGroup) view).setOnHierarchyChangeListener(null); + } } // Currently, layout listener is only attached when transform or transformOrigin is set. diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java index 3e85bd7c98df..86f2cad36b55 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -16,6 +16,7 @@ import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; import android.widget.EditText; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -72,6 +73,10 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper { private Handler mHandler; private final HashMap mAccessibilityActionsMap; + @Nullable + private AccessibilityManager.AccessibilityStateChangeListener accessibilityStateChangeListener = + null; + @Nullable View mAccessibilityLabelledBy; static { @@ -261,6 +266,22 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo ReadableArray axOrderIds = (ReadableArray) mView.getTag(R.id.accessibility_order); if (axOrderIds != null && axOrderIds.size() != 0) { + AccessibilityManager am = + (AccessibilityManager) host.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + + if (accessibilityStateChangeListener == null && am != null) { + AccessibilityManager.AccessibilityStateChangeListener newAccessibilityStateChangeListener = + enabled -> { + if (!enabled) { + ReactAxOrderHelper.restoreSubtreeFocusability(host); + host.setTag(R.id.accessibility_order_dirty, true); + } + }; + + am.addAccessibilityStateChangeListener(newAccessibilityStateChangeListener); + accessibilityStateChangeListener = newAccessibilityStateChangeListener; + } + Boolean isAxOrderDirty = (Boolean) mView.getTag(R.id.accessibility_order_dirty); if (isAxOrderDirty != null && isAxOrderDirty) { List axOrderIdsList = new ArrayList<>(); @@ -1098,4 +1119,17 @@ public static AccessibilityRole fromValue(@Nullable String value) { } } } + + // In case a view with accessibilityOrder is unmounted we need a way to clean up the listener on + // this delegate + public void cleanUp() { + if (accessibilityStateChangeListener != null) { + AccessibilityManager am = + (AccessibilityManager) mView.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + if (am != null) { + am.removeAccessibilityStateChangeListener(accessibilityStateChangeListener); + } + accessibilityStateChangeListener = null; + } + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAxOrderHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAxOrderHelper.kt index f8570f046fb5..12c139c8a50f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAxOrderHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAxOrderHelper.kt @@ -10,7 +10,6 @@ package com.facebook.react.uimanager import android.graphics.Rect import android.view.View import android.view.ViewGroup -import android.widget.TextView import com.facebook.react.R import com.facebook.react.bridge.ReadableArray @@ -76,10 +75,6 @@ private object ReactAxOrderHelper { } } - if (!isIncluded && !isContained && parent != view && view !is TextView) { - view.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO - } - // Don't traverse the children of a nested accessibility order if (view is ViewGroup) { val axChildren: ArrayList = getAxChildren(view) @@ -100,6 +95,13 @@ private object ReactAxOrderHelper { } } } + + if (!isIncluded && !isContained && parent != view) { + if (view.getTag(R.id.original_focusability) == null) { + view.setTag(R.id.original_focusability, view.isFocusable) + } + view.isFocusable = false + } } traverseAndBuildAxOrder( @@ -159,4 +161,21 @@ private object ReactAxOrderHelper { } return axChildren } + + @JvmStatic + public fun restoreSubtreeFocusability(view: View) { + val originalFocusability = view.getTag(R.id.original_focusability) + if (originalFocusability is Boolean) { + view.isFocusable = originalFocusability + } + + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + if (child != null) { + restoreSubtreeFocusability(child) + } + } + } + } } diff --git a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 5c1b9e434c8d..eb8ccbc8c808 100644 --- a/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/packages/react-native/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -15,6 +15,9 @@ + + + From a4579c92f52526f05befd6438e5caf5a193316f5 Mon Sep 17 00:00:00 2001 From: Jorge Cabiedes Acosta Date: Tue, 24 Jun 2025 08:44:42 -0700 Subject: [PATCH 2/2] Fix jumping talkback triggering scroll when reaching a view with accessibilityOrder Summary: Not calling `super.onInitializeAccessibilityNodeInfo` on the host view with accessibilityOrder prevents setting proper dimensions for the node that backs the view which leads TalkBack to trigger scrolling when under a ScrollVIew. We still need the host's node to not be accessible so we still set it to not be focusable and not have a content description since this should be handled by the virtual views Reviewed By: joevilches Differential Revision: D77180494 --- .../facebook/react/uimanager/ReactAccessibilityDelegate.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java index 86f2cad36b55..598b46f2e7b1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -261,10 +261,13 @@ private void populateAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompa @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); // If we set an accessibility order then all the focusing logic should go through our custom // virtual view tree hierarchy and ignore the default path ReadableArray axOrderIds = (ReadableArray) mView.getTag(R.id.accessibility_order); if (axOrderIds != null && axOrderIds.size() != 0) { + info.setContentDescription(""); + info.setFocusable(false); AccessibilityManager am = (AccessibilityManager) host.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE); @@ -299,7 +302,6 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo return; } - super.onInitializeAccessibilityNodeInfo(host, info); populateAccessibilityNodeInfo(host, info); }