Skip to content

Let Android keyboard focus reach a clipped direct child of a ScrollView - #59073

Draft
YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-focus-search-direct-child
Draft

YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-focus-search-direct-child

Conversation

@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor

Summary:

With removeClippedSubviews, Tab on a hardware keyboard skips the focusable direct children of a ScrollView's content once they are clipped: focus leaves the scroll view instead.

ReactScrollView.focusSearch asks ReactScrollViewHelper.findNextFocusableView for the clipped target, which un-clips it together with the ancestors getRelativeAncestorList returns. That method answers null whenever the ancestor list holds fewer than two entries, and a direct child's list holds exactly one, the content container, so the search gives up. The guard comes from findRelativeTopMostParent, where a direct child had no parent to return; the list form has an empty answer for it.

getRelativeAncestorList now returns null only when the child does not descend from the root, and an empty array for a direct child. The tag walk moves into FocusOrderingHelper::getRelativeAncestorTags so a test can reach it without a JavaVM; for the same reason FocusOrderingHelper.h drops its include of FabricUIManagerBinding.h, which it does not use, and gains #pragma once. The binding's Kotlin declaration returns IntArray?, matching the null it already returned. RNTester's removeClippedSubviews example makes its rows focusable so Tab can be tried with and without clipping.

Changelog:

[ANDROID] [FIXED] - Keyboard focus reaches a clipped direct child of a ScrollView with removeClippedSubviews

Test Plan:

  • New gtest react/fabric/test/FocusOrderingHelperTest.cpp: a direct child gets an empty list, a nested child its intermediate ancestors outermost first, and a node outside the root or the root itself gets none. The repository's CMake build has no target for that directory, so I added one to ReactAndroid's x86_64 CMake build, linked against libreactnative.so, and ran it on an Android 16 emulator: with the guard as it is on main the direct-child case fails (has_value() is false); with this change all four pass.
  • RNTester, ScrollView → Remove Clipped Subviews with removeClippedSubviews: true, Tab from Item 0 on an Android 16 emulator: on main focus goes through Item 4, the last row still attached, and then to the toggle below the list; with this change it goes on through Item 11, scrolling each clipped row into view, and then to the toggle. Two rounds per build gave the same sequence. Images in the comment below.
  • yarn lint and yarn flow-check pass; prettier, clang-format and ktfmt are clean on the changed files; the RNTester debug build compiles the Kotlin and native changes.

getRelativeAncestorList returned null whenever the next focusable node
had fewer than two ancestors below the root, which is every direct child
of a ScrollView's content container. findNextFocusableView reads null as
"no next view", so with removeClippedSubviews keyboard focus left the
scroll view instead of moving to a clipped direct child.

Null now means only that the child is not under the root, and a direct
child gets an empty array. The tag walk moves into
FocusOrderingHelper::getRelativeAncestorTags, covered by a gtest, and
the binding's Kotlin declaration returns IntArray? to match the null it
already returned. RNTester's removeClippedSubviews example makes its rows
focusable, so Tab can be tried with and without clipping.
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 14, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — with removeClippedSubviews on, Tab from Item 4, the last row still attached, leaves the list for the toggle below it.

FIXED — Tab moves to Item 5 and the list scrolls to it; further presses reach Item 11.

before — main at 892ea5e45d after — with this PR

RNTester debug builds of main at 892ea5e45d with the focusable example rows, without and with this change, on an Android 16 (API 36) emulator, 1140×2400 at density 480. ScrollView → Remove Clipped Subviews with removeClippedSubviews: true: Tab to Item 0, through Item 4, the last row still attached, then once more. Before: focus moves to the toggle below the list, which takes the darker focus fill, and the list stays where it was. After: focus moves to Item 5 and the list scrolls to it; further presses reach Item 11 and then the toggle. A second round gave the same frames.

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

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant