diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6c76d3a2197f..29c658339297 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6439,6 +6439,7 @@ 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/HasElevatedDescendantCache, com/facebook/react/uimanager/ReactClippingViewGroup, com/facebook/react/uimanager/ReactOverflowViewWithInset, com/facebook/react/uimanager/ReactPointerEventsView { public fun (Landroid/content/Context;)V public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public final fun cleanUpAxOrderListener ()V protected fun dispatchDraw (Landroid/graphics/Canvas;)V public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z @@ -6468,6 +6469,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun onViewAdded (Landroid/view/View;)V public fun onViewRemoved (Landroid/view/View;)V + public fun requestFocus (ILandroid/graphics/Rect;)Z public fun requestLayout ()V public final fun setAxOrderList (Ljava/util/List;)V public final fun setBackfaceVisibility (Ljava/lang/String;)V diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index e6e0660d174b..ef848d6cd9c4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -581,20 +581,41 @@ public object ReactScrollViewHelper { UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC) ?: return null - val nextFocusableViewId = - (uimanager as FabricUIManager).findNextFocusableElement(host.id, focused.id, direction) - ?: return null + // The shadow tree offers every focusable or accessible node, while a view can still keep + // itself out of keyboard navigation (focusable={false} on an accessible view), so the search + // moves on from a candidate that offers no keyboard focus. Forward and backward candidates can + // lead back to each other, which the visited ids end. + val visitedIds = mutableSetOf(focused.id) + var sourceId = focused.id + while (true) { + val nextFocusableViewId = + (uimanager as FabricUIManager).findNextFocusableElement(host.id, sourceId, direction) + ?: return null + if (!visitedIds.add(nextFocusableViewId)) { + return null + } + + val ancestorIdList = + uimanager + .getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId) + ?.toMutableSet() ?: return null - val ancestorIdList = - uimanager - .getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId) - ?.toMutableSet() ?: return null + ancestorIdList.add(nextFocusableViewId) - ancestorIdList.add(nextFocusableViewId) + host.updateClippingRect(ancestorIdList) - host.updateClippingRect(ancestorIdList) + val nextFocusableView = host.findViewById(nextFocusableViewId) ?: return null + if (offersKeyboardFocus(nextFocusableView, direction)) { + return nextFocusableView + } + sourceId = nextFocusableViewId + } + } - return host.findViewById(nextFocusableViewId) + private fun offersKeyboardFocus(view: View, @FocusDirection direction: Int): Boolean { + val focusables = ArrayList() + view.addFocusables(focusables, direction) + return focusables.isNotEmpty() } @JvmStatic 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 c861a961cf35..207da713fec2 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 @@ -160,6 +160,12 @@ public open class ReactViewGroup public constructor(context: Context?) : null private var focusOnAttach = false + /** + * Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a view excluded here + * stays focusable for accessibility services while keyboard navigation passes over it. + */ + internal var excludedFromKeyboardFocus = false + internal var nativeBackgroundMap: ReadableMap? = null internal var nativeForegroundMap: ReadableMap? = null @@ -201,6 +207,7 @@ public open class ReactViewGroup public constructor(context: Context?) : childrenRemovedWhileTransitioning = null nativeBackgroundMap = null nativeForegroundMap = null + excludedFromKeyboardFocus = false } internal open fun recycleView() { @@ -508,6 +515,23 @@ public open class ReactViewGroup public constructor(context: Context?) : updateClippingToRect(clippingRect, excludedViews) } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + // A focused view stays a candidate so that focus search can move on from it. + if (excludedFromKeyboardFocus && !isFocused) { + views.subList(firstAddedIndex, views.size).remove(this) + } + } + + override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean { + if (!excludedFromKeyboardFocus) { + return super.requestFocus(direction, previouslyFocusedRect) + } + return descendantFocusability != FOCUS_BLOCK_DESCENDANTS && + onRequestFocusInDescendants(direction, previouslyFocusedRect) + } + internal fun requestFocusFromJS() { if (isAttachedToWindow) { super.requestFocus(FOCUS_DOWN, null) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt index 21ef11182da3..75dde5d6ff96 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewManager.kt @@ -421,6 +421,7 @@ public open class ReactViewManager : ReactClippingViewManager() @ReactProp(name = "focusable") public open fun setFocusable(view: ReactViewGroup, focusable: Boolean) { + view.excludedFromKeyboardFocus = !focusable if (focusable) { view.setOnClickListener { val eventDispatcher = UIManagerHelper.getEventDispatcher(view.context as ReactContext) @@ -436,7 +437,7 @@ public open class ReactViewManager : ReactClippingViewManager() view.setOnClickListener(null) view.isClickable = false // Don't set view.setFocusable(false) because we might still want it to be focusable for - // accessibility reasons + // accessibility reasons. excludedFromKeyboardFocus keeps it out of keyboard navigation. } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt new file mode 100644 index 000000000000..5095e4d075e7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt @@ -0,0 +1,169 @@ +/* + * 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. + */ + +// The deprecated APIs exercised here remain public and required by this Robolectric setup. +@file:Suppress("DEPRECATION") + +package com.facebook.react.views.scroll + +import android.util.DisplayMetrics +import android.view.View +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.bridge.UIManager +import com.facebook.react.fabric.FabricUIManager +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.react.views.view.ReactViewManager +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * The shadow-tree search a scroll view falls back to, when the next focus lies outside it, passes + * over a candidate the view tree keeps out of keyboard navigation. + */ +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class]) +class ReactScrollViewHelperFocusSearchTest { + + private lateinit var themedContext: ThemedReactContext + private lateinit var uiManagerHelperMock: MockedStatic + private val fabricUIManager: FabricUIManager = mock() + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + uiManagerHelperMock = mockStatic(UIManagerHelper::class.java) + uiManagerHelperMock + .`when` { + UIManagerHelper.getUIManager(any(), eq(UIManagerType.FABRIC)) + } + .thenReturn(fabricUIManager) + } + + @After + fun tearDown() { + uiManagerHelperMock.close() + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `passes over a candidate that focusable false keeps out of keyboard navigation`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.last) + } + + @Test + fun `finds nothing when no later candidate takes keyboard focus`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate) + + assertThat(findNextFocusableView(scene)).isNull() + } + + @Test + fun `ends when the candidates lead back to where the search started`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + offerInOrder(scene, scene.first, scene.candidate, scene.first) + + assertThat(findNextFocusableView(scene)).isNull() + } + + @Test + fun `takes a candidate whose descendant takes keyboard focus`() { + val scene = buildScene() + ReactViewManager().setFocusable(scene.candidate, false) + scene.candidate.addView(focusableView(id = 4)) + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate) + } + + @Test + fun `takes a candidate that takes keyboard focus itself`() { + val scene = buildScene() + offerInOrder(scene, scene.first, scene.candidate, scene.last) + + assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate) + } + + private fun buildScene(): Scene { + val scrollView = ReactScrollView(themedContext).apply { id = 20 } + val content = ReactViewGroup(themedContext).apply { id = 10 } + val first = focusableView(id = 1) + val candidate = + ReactViewGroup(themedContext).apply { + id = 2 + ReactViewManager().setAccessible(this, true) + } + val last = focusableView(id = 3) + content.addView(first) + content.addView(candidate) + content.addView(last) + scrollView.addView(content) + whenever(fabricUIManager.getRelativeAncestorList(eq(content.id), any())) + .thenReturn(intArrayOf(content.id)) + return Scene(scrollView, first, candidate, last) + } + + private fun focusableView(id: Int): View = + View(themedContext).apply { + this.id = id + isFocusable = true + } + + private fun offerInOrder(scene: Scene, vararg order: View) { + order.toList().zipWithNext().forEach { (source, next) -> + whenever( + fabricUIManager.findNextFocusableElement( + eq(scene.scrollView.id), + eq(source.id), + eq(View.FOCUS_FORWARD), + ) + ) + .thenReturn(next.id) + } + } + + private fun findNextFocusableView(scene: Scene): View? = + ReactScrollViewHelper.findNextFocusableView(scene.scrollView, scene.first, View.FOCUS_FORWARD) + + private class Scene( + val scrollView: ReactScrollView, + val first: View, + val candidate: ReactViewGroup, + val last: View, + ) +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt index 4bc272951144..5a6ae4530ec4 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt @@ -12,6 +12,7 @@ import android.content.Context import android.graphics.BlendMode import android.graphics.Canvas import android.graphics.Paint +import android.view.FocusFinder import android.view.View import android.view.ViewGroup import android.widget.FrameLayout @@ -290,6 +291,118 @@ class ReactViewGroupTest { assertThat(outer.hasOverlappingRendering()).isTrue() } + @Test + fun `focusable false - keeps an accessible view out of keyboard focus search`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val excluded = accessibleView(top = 10, focusable = false) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(excluded) + root.addView(trailing) + + assertThat(collectKeyboardFocusables(root)).containsExactly(leading, trailing) + // Accessibility services still treat the view as one focusable element. + assertThat(excluded.isFocusable).isTrue() + } + + @Test + fun `focusable false - keeps the descendants of an excluded view in keyboard focus search`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + val descendant = focusableView(top = 0) + excluded.addView(descendant) + root.addView(excluded) + + assertThat(collectKeyboardFocusables(root)).containsExactly(descendant) + } + + @Test + fun `focusable false - default focus passes over an excluded view`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + val trailing = focusableView(top = 10) + root.addView(excluded) + root.addView(trailing) + + assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue() + + assertThat(excluded.isFocused).isFalse() + assertThat(trailing.isFocused).isTrue() + } + + @Test + fun `focusable false - focus requested from JS still reaches an excluded view`() { + val root = ReactViewGroup(context) + val excluded = accessibleView(top = 0, focusable = false) + root.addView(excluded) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + + excluded.requestFocusFromJS() + + assertThat(excluded.isFocused).isTrue() + } + + @Test + fun `focusable false - keyboard focus moves on from an excluded view that holds focus`() { + val root = ReactViewGroup(context) + val leading = focusableView(top = 0) + val excluded = accessibleView(top = 10, focusable = false) + val trailing = focusableView(top = 20) + root.addView(leading) + root.addView(excluded) + root.addView(trailing) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + excluded.requestFocusFromJS() + + assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_FORWARD)) + .isSameAs(trailing) + assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_BACKWARD)) + .isSameAs(leading) + } + + @Test + fun `focusable true - returns a view to keyboard focus search`() { + val root = ReactViewGroup(context) + val view = accessibleView(top = 0, focusable = false) + root.addView(view) + + ReactViewManager().setFocusable(view, true) + + assertThat(collectKeyboardFocusables(root)).containsExactly(view) + } + + @Test + fun `focusable false - recycling a view returns it to keyboard focus search`() { + val root = ReactViewGroup(context) + val view = accessibleView(top = 0, focusable = false) + + view.recycleView() + root.addView(view) + + assertThat(collectKeyboardFocusables(root)).containsExactly(view) + } + + private fun focusableView(top: Int): View = + View(context).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun accessibleView(top: Int, focusable: Boolean): ReactViewGroup = + ReactViewGroup(context).apply { + layout(0, top, 100, top + 10) + val manager = ReactViewManager() + manager.setAccessible(this, true) + manager.setFocusable(this, focusable) + } + + private fun collectKeyboardFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables + } + // Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache. private fun mount(parent: ReactViewGroup, child: View) { parent.addView(child)