Skip to content

fix(Android, Tabs): let touches through the strip a hidden tab bar leaves behind - #4489

Open
dyankov91 wants to merge 1 commit into
software-mansion:mainfrom
dyankov91:fix/android-tabs-hidden-tab-bar-swallows-touches
Open

fix(Android, Tabs): let touches through the strip a hidden tab bar leaves behind#4489
dyankov91 wants to merge 1 commit into
software-mansion:mainfrom
dyankov91:fix/android-tabs-hidden-tab-bar-swallows-touches

Conversation

@dyankov91

Copy link
Copy Markdown

Description

With tabBarHidden on, 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.

TabsAppearanceApplicator hides the bar with bottomNavigationView.isVisible = !isTabBarHidden, i.e. View.GONE. Android skips GONE children 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.canViewReceivePointerEvents requires VISIBLE).

React Native does not pick its touch target through Android's dispatch. JSTouchDispatcher calls TouchTargetHelper.findTargetTagAndCoordinatesForTouch, which runs a separate hit-test over the native view tree. That walk checks bounds, transforms and pointerEvents, and never looks at visibilityfindTouchTargetView iterates viewGroup.getChildAt(i) from the top of the z-order down and only asks isTouchPointInView. TabsContainer is a FrameLayout whose 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.NONE while the bar is not VISIBLE is 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 in DimmingViewPointerEventsImpl.

Closes #4132.

Measurements

Emulator: AOSP_API_35 AVD, Android 15 (API 35), 1080x2400 @ 420dpi (2.625x), 3-button navigation (system navigation bar at y=[2274,2400]). React Native 0.87.0-rc.3, Fabric, FabricExample debug build at edaabbb61. It does reproduce on an emulator, contrary to the issue body — no physical device was needed.

adb shell dumpsys activity top on the failing screen, using the snippet from the first comment (Test4132 in this PR), trimmed to the spine:

