Skip to content

Commit 68cd735

Browse files
Reset accessibility state when recycling Android views
With view recycling on, prepareToRecycleView clears the accessibility tags a view's props wrote but not the state those props applied to the view itself. The next view built from it applies only the props it declares, so it announces the previous view's content description and role, reads as selected, keeps its live region and screen-reader focusability, and stays hidden from accessibility services when the previous view was. Reset that state to a new view's, clear the accessibility delegate on the view, and restore TextView's default importance in ReactTextView.recycleView.
1 parent 977ca0a commit 68cd735

4 files changed

Lines changed: 141 additions & 0 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) {
9393
view.setTag(R.id.accessibility_actions, null);
9494
view.setTag(R.id.accessibility_value, null);
9595
view.setTag(R.id.accessibility_state_expanded, null);
96+
view.setTag(R.id.accessibility_collection, null);
97+
view.setTag(R.id.accessibility_collection_item, null);
98+
view.setTag(R.id.role, null);
9699
view.setTag(R.id.accessibility_state_disabled, null);
97100
view.setEnabled(true);
98101
view.setTag(R.id.view_clipped, null);
@@ -161,6 +164,17 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) {
161164
view.setFocusable(false);
162165
view.setFocusableInTouchMode(false);
163166

167+
// Accessibility state the props apply to the view itself, as a new view has it. The delegate
168+
// is cleared on the view: ViewCompat would install an empty one and mark the view important.
169+
view.setAccessibilityDelegate(null);
170+
view.setContentDescription(null);
171+
view.setSelected(false);
172+
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
173+
ViewCompat.setAccessibilityLiveRegion(view, ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE);
174+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
175+
view.setScreenReaderFocusable(false);
176+
}
177+
164178
// Predictably, alpha defaults to 1:
165179
// https://android.googlesource.com/platform/frameworks/base/+/a175a5b/core/java/android/view/View.java#2186
166180
// This accounts for resetting mBackfaceOpacity and mBackfaceVisibility

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ private void initView() {
192192
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
193193
setFocusable(View.FOCUSABLE_AUTO);
194194
}
195+
// TextView's constructor makes the view important for accessibility
196+
setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
195197

196198
setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE);
197199
updateView(); // call after changing ellipsizeLocation in particular

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,22 @@
1010
package com.facebook.react.uimanager
1111

1212
import android.view.View.OnFocusChangeListener
13+
import androidx.core.view.ViewCompat
1314
import com.facebook.react.R
1415
import com.facebook.react.bridge.BridgeReactContext
1516
import com.facebook.react.bridge.DynamicFromObject
1617
import com.facebook.react.bridge.JavaOnlyArray
1718
import com.facebook.react.bridge.JavaOnlyMap
19+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
20+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
1821
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
22+
import com.facebook.react.touch.JSResponderHandler
1923
import com.facebook.react.views.view.ReactViewGroup
2024
import com.facebook.react.views.view.ReactViewManager
2125
import com.facebook.testutils.shadows.ShadowArguments
2226
import java.util.Locale
2327
import org.assertj.core.api.Assertions
28+
import org.junit.After
2429
import org.junit.Before
2530
import org.junit.Test
2631
import org.junit.runner.RunWith
@@ -41,12 +46,24 @@ class BaseViewManagerTest {
4146
@Before
4247
fun setUp() {
4348
ReactNativeFeatureFlagsForTests.setUp()
49+
ReactNativeFeatureFlags.override(
50+
object : ReactNativeFeatureFlagsDefaults() {
51+
override fun enableViewRecycling(): Boolean = true
52+
},
53+
)
54+
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication())
4455
viewManager = ReactViewManager()
4556
val context = BridgeReactContext(RuntimeEnvironment.getApplication())
4657
themedReactContext = ThemedReactContext(context, context, null, -1)
4758
view = ReactViewGroup(themedReactContext)
4859
}
4960

61+
@After
62+
fun tearDown() {
63+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
64+
ReactNativeFeatureFlags.dangerouslyReset()
65+
}
66+
5067
@Test
5168
fun testAccessibilityRoleNone() {
5269
viewManager.setAccessibilityRole(view, "none")
@@ -162,4 +179,85 @@ class BaseViewManagerTest {
162179
viewManager.setAccessibilityLabelledBy(view, DynamicFromObject(JavaOnlyArray()))
163180
Assertions.assertThat(view.getTag(R.id.labelled_by)).isNull()
164181
}
182+
183+
@Test
184+
fun testRecycledViewHasTheAccessibilityStateOfANewView() {
185+
val recycled =
186+
recycleView(
187+
JavaOnlyMap.of(
188+
ViewProps.ACCESSIBILITY_LABEL,
189+
"Dismiss sheet",
190+
ViewProps.ACCESSIBILITY_STATE,
191+
JavaOnlyMap.of("selected", true),
192+
ViewProps.ACCESSIBILITY_LIVE_REGION,
193+
"assertive",
194+
ViewProps.IMPORTANT_FOR_ACCESSIBILITY,
195+
"no-hide-descendants",
196+
ViewProps.SCREEN_READER_FOCUSABLE,
197+
true,
198+
)
199+
)
200+
201+
Assertions.assertThat(recycled.contentDescription).isEqualTo(view.contentDescription)
202+
Assertions.assertThat(recycled.isSelected).isEqualTo(view.isSelected)
203+
Assertions.assertThat(recycled.accessibilityLiveRegion).isEqualTo(view.accessibilityLiveRegion)
204+
Assertions.assertThat(recycled.importantForAccessibility)
205+
.isEqualTo(view.importantForAccessibility)
206+
Assertions.assertThat(recycled.isScreenReaderFocusable).isEqualTo(view.isScreenReaderFocusable)
207+
}
208+
209+
@Test
210+
fun testRecycledViewDropsTheRoleAndCollectionOfItsPreviousProps() {
211+
val recycled =
212+
recycleView(
213+
JavaOnlyMap.of(
214+
ViewProps.ROLE,
215+
"list",
216+
ViewProps.ACCESSIBILITY_COLLECTION,
217+
JavaOnlyMap.of("rowCount", 1, "columnCount", 1, "hierarchical", false),
218+
ViewProps.ACCESSIBILITY_COLLECTION_ITEM,
219+
JavaOnlyMap.of(
220+
"rowIndex",
221+
0,
222+
"rowSpan",
223+
1,
224+
"columnIndex",
225+
0,
226+
"columnSpan",
227+
1,
228+
"heading",
229+
false,
230+
),
231+
)
232+
)
233+
234+
Assertions.assertThat(recycled.getTag(R.id.role)).isNull()
235+
Assertions.assertThat(recycled.getTag(R.id.accessibility_collection)).isNull()
236+
Assertions.assertThat(recycled.getTag(R.id.accessibility_collection_item)).isNull()
237+
}
238+
239+
@Test
240+
fun testRecycledViewWithoutAccessibilityPropsHasNoAccessibilityDelegate() {
241+
val recycled = recycleView(JavaOnlyMap.of(ViewProps.ROLE, "button"))
242+
243+
Assertions.assertThat(ViewCompat.hasAccessibilityDelegate(recycled))
244+
.isEqualTo(ViewCompat.hasAccessibilityDelegate(view))
245+
}
246+
247+
private fun recycleView(previousProps: JavaOnlyMap): ReactViewGroup {
248+
val previous = createView(previousProps)
249+
viewManager.onDropViewInstance(previous)
250+
val recycled = createView(JavaOnlyMap())
251+
Assertions.assertThat(recycled).isSameAs(previous)
252+
return recycled
253+
}
254+
255+
private fun createView(props: JavaOnlyMap): ReactViewGroup =
256+
viewManager.createView(
257+
1,
258+
themedReactContext,
259+
ReactStylesDiffMap(props),
260+
null,
261+
JSResponderHandler(),
262+
)
165263
}

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ import com.facebook.react.bridge.BridgeReactContext
1616
import com.facebook.react.bridge.CatalystInstance
1717
import com.facebook.react.bridge.JavaOnlyMap
1818
import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance
19+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
20+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults
1921
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
22+
import com.facebook.react.touch.JSResponderHandler
2023
import com.facebook.react.uimanager.BackgroundStyleApplicator
2124
import com.facebook.react.uimanager.DisplayMetricsHolder
2225
import com.facebook.react.uimanager.LengthPercentage
@@ -44,6 +47,11 @@ class ReactTextViewPropertyTest {
4447
@Before
4548
fun setup() {
4649
ReactNativeFeatureFlagsForTests.setUp()
50+
ReactNativeFeatureFlags.override(
51+
object : ReactNativeFeatureFlagsDefaults() {
52+
override fun enableViewRecycling(): Boolean = true
53+
},
54+
)
4755
context = BridgeReactContext(RuntimeEnvironment.getApplication())
4856
catalystInstanceMock = createMockCatalystInstance()
4957
context.initializeWithInstance(catalystInstanceMock)
@@ -55,6 +63,7 @@ class ReactTextViewPropertyTest {
5563
@After
5664
fun teardown() {
5765
DisplayMetricsHolder.setScreenDisplayMetrics(null)
66+
ReactNativeFeatureFlags.dangerouslyReset()
5867
}
5968

6069
private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap {
@@ -78,4 +87,22 @@ class ReactTextViewPropertyTest {
7887
assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS))
7988
.isNull()
8089
}
90+
91+
@Test
92+
fun testRecycledViewIsAsImportantForAccessibilityAsANewView() {
93+
val previous =
94+
manager.createView(
95+
1,
96+
themedContext,
97+
buildStyles("importantForAccessibility", "no-hide-descendants"),
98+
null,
99+
JSResponderHandler(),
100+
)
101+
manager.onDropViewInstance(previous)
102+
val recycled = manager.createView(2, themedContext, buildStyles(), null, JSResponderHandler())
103+
104+
assertThat(recycled).isSameAs(previous)
105+
assertThat(recycled.importantForAccessibility)
106+
.isEqualTo(manager.createViewInstance(themedContext).importantForAccessibility)
107+
}
81108
}

0 commit comments

Comments
 (0)