Skip to content

feat(Tabs): add preventNativeSelection support#3838

Merged
kkafar merged 22 commits intomainfrom
@kkafar/tabs-prevent-default
Apr 9, 2026
Merged

feat(Tabs): add preventNativeSelection support#3838
kkafar merged 22 commits intomainfrom
@kkafar/tabs-prevent-default

Conversation

@kkafar
Copy link
Copy Markdown
Member

@kkafar kkafar commented Apr 2, 2026

Description

Adds the ability to prevent native tab selection on a per-screen basis. When preventNativeSelection is set to true on a TabsScreen, tapping that tab in the tab bar (or selecting it from the "More" list on iOS) will be blocked. The TabsHost receives an onTabSelectionPrevented callback with the key of the prevented screen and the current navigation state, allowing JS to decide how to handle the attempt (e.g. show a confirmation dialog, redirect elsewhere).

Closes https://github.com/software-mansion/react-native-screens-labs/issues/1078

Changes

  • TabsScreen: New preventNativeSelection boolean prop plumbed from JS → codegen specs → native on both Android and iOS.
  • TabsHost: New onTabSelectionPrevented event (type + codegen spec + native emitter) fired when selection is blocked.
  • iOS (RNSTabBarController):
    • tabBarController:shouldSelectViewController: now checks per-screen preventNativeSelection and emits the prevented event.
    • ISA-swizzles UIMoreNavigationController to intercept pushViewController:animated: so screens behind the "More" list are also subject to prevention. Includes table-view deselection cleanup when a push is blocked.
    • Dynamic subclass name is derived from the actual runtime class (e.g. RNS_UIMoreNavigationController), so each distinct original class gets its own correct subclass — safe even if another library ISA-swizzles first.
    • Clears the OBJC_ASSOCIATION_ASSIGN back-reference in dealloc as a safety measure against potential dangling pointers.
    • Refactored screenKeyForSelectedViewControllerscreenKeyForViewController: and isSelectedViewControllerTheMoreNavigationControllerisViewControllerTheMoreNavigationController: to support querying arbitrary view controllers (not just the selected one).
  • iOS (RNSTabsScreenViewController): Added ScreenPropsForwarding category exposing isPreventNativeSelectionEnabled.
  • Android: Prevention logic in TabsContainer.onMenuItemSelected — checks isPreventNativeSelectionEnabled before progressing state, delegates to TabsContainerDelegate.onNavStateUpdatePrevented which emits the event via TabsHost.
  • Example app: New test scenario (TestTabsPreventNativeSelection) with 6 tabs, per-tab toggle, and toast on prevention.

Known issues

  • experimental_controlNavigationStateInJS is not respected in the "More" navigation controller flow on iOS. When the experimental controlled-mode flag is enabled and the user is already on the "More" tab, tapping items in the More list will not be blocked by the controlled-mode gate (only by preventNativeSelection). This is acceptable since the controlled-mode feature is planned for removal before release.

Visual documentation

Rudimentary scenarios

We navigate to a "third" tab natively, toggle preventNativeSelection, go back, and navigate again. That attempt is blocked (prevented) and an event is emitted, as it can be observed by appearance of toast. We navigate to the "third" tab via JS - this is allowed, as we only prevent native selection - toggle the option and repeat the experiment. This time navigation to "third" tab is allowed.

iOS Android
s-1-regular-tab.mov
s-1-android.mov

More navigation controller scenarios

In the video on the left I showcase that the navigation from "more list" is correctly prevented.

In the video on the right I cover a edge case, where a user / programmer navigates first the the "fifth" tab, then navigates away, e.g. to "third" and then comes back. W/o prevention mechanism, the "fifth" tab would be shown, as it is pushed onto more navigation controller stack. I've implemented logic to cover this case. Now, the "more list" will be displayed. I've decided that in such case we will emit the OnTabSelectionPrevented event, as we effectively prevent s user from navigating to that tab, even if this is not fully intentional. See more details here.

