Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
669db9a
feat(Tabs): add preventNativeSelection prop to TabsScreen
kkafar Apr 2, 2026
c66fe54
feat(Tabs): add onTabSelectionPrevented event to TabsHost
kkafar Apr 2, 2026
4b880ca
test(example): add prevent native selection test scenario
kkafar Apr 2, 2026
a0816d0
PoC of prevent native selection for iOS w/o more view controller
kkafar Apr 2, 2026
30503a4
PoC of handling the more navigation controller
kkafar Apr 2, 2026
34fef3d
Add two more tabs to example to test more navigation controller
kkafar Apr 2, 2026
8ebd657
Some light cleanup
kkafar Apr 2, 2026
b7281dd
feat(android): prevent native tab selection in TabsContainer
kkafar Apr 2, 2026
64aea23
fix(iOS): harden moreNavigationController push interceptor
kkafar Apr 2, 2026
80ffaa6
Format Android
kkafar Apr 2, 2026
610a4df
Prevent navigation to "preventNativeSelection enabled" tabs that are on
kkafar Apr 3, 2026
7560688
fix(ios): make moreNavigationController push interceptor resilient to…
kkafar Apr 3, 2026
ca3317d
Add logging for debugging purposes
kkafar Apr 7, 2026
34af6a9
fix(ios): prevent infinite recursion in moreNavigationController push…
kkafar Apr 7, 2026
db5ddee
fix(ios): add missing super calls in RNSTabsScreenViewController (#3839)
kkafar Apr 7, 2026
3ae2472
Improve category naming
kkafar Apr 7, 2026
12f9c59
Remove no Longer needed forward declaration
kkafar Apr 7, 2026
b0540f3
Remove debugging setup
kkafar Apr 8, 2026
a7c35f1
Add debug log when we fail to find a table view & document unbounded DFS
kkafar Apr 9, 2026
492da4f
Add comment documenting assumption of event asynchornicity
kkafar Apr 9, 2026
1e1b966
refactor(ios): replace associated object with UIKit parent chain lookup
kkafar Apr 9, 2026
3267fa0
style(ios): use C++ casts in rns_pushViewController
kkafar Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,12 @@ internal class TabsContainer(

val isRepeated = nextSelectedFragment === currSelectedFragment

// If this is user action we test whether it should be prevented before we progress the state.
if (!isRepeated && !isInExternalOperationContext && nextSelectedFragment.isPreventNativeSelectionEnabled) {
Comment thread
kligarski marked this conversation as resolved.
delegate.onNavStateUpdatePrevented(navState, nextSelectedFragment.requireScreenKey)
return false
}

val stateChanged = updateSelectedFragment(nextSelectedFragment)

val hasTriggeredSpecialEffect =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,17 @@ internal interface TabsContainerDelegate {
rejectedNavState: TabsNavState,
reason: TabsNavStateUpdateRejectionReason,
)

/**
* Called when a native user action (tap) attempts to select a tab that has
* [com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen.preventNativeSelection] enabled.
* The navigation state remains unchanged.
*
* @param currentNavState The currently active navigation state that was kept.
* @param preventedScreenKey The screen key of the tab whose selection was prevented.
*/
fun onNavStateUpdatePrevented(
currentNavState: TabsNavState,
preventedScreenKey: String,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ class TabsHost(
)
}

override fun onNavStateUpdatePrevented(
currentNavState: TabsNavState,
preventedScreenKey: String,
) {
eventEmitter.emitOnTabSelectionPreventedEvent(currentNavState, preventedScreenKey)
}

override fun didMountItems(uiManager: UIManager) {
container.performContainerUpdateIfNeeded()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.swmansion.rnscreens.gamma.common.event.BaseEventEmitter
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionReason
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectedEvent
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionPreventedEvent
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionRejectedEvent

internal class TabsHostEventEmitter(
Expand Down Expand Up @@ -53,4 +54,22 @@ internal class TabsHostEventEmitter(
),
)
}

/**
* Emits `onTabSelectionPrevented` event to JS when a tab selection is prevented
* because the target screen has `preventNativeSelection` enabled.
*/
fun emitOnTabSelectionPreventedEvent(
currentNavState: TabsNavState,
preventedScreenKey: String,
) {
reactEventDispatcher.dispatchEvent(
TabsHostTabSelectionPreventedEvent(
surfaceId,
viewTag,
currentNavState,
preventedScreenKey,
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme
import com.swmansion.rnscreens.gamma.helpers.makeEventRegistrationInfo
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectedEvent
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionPreventedEvent
import com.swmansion.rnscreens.gamma.tabs.host.event.TabsHostTabSelectionRejectedEvent
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen

Expand Down Expand Up @@ -59,6 +60,7 @@ class TabsHostViewManager :
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> =
mutableMapOf(
makeEventRegistrationInfo(TabsHostTabSelectedEvent),
makeEventRegistrationInfo(TabsHostTabSelectionPreventedEvent),
makeEventRegistrationInfo(TabsHostTabSelectionRejectedEvent),
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.swmansion.rnscreens.gamma.tabs.host.event

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType
import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState

/**
* React Native event dispatched to JS when a tab selection is prevented because the target
* screen has `preventNativeSelection` enabled.
*
* This event is never coalesced — every prevention is delivered individually.
*/
class TabsHostTabSelectionPreventedEvent(
surfaceId: Int,
viewId: Int,
val currentNavState: TabsNavState,
val preventedScreenKey: String,
) : Event<TabsHostTabSelectionPreventedEvent>(surfaceId, viewId),
NamingAwareEventType {
override fun getEventName() = EVENT_NAME

override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME

override fun canCoalesce(): Boolean = false

override fun getEventData(): WritableMap? =
Arguments.createMap().apply {
putString(EK_SELECTED_KEY, currentNavState.selectedKey)
putInt(EK_PROVENANCE, currentNavState.provenance)
putString(EK_PREVENTED_KEY, preventedScreenKey)
}

companion object : NamingAwareEventType {
const val EVENT_NAME = "topTabSelectionPrevented"
const val EVENT_REGISTRATION_NAME = "onTabSelectionPrevented"

private const val EK_SELECTED_KEY = "selectedScreenKey"
private const val EK_PROVENANCE = "provenance"
private const val EK_PREVENTED_KEY = "preventedScreenKey"

override fun getEventName() = EVENT_NAME

override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ class TabsScreen(
var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true
var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true

var preventNativeSelection: Boolean = false

private fun <T> updateMenuItemAttributesIfNeeded(
oldValue: T,
newValue: T,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class TabsScreenFragment(
internal val tabsScreen: TabsScreen,
) : Fragment() {
internal val requireScreenKey: String by tabsScreen::requireScreenKey
internal val isPreventNativeSelectionEnabled: Boolean by tabsScreen::preventNativeSelection

override fun onCreateView(
inflater: LayoutInflater,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ class TabsScreenViewManager :
view.shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = scrollToTop
}

override fun setPreventNativeSelection(
view: TabsScreen,
value: Boolean,
) {
view.preventNativeSelection = value
}

override fun setTabBarItemTestID(
view: TabsScreen,
value: String?,
Expand Down
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import TestTabsLayoutDirection from './test-tabs-layout-direction';
import TestTabsIMEInsets from './test-tabs-ime-insets';
import TestTabsSimpleNav from './test-tabs-simple-nav';
import TestTabsMoreNavigationController from './test-tabs-more-navigation-controller';
import TestTabsPreventNativeSelection from './test-tabs-prevent-native-selection';
import TestTabsStaleStateUpdateRejection from './test-tabs-stale-update-rejection';

const scenarios = {
Expand All @@ -23,6 +24,7 @@ const scenarios = {
TestTabsIMEInsets,
TestTabsSimpleNav,
TestTabsMoreNavigationController,
TestTabsPreventNativeSelection,
TestTabsStaleStateUpdateRejection,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import React from 'react';
import type { Scenario } from '../../shared/helpers';
import { Button, Text, View } from 'react-native';
import {
type TabRouteConfig,
DEFAULT_TAB_ROUTE_OPTIONS,
useTabsNavigationContext,
TabsContainerWithHostConfigContext,
} from '../../../shared/gamma/containers/tabs';
import { CenteredLayoutView } from '../../../shared/CenteredLayoutView';
import { ToastProvider, useToast } from '../../../shared/';
import Colors from '../../../shared/styling/Colors';

const SCENARIO: Scenario = {
name: 'Prevent native selection',
key: 'test-tabs-prevent-native-selection',
details: 'Test preventNativeSelection prop on TabsScreen',
platforms: ['android', 'ios'],
AppComponent: App,
};

export default SCENARIO;

function ContentView() {
const nav = useTabsNavigationContext();

const preventNativeSelection =
nav.routeOptions.preventNativeSelection ?? false;

return (
<CenteredLayoutView>
<Text style={{ fontWeight: 'bold', textAlign: 'center' }}>
{nav.routeKey}
</Text>
<Text style={{ textAlign: 'center' }}>
preventNativeSelection: {JSON.stringify(preventNativeSelection)}
</Text>
<Button
title="Toggle preventNativeSelection"
onPress={() =>
nav.setRouteOptions(nav.routeKey, {
preventNativeSelection: !preventNativeSelection,
})
}
/>
<TabsNavigationButtons />
</CenteredLayoutView>
);
}

function TabsNavigationButtons() {
const nav = useTabsNavigationContext();

return (
<View>
<Button title="Select First" onPress={() => nav.selectTab('First')} />
<Button title="Select Second" onPress={() => nav.selectTab('Second')} />
<Button title="Select Third" onPress={() => nav.selectTab('Third')} />
<Button title="Select Fourth" onPress={() => nav.selectTab('Fourth')} />
<Button title="Select Fifth" onPress={() => nav.selectTab('Fifth')} />
<Button title="Select Sixth" onPress={() => nav.selectTab('Sixth')} />
</View>
);
}

const ROUTE_CONFIGS: TabRouteConfig[] = [
{
name: 'First',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'First' },
},
{
name: 'Second',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Second' },
},
{
name: 'Third',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Third' },
},
{
name: 'Fourth',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Fourth' },
},
{
name: 'Fifth',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Fifth' },
},
{
name: 'Sixth',
Component: ContentView,
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Sixth' },
},
];

export function App() {
return (
<ToastProvider>
<AppContents />
</ToastProvider>
);
}

function AppContents() {
const toast = useToast();

return (
<TabsContainerWithHostConfigContext
routeConfigs={ROUTE_CONFIGS}
onTabSelectionPrevented={event => {
const message = `onTabSelectionPrevented: ${event.nativeEvent.preventedScreenKey}`;
console.warn(message);
toast.push({
message: message,
backgroundColor: Colors.GreenLight60,
});
}}
/>
);
}

Loading
Loading