Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-native/ReactAndroid/api/ReactAndroid.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<View>(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>()
view.addFocusables(focusables, direction)
return focusables.isNotEmpty()
}

@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -201,6 +207,7 @@ public open class ReactViewGroup public constructor(context: Context?) :
childrenRemovedWhileTransitioning = null
nativeBackgroundMap = null
nativeForegroundMap = null
excludedFromKeyboardFocus = false
}

internal open fun recycleView() {
Expand Down Expand Up @@ -508,6 +515,23 @@ public open class ReactViewGroup public constructor(context: Context?) :
updateClippingToRect(clippingRect, excludedViews)
}

override fun addFocusables(views: ArrayList<View>, 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()

@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)
Expand All @@ -436,7 +437,7 @@ public open class ReactViewManager : ReactClippingViewManager<ReactViewGroup>()
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.
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<UIManagerHelper>
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`<UIManager?> {
UIManagerHelper.getUIManager(any<ReactContext>(), 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,
)
}
Loading
Loading