Skip to content

Commit a52b1b4

Browse files
kkafarclaude
andauthored
feat(Tabs): add preventNativeSelection support (#3838)
## 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 software-mansion/react-native-screens-labs#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 `screenKeyForSelectedViewController` → `screenKeyForViewController:` and `isSelectedViewControllerTheMoreNavigationController` → `isViewControllerTheMoreNavigationController:` 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 | | -- | -- | | <video src="https://github.com/user-attachments/assets/fed8b101-265e-469a-8e18-ad4cc6148618" alt="s-1-ios" /> | <video src="https://github.com/user-attachments/assets/523b0fbd-9366-4943-95bc-c4f7059a62e3" alt="s-1-android" /> | ### 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](610a4df). | Block from more list | Forced pop on navigation | | -- | -- | | <video src="https://github.com/user-attachments/assets/67fb3e0e-b3bf-4ad8-8834-67e9cf8b439e" alt="block-from-more-list" /> | <video src="https://github.com/user-attachments/assets/d7302f29-dc27-4d9e-a335-6af5dcdbf2fc" alt="forced-pop-on-navigation" /> | ## 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 - [x] Included code example that can be used to test this change. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [ ] Ensured that CI passes --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d8528ec commit a52b1b4

26 files changed

+615
-35
lines changed

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,12 @@ internal class TabsContainer(
361361

362362
val isRepeated = nextSelectedFragment === currSelectedFragment
363363

364+
// If this is user action we test whether it should be prevented before we progress the state.
365+
if (!isRepeated && !isInExternalOperationContext && nextSelectedFragment.isPreventNativeSelectionEnabled) {
366+
delegate.onNavStateUpdatePrevented(navState, nextSelectedFragment.requireScreenKey)
367+
return false
368+
}
369+
364370
val stateChanged = updateSelectedFragment(nextSelectedFragment)
365371

366372
val hasTriggeredSpecialEffect =

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerDelegate.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,17 @@ internal interface TabsContainerDelegate {
3232
rejectedNavState: TabsNavState,
3333
reason: TabsNavStateUpdateRejectionReason,
3434
)
35+
36+
/**
37+
* Called when a native user action (tap) attempts to select a tab that has
38+
* [com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen.preventNativeSelection] enabled.
39+
* The navigation state remains unchanged.
40+
*
41+
* @param currentNavState The currently active navigation state that was kept.
42+
* @param preventedScreenKey The screen key of the tab whose selection was prevented.
43+
*/
44+
fun onNavStateUpdatePrevented(
45+
currentNavState: TabsNavState,
46+
preventedScreenKey: String,
47+
)
3548
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ class TabsHost(
182182
)
183183
}
184184

185+
override fun onNavStateUpdatePrevented(
186+
currentNavState: TabsNavState,
187+
preventedScreenKey: String,
188+
) {
189+
eventEmitter.emitOnTabSelectionPreventedEvent(currentNavState, preventedScreenKey)
190+
}
191+
185192
override fun didMountItems(uiManager: UIManager) {
186193
container.performContainerUpdateIfNeeded()
187194
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.swmansion.rnscreens.gamma.common.event.BaseEventEmitter
55
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState
66
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionReason
77
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectedEvent
8+
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionPreventedEvent
89
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionRejectedEvent
910

1011
internal class TabsHostEventEmitter(
@@ -53,4 +54,22 @@ internal class TabsHostEventEmitter(
5354
),
5455
)
5556
}
57+
58+
/**
59+
* Emits `onTabSelectionPrevented` event to JS when a tab selection is prevented
60+
* because the target screen has `preventNativeSelection` enabled.
61+
*/
62+
fun emitOnTabSelectionPreventedEvent(
63+
currentNavState: TabsNavState,
64+
preventedScreenKey: String,
65+
) {
66+
reactEventDispatcher.dispatchEvent(
67+
TabsHostTabSelectionPreventedEvent(
68+
surfaceId,
69+
viewTag,
70+
currentNavState,
71+
preventedScreenKey,
72+
),
73+
)
74+
}
5675
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
1313
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
1414
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState
1515
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectedEvent
16+
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionPreventedEvent
1617
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionRejectedEvent
1718
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
1819

@@ -59,6 +60,7 @@ class TabsHostViewManager :
5960
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
6061
mutableMapOf(
6162
makeEventRegistrationInfo(TabsHostTabSelectedEvent),
63+
makeEventRegistrationInfo(TabsHostTabSelectionPreventedEvent),
6264
makeEventRegistrationInfo(TabsHostTabSelectionRejectedEvent),
6365
)
6466

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.swmansion.rnscreens.gamma.tabs.host.event
2+
3+
import com.facebook.react.bridge.Arguments
4+
import com.facebook.react.bridge.WritableMap
5+
import com.facebook.react.uimanager.events.Event
6+
import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType
7+
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState
8+
9+
/**
10+
* React Native event dispatched to JS when a tab selection is prevented because the target
11+
* screen has `preventNativeSelection` enabled.
12+
*
13+
* This event is never coalesced — every prevention is delivered individually.
14+
*/
15+
class TabsHostTabSelectionPreventedEvent(
16+
surfaceId: Int,
17+
viewId: Int,
18+
val currentNavState: TabsNavState,
19+
val preventedScreenKey: String,
20+
) : Event<TabsHostTabSelectionPreventedEvent>(surfaceId, viewId),
21+
NamingAwareEventType {
22+
override fun getEventName() = EVENT_NAME
23+
24+
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
25+
26+
override fun canCoalesce(): Boolean = false
27+
28+
override fun getEventData(): WritableMap? =
29+
Arguments.createMap().apply {
30+
putString(EK_SELECTED_KEY, currentNavState.selectedKey)
31+
putInt(EK_PROVENANCE, currentNavState.provenance)
32+
putString(EK_PREVENTED_KEY, preventedScreenKey)
33+
}
34+
35+
companion object : NamingAwareEventType {
36+
const val EVENT_NAME = "topTabSelectionPrevented"
37+
const val EVENT_REGISTRATION_NAME = "onTabSelectionPrevented"
38+
39+
private const val EK_SELECTED_KEY = "selectedScreenKey"
40+
private const val EK_PROVENANCE = "provenance"
41+
private const val EK_PREVENTED_KEY = "preventedScreenKey"
42+
43+
override fun getEventName() = EVENT_NAME
44+
45+
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
46+
}
47+
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreen.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ class TabsScreen(
9494
var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true
9595
var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true
9696

97+
var preventNativeSelection: Boolean = false
98+
9799
private fun <T> updateMenuItemAttributesIfNeeded(
98100
oldValue: T,
99101
newValue: T,

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenFragment.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class TabsScreenFragment(
1111
internal val tabsScreen: TabsScreen,
1212
) : Fragment() {
1313
internal val requireScreenKey: String by tabsScreen::requireScreenKey
14+
internal val isPreventNativeSelectionEnabled: Boolean by tabsScreen::preventNativeSelection
1415

1516
override fun onCreateView(
1617
inflater: LayoutInflater,

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenViewManager.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ class TabsScreenViewManager :
9797
view.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = scrollToTop
9898
}
9999

100+
override fun setPreventNativeSelection(
101+
view: TabsScreen,
102+
value: Boolean,
103+
) {
104+
view.preventNativeSelection = value
105+
}
106+
100107
override fun setTabBarItemTestID(
101108
view: TabsScreen,
102109
value: String?,

apps/src/tests/single-feature-tests/tabs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import TestTabsTabBarLayoutDirection from './test-tabs-tab-bar-layout-direction'
1010
import TestTabsIMEInsets from './test-tabs-ime-insets';
1111
import TestTabsSimpleNav from './test-tabs-simple-nav';
1212
import TestTabsMoreNavigationController from './test-tabs-more-navigation-controller';
13+
import TestTabsPreventNativeSelection from './test-tabs-prevent-native-selection';
1314
import TestTabsStaleStateUpdateRejection from './test-tabs-stale-update-rejection';
1415
import TestTabsTabBarMinimizeBehavior from './test-tabs-tab-bar-minimize-behavior-ios';
1516
import TestTabsTabBarControllerMode from './test-tabs-tab-bar-controller-mode-ios';
@@ -25,6 +26,7 @@ const scenarios = {
2526
TestTabsIMEInsets,
2627
TestTabsSimpleNav,
2728
TestTabsMoreNavigationController,
29+
TestTabsPreventNativeSelection,
2830
TestTabsStaleStateUpdateRejection,
2931
TestTabsTabBarMinimizeBehavior,
3032
TestTabsTabBarControllerMode,

0 commit comments

Comments
 (0)