Skip to content

Commit 5a605ee

Browse files
Keep content hidden by any container out of keyboard navigation
aria-hidden on a ScrollView, a horizontal ScrollView, DrawerLayoutAndroid or RefreshControl left their 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.
1 parent 83fc08a commit 5a605ee

11 files changed

Lines changed: 327 additions & 22 deletions

File tree

‎packages/react-native/ReactAndroid/api/ReactAndroid.api‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5159,6 +5159,7 @@ public abstract interface class com/facebook/react/viewmanagers/VirtualViewManag
51595159

51605160
public final class com/facebook/react/views/drawer/ReactDrawerLayout : androidx/drawerlayout/widget/DrawerLayout, com/facebook/react/uimanager/HasChildPressedStateDelay {
51615161
public fun <init> (Lcom/facebook/react/bridge/ReactContext;)V
5162+
public fun addFocusables (Ljava/util/ArrayList;II)V
51625163
public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean;
51635164
public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z
51645165
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
@@ -5513,6 +5514,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android
55135514
public fun onLayoutChange (Landroid/view/View;IIIIIIII)V
55145515
protected fun onMeasure (II)V
55155516
protected fun onOverScrolled (IIZZ)V
5517+
protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z
55165518
protected fun onScrollChanged (IIII)V
55175519
protected fun onSizeChanged (IIII)V
55185520
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
@@ -5623,6 +5625,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
56235625
public fun <init> (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;)V
56245626
public synthetic fun <init> (Landroid/content/Context;Lcom/facebook/react/views/scroll/FpsListener;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
56255627
public fun abortAnimation ()V
5628+
public fun addFocusables (Ljava/util/ArrayList;II)V
56265629
public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z
56275630
public fun draw (Landroid/graphics/Canvas;)V
56285631
public fun executeKeyEvent (Landroid/view/KeyEvent;)Z
@@ -5662,6 +5665,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc
56625665
public fun onLayoutChange (Landroid/view/View;IIIIIIII)V
56635666
protected fun onMeasure (II)V
56645667
protected fun onOverScrolled (IIZZ)V
5668+
protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z
56655669
protected fun onScrollChanged (IIII)V
56665670
protected fun onSizeChanged (IIII)V
56675671
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
@@ -5977,10 +5981,12 @@ public final class com/facebook/react/views/scroll/VirtualViewContainerState$Com
59775981

59785982
public class com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout : androidx/swiperefreshlayout/widget/SwipeRefreshLayout, com/facebook/react/uimanager/HasChildPressedStateDelay {
59795983
public fun <init> (Lcom/facebook/react/bridge/ReactContext;)V
5984+
public fun addFocusables (Ljava/util/ArrayList;II)V
59805985
public fun canChildScrollUp ()Z
59815986
public fun getHasChildPressedStateDelay ()Ljava/lang/Boolean;
59825987
public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z
59835988
public fun onLayout (ZIIII)V
5989+
protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z
59845990
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
59855991
public fun requestDisallowInterceptTouchEvent (Z)V
59865992
public fun setHasChildPressedStateDelay (Ljava/lang/Boolean;)V
@@ -6436,6 +6442,7 @@ public final class com/facebook/react/views/view/ReactDrawableHelper {
64366442
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 {
64376443
public fun <init> (Landroid/content/Context;)V
64386444
public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V
6445+
public fun addFocusables (Ljava/util/ArrayList;II)V
64396446
public final fun cleanUpAxOrderListener ()V
64406447
protected fun dispatchDraw (Landroid/graphics/Canvas;)V
64416448
public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z
@@ -6461,6 +6468,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
64616468
public fun onInterceptTouchEvent (Landroid/view/MotionEvent;)Z
64626469
protected fun onLayout (ZIIII)V
64636470
protected fun onMeasure (II)V
6471+
protected fun onRequestFocusInDescendants (ILandroid/graphics/Rect;)Z
64646472
protected fun onSizeChanged (IIII)V
64656473
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
64666474
public fun onViewAdded (Landroid/view/View;)V

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ public void setImportantForAccessibility(
495495
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);
496496
} else if (importantForAccessibility.equals("no-hide-descendants")) {
497497
view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
498+
HiddenSubtreeFocusHelper.releaseHiddenKeyboardFocus(view);
498499
}
499500
}
500501

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.uimanager
9+
10+
import android.view.View
11+
import android.view.ViewGroup
12+
13+
/**
14+
* Keeps the descendants of a view that hides them from accessibility services out of keyboard and
15+
* D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot reach.
16+
* A tap or a `focus()` call still reaches them, and the view itself keeps the place the platform
17+
* gives it in the focus search.
18+
*/
19+
internal object HiddenSubtreeFocusHelper {
20+
21+
@JvmStatic
22+
fun hidesDescendants(view: View): Boolean =
23+
view.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
24+
25+
/**
26+
* Removes the descendants [group] added to [views] from [firstAddedIndex] on while it hides them.
27+
* A focused descendant stays, so that focus search can move on from it.
28+
*/
29+
@JvmStatic
30+
fun removeHiddenFocusables(group: ViewGroup, views: ArrayList<View>, firstAddedIndex: Int) {
31+
if (hidesDescendants(group)) {
32+
views.subList(firstAddedIndex, views.size).removeAll { it !== group && !it.isFocused }
33+
}
34+
}
35+
36+
/**
37+
* Moves keyboard focus off a descendant [view] now hides; touch focus stays where the user types.
38+
*/
39+
@JvmStatic
40+
fun releaseHiddenKeyboardFocus(view: View) {
41+
if (view is ViewGroup && hidesDescendants(view) && !view.isInTouchMode) {
42+
view.findFocus()?.takeIf { it !== view }?.clearFocus()
43+
}
44+
}
45+
}

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/drawer/ReactDrawerLayout.kt‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package com.facebook.react.views.drawer
99

1010
import android.annotation.SuppressLint
11+
import android.graphics.Rect
1112
import android.view.Gravity
1213
import android.view.MotionEvent
1314
import android.view.View
@@ -21,6 +22,7 @@ import com.facebook.react.R
2122
import com.facebook.react.bridge.ReactContext
2223
import com.facebook.react.common.ReactConstants
2324
import com.facebook.react.uimanager.HasChildPressedStateDelay
25+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
2426
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole
2527
import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureEnded
2628
import com.facebook.react.uimanager.events.NativeGestureUtil.notifyNativeGestureStarted
@@ -67,6 +69,16 @@ public class ReactDrawerLayout(reactContext: ReactContext) :
6769
override fun shouldDelayChildPressedState(): Boolean =
6870
hasChildPressedStateDelay ?: super.shouldDelayChildPressedState()
6971

72+
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
73+
val firstAddedIndex = views.size
74+
super.addFocusables(views, direction, focusableMode)
75+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
76+
}
77+
78+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
79+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
80+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
81+
7082
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
7183
try {
7284
if (super.onInterceptTouchEvent(ev)) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import com.facebook.react.common.build.ReactBuildConfig
3737
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
3838
import com.facebook.react.uimanager.BackgroundStyleApplicator
3939
import com.facebook.react.uimanager.HasChildPressedStateDelay
40+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
4041
import com.facebook.react.uimanager.LengthPercentage
4142
import com.facebook.react.uimanager.LengthPercentageType
4243
import com.facebook.react.uimanager.MeasureSpecAssertions
@@ -565,6 +566,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
565566
}
566567

567568
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
569+
val firstAddedIndex = views.size
570+
addPageFocusables(views, direction, focusableMode)
571+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
572+
}
573+
574+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
575+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
576+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
577+
578+
private fun addPageFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
568579
if (pagingEnabled && !pagedArrowScrolling) {
569580
// Only add elements within the current page to list of focusables
570581
val candidateViews = ArrayList<View>()

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<2f9a2ea0c223b08d9d1ea4517fd9671b>>
7+
* @generated SignedSource<<b0b9bd211c68ea3c4338e7c53eeb80c3>>
88
*/
99

1010
/**
@@ -39,6 +39,7 @@ import com.facebook.react.common.ReactConstants
3939
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
4040
import com.facebook.react.uimanager.BackgroundStyleApplicator
4141
import com.facebook.react.uimanager.HasChildPressedStateDelay
42+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
4243
import com.facebook.react.uimanager.LengthPercentage
4344
import com.facebook.react.uimanager.LengthPercentageType
4445
import com.facebook.react.uimanager.MeasureSpecAssertions
@@ -461,6 +462,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
461462
maintainVisibleContentPositionHelper?.stop()
462463
}
463464

465+
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
466+
val firstAddedIndex = views.size
467+
super.addFocusables(views, direction, focusableMode)
468+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
469+
}
470+
471+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
472+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
473+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
474+
464475
override fun focusSearch(focused: View, direction: Int): View? {
465476
val nextFocus = super.focusSearch(focused, direction)
466477

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import com.facebook.react.common.ReactConstants
3131
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
3232
import com.facebook.react.uimanager.BackgroundStyleApplicator
3333
import com.facebook.react.uimanager.HasChildPressedStateDelay
34+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
3435
import com.facebook.react.uimanager.LengthPercentage
3536
import com.facebook.react.uimanager.LengthPercentageType
3637
import com.facebook.react.uimanager.MeasureSpecAssertions
@@ -453,6 +454,16 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
453454
maintainVisibleContentPositionHelper?.stop()
454455
}
455456

457+
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
458+
val firstAddedIndex = views.size
459+
super.addFocusables(views, direction, focusableMode)
460+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
461+
}
462+
463+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
464+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
465+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
466+
456467
override fun focusSearch(focused: View, direction: Int): View? {
457468
val nextFocus = super.focusSearch(focused, direction)
458469

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,15 @@
77

88
package com.facebook.react.views.swiperefresh
99

10+
import android.graphics.Rect
1011
import android.view.MotionEvent
12+
import android.view.View
1113
import android.view.ViewConfiguration
1214
import android.view.ViewGroup
1315
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
1416
import com.facebook.react.bridge.ReactContext
1517
import com.facebook.react.uimanager.HasChildPressedStateDelay
18+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
1619
import com.facebook.react.uimanager.PixelUtil
1720
import com.facebook.react.uimanager.events.NativeGestureUtil
1821
import kotlin.math.abs
@@ -85,6 +88,16 @@ public open class ReactSwipeRefreshLayout(reactContext: ReactContext) :
8588
override fun shouldDelayChildPressedState(): Boolean =
8689
hasChildPressedStateDelay ?: super.shouldDelayChildPressedState()
8790

91+
public override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
92+
val firstAddedIndex = views.size
93+
super.addFocusables(views, direction, focusableMode)
94+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
95+
}
96+
97+
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
98+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
99+
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
100+
88101
public override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
89102
if (shouldInterceptTouchEvent(ev) && super.onInterceptTouchEvent(ev)) {
90103
NativeGestureUtil.notifyNativeGestureStarted(this, ev)

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt‎

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import com.facebook.react.uimanager.BackgroundStyleApplicator.setBorderWidth
4848
import com.facebook.react.uimanager.BackgroundStyleApplicator.setFeedbackUnderlay
4949
import com.facebook.react.uimanager.BlendModeHelper.needsIsolatedLayer
5050
import com.facebook.react.uimanager.HasElevatedDescendantCache
51+
import com.facebook.react.uimanager.HiddenSubtreeFocusHelper
5152
import com.facebook.react.uimanager.LengthPercentage
5253
import com.facebook.react.uimanager.LengthPercentageType
5354
import com.facebook.react.uimanager.MeasureSpecAssertions.assertExplicitMeasureSpec
@@ -508,33 +509,14 @@ public open class ReactViewGroup public constructor(context: Context?) :
508509
updateClippingToRect(clippingRect, excludedViews)
509510
}
510511

511-
/**
512-
* A view that hides its descendants from accessibility services keeps them out of keyboard and
513-
* D-pad navigation too, so a hardware keyboard never lands on content a screen reader cannot
514-
* reach. A tap or a `focus()` call still reaches them.
515-
*/
516-
private var hidesDescendantsFromNavigation = false
517-
518-
override fun setImportantForAccessibility(mode: Int) {
519-
super.setImportantForAccessibility(mode)
520-
hidesDescendantsFromNavigation = mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
521-
// Keyboard focus leaves content that is now hidden; touch focus stays where the user types.
522-
if (hidesDescendantsFromNavigation && !isInTouchMode) {
523-
findFocus()?.takeIf { it !== this }?.clearFocus()
524-
}
525-
}
526-
527512
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
528513
val firstAddedIndex = views.size
529514
super.addFocusables(views, direction, focusableMode)
530-
if (hidesDescendantsFromNavigation) {
531-
// A focused descendant stays a candidate so that focus search can move on from it.
532-
views.subList(firstAddedIndex, views.size).removeAll { it !== this && !it.isFocused }
533-
}
515+
HiddenSubtreeFocusHelper.removeHiddenFocusables(this, views, firstAddedIndex)
534516
}
535517

536518
override fun onRequestFocusInDescendants(direction: Int, previouslyFocusedRect: Rect?): Boolean =
537-
!hidesDescendantsFromNavigation &&
519+
!HiddenSubtreeFocusHelper.hidesDescendants(this) &&
538520
super.onRequestFocusInDescendants(direction, previouslyFocusedRect)
539521

540522
internal fun requestFocusFromJS() {

0 commit comments

Comments
 (0)