Remove Virtual View accessibilityOrder implementation (#52297)

Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/52297

Doing virtual views is the only way of making it possible to add the host view into the order. This however is too complex for very little gain, we are opting to go for a cleaner solution with the trade off of not being able to add the host view.

Changelog: [Internal]

Reviewed By: joevilches

Differential Revision: D77278752

fbshipit-source-id: 709b995f51a9a03f6d07f2e24f8aea21d62d95c4
This commit is contained in:
Jorge Cabiedes Acosta
2025-07-03 12:56:35 -07:00
committed by Facebook GitHub Bot
parent 76ff1aa5c6
commit 5b245767d6
4 changed files with 8 additions and 311 deletions
@@ -3659,7 +3659,6 @@ 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 <init> (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;
@@ -19,7 +19,6 @@ import android.view.accessibility.AccessibilityEvent;
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;
@@ -188,12 +187,6 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
((BaseVMFocusChangeListener) focusChangeListener).detach(view);
}
AccessibilityDelegateCompat axDelegate = ViewCompat.getAccessibilityDelegate(view);
if (axDelegate instanceof ReactAccessibilityDelegate) {
((ReactAccessibilityDelegate) axDelegate).cleanUp();
}
if (view instanceof ViewGroup) {
((ViewGroup) view).setOnHierarchyChangeListener(null);
}
@@ -16,7 +16,6 @@ import android.text.TextUtils;
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;
@@ -42,11 +41,8 @@ import com.facebook.react.uimanager.common.ViewUtil;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Utility class that handles the addition of a "role" for accessibility to either a View or
@@ -69,14 +65,9 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
private static final String STATE_CHECKED = "checked";
private final View mView;
private List<View> mAxOrderViews;
private Handler mHandler;
private final HashMap<Integer, String> mAccessibilityActionsMap;
@Nullable
private AccessibilityManager.AccessibilityStateChangeListener accessibilityStateChangeListener =
null;
@Nullable View mAccessibilityLabelledBy;
static {
@@ -116,7 +107,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
// problems, so leave it alone.
if (!ViewCompat.hasAccessibilityDelegate(view)
&& (view.getTag(R.id.accessibility_role) != null
|| view.getTag(R.id.accessibility_order) != null
|| view.getTag(R.id.accessibility_state) != null
|| view.getTag(R.id.accessibility_actions) != null
|| view.getTag(R.id.react_test_id) != null
@@ -142,7 +132,10 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
return mView;
}
private void populateAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
@Override
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (host.getTag(R.id.accessibility_state_expanded) != null) {
final boolean accessibilityStateExpanded =
(boolean) host.getTag(R.id.accessibility_state_expanded);
@@ -259,52 +252,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
}
}
@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);
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<String> axOrderIdsList = new ArrayList<>();
Set<String> axOrderSet = new HashSet<>();
for (int i = 0; i < axOrderIds.size(); i++) {
String id = axOrderIds.getString(i);
if (id != null) {
axOrderIdsList.add(id);
axOrderSet.add(id);
}
}
mAxOrderViews = ReactAxOrderHelper.processAxOrderTree(mView, axOrderIdsList, axOrderSet);
}
return;
}
populateAccessibilityNodeInfo(host, info);
}
@Override
public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
super.onInitializeAccessibilityEvent(host, event);
@@ -475,61 +422,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
@Override
protected int getVirtualViewAt(float x, float y) {
if (mAxOrderViews == null) {
return HOST_ID;
}
int closestViewId = HOST_ID;
int smallestArea = Integer.MAX_VALUE;
for (int i = 0; i < mAxOrderViews.size(); i++) {
Rect bounds = ReactAxOrderHelper.getVirtualViewBounds(mView, mAxOrderViews.get(i));
if (bounds.contains((int) x, (int) y)) {
int area = bounds.width() * bounds.height();
if (area < smallestArea) {
smallestArea = area;
closestViewId = i;
}
}
}
return closestViewId;
return INVALID_ID;
}
@Override
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {
if (mAxOrderViews != null && !mAxOrderViews.isEmpty()) {
for (int i = 0; i < mAxOrderViews.size(); i++) {
virtualViewIds.add(i);
}
}
}
protected void getVisibleVirtualViews(List<Integer> virtualViewIds) {}
@Override
protected void onPopulateNodeForVirtualView(
int virtualViewId, @NonNull AccessibilityNodeInfoCompat node) {
if (mView.getTag(R.id.accessibility_order) != null) {
if (mAxOrderViews.size() <= virtualViewId) {
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
return;
}
View virtualView = mAxOrderViews.get(virtualViewId);
node.setContentDescription("");
if (virtualView == mView) {
if (mView.getContentDescription() != null) {
node.setContentDescription(mView.getContentDescription());
}
populateAccessibilityNodeInfo(mView, node);
node.setBoundsInParent(new Rect(0, 0, mView.getWidth(), mView.getHeight()));
} else {
node.setBoundsInParent(ReactAxOrderHelper.getVirtualViewBounds(mView, virtualView));
}
node.addChild(virtualView);
}
node.setContentDescription("");
node.setBoundsInParent(new Rect(0, 0, 1, 1));
}
@Override
@@ -540,10 +443,6 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
@Override
public @Nullable AccessibilityNodeProviderCompat getAccessibilityNodeProvider(View host) {
if (mView.getTag(R.id.accessibility_order) != null) {
return super.getAccessibilityNodeProvider(host);
}
return null;
}
@@ -1121,17 +1020,4 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
}
}
}
// 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;
}
}
}
@@ -1,181 +0,0 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
package com.facebook.react.uimanager
import android.graphics.Rect
import android.view.View
import android.view.ViewGroup
import com.facebook.react.R
import com.facebook.react.bridge.ReadableArray
private object ReactAxOrderHelper {
/**
* Processes the View tree that begins at the View with AccessibilityOrder set
*
* Disables accessibility for views not included in the specified accessibility order.
*
* This method emulates iOS's focusing order behavior to facilitate cross-platform code sharing.
* It disables accessibility for views that are either not part of the accessibility order or
* don't have a container that belongs to the accessibility order.
*
* The container/element concept is borrowed from iOS, where a "container" is a non-accessible
* view with children, and an "element" is any accessible view.
*
* @return an array of views following the accessibility order
*/
@JvmStatic
fun processAxOrderTree(
root: View,
axOrderIds: MutableList<String?>,
axOrderSet: MutableSet<String?>
): List<View> {
val axOrderViews = Array(axOrderIds.size) { mutableListOf<View?>() }.toMutableList()
fun traverseAndBuildAxOrder(parent: View, view: View, containerId: String?) {
val nativeId = view.getTag(R.id.view_tag_native_id) as String?
val isContained = (containerId != null && axOrderSet.contains(containerId))
val isIncluded = (nativeId != null && axOrderSet.contains(nativeId))
val isNestedAxOrder = view.getTag(R.id.accessibility_order) != null && view != parent
if (isIncluded && view.isFocusable) {
axOrderViews[axOrderIds.indexOf(nativeId)].add(view)
if (parent != view) {
view.setTag(R.id.accessibility_order_parent, parent)
}
} else if (isContained && view.isFocusable) {
axOrderViews[axOrderIds.indexOf(containerId)].add(view)
if (parent != view) {
view.setTag(R.id.accessibility_order_parent, parent)
}
}
if (isNestedAxOrder) {
val nestedOrder = view.getTag(R.id.accessibility_order) as ReadableArray
for (i in 0 until nestedOrder.size()) {
val id = nestedOrder.getString(i)
if (id != null) {
val insertIdx = axOrderIds.indexOf(nativeId) + 1
if (insertIdx < axOrderIds.size) {
axOrderIds.add(axOrderIds.indexOf(nativeId) + 1 + i, id)
axOrderViews.add(axOrderIds.indexOf(nativeId) + 1 + i, mutableListOf())
} else {
axOrderIds.add(id)
axOrderViews.add(mutableListOf())
}
axOrderSet.add(id)
}
}
}
// Don't traverse the children of a nested accessibility order
if (view is ViewGroup) {
val axChildren: ArrayList<View> = getAxChildren(view)
// If the View is a "container" (Not focusable but is included in the order) We add all its
// children to the order.
if (containerId != null) {
for (i in 0 until axChildren.size) {
traverseAndBuildAxOrder(parent, axChildren[i], containerId)
}
} else if (!view.isFocusable && isIncluded) {
for (i in 0 until axChildren.size) {
traverseAndBuildAxOrder(parent, axChildren[i], nativeId)
}
} else {
for (i in 0 until axChildren.size) {
traverseAndBuildAxOrder(parent, axChildren[i], null)
}
}
}
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(
root,
root,
null,
)
val result = mutableListOf<View>()
for (viewList in axOrderViews) {
for (view in viewList) {
if (view != null) {
result.add(view)
}
}
}
root.setTag(R.id.accessibility_order_dirty, false)
return result
}
@JvmStatic
fun getVirtualViewBounds(host: View, virtualView: View): Rect {
var currentView: View = virtualView
val viewBoundsInParent =
Rect(virtualView.left, virtualView.top, virtualView.right, virtualView.bottom)
while (currentView.parent != host && currentView != host) {
val parent = currentView.parent as View
viewBoundsInParent.top += parent.top
viewBoundsInParent.bottom += parent.top
viewBoundsInParent.left += parent.left
viewBoundsInParent.right += parent.left
currentView = parent
}
return viewBoundsInParent
}
private fun getAxChildren(host: ViewGroup): ArrayList<View> {
val axChildren: ArrayList<View> = ArrayList()
// When host has an accessibilityNodeProvider it means the order is not default so ViewGroup's
// method bails, in this case we should just add the children to the node in the edge case where
// we need to coopt at accessibilityOrderParent level
if (host.accessibilityNodeProvider != null) {
for (i in 0 until host.childCount) {
val child = host.getChildAt(i)
if (child != null) {
axChildren.add(child)
}
}
} else {
// This extracts the children of host sorted in accessibility order, this is by layout
// top to bottom, left to right
host.addChildrenForAccessibility(axChildren)
}
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)
}
}
}
}
}