From 3ea99d7f8ac20f0236f377b39028ec494b59a34c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 12:05:40 +0300 Subject: [PATCH 1/2] Keep hidden Android view subtrees out of keyboard navigation A View with importantForAccessibility="no-hide-descendants", which is what aria-hidden becomes, hid its descendants from TalkBack while Tab, the arrow keys and a D-pad still landed on them. ReactViewGroup now leaves those descendants out of the focus search and out of focus that enters through the view, and keyboard focus sitting on one moves off when the view hides it. A tap or a focus() call still focuses them, and the view's own descendantFocusability is untouched. The shadow tree search a scroll view falls back to when focus leaves it skips those descendants too, so Tab past a scroll view's last control no longer picks a hidden one and stops there. --- .../react/views/view/ReactViewGroup.kt | 29 ++++ .../jni/react/fabric/FocusOrderingHelper.cpp | 8 + .../react/views/view/ReactViewGroupTest.kt | 147 ++++++++++++++++++ 3 files changed, 184 insertions(+) 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..a4c8e73f7393 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 @@ -508,6 +508,35 @@ public open class ReactViewGroup public constructor(context: Context?) : updateClippingToRect(clippingRect, excludedViews) } + /** + * A view that hides its descendants from accessibility services keeps them out of keyboard and + * D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot + * reach. A tap or a `focus()` call still reaches them. + */ + private var hidesDescendantsFromNavigation = false + + override fun setImportantForAccessibility(mode: Int) { + super.setImportantForAccessibility(mode) + hidesDescendantsFromNavigation = mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + // Keyboard focus leaves content that is now hidden; touch focus stays where the user types. + if (hidesDescendantsFromNavigation && !isInTouchMode) { + findFocus()?.takeIf { it !== this }?.clearFocus() + } + } + + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + if (hidesDescendantsFromNavigation) { + // A focused descendant stays a candidate so that focus search can move on from it. + views.subList(firstAddedIndex, views.size).removeAll { it !== this && !it.isFocused } + } + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !hidesDescendantsFromNavigation && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + internal fun requestFocusFromJS() { if (isAttachedToWindow) { super.requestFocus(FOCUS_DOWN, null) diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp index 1e4e04c49d9c..f1a97fc6798c 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FocusOrderingHelper.cpp @@ -227,6 +227,14 @@ void FocusOrderingHelper::traverseAndUpdateNextFocusableElement( } } + // A view that hides its descendants from accessibility services keeps them + // out of keyboard navigation, as ReactViewGroup does for the views it hosts. + if (props != nullptr && + props->importantForAccessibility == + ImportantForAccessibility::NoHideDescendants) { + return; + } + for (auto& child : currNode->getChildren()) { if (child->getTraits().check(ShadowNodeTraits::Trait::RootNodeKind)) { continue; 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 56383ff7efeb..2346d53dd691 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 @@ -22,6 +22,7 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) class ReactViewGroupTest { @@ -260,6 +261,131 @@ class ReactViewGroupTest { assertThat(outer.hasOverlappingRendering()).isTrue() } + @Test + fun `hidden descendants - leave keyboard focus search`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(shown) + } + + @Test + fun `hidden descendants - return to keyboard focus search once shown again`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + val manager = ReactViewManager() + manager.setImportantForAccessibility(hidden, "no-hide-descendants") + + manager.setImportantForAccessibility(hidden, null) + + assertThat(collectKeyboardFocusables(root)).containsExactly(hiddenControl, shown) + } + + @Test + fun `hidden descendants - default focus passes over them`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue() + + assertThat(hiddenControl.isFocused).isFalse() + assertThat(shown.isFocused).isTrue() + } + + @Test + fun `hidden descendants - a descendant keyboard focus sits on gives it up as they hide`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + hidden.addView(hiddenControl) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(hiddenControl.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hiddenControl.isFocused).isFalse() + } + + @Test + fun `hidden descendants - still take focus directly, as a tap or a focus() call gives it`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val hiddenControl = focusableView(top = 0) + hidden.addView(hiddenControl) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hiddenControl.requestFocus()).isTrue() + } + + @Test + fun `hidden descendants - a descendant focused by touch keeps its focus as they hide`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val field = focusableView(top = 0).apply { isFocusableInTouchMode = true } + hidden.addView(field) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + enterTouchMode(root) + assertThat(field.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(field.isFocused).isTrue() + } + + @Test + fun `hidden descendants - keyboard navigation moves on from one that keeps its focus`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0) + val field = focusableView(top = 0).apply { isFocusableInTouchMode = true } + val otherHiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(field) + hidden.addView(otherHiddenControl) + root.addView(hidden) + root.addView(shown) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + enterTouchMode(root) + assertThat(field.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(field, shown) + } + + @Test + fun `hidden descendants - leave the view's own descendant focusability as it was`() { + val hidden = + containerView(top = 0).apply { descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS } + val manager = ReactViewManager() + + manager.setImportantForAccessibility(hidden, "no-hide-descendants") + manager.setImportantForAccessibility(hidden, null) + + assertThat(hidden.descendantFocusability).isEqualTo(ViewGroup.FOCUS_AFTER_DESCENDANTS) + } + // Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache. private fun mount(parent: ReactViewGroup, child: View) { parent.addView(child) @@ -280,6 +406,27 @@ class ReactViewGroupTest { } private fun elevatedChild(): View = View(context).apply { elevation = 10f } + + private fun containerView(top: Int): ReactViewGroup = + ReactViewGroup(context).apply { layout(0, top, 100, top + 10) } + + private fun focusableView(top: Int): View = + View(context).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun collectKeyboardFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables + } + + // A touch puts the window in touch mode; Robolectric's windows start out of it. + private fun enterTouchMode(attached: View) { + val attachInfo = ReflectionHelpers.getField(attached, "mAttachInfo") + ReflectionHelpers.setField(attachInfo, "mInTouchMode", true) + } } class TestView(context: Context, yPos: Int) : View(context) { From 27b042e8d44c75ec92259d1d171718a453d55e7a Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 12:06:19 +0300 Subject: [PATCH 2/2] Keep content hidden by any container out of keyboard navigation A ScrollView, a horizontal ScrollView, DrawerLayoutAndroid or RefreshControl with importantForAccessibility="no-hide-descendants" left its content in keyboard navigation, because only ReactViewGroup filtered the descendants it hides. The filtering moves into HiddenSubtreeFocusHelper, which every container React Native renders calls from addFocusables and onRequestFocusInDescendants, reading importantForAccessibility rather than a copy of it, and BaseViewManager moves keyboard focus off a subtree as it hides it, whatever the view type. The new public overrides are added to ReactAndroid.api. --- .../ReactAndroid/api/ReactAndroid.api | 8 + .../react/uimanager/BaseViewManager.java | 1 + .../uimanager/HiddenSubtreeFocusHelper.kt | 45 +++++ .../react/views/drawer/ReactDrawerLayout.kt | 12 ++ .../views/scroll/ReactHorizontalScrollView.kt | 11 ++ .../views/scroll/ReactNestedScrollView.kt | 13 +- .../react/views/scroll/ReactScrollView.kt | 11 ++ .../swiperefresh/ReactSwipeRefreshLayout.kt | 13 ++ .../react/views/view/ReactViewGroup.kt | 24 +-- .../HiddenSubtreeKeyboardFocusTest.kt | 180 ++++++++++++++++++ .../react/views/view/ReactViewGroupTest.kt | 31 +++ 11 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 6c76d3a2197f..b5064c467d9c 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -5161,6 +5161,7 @@ public abstract interface class com/facebook/react/viewmanagers/VirtualViewManag public final class com/facebook/react/views/drawer/ReactDrawerLayout : androidx/drawerlayout/widget/DrawerLayout, com/facebook/react/uimanager/HasChildPressedStateDelay { public fun (Lcom/facebook/react/bridge/ReactContext;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean; public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5515,6 +5516,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android public fun onLayoutChange (Landroid/view/View;IIIIIIII)V protected fun onMeasure (II)V protected fun onOverScrolled (IIZZ)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onScrollChanged (IIII)V protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5625,6 +5627,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;)V public synthetic fun (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun abortAnimation ()V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z public fun draw (Landroid/graphics/Canvas;)V public fun executeKeyEvent (Landroid/view/KeyEvent;)Z @@ -5665,6 +5668,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun onLayoutChange (Landroid/view/View;IIIIIIII)V protected fun onMeasure (II)V protected fun onOverScrolled (IIZZ)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onScrollChanged (IIII)V protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z @@ -5980,10 +5984,12 @@ public final class com/facebook/react/views/scroll/VirtualViewContainerState$Com public class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout, com/facebook/react/uimanager/HasChildPressedStateDelay { public fun (Lcom/facebook/react/bridge/ReactContext;)V + public fun addFocusables (Ljava/util/ArrayList;II)V public fun canChildScrollUp ()Z public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean; public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z public fun onLayout (ZIIII)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun requestDisallowInterceptTouchEvent (Z)V public fun setHasChildPressedStateDelay (Ljava/lang/Boolean;)V @@ -6439,6 +6445,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 @@ -6464,6 +6471,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z protected fun onLayout (ZIIII)V protected fun onMeasure (II)V + protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z protected fun onSizeChanged (IIII)V public fun onTouchEvent (Landroid/view/MotionEvent;)Z public fun onViewAdded (Landroid/view/View;)V 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..5bc48ca11e7c 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 @@ -505,6 +505,7 @@ public void setImportantForAccessibility( view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); } else if (importantForAccessibility.equals("no-hide-descendants")) { view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + HiddenSubtreeFocusHelper.releaseHiddenKeyboardFocus(view); } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt new file mode 100644 index 000000000000..40d71b01113f --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/HiddenSubtreeFocusHelper.kt @@ -0,0 +1,45 @@ +/* + * 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. + */ + +package com.facebook.react.uimanager + +import android.view.View +import android.view.ViewGroup + +/** + * Keeps the descendants of a view that hides them from accessibility services out of keyboard and + * D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot reach. + * A tap or a `focus()` call still reaches them, and the view itself keeps the place the platform + * gives it in the focus search. + */ +internal object HiddenSubtreeFocusHelper { + + @JvmStatic + fun hidesDescendants(view: View): Boolean = + view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + + /** + * Removes the descendants [group] added to [views] from [firstAddedIndex] on while it hides them. + * A focused descendant stays, so that focus search can move on from it. + */ + @JvmStatic + fun removeHiddenFocusables(group: ViewGroup, views: ArrayList, firstAddedIndex: Int) { + if (hidesDescendants(group)) { + views.subList(firstAddedIndex, views.size).removeAll { it !== group && !it.isFocused } + } + } + + /** + * Moves keyboard focus off a descendant [view] now hides; touch focus stays where the user types. + */ + @JvmStatic + fun releaseHiddenKeyboardFocus(view: View) { + if (view is ViewGroup && hidesDescendants(view) && !view.isInTouchMode) { + view.findFocus()?.takeIf { it !== view }?.clearFocus() + } + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt index 7523bf1fe930..9ebae1da37f7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt @@ -8,6 +8,7 @@ package com.facebook.react.views.drawer import android.annotation.SuppressLint +import android.graphics.Rect import android.view.Gravity import android.view.MotionEvent import android.view.View @@ -21,6 +22,7 @@ import com.facebook.react.R import com.facebook.react.bridge.ReactContext import com.facebook.react.common.ReactConstants import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureEnded import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureStarted @@ -67,6 +69,16 @@ public class ReactDrawerLayout(reactContext: ReactContext) : override fun shouldDelayChildPressedState(): Boolean = hasChildPressedStateDelay ?: super.shouldDelayChildPressedState() + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { try { if (super.onInterceptTouchEvent(ev)) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt index 1bfb84f45368..33a536149869 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt @@ -37,6 +37,7 @@ import com.facebook.react.common.build.ReactBuildConfig import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -565,6 +566,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : } override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + addPageFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + + private fun addPageFocusables(views: ArrayList, direction: Int, focusableMode: Int) { if (pagingEnabled && !pagedArrowScrolling) { // Only add elements within the current page to list of focusables val candidateViews = ArrayList() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt index e3c1dfd891af..8e5d10ddfcda 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<37e2e57a137f899bb94e78bd265de562>> + * @generated SignedSource<<71b6e6eea33dce97af22540ffdbeec49>> */ /** @@ -39,6 +39,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -461,6 +462,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : maintainVisibleContentPositionHelper?.stop() } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun focusSearch(focused: View, direction: Int): View? { val nextFocus = super.focusSearch(focused, direction) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt index 8db61a4d0433..bfffddea08be 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt @@ -31,6 +31,7 @@ import com.facebook.react.common.ReactConstants import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions @@ -453,6 +454,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : maintainVisibleContentPositionHelper?.stop() } + override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + override fun focusSearch(focused: View, direction: Int): View? { val nextFocus = super.focusSearch(focused, direction) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt index d2c6fc8d6d90..8a0f86352d47 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt @@ -7,12 +7,15 @@ package com.facebook.react.views.swiperefresh +import android.graphics.Rect import android.view.MotionEvent +import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.HasChildPressedStateDelay +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.events.NativeGestureUtil import kotlin.math.abs @@ -85,6 +88,16 @@ public open class ReactSwipeRefreshLayout(reactContext: ReactContext) : override fun shouldDelayChildPressedState(): Boolean = hasChildPressedStateDelay ?: super.shouldDelayChildPressedState() + public override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { + val firstAddedIndex = views.size + super.addFocusables(views, direction, focusableMode) + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) + } + + override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = + !HiddenSubtreeFocusHelper.hidesDescendants(this) && + super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + public override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (shouldInterceptTouchEvent(ev) && super.onInterceptTouchEvent(ev)) { NativeGestureUtil.notifyNativeGestureStarted(this, ev) 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 a4c8e73f7393..490fa007ef09 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 @@ -48,6 +48,7 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth import com.facebook.react.uimanager.BackgroundStyleApplicator.setFeedbackUnderlay import com.facebook.react.uimanager.BlendModeHelper.needsIsolatedLayer import com.facebook.react.uimanager.HasElevatedDescendantCache +import com.facebook.react.uimanager.HiddenSubtreeFocusHelper import com.facebook.react.uimanager.LengthPercentage import com.facebook.react.uimanager.LengthPercentageType import com.facebook.react.uimanager.MeasureSpecAssertions.assertExplicitMeasureSpec @@ -508,33 +509,14 @@ public open class ReactViewGroup public constructor(context: Context?) : updateClippingToRect(clippingRect, excludedViews) } - /** - * A view that hides its descendants from accessibility services keeps them out of keyboard and - * D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot - * reach. A tap or a `focus()` call still reaches them. - */ - private var hidesDescendantsFromNavigation = false - - override fun setImportantForAccessibility(mode: Int) { - super.setImportantForAccessibility(mode) - hidesDescendantsFromNavigation = mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS - // Keyboard focus leaves content that is now hidden; touch focus stays where the user types. - if (hidesDescendantsFromNavigation && !isInTouchMode) { - findFocus()?.takeIf { it !== this }?.clearFocus() - } - } - override fun addFocusables(views: ArrayList, direction: Int, focusableMode: Int) { val firstAddedIndex = views.size super.addFocusables(views, direction, focusableMode) - if (hidesDescendantsFromNavigation) { - // A focused descendant stays a candidate so that focus search can move on from it. - views.subList(firstAddedIndex, views.size).removeAll { it !== this && !it.isFocused } - } + HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex) } override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean = - !hidesDescendantsFromNavigation && + !HiddenSubtreeFocusHelper.hidesDescendants(this) && super.onRequestFocusInDescendants(direction, previouslyFocusedRect) internal fun requestFocusFromJS() { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt new file mode 100644 index 000000000000..442a3f499c70 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/HiddenSubtreeKeyboardFocusTest.kt @@ -0,0 +1,180 @@ +/* + * 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.uimanager + +import android.app.Activity +import android.util.DisplayMetrics +import android.view.View +import android.view.ViewGroup +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.views.drawer.ReactDrawerLayout +import com.facebook.react.views.drawer.ReactDrawerLayoutManager +import com.facebook.react.views.scroll.ReactHorizontalScrollView +import com.facebook.react.views.scroll.ReactHorizontalScrollViewManager +import com.facebook.react.views.scroll.ReactNestedScrollView +import com.facebook.react.views.scroll.ReactNestedScrollViewManager +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager +import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout +import com.facebook.react.views.swiperefresh.SwipeRefreshLayoutManager +import com.facebook.react.views.view.ReactViewGroup +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.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Every container React Native renders holds the content it hides out of keyboard navigation. */ +@RunWith(RobolectricTestRunner::class) +class HiddenSubtreeKeyboardFocusTest { + + private lateinit var themedContext: ThemedReactContext + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val context = BridgeReactContext(RuntimeEnvironment.getApplication()) + context.initializeWithInstance(createMockCatalystInstance()) + themedContext = ThemedReactContext(context, context, null, -1) + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics()) + } + + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + @Test + fun `hidden content leaves keyboard focus search`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + + container.applyImportance(scene.container, "no-hide-descendants") + + assertThat(collectFocusables(scene.root)) + .describedAs(container.name) + .containsExactly(scene.shown) + } + } + + @Test + fun `hidden content returns to keyboard focus search once shown again`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + container.applyImportance(scene.container, "no-hide-descendants") + + container.applyImportance(scene.container, null) + + assertThat(collectFocusables(scene.root)) + .describedAs(container.name) + .containsExactly(scene.hidden, scene.shown) + } + } + + @Test + fun `hidden content takes no default focus`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + attachToWindow(scene.root) + container.applyImportance(scene.container, "no-hide-descendants") + + scene.root.requestFocus(View.FOCUS_DOWN) + + assertThat(scene.hidden.isFocused).describedAs(container.name).isFalse() + } + } + + @Test + fun `keyboard focus on hidden content moves off as the container hides it`() { + for (container in CONTAINERS) { + val scene = buildScene(container) + attachToWindow(scene.root) + assertThat(scene.hidden.requestFocus()).describedAs(container.name).isTrue() + + container.applyImportance(scene.container, "no-hide-descendants") + + assertThat(scene.hidden.isFocused).describedAs(container.name).isFalse() + } + } + + private fun buildScene(container: Container): Scene { + val root = ReactViewGroup(themedContext) + val hiding = container.create(themedContext) + val content = ReactViewGroup(themedContext).apply { layout(0, 0, 100, 10) } + val hidden = focusableView(top = 0) + val shown = focusableView(top = 20) + content.addView(hidden) + hiding.addView(content) + hiding.layout(0, 0, 100, 10) + root.addView(hiding) + root.addView(shown) + return Scene(root, hiding, hidden, shown) + } + + private fun focusableView(top: Int): View = + View(themedContext).apply { + isFocusable = true + layout(0, top, 100, top + 10) + } + + private fun collectFocusables(root: ViewGroup): List { + val focusables = ArrayList() + root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL) + return focusables.filter { it !is ViewGroup } + } + + private fun attachToWindow(root: View) { + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + } + + private class Scene( + val root: ReactViewGroup, + val container: ViewGroup, + val hidden: View, + val shown: View, + ) + + private class Container( + val name: String, + val create: (ThemedReactContext) -> ViewGroup, + val applyImportance: (ViewGroup, String?) -> Unit, + ) + + private companion object { + val CONTAINERS: List = + listOf( + Container("vertical scroll view", ::ReactScrollView) { view, mode -> + ReactScrollViewManager().setImportantForAccessibility(view as ReactScrollView, mode) + }, + Container("horizontal scroll view", ::ReactHorizontalScrollView) { view, mode -> + ReactHorizontalScrollViewManager() + .setImportantForAccessibility(view as ReactHorizontalScrollView, mode) + }, + Container("nested scroll view", ::ReactNestedScrollView) { view, mode -> + ReactNestedScrollViewManager() + .setImportantForAccessibility(view as ReactNestedScrollView, mode) + }, + Container("drawer layout", ::ReactDrawerLayout) { view, mode -> + ReactDrawerLayoutManager() + .setImportantForAccessibility(view as ReactDrawerLayout, mode) + }, + Container("swipe refresh layout", ::ReactSwipeRefreshLayout) { view, mode -> + SwipeRefreshLayoutManager() + .setImportantForAccessibility(view as ReactSwipeRefreshLayout, mode) + }, + ) + } +} 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 2346d53dd691..e9b7384030e7 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 @@ -374,6 +374,37 @@ class ReactViewGroupTest { assertThat(collectKeyboardFocusables(root)).containsExactly(field, shown) } + @Test + fun `hidden descendants - the view hiding them stays a keyboard focus candidate itself`() { + val root = ReactViewGroup(context) + val hidden = containerView(top = 0).apply { isFocusable = true } + val hiddenControl = focusableView(top = 0) + val shown = focusableView(top = 20) + hidden.addView(hiddenControl) + root.addView(hidden) + root.addView(shown) + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(collectKeyboardFocusables(root)).containsExactly(hidden, shown) + } + + @Test + fun `hidden descendants - the view hiding them keeps keyboard focus it holds itself`() { + val root = ReactViewGroup(context) + val before = focusableView(top = 0) + val hidden = containerView(top = 20).apply { isFocusable = true } + hidden.addView(focusableView(top = 20)) + root.addView(before) + root.addView(hidden) + Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root) + assertThat(hidden.requestFocus()).isTrue() + + ReactViewManager().setImportantForAccessibility(hidden, "no-hide-descendants") + + assertThat(hidden.isFocused).isTrue() + } + @Test fun `hidden descendants - leave the view's own descendant focusability as it was`() { val hidden =