Implement accessibilityOrder by building the accessibilityTree through addChildrenForAccessibility (#52743)

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

We can build an accessibility tree for Talkback by overriding addChildrenForAccessibility of ViewGroup.

With this we just manually build a tree that contains the elements we care about in the order we want.

We also try to keep most of the tree intact so that coopting works properly
Changelog: [Internal]

EDITS: After backout, fixed the issue where we were incorrectly setting the `accessibility_order_parent` tag the ReactAxOrderHelper class instead of the actual view. Also, made the cast safe to prevent any unexpected issues.

Also refactored the ReactAxOrderHelper functions to not have the block scoped `traverse` functions in favor of just looping through the children of a view when calling them

Reviewed By: joevilches

Differential Revision: D78669715

fbshipit-source-id: e714367c28e722ce42895531cf18e6f2dc926556
This commit is contained in:
Jorge Cabiedes Acosta
2025-07-23 18:08:29 -07:00
committed by Facebook GitHub Bot
parent 2de4984970
commit e1ae619fce
9 changed files with 216 additions and 63 deletions
@@ -673,7 +673,7 @@ type ScrollViewBaseProps = $ReadOnly<{
}>;
export type ScrollViewProps = $ReadOnly<{
...ViewProps,
...Omit<ViewProps, 'experimental_accessibilityOrder'>,
...ScrollViewPropsIOS,
...ScrollViewPropsAndroid,
...ScrollViewBaseProps,
@@ -1031,7 +1031,7 @@ type TextInputBaseProps = $ReadOnly<{
}>;
export type TextInputProps = $ReadOnly<{
...Omit<ViewProps, 'style'>,
...Omit<ViewProps, 'style' | 'experimental_accessibilityOrder'>,
...TextInputIOSProps,
...TextInputAndroidProps,
...TextInputBaseProps,
@@ -3287,7 +3287,6 @@ public abstract class com/facebook/react/uimanager/BaseViewManager : com/faceboo
public fun setAccessibilityLabel (Landroid/view/View;Ljava/lang/String;)V
public fun setAccessibilityLabelledBy (Landroid/view/View;Lcom/facebook/react/bridge/Dynamic;)V
public fun setAccessibilityLiveRegion (Landroid/view/View;Ljava/lang/String;)V
public fun setAccessibilityOrder (Landroid/view/View;Lcom/facebook/react/bridge/ReadableArray;)V
public fun setAccessibilityRole (Landroid/view/View;Ljava/lang/String;)V
public fun setAccessibilityValue (Landroid/view/View;Lcom/facebook/react/bridge/ReadableMap;)V
public fun setBackgroundColor (Landroid/view/View;I)V
@@ -3778,6 +3777,14 @@ public final class com/facebook/react/uimanager/ReactAccessibilityDelegate$Role
public static fun values ()[Lcom/facebook/react/uimanager/ReactAccessibilityDelegate$Role;
}
public final class com/facebook/react/uimanager/ReactAxOrderHelper {
public static final field INSTANCE Lcom/facebook/react/uimanager/ReactAxOrderHelper;
public final fun buildAxOrderList (Landroid/view/View;Landroid/view/View;Ljava/util/List;[Landroid/view/View;)V
public static final fun cleanUpAxOrder (Landroid/view/View;)V
public final fun disableFocusForSubtree (Landroid/view/View;Ljava/util/List;)V
public static final fun restoreFocusability (Landroid/view/View;)V
}
public abstract interface class com/facebook/react/uimanager/ReactClippingProhibitedView {
}
@@ -6561,6 +6568,8 @@ public final class com/facebook/react/views/view/ReactDrawableHelper {
public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGroup, com/facebook/react/touch/ReactHitSlopView, com/facebook/react/touch/ReactInterceptingViewGroup, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/uimanager/ReactPointerEventsView, com/facebook/react/uimanager/ReactZIndexedViewGroup {
public fun <init> (Landroid/content/Context;)V
public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V
public final fun cleanUpAxOrderListener ()V
protected fun dispatchDraw (Landroid/graphics/Canvas;)V
public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z
public fun dispatchProvideStructure (Landroid/view/ViewStructure;)V
@@ -6568,6 +6577,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
public fun draw (Landroid/graphics/Canvas;)V
protected fun drawChild (Landroid/graphics/Canvas;Landroid/view/View;J)Z
public fun endViewTransition (Landroid/view/View;)V
public final fun getAxOrderList ()Ljava/util/List;
protected fun getChildDrawingOrder (II)I
public fun getClippingRect (Landroid/graphics/Rect;)V
public fun getHitSlopRect ()Landroid/graphics/Rect;
@@ -6587,6 +6597,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
public fun onViewAdded (Landroid/view/View;)V
public fun onViewRemoved (Landroid/view/View;)V
public fun requestLayout ()V
public final fun setAxOrderList (Ljava/util/List;)V
public final fun setBackfaceVisibility (Ljava/lang/String;)V
public final fun setBackfaceVisibilityDependantOpacity ()V
public fun setBackgroundColor (I)V
@@ -6623,12 +6634,15 @@ public class com/facebook/react/views/view/ReactViewManager : com/facebook/react
public fun nextFocusLeft (Lcom/facebook/react/views/view/ReactViewGroup;I)V
public fun nextFocusRight (Lcom/facebook/react/views/view/ReactViewGroup;I)V
public fun nextFocusUp (Lcom/facebook/react/views/view/ReactViewGroup;I)V
public synthetic fun onDropViewInstance (Landroid/view/View;)V
public fun onDropViewInstance (Lcom/facebook/react/views/view/ReactViewGroup;)V
public synthetic fun prepareToRecycleView (Lcom/facebook/react/uimanager/ThemedReactContext;Landroid/view/View;)Landroid/view/View;
protected fun prepareToRecycleView (Lcom/facebook/react/uimanager/ThemedReactContext;Lcom/facebook/react/views/view/ReactViewGroup;)Lcom/facebook/react/views/view/ReactViewGroup;
public synthetic fun receiveCommand (Landroid/view/View;ILcom/facebook/react/bridge/ReadableArray;)V
public synthetic fun receiveCommand (Landroid/view/View;Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun receiveCommand (Lcom/facebook/react/views/view/ReactViewGroup;ILcom/facebook/react/bridge/ReadableArray;)V
public fun receiveCommand (Lcom/facebook/react/views/view/ReactViewGroup;Ljava/lang/String;Lcom/facebook/react/bridge/ReadableArray;)V
public fun setAccessibilityOrder (Lcom/facebook/react/views/view/ReactViewGroup;Lcom/facebook/react/bridge/ReadableArray;)V
public fun setAccessible (Lcom/facebook/react/views/view/ReactViewGroup;Z)V
public fun setBackfaceVisibility (Lcom/facebook/react/views/view/ReactViewGroup;Ljava/lang/String;)V
public fun setBackgroundImage (Lcom/facebook/react/views/view/ReactViewGroup;Lcom/facebook/react/bridge/ReadableArray;)V
@@ -30,7 +30,6 @@ import com.facebook.react.bridge.ReadableMapKeySetIterator;
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.common.MapBuilder;
import com.facebook.react.common.ReactConstants;
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.Role;
import com.facebook.react.uimanager.annotations.ReactProp;
@@ -312,61 +311,9 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
public void setNativeId(@NonNull T view, @Nullable String nativeId) {
view.setTag(R.id.view_tag_native_id, nativeId);
/*
* If we change the nativeId we need to notify the relevant accessibility parent to update the
* focusing order.
*/
if (view.getTag(R.id.accessibility_order_parent) != null) {
ViewGroup accessibilityParent = (ViewGroup) view.getTag(R.id.accessibility_order_parent);
accessibilityParent.setTag(R.id.accessibility_order_dirty, true);
accessibilityParent.notifySubtreeAccessibilityStateChanged(
accessibilityParent, accessibilityParent, AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);
}
ReactFindViewUtil.notifyViewRendered(view);
}
@ReactProp(name = ViewProps.ACCESSIBILITY_ORDER)
public void setAccessibilityOrder(@NonNull T view, @Nullable ReadableArray nativeIds) {
if (!ReactNativeFeatureFlags.enableAccessibilityOrder()) {
return;
}
view.setTag(R.id.accessibility_order, nativeIds);
view.setTag(R.id.accessibility_order_dirty, true);
if (view instanceof ViewGroup) {
((ViewGroup) view)
.setOnHierarchyChangeListener(
new ViewGroup.OnHierarchyChangeListener() {
@Override
public void onChildViewAdded(View parent, View child) {
view.setTag(R.id.accessibility_order_dirty, true);
// We also want to listen to changes on the hierarchy of nested ViewGroups
if (child instanceof ViewGroup) {
ViewGroup childGroup = (ViewGroup) child;
childGroup.setOnHierarchyChangeListener(this);
for (int i = 0; i < childGroup.getChildCount(); i++) {
onChildViewAdded(childGroup, childGroup.getChildAt(i));
}
}
}
@Override
public void onChildViewRemoved(View parent, View child) {
view.setTag(R.id.accessibility_order_dirty, true);
}
});
((ViewGroup) view)
.notifySubtreeAccessibilityStateChanged(
view, view, AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);
}
}
@ReactProp(name = ViewProps.ACCESSIBILITY_LABELLED_BY)
public void setAccessibilityLabelledBy(@NonNull T view, @Nullable Dynamic nativeId) {
if (nativeId.isNull()) {
@@ -91,10 +91,7 @@ public abstract class BaseViewManagerDelegate<
val dynamicFromObject: Dynamic = DynamicFromObject(value)
mViewManager.setAccessibilityLabelledBy(view, dynamicFromObject)
}
ViewProps.ACCESSIBILITY_ORDER ->
mViewManager.setAccessibilityOrder(view, value as ReadableArray?)
ViewProps.OPACITY -> mViewManager.setOpacity(view, (value as Double?)?.toFloat() ?: 1.0f)
ViewProps.OUTLINE_COLOR -> mViewManager.setOutlineColor(view, value as Int?)
ViewProps.OUTLINE_OFFSET ->
@@ -0,0 +1,85 @@
/*
* 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.view.View
import android.view.ViewGroup
import com.facebook.react.R
public object ReactAxOrderHelper {
@JvmStatic
public fun cleanUpAxOrder(view: View) {
val originalFocusability = view.getTag(R.id.original_focusability) as Boolean?
if (originalFocusability != null) {
view.isFocusable = originalFocusability
}
val axOrderParent = view.getTag(R.id.accessibility_order_parent) as View?
if (axOrderParent != null) {
view.setTag(R.id.accessibility_order_parent, null)
}
if (view is ViewGroup) {
for (i in 0..<view.childCount) {
cleanUpAxOrder(view.getChildAt(i))
}
}
}
@JvmStatic
public fun restoreFocusability(view: View) {
val originalFocusability = view.getTag(R.id.original_focusability) as Boolean?
if (originalFocusability != null) {
view.isFocusable = originalFocusability
}
if (view is ViewGroup) {
for (i in 0..<view.childCount) {
restoreFocusability(view.getChildAt(i))
}
}
}
public fun disableFocusForSubtree(view: View, axOrderList: MutableList<*>) {
if (!axOrderList.contains(view.getTag(R.id.view_tag_native_id))) {
if (view.getTag(R.id.original_focusability) == null) {
view.setTag(R.id.original_focusability, view.isFocusable)
}
view.isFocusable = false
}
if (view is ViewGroup) {
for (i in 0..<view.childCount) {
disableFocusForSubtree(view.getChildAt(i), axOrderList)
}
}
}
public fun buildAxOrderList(
view: View,
parent: View,
axOrderList: MutableList<*>,
result: Array<View?>,
) {
val nativeId = view.getTag(R.id.view_tag_native_id)
view.setTag(R.id.accessibility_order_parent, parent)
if (axOrderList.contains(nativeId)) {
val idx = axOrderList.indexOf(nativeId)
if (idx != -1) {
result[idx] = view
}
}
if (view is ViewGroup) {
for (i in 0..<view.childCount) {
buildAxOrderList(view.getChildAt(i), parent, axOrderList, result)
}
}
}
}
@@ -22,6 +22,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewStructure
import android.view.accessibility.AccessibilityManager
import com.facebook.common.logging.FLog
import com.facebook.react.R
import com.facebook.react.bridge.ReactNoCrashSoftException
@@ -49,6 +50,7 @@ import com.facebook.react.uimanager.PixelUtil.toDIPFromPixel
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.PointerEvents.Companion.canBeTouchTarget
import com.facebook.react.uimanager.PointerEvents.Companion.canChildrenBeTouchTarget
import com.facebook.react.uimanager.ReactAxOrderHelper
import com.facebook.react.uimanager.ReactClippingProhibitedView
import com.facebook.react.uimanager.ReactClippingViewGroup
import com.facebook.react.uimanager.ReactClippingViewGroupHelper.calculateClippingRect
@@ -63,6 +65,7 @@ import com.facebook.react.uimanager.style.BorderStyle
import com.facebook.react.uimanager.style.LogicalEdge
import com.facebook.react.uimanager.style.Overflow
import com.facebook.react.views.view.CanvasUtil.enableZ
import java.util.ArrayList
import kotlin.concurrent.Volatile
import kotlin.math.max
@@ -139,12 +142,17 @@ public open class ReactViewGroup public constructor(context: Context?) :
public override var hitSlopRect: Rect? = null
public override var pointerEvents: PointerEvents = PointerEvents.AUTO
public var axOrderList: MutableList<String>? = null
private var childrenLayoutChangeListener: ChildrenLayoutChangeListener? = null
private var onInterceptTouchEventListener: OnInterceptTouchEventListener? = null
private var needsOffscreenAlphaCompositing = false
private var backfaceOpacity = 0f
private var backfaceVisible = false
private var childrenRemovedWhileTransitioning: MutableSet<Int>? = null
private var accessibilityStateChangeListener:
AccessibilityManager.AccessibilityStateChangeListener? =
null
init {
initView()
@@ -931,6 +939,78 @@ public open class ReactViewGroup public constructor(context: Context?) :
alpha = 0f
}
override fun addChildrenForAccessibility(outChildren: ArrayList<View>) {
val axOrderParent = getTag(R.id.accessibility_order_parent)
var axOrderParentOrderList: MutableList<String>? = null
if (axOrderParent is ReactViewGroup) {
axOrderParentOrderList = (axOrderParent as ReactViewGroup?)?.axOrderList
}
val axOrder: MutableList<*>? = axOrderList
if (axOrder != null) {
val am: AccessibilityManager? =
this.getContext().getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager?
if (accessibilityStateChangeListener == null && am != null) {
val newAccessibilityStateChangeListener =
AccessibilityManager.AccessibilityStateChangeListener { enabled ->
if (!enabled) {
for (i in 0..<childCount) {
ReactAxOrderHelper.restoreFocusability(getChildAt(i))
}
}
}
am.addAccessibilityStateChangeListener(newAccessibilityStateChangeListener)
accessibilityStateChangeListener = newAccessibilityStateChangeListener
}
val result = arrayOfNulls<View?>(axOrder.size)
for (i in 0..<childCount) {
ReactAxOrderHelper.buildAxOrderList(getChildAt(i), this, axOrder, result)
}
for (i in result.indices) {
val view = result[i]
if (view != null) {
if (view.isFocusable) {
outChildren.add(view)
} else {
view.addChildrenForAccessibility(outChildren)
}
}
}
} else if (axOrderParentOrderList != null) {
// view is a container so add its children normally
if (!isFocusable) {
super.addChildrenForAccessibility(outChildren)
return
// If this view can coopt, turn the focusability off its children but add them to the tree
} else if (isFocusable && (contentDescription == null || contentDescription == "")) {
super.addChildrenForAccessibility(outChildren)
for (i in 0..<childCount) {
ReactAxOrderHelper.disableFocusForSubtree(getChildAt(i), axOrderParentOrderList)
}
// if this view is focusable and has a contentDescription then we don't care about its
// descendants for accessibility
} else if (isFocusable && !(contentDescription == null || contentDescription == "")) {
return
}
} else {
super.addChildrenForAccessibility(outChildren)
}
}
public fun cleanUpAxOrderListener() {
val am = this.context.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
if (am != null) {
accessibilityStateChangeListener?.let { am.removeAccessibilityStateChangeListener(it) }
}
accessibilityStateChangeListener = null
}
private companion object {
private const val ARRAY_CAPACITY_INCREMENT = 12
private val defaultLayoutParam = LayoutParams(0, 0)
@@ -25,6 +25,7 @@ import com.facebook.react.uimanager.LengthPercentage
import com.facebook.react.uimanager.LengthPercentageType
import com.facebook.react.uimanager.PixelUtil.dpToPx
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactAxOrderHelper
import com.facebook.react.uimanager.Spacing
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
@@ -83,11 +84,43 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
return preparedView
}
override fun onDropViewInstance(view: ReactViewGroup) {
super.onDropViewInstance(view)
view.cleanUpAxOrderListener()
}
@ReactProp(name = "accessible")
public open fun setAccessible(view: ReactViewGroup, accessible: Boolean) {
view.isFocusable = accessible
}
@ReactProp(name = ViewProps.ACCESSIBILITY_ORDER)
public open fun setAccessibilityOrder(view: ReactViewGroup, nativeIds: ReadableArray?) {
if (!ReactNativeFeatureFlags.enableAccessibilityOrder()) {
return
}
for (i in 0 until view.childCount) {
ReactAxOrderHelper.cleanUpAxOrder(view.getChildAt(i))
}
if (nativeIds == null) {
view.axOrderList = null
return
}
val axOrderList = mutableListOf<String>()
for (i in 0 until nativeIds.size()) {
val id = nativeIds.getString(i)
if (id != null) {
axOrderList.add(id)
}
}
view.axOrderList = axOrderList
}
@ReactProp(name = "hasTVPreferredFocus")
public open fun setTVPreferredFocus(view: ReactViewGroup, hasTVPreferredFocus: Boolean) {
if (hasTVPreferredFocus) {
@@ -12,10 +12,7 @@
<!-- tag is used to store whether the view is coopted by an accessibilityOrder-->
<item type="id" name="accessibility_order_parent"/>
<!-- tag is used to store the current state of the accessibility order tree-->
<item type="id" name="accessibility_order_dirty"/>
<!-- tag is used to store the original focusability value of a view within the accessibility order tree if it was changed-->
<!-- tag is used to store the original focusability value of a view -->
<item type="id" name="original_focusability"/>
<!-- tag is used to store the nativeID tag -->