From f912da089d0448c09d682ef754d201b173b6c2c3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 18:47:28 +0300 Subject: [PATCH 1/2] Keep focusable={false} Android views out of keyboard navigation An accessible view keeps isFocusable for accessibility services, so setFocusable(false) left it in Tab and arrow-key navigation: every Pressable is accessible, and tabIndex={-1} or focusable={false} on one changed nothing about where a hardware keyboard moved focus. ReactViewGroup now records the prop and skips the view in addFocusables and requestFocus, the two ways keyboard navigation reaches it, while its descendants, accessibility services and requestFocusFromJS still do. --- .../react/views/view/ReactViewGroup.kt | 24 ++++ .../react/views/view/ReactViewManager.kt | 3 +- .../react/views/view/ReactViewGroupTest.kt | 113 ++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) 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/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) From b969e8393335412b889aa1378520cc1063077e17 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 12:33:49 +0300 Subject: [PATCH 2/2] Pass over a scroll view's focus candidate that takes no keyboard focus When the next focus lies outside a scroll view, ReactScrollView and its horizontal and nested siblings ask the shadow tree for the next focusable element inside it, and that search offers every focusable or accessible node. A view focusable={false} keeps out of keyboard navigation is still accessible, so the search handed it back, the view refused focus and Tab stopped moving. findNextFocusableView now moves on from a candidate whose addFocusables adds nothing, and ends at a candidate it already offered, since forward candidates can lead back to each other. ReactViewGroup's addFocusables and requestFocus overrides are added to ReactAndroid.api. --- .../ReactAndroid/api/ReactAndroid.api | 2 + .../views/scroll/ReactScrollViewHelper.kt | 41 +++-- .../ReactScrollViewHelperFocusSearchTest.kt | 169 ++++++++++++++++++ 3 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusSearchTest.kt 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/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, + ) +}