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 55daec06a8c6..f4c8744f5ba4 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; @@ -93,6 +94,11 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setTag(R.id.accessibility_actions, null); view.setTag(R.id.accessibility_value, null); view.setTag(R.id.accessibility_state_expanded, null); + view.setTag(R.id.accessibility_collection, null); + view.setTag(R.id.accessibility_collection_item, null); + view.setTag(R.id.role, null); + view.setTag(R.id.accessibility_order_parent, null); + view.setTag(R.id.original_focusability, null); view.setTag(R.id.accessibility_state_disabled, null); view.setEnabled(true); view.setTag(R.id.view_clipped, null); @@ -161,6 +167,20 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setFocusable(false); view.setFocusableInTouchMode(false); + // Accessibility state the props apply to the view itself, as a new view has it. The delegate + // stays, since a view's constructor may have installed it, and forgets what it cached. + AccessibilityDelegateCompat accessibilityDelegate = ViewCompat.getAccessibilityDelegate(view); + if (accessibilityDelegate instanceof ReactAccessibilityDelegate) { + ((ReactAccessibilityDelegate) accessibilityDelegate).internal_prepareToRecycle(); + } + view.setContentDescription(null); + view.setSelected(false); + view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); + ViewCompat.setAccessibilityLiveRegion(view, ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + view.setScreenReaderFocusable(false); + } + // Predictably, alpha defaults to 1: // https://android.googlesource.com/platform/frameworks/base/+/a175a5b/core/java/android/view/View.java#2186 // This accounts for resetting mBackfaceOpacity and mBackfaceVisibility diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 6a227761f256..94e906f6995e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -57,7 +57,6 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac } } private val accessibilityActionsMap = HashMap() - private var accessibilityLabelledBy: View? = null init { // We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and @@ -68,6 +67,12 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac hostView.importantForAccessibility = originalImportantForAccessibility } + /** Forgets the actions cached from the props of a view that is about to be recycled. */ + @JvmName("internal_prepareToRecycle") + internal fun prepareToRecycle() { + accessibilityActionsMap.clear() + } + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { super.onInitializeAccessibilityNodeInfo(host, info) @@ -90,9 +95,9 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac val accessibilityLabelledBy = host.getTag(R.id.labelled_by) if (accessibilityLabelledBy != null) { - this.accessibilityLabelledBy = findView(host.rootView, accessibilityLabelledBy as String) - if (this.accessibilityLabelledBy != null) { - info.setLabeledBy(this.accessibilityLabelledBy) + val labellingView = findView(host.rootView, accessibilityLabelledBy as String) + if (labellingView != null) { + info.setLabeledBy(labellingView) } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 677eb472660b..ae22a06e8094 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -192,6 +192,8 @@ private void initView() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { setFocusable(View.FOCUSABLE_AUTO); } + // TextView's constructor makes the view important for accessibility + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE); updateView(); // call after changing ellipsizeLocation in particular diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 363f1336d000..acb54c03f66f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -189,6 +189,7 @@ public open class ReactViewGroup public constructor(context: Context?) : allChildrenCount = 0 clippingRect = null hitSlopRect = null + axOrderList = null // pointerEvents setter reads _overflow, so _overflow must be assigned first. _overflow = Overflow.VISIBLE pointerEvents = PointerEvents.AUTO diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 95e47b23e9a2..00d1ea51ea47 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -9,18 +9,29 @@ package com.facebook.react.uimanager +import android.view.View import android.view.View.OnFocusChangeListener +import android.view.accessibility.AccessibilityNodeInfo +import androidx.core.view.ViewCompat import com.facebook.react.R import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.DynamicFromObject import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.touch.JSResponderHandler +import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager +import com.facebook.react.views.scroll.ReactNestedScrollViewManager +import com.facebook.react.views.scroll.ReactScrollViewAccessibilityDelegate +import com.facebook.react.views.scroll.ReactScrollViewManager import com.facebook.react.views.view.ReactViewGroup import com.facebook.react.views.view.ReactViewManager import com.facebook.testutils.shadows.ShadowArguments import java.util.Locale import org.assertj.core.api.Assertions +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -41,12 +52,24 @@ class BaseViewManagerTest { @Before fun setUp() { ReactNativeFeatureFlagsForTests.setUp() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + }, + ) + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) viewManager = ReactViewManager() val context = BridgeReactContext(RuntimeEnvironment.getApplication()) themedReactContext = ThemedReactContext(context, context, null, -1) view = ReactViewGroup(themedReactContext) } + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() + } + @Test fun testAccessibilityRoleNone() { viewManager.setAccessibilityRole(view, "none") @@ -162,4 +185,168 @@ class BaseViewManagerTest { viewManager.setAccessibilityLabelledBy(view, DynamicFromObject(JavaOnlyArray())) Assertions.assertThat(view.getTag(R.id.labelled_by)).isNull() } + + @Test + fun testRecycledViewHasTheAccessibilityStateOfANewView() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ACCESSIBILITY_LABEL, + "Dismiss sheet", + ViewProps.ACCESSIBILITY_STATE, + JavaOnlyMap.of("selected", true), + ViewProps.ACCESSIBILITY_LIVE_REGION, + "assertive", + ViewProps.IMPORTANT_FOR_ACCESSIBILITY, + "no-hide-descendants", + ViewProps.SCREEN_READER_FOCUSABLE, + true, + ) + ) + + Assertions.assertThat(recycled.contentDescription).isEqualTo(view.contentDescription) + Assertions.assertThat(recycled.isSelected).isEqualTo(view.isSelected) + Assertions.assertThat(recycled.accessibilityLiveRegion).isEqualTo(view.accessibilityLiveRegion) + Assertions.assertThat(recycled.importantForAccessibility) + .isEqualTo(view.importantForAccessibility) + Assertions.assertThat(recycled.isScreenReaderFocusable).isEqualTo(view.isScreenReaderFocusable) + } + + @Test + fun testRecycledViewDropsTheRoleAndCollectionOfItsPreviousProps() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ROLE, + "list", + ViewProps.ACCESSIBILITY_COLLECTION, + JavaOnlyMap.of("rowCount", 1, "columnCount", 1, "hierarchical", false), + ViewProps.ACCESSIBILITY_COLLECTION_ITEM, + JavaOnlyMap.of( + "rowIndex", + 0, + "rowSpan", + 1, + "columnIndex", + 0, + "columnSpan", + 1, + "heading", + false, + ), + ) + ) + + Assertions.assertThat(recycled.getTag(R.id.role)).isNull() + Assertions.assertThat(recycled.getTag(R.id.accessibility_collection)).isNull() + Assertions.assertThat(recycled.getTag(R.id.accessibility_collection_item)).isNull() + } + + @Test + fun testRecycledScrollViewsKeepTheAccessibilityDelegateTheirConstructorsInstall() { + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + + override fun enableViewRecyclingForScrollView(): Boolean = true + }, + ) + + val recycledScrollViews = + listOf( + recycleView(ReactScrollViewManager(), JavaOnlyMap()), + recycleView(ReactHorizontalScrollViewManager(), JavaOnlyMap()), + recycleView(ReactNestedScrollViewManager(), JavaOnlyMap()), + ) + + for (recycled in recycledScrollViews) { + Assertions.assertThat(ViewCompat.getAccessibilityDelegate(recycled)) + .isInstanceOf(ReactScrollViewAccessibilityDelegate::class.java) + } + } + + @Test + fun testRecycledViewPerformsItsOwnAccessibilityClick() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ACCESSIBILITY_ACTIONS, + JavaOnlyArray.of(JavaOnlyMap.of("name", "activate")), + ) + ) { previous -> + previous.createAccessibilityNodeInfo() + } + var clicks = 0 + recycled.setOnClickListener { clicks++ } + + recycled.performAccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, null) + + Assertions.assertThat(clicks).isEqualTo(1) + } + + @Test + fun testRecycledViewDropsTheAccessibilityOrderOfItsPreviousProps() { + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + + override fun enableAccessibilityOrder(): Boolean = true + }, + ) + + val recycled = + recycleView(JavaOnlyMap.of(ViewProps.ACCESSIBILITY_ORDER, JavaOnlyArray.of("first"))) + + Assertions.assertThat(recycled.axOrderList).isNull() + } + + @Test + fun testRecycledViewDropsTheAccessibilityOrderItsParentApplied() { + val parent = ReactViewGroup(themedReactContext) + + val recycled = + recycleView(JavaOnlyMap()) { previous -> + ReactAxOrderHelper.buildAxOrderList( + previous, + parent, + mutableListOf(), + arrayOfNulls(0), + ) + ReactAxOrderHelper.disableFocusForSubtree(previous, mutableListOf()) + } + + Assertions.assertThat(recycled.getTag(R.id.accessibility_order_parent)).isNull() + Assertions.assertThat(recycled.getTag(R.id.original_focusability)).isNull() + } + + private fun recycleView( + previousProps: JavaOnlyMap, + beforeDrop: (ReactViewGroup) -> Unit = {}, + ): ReactViewGroup = recycleView(viewManager, previousProps, beforeDrop) + + private fun recycleView( + manager: ViewManager, + previousProps: JavaOnlyMap, + beforeDrop: (T) -> Unit = {}, + ): T { + val previous = createView(manager, previousProps) + beforeDrop(previous) + manager.onDropViewInstance(previous) + val recycled = createView(manager, JavaOnlyMap()) + Assertions.assertThat(recycled).isSameAs(previous) + return recycled + } + + private fun createView(props: JavaOnlyMap): ReactViewGroup = createView(viewManager, props) + + private fun createView(manager: ViewManager, props: JavaOnlyMap): T = + manager.createView( + 1, + themedReactContext, + ReactStylesDiffMap(props), + null, + JSResponderHandler(), + ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt index 12adce0d1308..b0e5258e65a3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt @@ -16,7 +16,10 @@ import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.touch.JSResponderHandler import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.LengthPercentage @@ -44,6 +47,11 @@ class ReactTextViewPropertyTest { @Before fun setup() { ReactNativeFeatureFlagsForTests.setUp() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + }, + ) context = BridgeReactContext(RuntimeEnvironment.getApplication()) catalystInstanceMock = createMockCatalystInstance() context.initializeWithInstance(catalystInstanceMock) @@ -55,6 +63,7 @@ class ReactTextViewPropertyTest { @After fun teardown() { DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() } private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap { @@ -78,4 +87,22 @@ class ReactTextViewPropertyTest { assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) .isNull() } + + @Test + fun testRecycledViewIsAsImportantForAccessibilityAsANewView() { + val previous = + manager.createView( + 1, + themedContext, + buildStyles("importantForAccessibility", "no-hide-descendants"), + null, + JSResponderHandler(), + ) + manager.onDropViewInstance(previous) + val recycled = manager.createView(2, themedContext, buildStyles(), null, JSResponderHandler()) + + assertThat(recycled).isSameAs(previous) + assertThat(recycled.importantForAccessibility) + .isEqualTo(manager.createViewInstance(themedContext).importantForAccessibility) + } }