Skip to content

Commit f09193e

Browse files
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.
1 parent 861635c commit f09193e

3 files changed

Lines changed: 139 additions & 1 deletion

File tree

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,12 @@ public open class ReactViewGroup public constructor(context: Context?) :
160160
null
161161
private var focusOnAttach = false
162162

163+
/**
164+
* Set from the `focusable` prop. [isFocusable] also carries `accessible`, so a view excluded here
165+
* stays focusable for accessibility services while keyboard navigation passes over it.
166+
*/
167+
internal var excludedFromKeyboardFocus = false
168+
163169
internal var nativeBackgroundMap: ReadableMap? = null
164170
internal var nativeForegroundMap: ReadableMap? = null
165171

@@ -201,6 +207,7 @@ public open class ReactViewGroup public constructor(context: Context?) :
201207
childrenRemovedWhileTransitioning = null
202208
nativeBackgroundMap = null
203209
nativeForegroundMap = null
210+
excludedFromKeyboardFocus = false
204211
}
205212

206213
internal open fun recycleView() {
@@ -508,6 +515,23 @@ public open class ReactViewGroup public constructor(context: Context?) :
508515
updateClippingToRect(clippingRect, excludedViews)
509516
}
510517

518+
override fun addFocusables(views: ArrayList<View>, direction: Int, focusableMode: Int) {
519+
val firstAddedIndex = views.size
520+
super.addFocusables(views, direction, focusableMode)
521+
// A focused view stays a candidate so that focus search can move on from it.
522+
if (excludedFromKeyboardFocus && !isFocused) {
523+
views.subList(firstAddedIndex, views.size).remove(this)
524+
}
525+
}
526+
527+
override fun requestFocus(direction: Int, previouslyFocusedRect: Rect?): Boolean {
528+
if (!excludedFromKeyboardFocus) {
529+
return super.requestFocus(direction, previouslyFocusedRect)
530+
}
531+
return descendantFocusability != FOCUS_BLOCK_DESCENDANTS &&
532+
onRequestFocusInDescendants(direction, previouslyFocusedRect)
533+
}
534+
511535
internal fun requestFocusFromJS() {
512536
if (isAttachedToWindow) {
513537
super.requestFocus(FOCUS_DOWN, null)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
421421

422422
@ReactProp(name = "focusable")
423423
public open fun setFocusable(view: ReactViewGroup, focusable: Boolean) {
424+
view.excludedFromKeyboardFocus = !focusable
424425
if (focusable) {
425426
view.setOnClickListener {
426427
val eventDispatcher = UIManagerHelper.getEventDispatcher(view.context as ReactContext)
@@ -436,7 +437,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
436437
view.setOnClickListener(null)
437438
view.isClickable = false
438439
// Don't set view.setFocusable(false) because we might still want it to be focusable for
439-
// accessibility reasons
440+
// accessibility reasons. excludedFromKeyboardFocus keeps it out of keyboard navigation.
440441
}
441442
}
442443

‎packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/view/ReactViewGroupTest.kt‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.facebook.react.views.view
99

1010
import android.app.Activity
1111
import android.content.Context
12+
import android.view.FocusFinder
1213
import android.view.View
1314
import android.view.ViewGroup
1415
import android.widget.FrameLayout
@@ -260,6 +261,118 @@ class ReactViewGroupTest {
260261
assertThat(outer.hasOverlappingRendering()).isTrue()
261262
}
262263

264+
@Test
265+
fun `focusable false - keeps an accessible view out of keyboard focus search`() {
266+
val root = ReactViewGroup(context)
267+
val leading = focusableView(top = 0)
268+
val excluded = accessibleView(top = 10, focusable = false)
269+
val trailing = focusableView(top = 20)
270+
root.addView(leading)
271+
root.addView(excluded)
272+
root.addView(trailing)
273+
274+
assertThat(collectKeyboardFocusables(root)).containsExactly(leading, trailing)
275+
// Accessibility services still treat the view as one focusable element.
276+
assertThat(excluded.isFocusable).isTrue()
277+
}
278+
279+
@Test
280+
fun `focusable false - keeps the descendants of an excluded view in keyboard focus search`() {
281+
val root = ReactViewGroup(context)
282+
val excluded = accessibleView(top = 0, focusable = false)
283+
val descendant = focusableView(top = 0)
284+
excluded.addView(descendant)
285+
root.addView(excluded)
286+
287+
assertThat(collectKeyboardFocusables(root)).containsExactly(descendant)
288+
}
289+
290+
@Test
291+
fun `focusable false - default focus passes over an excluded view`() {
292+
val root = ReactViewGroup(context)
293+
val excluded = accessibleView(top = 0, focusable = false)
294+
val trailing = focusableView(top = 10)
295+
root.addView(excluded)
296+
root.addView(trailing)
297+
298+
assertThat(root.requestFocus(View.FOCUS_DOWN)).isTrue()
299+
300+
assertThat(excluded.isFocused).isFalse()
301+
assertThat(trailing.isFocused).isTrue()
302+
}
303+
304+
@Test
305+
fun `focusable false - focus requested from JS still reaches an excluded view`() {
306+
val root = ReactViewGroup(context)
307+
val excluded = accessibleView(top = 0, focusable = false)
308+
root.addView(excluded)
309+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
310+
311+
excluded.requestFocusFromJS()
312+
313+
assertThat(excluded.isFocused).isTrue()
314+
}
315+
316+
@Test
317+
fun `focusable false - keyboard focus moves on from an excluded view that holds focus`() {
318+
val root = ReactViewGroup(context)
319+
val leading = focusableView(top = 0)
320+
val excluded = accessibleView(top = 10, focusable = false)
321+
val trailing = focusableView(top = 20)
322+
root.addView(leading)
323+
root.addView(excluded)
324+
root.addView(trailing)
325+
Robolectric.buildActivity(Activity::class.java).setup().get().setContentView(root)
326+
excluded.requestFocusFromJS()
327+
328+
assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_FORWARD))
329+
.isSameAs(trailing)
330+
assertThat(FocusFinder.getInstance().findNextFocus(root, excluded, View.FOCUS_BACKWARD))
331+
.isSameAs(leading)
332+
}
333+
334+
@Test
335+
fun `focusable true - returns a view to keyboard focus search`() {
336+
val root = ReactViewGroup(context)
337+
val view = accessibleView(top = 0, focusable = false)
338+
root.addView(view)
339+
340+
ReactViewManager().setFocusable(view, true)
341+
342+
assertThat(collectKeyboardFocusables(root)).containsExactly(view)
343+
}
344+
345+
@Test
346+
fun `focusable false - recycling a view returns it to keyboard focus search`() {
347+
val root = ReactViewGroup(context)
348+
val view = accessibleView(top = 0, focusable = false)
349+
350+
view.recycleView()
351+
root.addView(view)
352+
353+
assertThat(collectKeyboardFocusables(root)).containsExactly(view)
354+
}
355+
356+
private fun focusableView(top: Int): View =
357+
View(context).apply {
358+
isFocusable = true
359+
layout(0, top, 100, top + 10)
360+
}
361+
362+
private fun accessibleView(top: Int, focusable: Boolean): ReactViewGroup =
363+
ReactViewGroup(context).apply {
364+
layout(0, top, 100, top + 10)
365+
val manager = ReactViewManager()
366+
manager.setAccessible(this, true)
367+
manager.setFocusable(this, focusable)
368+
}
369+
370+
private fun collectKeyboardFocusables(root: ViewGroup): List<View> {
371+
val focusables = ArrayList<View>()
372+
root.addFocusables(focusables, View.FOCUS_FORWARD, View.FOCUSABLES_ALL)
373+
return focusables
374+
}
375+
263376
// Mirrors the ViewManager mount/unmount hooks: attach/detach the child, then refresh the cache.
264377
private fun mount(parent: ReactViewGroup, child: View) {
265378
parent.addView(child)

0 commit comments

Comments
 (0)