Block from more list Forced pop on navigation
s-2-more-nav-ctrl.mov
s-3-more-with-stack.mov

Test plan

  • Run TestTabsPreventNativeSelection scenario from the example app.
  • Tap a tab → verify it switches normally.
  • Toggle preventNativeSelection on a tab → tap it → verify it does not switch and the onTabSelectionPrevented toast appears.
  • On iOS with 6 tabs: verify prevention also works for tabs listed under the "More" navigation controller.
  • Toggle the flag back off → verify the tab becomes selectable again.

Checklist

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

kkafar and others added 9 commits April 2, 2026 12:17
Add boolean prop plumbing from JS to native on both Android and iOS.
No logic attached yet — this will be used to implement tab selection
prevention mechanism.
Add event plumbing for when native tab selection is prevented because
the target screen has preventNativeSelection enabled. Payload carries
current nav state (selectedScreenKey, provenance) and the prevented
screen key. No emission logic yet — only types, codegen specs, event
classes, and emitter methods.
When a tab has preventNativeSelection enabled, block native user taps
from selecting it. Returns false from OnItemSelectedListener to prevent
BottomNavigationView from updating its visual selection state, and emits
onTabSelectionPrevented event to JS via TabsContainerDelegate chain.
- Clear OBJC_ASSOCIATION_ASSIGN back-reference in dealloc to avoid
  potential dangling pointer if moreNavigationController ever outlives
  the tab bar controller.
- Derive dynamic subclass name from runtime class of
  moreNavigationController (e.g. RNS_UIMoreNavigationController)
  instead of using a static name, so each distinct original class
  gets its own correct subclass.
- Move experimental_controlNavigationStateInJS check above
  preventNativeSelection to avoid firing onTabSelectionPrevented
  for the experimental controlled-mode path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kkafar kkafar changed the title feat(Tabs): add preventNativeSelection prop to TabsScreen feat(Tabs): add preventNativeSelection support Apr 2, 2026
kkafar added 2 commits April 2, 2026 21:29
stack of more navigation controller

In such cases, now we emit an `OnTabSelectionPrevented` event. I've
thought about whether we should, but I came to conclusion that it is
better to expose this information and let user ignore it, than not
expose it at all, in the end we're effectively preventing user
navigation to such tab.

In the future we might consider adding some payload to indicate that
the prevention happened exactly in such case, so that it is easier to ignore.
@kkafar kkafar marked this pull request as ready for review April 3, 2026 09:56
@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Apr 3, 2026

I've found a crash.

Screen.Recording.2026-04-03.at.12.24.08.mov

Need to investigate it next week.

@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Apr 3, 2026

I've also noticed on @satya164 suggestion, that we don't emit an OnTabSelected event in case user is on moreNavigationController morelist screen, and he resizes the app, so that the tab changes to the one previously selected. This breaks the state!

I do not know how to fix it yet, as we literally get no information from UIKit that the tab has been effectively changed - this sucks.

@satya164
Copy link
Copy Markdown
Contributor

satya164 commented Apr 3, 2026

@kkafar I was thinking, could moreNavigationController be a separate thing instead of a screen key?

  • Since we don't get an event when this changes due to screen resize, the JS state that uses screen key will not get out of date, as it would still be the actual screen that was selected
  • It'll represent its ephemeral nature, compared to actual screen keys
  • For React Navigation, that's how I'm planning to treat it as - so for us it'll be closer to the truth

@LKuchno
Copy link
Copy Markdown
Collaborator

LKuchno commented Apr 3, 2026

I've checked basic scenarios on iPhone 17pro (ios26.2) simulator:

  • switching between tabs with option preventNativeSelection disabled
  • setting preventNativeSelection to true - navigate between tabs selecting them from tab bar and using button - ok
  • reset preventNativeSelection from true to false - ok
  • for More Tabs:
    - I also performed above tests,
    - Added steps where first I've select 5th or 6th tab set preventNativeSelection to true navigate to different tab and then using More tab bar item when back to more tabs list - correct event was emmited - refering to last displayed in More option tab even both tabs have preventNativeSelection to true.
    - Also check scenario where I set preventNativeSelection: true for all available tabs and then navigate to More tabs list which result with unusable application as I can't navigate to any of tab
    NOTE: In my opinion is the way this test screen was design but maybe we shouldn't let user/developer disable selection of ALL tabs ?

I started test on iPad Pro simulator:

  • checked first 2 points from iPhone flow but with side bar - ok

kkafar added 2 commits April 3, 2026 23:22
… isa resets

UIKit resets the isa pointer of moreNavigationController back to
the original UIMoreNavigationController class during tab bar
reconfiguration (e.g. iPad multitasking resize crossing the >5 tab
threshold). The instance stays the same, but our ISA-swizzle is
wiped out, causing preventNativeSelection to stop working for
screens in the More list after a resize cycle.

Make ensurePushInterceptorOnMoreNavigationController idempotent —
re-apply object_setClass + objc_setAssociatedObject on every call
instead of guarding with a one-shot BOOL. The dynamic subclass is
created once and reused; only the isa assignment is repeated.

Rename _didInstallPushInterceptor to _didAccessMoreNavigationController
to reflect its new purpose: guarding against lazy creation of
moreNavigationController in dealloc.
@LKuchno
Copy link
Copy Markdown
Collaborator

LKuchno commented Apr 7, 2026

Continued checks on iOS.
iPad:

  1. The fix for the problem with settings resetting while resizing the app on iPad (described by @kkafar above) has been tested on an iOS 26.2 iPad Air (M3) and works fine. Smoke tests on iPhone after this change did not find any regressions.
  2. Side bar: when prevented tab is selected from side bar its not displayed but if we open side bar this tab is shown as selected - this behavior is not always reproducible but most of the time:
Screen.Recording.2026-04-07.at.09.48.16.mov
  1. iPad and Iphone:
    App is hangs while we prevent one of tab from more tabs switch to other tabs and then go back to more tabs and want to select the tab which is not prevented:
Screenshot 2026-04-07 at 10 00 21

Copy link
Copy Markdown
Contributor

@kligarski kligarski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be related to the bug you mentioned:

Screen.Recording.2026-04-07.at.10.25.25.mov

Comment thread ios/tabs/screen/RNSTabsScreenComponentView.h
Comment thread ios/tabs/screen/RNSTabsScreenViewController.h Outdated
Comment thread ios/tabs/screen/RNSTabsScreenViewController.h Outdated
Comment thread ios/tabs/host/RNSTabBarController.mm Outdated
Comment thread ios/tabs/host/RNSTabBarController.mm
@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Apr 7, 2026

@satya164 could you elaborate what do you mean by "a separate thing"?

I've initially considered exposing support for the moreNavigationController in "silent way", so that when user taps it we either send no information to JS or just a separate event that such action has just been taken. This would however require either a very special treatment when attempting to navigate to moreNavigationController or no support for such navigation at all. I've got no idea, whether people (and how often) will have use cases for such navigation, but UIKit exposes such possibility and I did not want to restrict it. If react-native-screens restrict it and don't account for it, it'll be hard for anybody in the downstream to add proper support for it.

On the other hand, I can see that incorporating navigation to moreNavigationController into state changes, might complicate the decision of whether to render and what tab.

Until we stabilise the API it's shape is modifiable. Waiting for any valid input here.
I think I might create a dedicated discussion from this & ask on X for comments.

Edit: Also I think I've found a way to get information from UIKit that it changes the tab from moreNavigationController to "previously selected one" in cases where moreTab disappears. This will require an substantial refactor to core container update logic to take it into account, but should be doable & rather robust.

@LKuchno
Copy link
Copy Markdown
Collaborator

LKuchno commented Apr 7, 2026

Android 16.0:

PROBLEM:

  • currently, after few tabs switch app is reloaded and direction is changed from ltr to rtl - to be investigated
  • this block further test in ltr direction but I make smoke tests before reload and on rtl direction to have some feedback already.

