diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt index 1a96320231..3d2913f325 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt @@ -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) { + delegate.onNavStateUpdatePrevented(navState, nextSelectedFragment.requireScreenKey) + return false + } + val stateChanged = updateSelectedFragment(nextSelectedFragment) val hasTriggeredSpecialEffect = diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerDelegate.kt index ef8e617a6b..a1974440e1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerDelegate.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerDelegate.kt @@ -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, + ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt index ad3b6a2f22..c7f3dc545f 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt @@ -182,6 +182,13 @@ class TabsHost( ) } + override fun onNavStateUpdatePrevented( + currentNavState: TabsNavState, + preventedScreenKey: String, + ) { + eventEmitter.emitOnTabSelectionPreventedEvent(currentNavState, preventedScreenKey) + } + override fun didMountItems(uiManager: UIManager) { container.performContainerUpdateIfNeeded() } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt index 229df14bba..e971268e8e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt @@ -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( @@ -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, + ), + ) + } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt index 59a5701ddb..5b81f7ee8c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt @@ -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 @@ -59,6 +60,7 @@ class TabsHostViewManager : override fun getExportedCustomDirectEventTypeConstants(): MutableMap = mutableMapOf( makeEventRegistrationInfo(TabsHostTabSelectedEvent), + makeEventRegistrationInfo(TabsHostTabSelectionPreventedEvent), makeEventRegistrationInfo(TabsHostTabSelectionRejectedEvent), ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionPreventedEvent.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionPreventedEvent.kt new file mode 100644 index 0000000000..b65ac57136 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionPreventedEvent.kt @@ -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(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 + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreen.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreen.kt index 646c1fa34d..24d107f6fe 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreen.kt @@ -94,6 +94,8 @@ class TabsScreen( var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true + var preventNativeSelection: Boolean = false + private fun updateMenuItemAttributesIfNeeded( oldValue: T, newValue: T, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenFragment.kt index 6d70800492..ee0a07ce85 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenFragment.kt @@ -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, diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenViewManager.kt index 02fd4788e2..73ea07790a 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/screen/TabsScreenViewManager.kt @@ -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?, diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts index 75419931cc..aedc34ad7d 100644 --- a/apps/src/tests/single-feature-tests/tabs/index.ts +++ b/apps/src/tests/single-feature-tests/tabs/index.ts @@ -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 = { @@ -23,6 +24,7 @@ const scenarios = { TestTabsIMEInsets, TestTabsSimpleNav, TestTabsMoreNavigationController, + TestTabsPreventNativeSelection, TestTabsStaleStateUpdateRejection, }; diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-prevent-native-selection.tsx b/apps/src/tests/single-feature-tests/tabs/test-tabs-prevent-native-selection.tsx new file mode 100644 index 0000000000..846487779d --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-prevent-native-selection.tsx @@ -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 ( + + + {nav.routeKey} + + + preventNativeSelection: {JSON.stringify(preventNativeSelection)} + +