Skip to content

Commit 11de4dc

Browse files
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.
1 parent f09193e commit 11de4dc

3 files changed

Lines changed: 202 additions & 10 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6439,6 +6439,7 @@ public final class com/facebook/react/views/view/ReactDrawableHelper {
64396439
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 {
64406440
public fun <init> (Landroid/content/Context;)V
64416441
public fun addChildrenForAccessibility (Ljava/util/ArrayList;)V
6442+
public fun addFocusables (Ljava/util/ArrayList;II)V
64426443
public final fun cleanUpAxOrderListener ()V
64436444
protected fun dispatchDraw (Landroid/graphics/Canvas;)V
64446445
public fun dispatchGenericMotionEvent (Landroid/view/MotionEvent;)Z
@@ -6468,6 +6469,7 @@ public class com/facebook/react/views/view/ReactViewGroup : android/view/ViewGro
64686469
public fun onTouchEvent (Landroid/view/MotionEvent;)Z
64696470
public fun onViewAdded (Landroid/view/View;)V
64706471
public fun onViewRemoved (Landroid/view/View;)V
6472+
public fun requestFocus (ILandroid/graphics/Rect;)Z
64716473
public fun requestLayout ()V
64726474
public final fun setAxOrderList (Ljava/util/List;)V
64736475
public final fun setBackfaceVisibility (Ljava/lang/String;)V

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -581,20 +581,41 @@ public object ReactScrollViewHelper {
581581
UIManagerHelper.getUIManager(host.context as ReactContext, UIManagerType.FABRIC)
582582
?: return null
583583

584-
val nextFocusableViewId =
585-
(uimanager as FabricUIManager).findNextFocusableElement(host.id, focused.id, direction)
586-
?: return null
584+
// The shadow tree offers every focusable or accessible node, while a view can still keep
585+
// itself out of keyboard navigation (focusable={false} on an accessible view), so the search
586+
// moves on from a candidate that offers no keyboard focus. Forward and backward candidates can
587+
// lead back to each other, which the visited ids end.
588+
val visitedIds = mutableSetOf(focused.id)
589+
var sourceId = focused.id
590+
while (true) {
591+
val nextFocusableViewId =
592+
(uimanager as FabricUIManager).findNextFocusableElement(host.id, sourceId, direction)
593+
?: return null
594+
if (!visitedIds.add(nextFocusableViewId)) {
595+
return null
596+
}
597+
598+
val ancestorIdList =
599+
uimanager
600+
.getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId)
601+
?.toMutableSet() ?: return null
587602

588-
val ancestorIdList =
589-
uimanager
590-
.getRelativeAncestorList(host.getChildAt(0).id, nextFocusableViewId)
591-
?.toMutableSet() ?: return null
603+
ancestorIdList.add(nextFocusableViewId)
592604

593-
ancestorIdList.add(nextFocusableViewId)
605+
host.updateClippingRect(ancestorIdList)
594606

595-
host.updateClippingRect(ancestorIdList)
607+
val nextFocusableView = host.findViewById<View>(nextFocusableViewId) ?: return null
608+
if (offersKeyboardFocus(nextFocusableView, direction)) {
609+
return nextFocusableView
610+
}
611+
sourceId = nextFocusableViewId
612+
}
613+
}
596614

597-
return host.findViewById(nextFocusableViewId)
615+
private fun offersKeyboardFocus(view: View, @FocusDirection direction: Int): Boolean {
616+
val focusables = ArrayList<View>()
617+
view.addFocusables(focusables, direction)
618+
return focusables.isNotEmpty()
598619
}
599620

600621
@JvmStatic
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
// The deprecated APIs exercised here remain public and required by this Robolectric setup.
9+
@file:Suppress("DEPRECATION")
10+
11+
package com.facebook.react.views.scroll
12+
13+
import android.util.DisplayMetrics
14+
import android.view.View
15+
import com.facebook.react.bridge.BridgeReactContext
16+
import com.facebook.react.bridge.ReactContext
17+
import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance
18+
import com.facebook.react.bridge.UIManager
19+
import com.facebook.react.fabric.FabricUIManager
20+
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
21+
import com.facebook.react.uimanager.DisplayMetricsHolder
22+
import com.facebook.react.uimanager.ThemedReactContext
23+
import com.facebook.react.uimanager.UIManagerHelper
24+
import com.facebook.react.uimanager.common.UIManagerType
25+
import com.facebook.react.views.view.ReactViewGroup
26+
import com.facebook.react.views.view.ReactViewManager
27+
import com.facebook.testutils.shadows.ShadowNativeLoader
28+
import com.facebook.testutils.shadows.ShadowSoLoader
29+
import org.assertj.core.api.Assertions.assertThat
30+
import org.junit.After
31+
import org.junit.Before
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
import org.mockito.MockedStatic
35+
import org.mockito.Mockito.mockStatic
36+
import org.mockito.kotlin.any
37+
import org.mockito.kotlin.eq
38+
import org.mockito.kotlin.mock
39+
import org.mockito.kotlin.whenever
40+
import org.robolectric.RobolectricTestRunner
41+
import org.robolectric.RuntimeEnvironment
42+
import org.robolectric.annotation.Config
43+
44+
/**
45+
* The shadow-tree search a scroll view falls back to, when the next focus lies outside it, passes
46+
* over a candidate the view tree keeps out of keyboard navigation.
47+
*/
48+
@RunWith(RobolectricTestRunner::class)
49+
@Config(shadows = [ShadowSoLoader::class, ShadowNativeLoader::class])
50+
class ReactScrollViewHelperFocusSearchTest {
51+
52+
private lateinit var themedContext: ThemedReactContext
53+
private lateinit var uiManagerHelperMock: MockedStatic<UIManagerHelper>
54+
private val fabricUIManager: FabricUIManager = mock()
55+
56+
@Before
57+
fun setUp() {
58+
ReactNativeFeatureFlagsForTests.setUp()
59+
val context = BridgeReactContext(RuntimeEnvironment.getApplication())
60+
context.initializeWithInstance(createMockCatalystInstance())
61+
themedContext = ThemedReactContext(context, context, null, -1)
62+
DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics())
63+
uiManagerHelperMock = mockStatic(UIManagerHelper::class.java)
64+
uiManagerHelperMock
65+
.`when`<UIManager?> {
66+
UIManagerHelper.getUIManager(any<ReactContext>(), eq(UIManagerType.FABRIC))
67+
}
68+
.thenReturn(fabricUIManager)
69+
}
70+
71+
@After
72+
fun tearDown() {
73+
uiManagerHelperMock.close()
74+
DisplayMetricsHolder.setScreenDisplayMetrics(null)
75+
}
76+
77+
@Test
78+
fun `passes over a candidate that focusable false keeps out of keyboard navigation`() {
79+
val scene = buildScene()
80+
ReactViewManager().setFocusable(scene.candidate, false)
81+
offerInOrder(scene, scene.first, scene.candidate, scene.last)
82+
83+
assertThat(findNextFocusableView(scene)).isSameAs(scene.last)
84+
}
85+
86+
@Test
87+
fun `finds nothing when no later candidate takes keyboard focus`() {
88+
val scene = buildScene()
89+
ReactViewManager().setFocusable(scene.candidate, false)
90+
offerInOrder(scene, scene.first, scene.candidate)
91+
92+
assertThat(findNextFocusableView(scene)).isNull()
93+
}
94+
95+
@Test
96+
fun `ends when the candidates lead back to where the search started`() {
97+
val scene = buildScene()
98+
ReactViewManager().setFocusable(scene.candidate, false)
99+
offerInOrder(scene, scene.first, scene.candidate, scene.first)
100+
101+
assertThat(findNextFocusableView(scene)).isNull()
102+
}
103+
104+
@Test
105+
fun `takes a candidate whose descendant takes keyboard focus`() {
106+
val scene = buildScene()
107+
ReactViewManager().setFocusable(scene.candidate, false)
108+
scene.candidate.addView(focusableView(id = 4))
109+
offerInOrder(scene, scene.first, scene.candidate, scene.last)
110+
111+
assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate)
112+
}
113+
114+
@Test
115+
fun `takes a candidate that takes keyboard focus itself`() {
116+
val scene = buildScene()
117+
offerInOrder(scene, scene.first, scene.candidate, scene.last)
118+
119+
assertThat(findNextFocusableView(scene)).isSameAs(scene.candidate)
120+
}
121+
122+
private fun buildScene(): Scene {
123+
val scrollView = ReactScrollView(themedContext).apply { id = 20 }
124+
val content = ReactViewGroup(themedContext).apply { id = 10 }
125+
val first = focusableView(id = 1)
126+
val candidate =
127+
ReactViewGroup(themedContext).apply {
128+
id = 2
129+
ReactViewManager().setAccessible(this, true)
130+
}
131+
val last = focusableView(id = 3)
132+
content.addView(first)
133+
content.addView(candidate)
134+
content.addView(last)
135+
scrollView.addView(content)
136+
whenever(fabricUIManager.getRelativeAncestorList(eq(content.id), any()))
137+
.thenReturn(intArrayOf(content.id))
138+
return Scene(scrollView, first, candidate, last)
139+
}
140+
141+
private fun focusableView(id: Int): View =
142+
View(themedContext).apply {
143+
this.id = id
144+
isFocusable = true
145+
}
146+
147+
private fun offerInOrder(scene: Scene, vararg order: View) {
148+
order.toList().zipWithNext().forEach { (source, next) ->
149+
whenever(
150+
fabricUIManager.findNextFocusableElement(
151+
eq(scene.scrollView.id),
152+
eq(source.id),
153+
eq(View.FOCUS_FORWARD),
154+
)
155+
)
156+
.thenReturn(next.id)
157+
}
158+
}
159+
160+
private fun findNextFocusableView(scene: Scene): View? =
161+
ReactScrollViewHelper.findNextFocusableView(scene.scrollView, scene.first, View.FOCUS_FORWARD)
162+
163+
private class Scene(
164+
val scrollView: ReactScrollView,
165+
val first: View,
166+
val candidate: ReactViewGroup,
167+
val last: View,
168+
)
169+
}

0 commit comments

Comments
 (0)