com.facebook.react.runtime.ReactSurfaceView{c3c7c27 V.E...... 0,0-1080,2400 #1}
  com.th3rdwave.safeareacontext.SafeAreaProvider{beeed4 V.E...... 0,0-1080,2400 #3a}
    com.swmansion.rnscreens.legacy.ScreenStack{27024ff V.E...... 0,0-1080,2400 #38}
      com.swmansion.rnscreens.legacy.Screen{881ed16 V.E...... 0,0-1080,2400 #36}
        com.swmansion.rnscreens.legacy.ScreenContentWrapper{7ff9372 V.E...... 0,0-1080,2400 #32}
          com.swmansion.rnscreens.tabs.host.TabsHost{2eab3c3 V.E...... 0,0-1080,2400 #2e}
            com.swmansion.rnscreens.tabs.container.TabsContainer{c862440 V.E...... 0,0-1080,2400 #3}
              android.widget.FrameLayout{afadc3d V.E...... 0,0-1080,2400 #5}
                com.swmansion.rnscreens.tabs.screen.TabsScreen{da91f79 V.E...... 0,0-1080,2400 #2a}
                  com.swmansion.rnscreens.legacy.ScreenStack{c2ccf3a V.E...... 0,0-1080,2400 #22}
                    com.swmansion.rnscreens.legacy.Screen{6e06d55 V.E...... 0,0-1080,2400 #18a}
                      com.swmansion.rnscreens.legacy.ScreenContentWrapper{c9446c V.E...... 0,0-1080,2400 #186}
                        com.facebook.react.views.scroll.ReactScrollView{4ca44b1 VFED.V... 42,522-1038,2400 #11a}
              com.swmansion.rnscreens.tabs.container.CustomBottomNavigationView{69a2b73 G.E...... 0,2063-1080,2400}
                com.google.android.material.bottomnavigation.BottomNavigationMenuView{6d3ba30 V.E...... 98,0-982,211}

Three things fall out of that dump:

  • The bar is G.E...... — correctly GONE.
  • It has kept 0,2063-1080,2400, a 337px (128dp) rect: an 80dp Material bar plus the 48dp bottom system inset.
  • Every ancestor from ReactSurfaceView down to ScreenContentWrapper is 0,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 30dp Pressables, 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's top, 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 GONE view cannot; dispatchTouchEvent skips it. It is React Native's own hit-test that still finds it.

Credit to @Linden-786, whose dumpsys established both of the load-bearing facts here — that the bar is properly GONE, and that the dead band's top edge sits at exactly screen_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 a SafeAreaView ancestor. In the harness above there is no com.swmansion.rnscreens.safearea.SafeAreaView in the tree at all — @react-navigation/bottom-tabs/unstable wraps in react-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. disableAutomaticContentInsets fixing it also fits: dropping the wrapper moves the content out of the strip rather than making the strip live.

Changes

  • CustomBottomNavigationView implements ReactPointerEventsView and reports PointerEvents.NONE whenever it is not VISIBLE, 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 for console.log so taps injected with adb shell input tap can be read back from logcat, replaces the single bottom button with a ladder of 30dp Pressables 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-hidden grows a bottom-anchored Pressable with a press counter, and opts out of the default Android SafeAreaView wrapper so its content runs edge to edge and that Pressable really sits in the strip the bar occupies.
  • Its e2e spec gains an Android-only case asserting the counter increments on a tap inside that strip.

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 tap at fixed coordinates on the repro screen, reading onPress back from logcat:

tap y before after
static bottom-anchored Pressable 1967 fires fires
static bottom-anchored Pressable 2045 fires fires
static bottom-anchored Pressable 2124 dead fires
static bottom-anchored Pressable 2203 dead fires
row scrolled into the strip 1890 fires fires
row scrolled into the strip 2046 fires fires
row scrolled into the strip 2202 dead fires

With the bar visible, before and after are identical: taps above y=2063 reach the content, taps below it reach the bar and select a tab.

Test plan

Repro added as Test4132. From the Home tab:

  1. Open /detail pushes a screen inside the tabs that sets tabBarHidden from a useLayoutEffect — the runtime-toggle path from the issue. Tap the lower probe rows and the scrolled rows that sit in the bottom ~128dp.
  2. Open /static-hidden mounts a second tab host with tabBarStyle: { display: 'none' } from the first render — the initial-mount path. Same taps.
  3. Other tab renders the same content with the bar visible — the regression control.
  4. Open /control pushes the same content outside the tabs, where it always worked.

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=2190 instead of y=2063. Both are gone after the fix.

Also checked, on the same emulator:

  • Bringing the bar back: it returns V.E...... at 0,2063-1080,2400 and selects tabs both 250ms after the unhide and after settling — no window where it is visible but not yet hit-testable.
  • With the bar visible, content under it stays untappable and the bar takes those touches, before and after the change.

Automated:

  • test-tabs-tab-bar-hidden.e2e.ts — the new case fails on main (Bottom presses: 0) and passes with the fix.
  • Full Android tabs suite: yarn test-e2e-android e2e/single-feature-tests/tabs — 9 passed, 5 skipped (iOS-only), 58 tests, 0 failures.
  • yarn check-types and ./android/gradlew -p android spotlessCheck clean.

Notes

Not fixed here, but noticed while reading the surrounding code and worth its own change: TabsContainer.getInterfaceInsets() returns bottomNavigationView.height with no tabBarHidden check, while its sibling updateInterfaceInsets() does honour it. getInterfaceInsets() is what SafeAreaView.onAttachedToWindow reads, and the dump above shows a GONE bar keeping a 337px height — so a SafeAreaView that 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 RNS SafeAreaView in it at all. Happy to send it separately.

Checklist

  • Included code example that can be used to test this change. (Test4132)
  • For visual changes, included screenshots / GIFs / recordings documenting the change. (no visual change; tap measurements above)
  • For API changes, updated relevant public types. (no public API changes)
  • Ensured that CI passes

…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.
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.

[Android][NativeTabs] Pressables in bottom ~56dp of Stack-pushed screens don't fire when hidden=true (also affects scrolled rows)

1 participant