From 76c2184cf962748a08e4f0837677197818dab50e Mon Sep 17 00:00:00 2001 From: LLUhlin Date: Wed, 14 Oct 2026 10:02:09 +0200 Subject: [PATCH] Fix keyboard focus skipping id-less views inside ScrollView on Android focusSearch kept Android's result only if findViewById(nextFocus.id) found it, so focusable views without an id, such as the AndroidComposeView inside a ComposeView, were replaced by the shadow tree search and skipped by Tab and Shift+Tab. Check whether the view is inside the scroll view by walking its parents instead. --- .../views/scroll/ReactHorizontalScrollView.kt | 3 +- .../views/scroll/ReactNestedScrollView.kt | 5 +- .../react/views/scroll/ReactScrollView.kt | 3 +- .../views/scroll/ReactScrollViewHelper.kt | 17 +++++ .../scroll/ReactScrollViewHelperFocusTest.kt | 72 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusTest.kt 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..82808ad6111c 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 @@ -62,6 +62,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.react.views.scroll.ReactScrollViewHelper.isSelfOrDescendant import com.facebook.react.views.view.ImportantForInteractionHelper import com.facebook.systrace.Systrace import kotlin.math.abs @@ -912,7 +913,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { // If we can find the next focus and it is a child of this view, return it, else it means we // are leaving the scroll view and we should try to find a clipped element - if (nextFocus != null && findViewById(nextFocus.id) != null) { + if (nextFocus != null && isSelfOrDescendant(this, nextFocus)) { return nextFocus } val nextFocusableView = findNextFocusableView(this, focused, direction) 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..38969b5816db 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<<3cd05abbef2823a5f07515fe17ce394c>> */ /** @@ -64,6 +64,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.react.views.scroll.ReactScrollViewHelper.isSelfOrDescendant import com.facebook.react.views.view.ImportantForInteractionHelper import com.facebook.systrace.Systrace import kotlin.math.abs @@ -465,7 +466,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : val nextFocus = super.focusSearch(focused, direction) if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { - if (nextFocus != null && findViewById(nextFocus.id) != null) { + if (nextFocus != null && isSelfOrDescendant(this, nextFocus)) { return nextFocus } val nextFocusableView = findNextFocusableView(this, 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..3c87046fad72 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 @@ -56,6 +56,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_DISA import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END import com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START import com.facebook.react.views.scroll.ReactScrollViewHelper.findNextFocusableView +import com.facebook.react.views.scroll.ReactScrollViewHelper.isSelfOrDescendant import com.facebook.react.views.view.ImportantForInteractionHelper import com.facebook.systrace.Systrace import kotlin.math.abs @@ -457,7 +458,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) : val nextFocus = super.focusSearch(focused, direction) if (ReactNativeFeatureFlags.enableCustomFocusSearchOnClippedElementsAndroid()) { - if (nextFocus != null && findViewById(nextFocus.id) != null) { + if (nextFocus != null && isSelfOrDescendant(this, nextFocus)) { return nextFocus } val nextFocusableView = findNextFocusableView(this, focused, direction) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index e6e0660d174b..2b61aba78f39 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -597,6 +597,23 @@ public object ReactScrollViewHelper { return host.findViewById(nextFocusableViewId) } + /** + * Returns whether [view] is [host] or one of its descendants. Unlike looking the view up by id, + * this also holds for views without an id, such as the `AndroidComposeView` that a `ComposeView` + * adds as its child. + */ + @JvmStatic + internal fun isSelfOrDescendant(host: ViewGroup, view: View): Boolean { + var current: View? = view + while (current != null) { + if (current === host) { + return true + } + current = current.parent as? View + } + return false + } + @JvmStatic public fun resolveAbsoluteDirection( @FocusRealDirection direction: Int, diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusTest.kt new file mode 100644 index 000000000000..ba70da8e1008 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperFocusTest.kt @@ -0,0 +1,72 @@ +/* + * 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.views.scroll + +import android.view.View +import android.widget.FrameLayout +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +/** Verifies the containment check used by the scroll views' keyboard focus search. */ +@RunWith(RobolectricTestRunner::class) +class ReactScrollViewHelperFocusTest { + + private lateinit var host: FrameLayout + private lateinit var content: FrameLayout + + @Before + fun setup() { + val context = RuntimeEnvironment.getApplication() + host = FrameLayout(context) + content = FrameLayout(context) + host.addView(content) + } + + @Test + fun hostItselfIsIncluded() { + assertThat(ReactScrollViewHelper.isSelfOrDescendant(host, host)).isTrue() + } + + @Test + fun descendantWithIdIsIncluded() { + val child = View(host.context).apply { id = 42 } + content.addView(child) + + assertThat(ReactScrollViewHelper.isSelfOrDescendant(host, child)).isTrue() + } + + @Test + fun descendantWithoutIdIsIncluded() { + // For example the AndroidComposeView that a ComposeView adds as its child. + val child = View(host.context) + content.addView(child) + + assertThat(child.id).isEqualTo(View.NO_ID) + assertThat(ReactScrollViewHelper.isSelfOrDescendant(host, child)).isTrue() + } + + @Test + fun viewOutsideHostIsExcludedEvenIfIdMatchesADescendant() { + content.addView(View(host.context).apply { id = 42 }) + val parent = FrameLayout(host.context) + parent.addView(host) + val outside = View(host.context).apply { id = 42 } + parent.addView(outside) + + assertThat(ReactScrollViewHelper.isSelfOrDescendant(host, outside)).isFalse() + } + + @Test + fun detachedViewIsExcluded() { + assertThat(ReactScrollViewHelper.isSelfOrDescendant(host, View(host.context))).isFalse() + } +}