Skip to content

Keep focusable={false} Android views out of keyboard navigation - #59048

Open
YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-focusable-keyboard-focus
Open

YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-focusable-keyboard-focus

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, focusable={false} — and tabIndex={-1}, which View turns into focusable={false} — does not take a view out of hardware-keyboard navigation when the view is also accessible. Pressable, TouchableOpacity and TouchableHighlight are accessible by default, so on any of them Tab, Shift+Tab and the arrow keys still land on the view.

setAccessible sets isFocusable so accessibility services treat the view as one element, and setFocusable(false) deliberately leaves that flag alone. Android's keyboard navigation reads the same flag: FocusFinder collects candidates through addFocusables, and with nothing focused ViewRootImpl calls restoreDefaultFocus, which reaches a view through requestFocus.

ReactViewGroup now records the prop and honours it at those two entry points: addFocusables leaves the view out and keeps its descendants, and requestFocus hands the request to its descendants. isFocusable is untouched, so accessibility services, ref.focus() (which already calls super.requestFocus) and the click wiring behave as before. A view that holds focus stays a candidate: FocusFinder loops to the first candidate when the focused view is missing from the list, so Tab from a view focused with ref.focus() would otherwise jump to the start of the window.

Inside a scroll view one more search runs. When the next focus lies outside it, ReactScrollView and its horizontal and nested siblings ask the shadow tree for the next focusable element (enableCustomFocusSearchOnClippedElementsAndroid), and that search offers every focusable or accessible node. It would hand back an excluded view nested in the scroll view's content, the view would refuse focus, and Tab would stop moving, so ReactScrollViewHelper.findNextFocusableView now moves on from a candidate whose addFocusables adds nothing. Forward candidates can lead back to each other, so it also ends at a candidate it has already offered. The two overrides are added to ReactAndroid.api. This overlaps #59052, which also overrides ReactViewGroup.addFocusables; whichever lands second combines the two bodies in one function.

I searched the tracker for focusable={false} and tabIndex with keyboard focus and found no existing report or PR.

Changelog:

[ANDROID] [FIXED] - focusable={false} and tabIndex={-1} take an accessible view out of hardware-keyboard navigation

Test Plan:

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.views.view.ReactViewGroupTest" --tests "com.facebook.react.views.scroll.ReactScrollViewHelperFocusSearchTest" -Preact.internal.useHermesStable=true: on main six of the twelve new tests fail — addFocusables returns the excluded view (Expecting actual: [View, ReactViewGroup, View] to contain exactly (and in same order): [View, View], and the same with a descendant), requestFocus(FOCUS_DOWN) gives it focus, and the scroll view search hands back the excluded candidate where the next one or none belongs, also when the candidates lead back to the first box. With the fix all 29 tests in the two classes pass, including the ones for ref.focus(), Tab and Shift+Tab from a focused excluded view, focusable={true} restoring the view, recycling, and a candidate that takes focus itself or through a descendant.
  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true -Preact.internal.useHermesStable=true: 725 tests in 104 suites, 0 failures, and the release sources compile.
  • node ./scripts/format-kotlin.js --check passes.
  • The RNTester debug build with this change on main at b8ec87e457 passes with -PenableWarningsAsErrors=true; the rest of build_android was not run. With only the first commit, the scroll view scene below stops Tab on its first box.
  • RNTester on an Android 16 emulator, before and after: the second Tab lands on a tabIndex={-1} pressable without the change and passes over it with the change, in a plain view and in a scroll view.
  • The ReactViewGroup change on 0.86.3, in an app on a Galaxy S23 (Android 14), with Tab sent as key events: Tab passes over pressables marked tabIndex={-1}, where it landed on them before.

An accessible view keeps isFocusable for accessibility services, so
setFocusable(false) left it in Tab and arrow-key navigation: every
Pressable is accessible, and tabIndex={-1} or focusable={false} on one
changed nothing about where a hardware keyboard moved focus.

ReactViewGroup now records the prop and skips the view in addFocusables
and requestFocus, the two ways keyboard navigation reaches it, while its
descendants, accessibility services and requestFocusFromJS still do.
@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 13, 2026
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — from touch mode, the first Tab focuses the first box and the second lands on the box marked tabIndex={-1}, in a plain view and in a scroll view.

FIXED — the second Tab passes over it to the next box.

before — main at b8ec87e457 after — with this PR
tabIndex={-1}
tabIndex={-1} in a scroll view

RNTester debug builds from main at b8ec87e457 with and without this change, on an Android 16 (API 36) emulator, 1140×2400 at density 480. Each scene is three Pressables that print their onFocus and onBlur, the middle one tabIndex={-1}; in the second scene the first two sit in a ScrollView, the middle one inside a plain View, so the next box lies outside the scroll view and the scroll view's own focus search picks the target. Each image is taken after Tab key presses from touch mode reach the first box and one more Tab follows.

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.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 13:41
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 14, 2026

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. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant