Skip to content

Fix keyboard focus skipping id-less views inside ScrollView on Android - #59056

Open
LLUhlin wants to merge 1 commit into
react:mainfrom
LLUhlin:fix-android-scrollview-focus-search-id-less-descendants
Open

LLUhlin wants to merge 1 commit into
react:mainfrom
LLUhlin:fix-android-scrollview-focus-search-id-less-descendants

Conversation

@LLUhlin

@LLUhlin LLUhlin commented Oct 14, 2026

Copy link
Copy Markdown

Summary:

On Android, a hardware keyboard's Tab and Shift+Tab skip focusable views without an id inside a ScrollView. We hit this with a Jetpack Compose Host from @expo/ui (a SingleChoiceSegmentedButtonRow): Tab from the view above, or Shift+Tab from the view below, jumps past it.

With enableCustomFocusSearchOnClippedElementsAndroid on (the default), focusSearch in ReactScrollView, ReactHorizontalScrollView and ReactNestedScrollView keeps Android's result only if findViewById(nextFocus.id) finds it. The intent, per the comment in ReactHorizontalScrollView, is to check that the next focus is inside the scroll view. An id lookup only approximates that:

  • A view without an id (View.NO_ID) is never found, because findViewById(NO_ID) returns null. The search then falls back to findNextFocusableView, which only knows about the shadow tree and returns the next React view instead. The view Android focuses for Compose content is the AndroidComposeView that ComposeView adds as its child, and it has no id.
  • A view outside the scroll view is accepted if a descendant happens to have the same id.

This change replaces the lookup with a walk up the parents of nextFocus (ReactScrollViewHelper.isSelfOrDescendant). Views inside the scroll view keep Android's result whether or not they have an id. Views outside it, and views detached by removeClippedSubviews, still go through findNextFocusableView as before. The walk is also cheaper than findViewById, which searches the whole subtree.

ReactNestedScrollView.kt was regenerated with generate-nested-scroll-view.js.

Related: expo/expo#51207 works around the same problem from the Expo side by giving the AndroidComposeView an id.

Changelog:

[ANDROID] [FIXED] - Fix keyboard Tab and Shift+Tab skipping focusable views without an id, such as Jetpack Compose content, inside a ScrollView

Test Plan:

Unit tests: added ReactScrollViewHelperFocusTest. It covers the host itself, descendants with and without an id, a view outside the host whose id matches a descendant, and a detached view.

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.views.scroll.*"

All 14 tests in com.facebook.react.views.scroll pass. node ./scripts/format-kotlin.js --check and node generate-nested-scroll-view.js --verify pass.

On device: tested on React Native 0.86.2 (New Architecture, @expo/ui 57.0.14) on a physical Android phone with a Bluetooth keyboard. The same change, in the 0.86.2 Java sources, was compiled into the react-android 0.86.2 AAR, and the Expo workaround was removed, so this change was the only fix in the build. Screen: a ScrollView with a React view, an @expo/ui Host with a SingleChoiceSegmentedButtonRow, then another React view.

  • Before: Tab from the view above, or Shift+Tab from the view below, skipped the segmented buttons.
  • After: Tab and Shift+Tab move into the segmented buttons from both directions, between segments, and back out.

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.
@meta-cla

meta-cla Bot commented Oct 14, 2026

Copy link
Copy Markdown

Hi @LLUhlin!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant