fix(Android, Tabs): let touches through the strip a hidden tab bar leaves behind - #4489
Open
dyankov91 wants to merge 1 commit into
Open
Conversation
…aves behind With `tabBarHidden` on, a strip across the bottom of an Android tab screen rendered content but did not accept touches. It was exactly as tall as the tab bar had been, so a bottom-anchored `Pressable` never fired, and a row scrolled into that strip stopped firing until it was scrolled back out. `TabsAppearanceApplicator` hides the bar with `isVisible = false`, i.e. `View.GONE`. Android skips `GONE` children when laying out, so the bar keeps the bounds it was last laid out with. Android's own dispatch never reaches it - `ViewGroup.canViewReceivePointerEvents` requires `VISIBLE` - but React Native does not pick its touch target that way. It runs a separate hit-test over the native view tree in `TouchTargetHelper`, and that walk only checks bounds, transforms and `pointerEvents`; it never looks at visibility. `TabsContainer` is a `FrameLayout` whose last child is the bar, so the hidden bar was the first candidate tested for every touch and its retained rect captured everything aimed at the content underneath. The tag that came back resolved to the Material item views inside the bar, which are not in the shadow tree, so the touch was dispatched to a tag no component owned and dropped. Report `PointerEvents.NONE` while the bar is not `VISIBLE`, which is how a native view opts out of that hit-test and brings it back in line with Android's dispatch. On a Pixel-sized emulator the dead band began at exactly the hidden bar's `top`: last live tap y=2062, first dead y=2063, against bounds of `0,2063-1080,2400`. Both entry points share this cause - only the height the bar retained differs, 337px after it had been laid out with the system inset applied and 210px when the host mounted already hidden. Closes software-mansion#4132.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
With
tabBarHiddenon, a strip across the bottom of an Android tab screen renders content but does not accept touches. The strip is exactly as tall as the tab bar was.TabsAppearanceApplicatorhides the bar withbottomNavigationView.isVisible = !isTabBarHidden, i.e.View.GONE. Android skipsGONEchildren when laying out, so the bar keeps the bounds it was last laid out with, and Android's own touch dispatch never reaches it (ViewGroup.canViewReceivePointerEventsrequiresVISIBLE).React Native does not pick its touch target through Android's dispatch.
JSTouchDispatchercallsTouchTargetHelper.findTargetTagAndCoordinatesForTouch, which runs a separate hit-test over the native view tree. That walk checks bounds, transforms andpointerEvents, and never looks at visibility —findTouchTargetViewiteratesviewGroup.getChildAt(i)from the top of the z-order down and only asksisTouchPointInView.TabsContaineris aFrameLayoutwhose last child is the bar, so the hidden bar is the first candidate tested for every touch, and its retained bounds capture everything aimed at the content laid out underneath. The tag that comes back is resolved from the Material item views inside the bar, which are not part of the shadow tree, so the touch is dispatched to a tag no component owns and is silently dropped.Declaring
PointerEvents.NONEwhile the bar is notVISIBLEis how a native view opts out of that hit-test, and brings React Native's walk back in line with Android's dispatch. The repo already uses this mechanism for the same purpose inDimmingViewPointerEventsImpl.Closes #4132.
Measurements
Emulator:
AOSP_API_35AVD, Android 15 (API 35), 1080x2400 @ 420dpi (2.625x), 3-button navigation (system navigation bar aty=[2274,2400]). React Native 0.87.0-rc.3, Fabric,FabricExampledebug build atedaabbb61. It does reproduce on an emulator, contrary to the issue body — no physical device was needed.adb shell dumpsys activity topon the failing screen, using the snippet from the first comment (Test4132in this PR), trimmed to the spine:Three things fall out of that dump:
G.E......— correctlyGONE.0,2063-1080,2400, a 337px (128dp) rect: an 80dp Material bar plus the 48dp bottom system inset.ReactSurfaceViewdown toScreenContentWrapperis0,0-1080,2400. Nothing above the content has stale or short bounds.Probing the boundary one pixel at a time with raw
adb shell input tap(a ladder of 30dpPressables, each logging its own index): the last coordinate that fires is y=2062, the first dead one is y=2063. The dead band starts at the hidden bar'stop, to the pixel.Note
This is where the evidence parts company with the initial investigation in the thread. The bar is the eater, so the instinct in #4132 (comment) was pointing at the right view — but it is not "still intercepting touch events" in the Android sense. A
GONEview cannot;dispatchTouchEventskips it. It is React Native's own hit-test that still finds it.Credit to @Linden-786, whose
dumpsysestablished both of the load-bearing facts here — that the bar is properlyGONE, and that the dead band's top edge sits at exactlyscreen_height - bottomNavigationView.height. Their reading of why differs from what this reproduction shows: the strip is the bar's own retained rect rather than an inset applied to aSafeAreaViewancestor. In the harness above there is nocom.swmansion.rnscreens.safearea.SafeAreaViewin the tree at all —@react-navigation/bottom-tabs/unstablewraps inreact-native-safe-area-context's provider, not the RNS one — and the strip is dead anyway. Both readings predict the same boundary, because that inset is the bar's height; only the dump distinguishes them.disableAutomaticContentInsetsfixing it also fits: dropping the wrapper moves the content out of the strip rather than making the strip live.Changes
CustomBottomNavigationViewimplementsReactPointerEventsViewand reportsPointerEvents.NONEwhenever it is notVISIBLE, so React Native's hit-test skips it and its subtree.apps/src/tests/issue-tests/Test4132.tsx— the issue's repro, adapted from the maintainer's snippet: it swaps the alerts forconsole.logso taps injected withadb shell input tapcan be read back from logcat, replaces the single bottom button with a ladder of 30dpPressables so a tap sweep locates the boundary, and adds a route that mounts the tab host with the bar already hidden.test-tabs-tab-bar-hiddengrows a bottom-anchoredPressablewith a press counter, and opts out of the default AndroidSafeAreaViewwrapper so its content runs edge to edge and thatPressablereally sits in the strip the bar occupies.Before & after - visual documentation
No visual change — the bar hides and shows exactly as before. What changes is where touches land. Raw
adb shell input tapat fixed coordinates on the repro screen, readingonPressback from logcat:PressablePressablePressablePressableWith the bar visible, before and after are identical: taps above
y=2063reach the content, taps below it reach the bar and select a tab.Test plan
Repro added as
Test4132. From the Home tab:tabBarHiddenfrom auseLayoutEffect— the runtime-toggle path from the issue. Tap the lower probe rows and the scrolled rows that sit in the bottom ~128dp.tabBarStyle: { display: 'none' }from the first render — the initial-mount path. Same taps.Both paths have the same cause; only the size of the strip differs. On the runtime-toggle path the bar had been laid out with the system inset applied and retained 337px; mounted already hidden it retained 210px (80dp, no inset yet), and the dead band started at
y=2190instead ofy=2063. Both are gone after the fix.Also checked, on the same emulator:
V.E......at0,2063-1080,2400and selects tabs both 250ms after the unhide and after settling — no window where it is visible but not yet hit-testable.Automated:
test-tabs-tab-bar-hidden.e2e.ts— the new case fails onmain(Bottom presses: 0) and passes with the fix.yarn test-e2e-android e2e/single-feature-tests/tabs— 9 passed, 5 skipped (iOS-only), 58 tests, 0 failures.yarn check-typesand./android/gradlew -p android spotlessCheckclean.Notes
Not fixed here, but noticed while reading the surrounding code and worth its own change:
TabsContainer.getInterfaceInsets()returnsbottomNavigationView.heightwith notabBarHiddencheck, while its siblingupdateInterfaceInsets()does honour it.getInterfaceInsets()is whatSafeAreaView.onAttachedToWindowreads, and the dump above shows aGONEbar keeping a 337px height — so aSafeAreaViewthat attaches while the bar is hidden is handed a full-height bottom inset for a bar nobody can see. That is a layout defect rather than a touch one, and it is not what makes the strip dead: the strip is dead in a tree with no RNSSafeAreaViewin it at all. Happy to send it separately.Checklist
Test4132)