Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac
}
}
private val accessibilityActionsMap = HashMap<Int, String?>()
private var accessibilityLabelledBy: View? = null

init {
// We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and
Expand All @@ -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)

Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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<String>(),
arrayOfNulls(0),
)
ReactAxOrderHelper.disableFocusForSubtree(previous, mutableListOf<String>())
}

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 <T : View> recycleView(
manager: ViewManager<T, *>,
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 <T : View> createView(manager: ViewManager<T, *>, props: JavaOnlyMap): T =
manager.createView(
1,
themedReactContext,
ReactStylesDiffMap(props),
null,
JSResponderHandler(),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -55,6 +63,7 @@ class ReactTextViewPropertyTest {
@After
fun teardown() {
DisplayMetricsHolder.setScreenDisplayMetrics(null)
ReactNativeFeatureFlags.dangerouslyReset()
}

private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap {
Expand All @@ -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)
}
}
Loading