On Android phone and tablet

  • smoke test ok

@satya164
Copy link
Copy Markdown
Contributor

satya164 commented Apr 7, 2026

@kkafar I meant something other than the controlled screenKey + onTabSelected combination.

potential APIs maybe:

  • just some event like onMoreNavigationSelected without an accompanying controlled prop/method - which still leaves room to add the ability to add a way to manually navigate to it in the future
  • event + method on ref - following how uncontrolled components usually work

i'm unsure if a controlled API is appropriate for more navigation controller given that we can't actually control it consistently.

… interceptor

When UIKit does NOT reset the ISA of moreNavigationController between
tab switches, ensurePushInterceptorOnMoreNavigationController would
prepend another RNS_ layer (RNS_RNS_UIMoreNavigationController) on
top of the existing swizzled class. The objc_msgSendSuper call in
rns_pushViewController then resolved to the intermediate class which
also carried our override, causing infinite recursion and a crash.

Guard against this by checking the RNS_ prefix on the current ISA
before attempting to create a new dynamic subclass. Also extract the
associated-object setup into installSelfAssociationWithMoreNavigationController
to keep the back-reference and access flag co-located.
@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Apr 7, 2026

@kligarski @LKuchno
34af6a9 should fix the crash you reported.

kkafar and others added 3 commits April 7, 2026 13:40
## Description

`RNSTabsScreenViewController` overrides `viewWillAppear:`,
`viewDidAppear:`, `viewWillDisappear:`, and `viewDidDisappear:` to emit
React Native lifecycle events but none of them call their `super`
implementation. Apple's documentation requires calling super in these
methods — skipping it can break UIKit's internal bookkeeping for
appearance transitions, trait collection propagation, and child
container callbacks.

## Changes

- Added `[super viewWillAppear:animated]`, `[super
viewDidAppear:animated]`, `[super viewWillDisappear:animated]`, and
`[super viewDidDisappear:animated]` calls before the event emission in
each method.

## Test plan

- Run any Tabs test scenario from the example app.
- Verify tab switching, appearance/disappearance events, and orientation
changes still work correctly.

## Checklist

- [ ] Included code example that can be used to test this change.
- [ ] For visual changes, included screenshots / GIFs / recordings
documenting the change.
- [ ] For API changes, updated relevant public types.
- [ ] Ensured that CI passes
@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Apr 7, 2026

I've opened a separate issue for the problem with sidebar.
https://github.com/software-mansion/react-native-screens-labs/issues/1112

@kkafar kkafar requested a review from kligarski April 7, 2026 13:19
Copy link
Copy Markdown
Contributor

@kligarski kligarski left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks good. I can check the runtime tomorrow.

@kligarski
Copy link
Copy Markdown
Contributor

@kligarski @LKuchno 34af6a9 should fix the crash you reported.

Seems to be fixed now, the runtime on the iPhone simulator looks good.

We can add a background color to tabs to avoid this glitch (iOS 18):

Simulator.Screen.Recording.-.iPhone.16.Pro.-.2026-04-08.at.12.25.42.mov

kkafar added 4 commits April 8, 2026 14:49
The ISA-swizzled rns_pushViewController on moreNavigationController
used OBJC_ASSOCIATION_ASSIGN to store a back-reference to the owning
RNSTabBarController. This required careful cleanup in dealloc to
avoid dangling pointers.

Since moreNavigationController is always a child of the tab bar
controller, UIViewController.tabBarController already provides the
reference via the parent chain. This eliminates the associated object,
the dealloc cleanup, and the _didAccessMoreNavigationController guard.
Replace C-style casts with static_cast and reinterpret_cast.
Extract the objc_msgSendSuper function pointer cast to a named
local for readability.
@kkafar kkafar merged commit a52b1b4 into main Apr 9, 2026
8 checks passed
@kkafar kkafar deleted the @kkafar/tabs-prevent-default branch April 9, 2026 15:07
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.

5 participants