diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsActionOrigin.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsActionOrigin.kt new file mode 100644 index 0000000000..951671d60f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsActionOrigin.kt @@ -0,0 +1,22 @@ +package com.swmansion.rnscreens.gamma.tabs.container + +/** + * Origin (actor) that requested a tab transition. Mirrors the public `actionOrigin` event field. + * + * - [USER] — direct native UI interaction (tab bar tap). + * - [PROGRAMMATIC_JS] — JS-initiated request delivered via the `navStateRequest` prop. + * + * The `implicit` origin defined on the public TS API is iOS-only at the moment; + * Android does not currently produce it. + */ +enum class TabsActionOrigin { + USER, + PROGRAMMATIC_JS, + ; + + override fun toString(): String = + when (this) { + USER -> "user" + PROGRAMMATIC_JS -> "programmatic-js" + } +} 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 3d2913f325..080a2298f1 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 @@ -377,7 +377,7 @@ internal class TabsContainer( navState, isRepeated = isRepeated, hasTriggeredSpecialEffect = hasTriggeredSpecialEffect, - isNativeAction = !isInExternalOperationContext, + actionOrigin = if (isInExternalOperationContext) TabsActionOrigin.PROGRAMMATIC_JS else TabsActionOrigin.USER, ) } 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 a1974440e1..63847e7303 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 @@ -11,13 +11,13 @@ internal interface TabsContainerDelegate { * @param navState The new navigation state after the change. * @param isRepeated Whether the same tab that was already selected has been selected again. * @param hasTriggeredSpecialEffect Whether a special effect (e.g. scroll-to-top) was triggered. - * @param isNativeAction Whether the change was initiated by a native user action (tap). + * @param actionOrigin Origin (actor) that requested this transition. */ fun onNavStateUpdate( navState: TabsNavState, isRepeated: Boolean, hasTriggeredSpecialEffect: Boolean, - isNativeAction: Boolean, + actionOrigin: TabsActionOrigin, ) /** 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 78629fd824..4bb8ef4090 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 @@ -13,6 +13,7 @@ import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.rnscreens.gamma.common.colorscheme.ColorScheme import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull import com.swmansion.rnscreens.gamma.tabs.container.TabSelectOp +import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin import com.swmansion.rnscreens.gamma.tabs.container.TabsContainer import com.swmansion.rnscreens.gamma.tabs.container.TabsContainerDelegate import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState @@ -159,14 +160,14 @@ class TabsHost( navState: TabsNavState, isRepeated: Boolean, hasTriggeredSpecialEffect: Boolean, - isNativeAction: Boolean, + actionOrigin: TabsActionOrigin, ) { eventEmitter.emitOnTabSelectedEvent( navState.selectedKey, navState.provenance, isRepeated, hasTriggeredSpecialEffect, - isNativeAction, + actionOrigin, ) } 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 e971268e8e..328b681733 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 @@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.tabs.host import com.facebook.react.bridge.ReactContext import com.swmansion.rnscreens.gamma.common.event.BaseEventEmitter +import com.swmansion.rnscreens.gamma.tabs.container.TabsActionOrigin 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 @@ -20,7 +21,7 @@ internal class TabsHostEventEmitter( provenance: Int, isRepeated: Boolean, hasTriggeredSpecialEffect: Boolean, - isNativeAction: Boolean, + actionOrigin: TabsActionOrigin, ) { reactEventDispatcher.dispatchEvent( TabsHostTabSelectedEvent( @@ -30,7 +31,7 @@ internal class TabsHostEventEmitter( provenance, isRepeated, hasTriggeredSpecialEffect, - isNativeAction, + actionOrigin, ), ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectedEvent.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectedEvent.kt index 767d7b8012..604aceeeb9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectedEvent.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectedEvent.kt @@ -4,6 +4,7 @@ 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.TabsActionOrigin class TabsHostTabSelectedEvent( surfaceId: Int, @@ -12,7 +13,7 @@ class TabsHostTabSelectedEvent( val provenance: Int, val isRepeated: Boolean, val hasTriggeredSpecialEffect: Boolean, - val isNativeAction: Boolean, + val actionOrigin: TabsActionOrigin, ) : Event(surfaceId, viewId), NamingAwareEventType { override fun getEventName() = EVENT_NAME @@ -28,7 +29,7 @@ class TabsHostTabSelectedEvent( putInt(EK_PROVENANCE, provenance) putBoolean(EK_IS_REPEATED, isRepeated) putBoolean(EK_HAS_TRIGGERED_SPECIAL_EFFECT, hasTriggeredSpecialEffect) - putBoolean(EK_IS_NATIVE_ACTION, isNativeAction) + putString(EK_ACTION_ORIGIN, actionOrigin.toString()) } companion object : NamingAwareEventType { @@ -39,7 +40,7 @@ class TabsHostTabSelectedEvent( private const val EK_PROVENANCE = "provenance" private const val EK_IS_REPEATED = "isRepeated" private const val EK_HAS_TRIGGERED_SPECIAL_EFFECT = "hasTriggeredSpecialEffect" - private const val EK_IS_NATIVE_ACTION = "isNativeAction" + private const val EK_ACTION_ORIGIN = "actionOrigin" override fun getEventName() = EVENT_NAME diff --git a/ios/conversion/RNSConversions-Tabs.mm b/ios/conversion/RNSConversions-Tabs.mm index 424930876a..4ec65e9e2b 100644 --- a/ios/conversion/RNSConversions-Tabs.mm +++ b/ios/conversion/RNSConversions-Tabs.mm @@ -233,6 +233,23 @@ UITabBarControllerMode UITabBarControllerModeFromRNSTabBarControllerMode(RNSTabB } } +react::RNSTabsHostIOSEventEmitter::OnTabSelectedActionOrigin RNSOnTabSelectedActionOriginFromRNSTabsActionOrigin( + RNSTabsActionOrigin actionOrigin) +{ + using enum facebook::react::RNSTabsHostIOSEventEmitter::OnTabSelectedActionOrigin; + switch (actionOrigin) { + case RNSTabsActionOriginUser: + return User; + case RNSTabsActionOriginProgrammaticJs: + return ProgrammaticJs; + case RNSTabsActionOriginImplicit: + return Implicit; + default: + RCTLogError(@"[RNScreens] Unexpected actionOrigin: %ld", actionOrigin); + } + return User; +} + RNSTabsIconType RNSTabsIconTypeFromIcon(react::RNSTabsScreenIOSIconType iconType) { using enum facebook::react::RNSTabsScreenIOSIconType; diff --git a/ios/conversion/RNSConversions.h b/ios/conversion/RNSConversions.h index 9f28007079..2edfbc59ef 100644 --- a/ios/conversion/RNSConversions.h +++ b/ios/conversion/RNSConversions.h @@ -63,6 +63,9 @@ react::RNSTabsHostIOSEventEmitter::OnTabSelectionRejectedRejectionReason RNSOnTabSelectionRejectedRejectionReasonFromRNSTabsNavigationStateRejectionReason( RNSTabsNavigationStateRejectionReason reason); +react::RNSTabsHostIOSEventEmitter::OnTabSelectedActionOrigin RNSOnTabSelectedActionOriginFromRNSTabsActionOrigin( + RNSTabsActionOrigin actionOrigin); + RNSTabsIconType RNSTabsIconTypeFromIcon(react::RNSTabsScreenIOSIconType iconType); RNSTabsScreenSystemItem RNSTabsScreenSystemItemFromReactRNSTabsScreenSystemItem( diff --git a/ios/tabs/host/RNSTabBarController.mm b/ios/tabs/host/RNSTabBarController.mm index 774c10befc..0d2a18f773 100644 --- a/ios/tabs/host/RNSTabBarController.mm +++ b/ios/tabs/host/RNSTabBarController.mm @@ -236,7 +236,7 @@ - (BOOL)updateSelectedViewControllerTo:(nullable UIViewController *)nextSelected ![NSString rnscreens_isBlankOrNull:screenKey], @"[RNScreens] The screenKey MUST NOT be null if the view controller is not null"); - [self progressNavigationState:screenKey withSource:RNSTabsNavigationStateUpdateSourceExternal]; + [self progressNavigationState:screenKey withOrigin:RNSTabsActionOriginProgrammaticJs]; if (currSelectedViewController == nextSelectedViewController) { return YES; @@ -254,8 +254,7 @@ - (BOOL)updateSelectedViewControllerTo:(nullable UIViewController *)nextSelected */ - (void)updateNavigationStateOnModelUpdate { - [self progressNavigationState:[self screenKeyForSelectedViewController] - withSource:RNSTabsNavigationStateUpdateSourceUser]; + [self progressNavigationState:[self screenKeyForSelectedViewController] withOrigin:RNSTabsActionOriginUser]; } - (void)userDidRepeatViewControllerSelection:(nonnull UIViewController *)viewController @@ -277,7 +276,7 @@ - (void)userDidRepeatViewControllerSelection:(nonnull UIViewController *)viewCon [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState isRepeated:YES hasTriggeredSpecialEffect:repeatedSelectionHandledBySpecialEffect - isNativeAction:YES]; + actionOrigin:RNSTabsActionOriginUser]; [self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:updateContext]; } @@ -299,7 +298,7 @@ - (void)userDidSelectViewController:(nonnull UIViewController *)viewController auto *updateContext = [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState isRepeated:NO hasTriggeredSpecialEffect:NO - isNativeAction:YES]; + actionOrigin:RNSTabsActionOriginUser]; [self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:updateContext]; } } @@ -517,7 +516,7 @@ - (void)updateSelectedViewControllerInner [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState isRepeated:NO hasTriggeredSpecialEffect:NO - isNativeAction:NO]; + actionOrigin:RNSTabsActionOriginProgrammaticJs]; [self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:context]; } } @@ -574,8 +573,7 @@ - (nullable RNSTabsScreenViewController *)findChildViewControllerForKey:(nullabl return nil; } -- (void)progressNavigationState:(nonnull NSString *)newSelectedScreenKey - withSource:(RNSTabsNavigationStateUpdateSource)updateSource +- (void)progressNavigationState:(nonnull NSString *)newSelectedScreenKey withOrigin:(RNSTabsActionOrigin)origin { RCTAssert(newSelectedScreenKey != nil, @"[RNScreens] newSelectedScreenKey MUST NOT be nil"); @@ -587,7 +585,7 @@ - (void)progressNavigationState:(nonnull NSString *)newSelectedScreenKey _navigationState = [RNSTabsNavigationState stateWithSelectedScreenKey:newSelectedScreenKey provenance:_navigationState.provenance + 1]; - if (updateSource != RNSTabsNavigationStateUpdateSourceExternal) { + if (origin != RNSTabsActionOriginProgrammaticJs) { _lastUINavigationState = [_navigationState cloneState]; } } @@ -666,12 +664,12 @@ - (void)reconcileNavigationStateWithUIKitState @"TabBarCtrl reconcileNavigationStateWithUIKitState: %@ -> %@", _navigationState.selectedScreenKey, selectedScreenKey); - [self progressNavigationState:selectedScreenKey withSource:RNSTabsNavigationStateUpdateSourceImplicit]; + [self progressNavigationState:selectedScreenKey withOrigin:RNSTabsActionOriginImplicit]; auto *context = [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState isRepeated:NO hasTriggeredSpecialEffect:NO - isNativeAction:YES]; + actionOrigin:RNSTabsActionOriginImplicit]; [self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:context]; } diff --git a/ios/tabs/host/RNSTabsHostComponentView.mm b/ios/tabs/host/RNSTabsHostComponentView.mm index 6cbec6f17d..f7a194e586 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.mm +++ b/ios/tabs/host/RNSTabsHostComponentView.mm @@ -609,7 +609,7 @@ - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController .provenance = navState.provenance, .isRepeated = context.isRepeated, .hasTriggeredSpecialEffect = context.hasTriggeredSpecialEffect, - .isNativeAction = context.isNativeAction}]; + .actionOrigin = context.actionOrigin}]; } - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController diff --git a/ios/tabs/host/RNSTabsHostEventEmitter.h b/ios/tabs/host/RNSTabsHostEventEmitter.h index 71d1d873cf..fe112ebc51 100644 --- a/ios/tabs/host/RNSTabsHostEventEmitter.h +++ b/ios/tabs/host/RNSTabsHostEventEmitter.h @@ -26,8 +26,8 @@ typedef struct { BOOL isRepeated; /** Whether a special effect (e.g. scroll-to-top) was triggered. */ BOOL hasTriggeredSpecialEffect; - /** Whether the selection was initiated by a native user action (tap). */ - BOOL isNativeAction; + /** Origin (actor) that requested this transition. */ + RNSTabsActionOrigin actionOrigin; } OnTabSelectedPayload; /** Payload for the `onTabSelectionRejected` event emitted when a tab selection request is rejected. */ diff --git a/ios/tabs/host/RNSTabsHostEventEmitter.mm b/ios/tabs/host/RNSTabsHostEventEmitter.mm index 42a3250170..0977f48c8f 100644 --- a/ios/tabs/host/RNSTabsHostEventEmitter.mm +++ b/ios/tabs/host/RNSTabsHostEventEmitter.mm @@ -37,12 +37,14 @@ - (void)updateEventEmitter:(const std::shared_ptronTabSelected( {.selectedScreenKey = RCTStringFromNSString(payload.selectedScreenKey), .provenance = payload.provenance, .isRepeated = static_cast(payload.isRepeated), .hasTriggeredSpecialEffect = static_cast(payload.hasTriggeredSpecialEffect), - .isNativeAction = static_cast(payload.isNativeAction)}); + .actionOrigin = convertedActionOrigin}); return YES; } else { RCTLogWarn(@"[RNScreens] Skipped OnTabSelected event emission due to nullish emitter"); diff --git a/ios/tabs/host/RNSTabsNavigationState.h b/ios/tabs/host/RNSTabsNavigationState.h index 0b3fa22eee..adf157dabf 100644 --- a/ios/tabs/host/RNSTabsNavigationState.h +++ b/ios/tabs/host/RNSTabsNavigationState.h @@ -28,6 +28,21 @@ NS_ASSUME_NONNULL_BEGIN @end +/** + * Origin (actor) that requested a tab transition. Mirrors the public `actionOrigin` event field. + * + * - [User] direct native UI interaction (tab bar tap, drag-and-drop). + * - [ProgrammaticJs] JS-initiated request delivered via the `navStateRequest` prop. + * - [Implicit] platform side effect not attributable to an explicit actor — UIKit changed the selection + * as a side effect of another operation (e.g. More navigation controller disappearing during a + * horizontal size class transition on iPad). + */ +typedef NS_ENUM(NSInteger, RNSTabsActionOrigin) { + RNSTabsActionOriginUser = 0, + RNSTabsActionOriginProgrammaticJs, + RNSTabsActionOriginImplicit, +}; + /** Bundles a navigation state change together with metadata about the selection context. */ @interface RNSTabsNavigationStateUpdateContext : NSObject @@ -37,27 +52,16 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL isRepeated; /** Whether a special effect (e.g. scroll-to-top) was triggered by the selection. */ @property (nonatomic, readonly) BOOL hasTriggeredSpecialEffect; -/** Whether the selection was initiated by a native user action (tap) as opposed to a JS-driven update. */ -@property (nonatomic, readonly) BOOL isNativeAction; +/** Origin (actor) that requested this transition. */ +@property (nonatomic, readonly) RNSTabsActionOrigin actionOrigin; - (instancetype)initWithNavState:(nonnull RNSTabsNavigationState *)navState isRepeated:(BOOL)isRepeated hasTriggeredSpecialEffect:(BOOL)hasTriggeredSpecialEffect - isNativeAction:(BOOL)isNativeAction; + actionOrigin:(RNSTabsActionOrigin)actionOrigin; @end -/** Source of a navigation state update. */ -typedef NS_ENUM(NSInteger, RNSTabsNavigationStateUpdateSource) { - /** Update initiated by a native user interaction (e.g. tab tap). */ - RNSTabsNavigationStateUpdateSourceUser = 0, - /** Update initiated externally (e.g. from JS via props). */ - RNSTabsNavigationStateUpdateSourceExternal, - /** Update detected implicitly — UIKit changed the selection as a side effect of another operation - * (e.g. More navigation controller disappearing during a horizontal size class transition on iPad). */ - RNSTabsNavigationStateUpdateSourceImplicit -}; - /** Reason why a navigation state update was rejected by the container. */ typedef NS_ENUM(NSInteger, RNSTabsNavigationStateRejectionReason) { /** The update's provenance is based on a stale state. */ diff --git a/ios/tabs/host/RNSTabsNavigationState.mm b/ios/tabs/host/RNSTabsNavigationState.mm index 9cd30c41a8..f3911e1eca 100644 --- a/ios/tabs/host/RNSTabsNavigationState.mm +++ b/ios/tabs/host/RNSTabsNavigationState.mm @@ -31,13 +31,13 @@ @implementation RNSTabsNavigationStateUpdateContext - (instancetype)initWithNavState:(nonnull RNSTabsNavigationState *)navState isRepeated:(BOOL)isRepeated hasTriggeredSpecialEffect:(BOOL)hasTriggeredSpecialEffect - isNativeAction:(BOOL)isNativeAction + actionOrigin:(RNSTabsActionOrigin)actionOrigin { if (self = [super init]) { _navState = navState; _isRepeated = isRepeated; _hasTriggeredSpecialEffect = hasTriggeredSpecialEffect; - _isNativeAction = isNativeAction; + _actionOrigin = actionOrigin; } return self; } diff --git a/src/components/tabs/host/TabsHost.types.ts b/src/components/tabs/host/TabsHost.types.ts index 1442d4a7b0..20dd2bdf70 100644 --- a/src/components/tabs/host/TabsHost.types.ts +++ b/src/components/tabs/host/TabsHost.types.ts @@ -62,10 +62,16 @@ export type TabSelectedEvent = { /** Whether the selection triggered a special effect (e.g. scroll-to-top on repeated selection). */ hasTriggeredSpecialEffect: boolean; /** - * False in case the event is a result of JS-driven update. True otherwise, e.g. in case of user action (tap) - * or implicit UIKit action (app resize, orientation change, etc.). + * @summary Origin (actor) that requested this tab transition. + * + * @description + * - `user` — direct native UI interaction (e.g. tab bar tap, iOS tab drag-and-drop). + * - `programmatic-js` — JS-initiated request delivered via the `navStateRequest` prop. + * - `implicit` — platform side effect not attributable to an explicit actor + * (e.g. UIKit reshuffling the selection during a horizontal size-class transition on iPad). + * Currently only emitted on iOS. */ - isNativeAction: boolean; + actionOrigin: 'user' | 'programmatic-js' | 'implicit'; }; /** diff --git a/src/fabric/tabs/TabsHostAndroidNativeComponent.ts b/src/fabric/tabs/TabsHostAndroidNativeComponent.ts index cb9fb6257c..bab550d0c8 100644 --- a/src/fabric/tabs/TabsHostAndroidNativeComponent.ts +++ b/src/fabric/tabs/TabsHostAndroidNativeComponent.ts @@ -10,7 +10,7 @@ type TabSelectedEvent = { provenance: CT.Int32; isRepeated: boolean; hasTriggeredSpecialEffect: boolean; - isNativeAction: boolean; + actionOrigin: 'user' | 'programmatic-js' | 'implicit'; }; type NavigationStateRequest = { diff --git a/src/fabric/tabs/TabsHostIOSNativeComponent.ts b/src/fabric/tabs/TabsHostIOSNativeComponent.ts index f0cc4635cc..72e37315fd 100644 --- a/src/fabric/tabs/TabsHostIOSNativeComponent.ts +++ b/src/fabric/tabs/TabsHostIOSNativeComponent.ts @@ -10,7 +10,7 @@ type TabSelectedEvent = Readonly<{ provenance: CT.Int32; isRepeated: boolean; hasTriggeredSpecialEffect: boolean; - isNativeAction: boolean; + actionOrigin: 'user' | 'programmatic-js' | 'implicit'; }>; type NavigationStateRequest = Readonly<{