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 080a2298f1..438b8e2cc0 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 @@ -77,7 +77,7 @@ internal class TabsContainer( internal val selectedTab: TabsScreenFragment get() = - checkNotNull(getFragmentForScreenKey(navState.selectedKey)) { "[RNScreens] No selected tab present" } + checkNotNull(getFragmentForScreenKey(navState.selectedScreenKey)) { "[RNScreens] No selected tab present" } internal val invalidationFlags = TabsContainerInvalidationFlags() @@ -259,14 +259,14 @@ internal class TabsContainer( val tabSelectOp = pendingOperation as TabSelectOp val nextSelectedMenuItemId = - checkNotNull(getMenuItemIdForFragment(requireFragmentForScreenKey(tabSelectOp.navState.selectedKey))) { - "[RNScreens] Failed to find Menu Item for screenKey: ${tabSelectOp.navState.selectedKey}" + checkNotNull(getMenuItemIdForFragment(requireFragmentForScreenKey(tabSelectOp.request.selectedScreenKey))) { + "[RNScreens] Failed to find Menu Item for screenKey: ${tabSelectOp.request.selectedScreenKey}" } - if (rejectOpsWithStaleNavState && isNavStateStale(tabSelectOp.navState)) { + if (rejectOpsWithStaleNavState && isNavStateStale(tabSelectOp.request)) { delegate.onNavStateUpdateRejected( navState, - tabSelectOp.navState, + tabSelectOp.request, TabsNavStateUpdateRejectionReason.STALE, ) pendingOperation = null @@ -281,7 +281,7 @@ internal class TabsContainer( } else { delegate.onNavStateUpdateRejected( navState, - tabSelectOp.navState, + tabSelectOp.request, TabsNavStateUpdateRejectionReason.REPEATED, ) } @@ -328,7 +328,7 @@ internal class TabsContainer( val currentSelectedFragment = selectedTab if (nextSelectedFragment === currentSelectedFragment) { - progressNavigationState(navState.selectedKey) + progressNavigationState(navState.selectedScreenKey) return true } @@ -343,8 +343,8 @@ internal class TabsContainer( return true } - private fun progressNavigationState(selectedKey: String) { - navState = TabsNavState(selectedKey, navState.provenance + 1) + private fun progressNavigationState(selectedScreenKey: String) { + navState = TabsNavState(selectedScreenKey, navState.provenance + 1) if (!isInExternalOperationContext) { lastUINavState = navState } @@ -377,7 +377,15 @@ internal class TabsContainer( navState, isRepeated = isRepeated, hasTriggeredSpecialEffect = hasTriggeredSpecialEffect, - actionOrigin = if (isInExternalOperationContext) TabsActionOrigin.PROGRAMMATIC_JS else TabsActionOrigin.USER, + actionOrigin = + if (isInExternalOperationContext) { + check(pendingOperation != null && pendingOperation is TabSelectOp) { + "[RNScreens] Unexpected pending operation $pendingOperation while in external operation context" + } + (pendingOperation as TabSelectOp).request.actionOrigin + } else { + TabsActionOrigin.USER + }, ) } @@ -413,7 +421,7 @@ internal class TabsContainer( private fun getSelectedTabsScreenFragmentId(): Int? = tabsModel - .indexOfFirst { it.requireScreenKey == navState.selectedKey } + .indexOfFirst { it.requireScreenKey == navState.selectedScreenKey } .takeIf { it != -1 } private fun getMenuItemForTabsScreen(tabsScreen: TabsScreen): MenuItem? = @@ -544,9 +552,9 @@ internal class TabsContainer( fragmentManager = null } - private fun isNavStateStale(state: TabsNavState): Boolean { + private fun isNavStateStale(request: TabsNavStateUpdateRequest): Boolean { if (navState.isEmpty() || lastUINavState.isEmpty()) return false - return state.provenance < lastUINavState.provenance + return request.baseProvenance < lastUINavState.provenance } companion object { 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 63847e7303..1caafe141c 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 @@ -24,12 +24,12 @@ internal interface TabsContainerDelegate { * Called when the container rejects a navigation state update. * * @param currentNavState The currently active navigation state that was kept. - * @param rejectedNavState The navigation state update that was rejected. + * @param rejectedRequest The navigation state update request that was rejected. * @param reason Why the update was rejected. */ fun onNavStateUpdateRejected( currentNavState: TabsNavState, - rejectedNavState: TabsNavState, + rejectedRequest: TabsNavStateUpdateRequest, reason: TabsNavStateUpdateRejectionReason, ) diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerOps.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerOps.kt index 37663023b5..375fb9e6f6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerOps.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerOps.kt @@ -3,5 +3,5 @@ package com.swmansion.rnscreens.gamma.tabs.container internal sealed class TabsContainerOp internal data class TabSelectOp( - val navState: TabsNavState, + val request: TabsNavStateUpdateRequest, ) : TabsContainerOp() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavState.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavState.kt index 9bee10cd2d..efa1c42f7b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavState.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavState.kt @@ -6,13 +6,13 @@ import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionR /** * Describes navigation state of a tabs container. * - * @property selectedKey Screen key of the currently selected tab. + * @property selectedScreenKey Screen key of the currently selected tab. * @property provenance Monotonically increasing number describing the history (generation) of the state. * State with provenance `N + 1` is derived from state with provenance `N`. * This allows detecting stale updates. */ data class TabsNavState( - val selectedKey: String, + val selectedScreenKey: String, val provenance: Int, ) { internal fun isEmpty(): Boolean = this === EMPTY @@ -24,6 +24,23 @@ data class TabsNavState( } } +/** + * A request to change navigation state. + * + * Carries the target [selectedScreenKey], the [baseProvenance] of the state the request was derived from, + * and the [actionOrigin] (actor) that initiated it. Mirrors the public `TabsHostNavStateRequest` TS type + * plus an [actionOrigin] carried internally. + * + * @property selectedScreenKey Screen key of the requested tab. + * @property baseProvenance Provenance of the state this request was derived from. Used for staleness detection. + * @property actionOrigin Origin (actor) that initiated this request. + */ +data class TabsNavStateUpdateRequest( + val selectedScreenKey: String, + val baseProvenance: Int, + val actionOrigin: TabsActionOrigin, +) + /** * Reason why a navigation state update was rejected by the container. * 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 4bb8ef4090..08e005eaea 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 @@ -18,6 +18,7 @@ import com.swmansion.rnscreens.gamma.tabs.container.TabsContainer import com.swmansion.rnscreens.gamma.tabs.container.TabsContainerDelegate import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionReason +import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRequest import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen import com.swmansion.rnscreens.utils.RNSLog import kotlin.properties.Delegates @@ -30,7 +31,7 @@ class TabsHost( TabsContainerDelegate, UIManagerListener { private val renderedScreens: ArrayList = arrayListOf() - private var jsNavStateRequest: TabsNavState = TabsNavState.EMPTY + private var jsNavStateRequest: TabsNavStateUpdateRequest? = null private val container: TabsContainer = TabsContainer(reactContext, this).apply { @@ -110,9 +111,9 @@ class TabsHost( container.removeAllTabsScreens() } - internal fun updateJSNavStateRequest(navStateRequest: TabsNavState) { + internal fun updateJSNavStateRequest(navStateRequest: TabsNavStateUpdateRequest) { jsNavStateRequest = navStateRequest - container.setContainerOperation(TabSelectOp(jsNavStateRequest.copy())) + container.setContainerOperation(TabSelectOp(navStateRequest.copy())) } private val layoutCallback = @@ -163,7 +164,7 @@ class TabsHost( actionOrigin: TabsActionOrigin, ) { eventEmitter.emitOnTabSelectedEvent( - navState.selectedKey, + navState.selectedScreenKey, navState.provenance, isRepeated, hasTriggeredSpecialEffect, @@ -173,12 +174,12 @@ class TabsHost( override fun onNavStateUpdateRejected( currentNavState: TabsNavState, - rejectedNavState: TabsNavState, + rejectedRequest: TabsNavStateUpdateRequest, reason: TabsNavStateUpdateRejectionReason, ) { eventEmitter.emitOnTabSelectionRejectedEvent( currentNavState, - rejectedNavState, + rejectedRequest, reason, ) } 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 328b681733..c7aaa01524 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.TabsActionOrigin import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionReason +import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRequest 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 @@ -42,7 +43,7 @@ internal class TabsHostEventEmitter( */ fun emitOnTabSelectionRejectedEvent( currentNavState: TabsNavState, - rejectedNavState: TabsNavState, + rejectedRequest: TabsNavStateUpdateRequest, rejectionReason: TabsNavStateUpdateRejectionReason, ) { reactEventDispatcher.dispatchEvent( @@ -50,7 +51,7 @@ internal class TabsHostEventEmitter( surfaceId, viewTag, currentNavState, - rejectedNavState, + rejectedRequest, rejectionReason, ), ) 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 d4a336e4a8..40698723be 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 @@ -11,7 +11,8 @@ import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerDelegate import com.facebook.react.viewmanagers.RNSTabsHostAndroidManagerInterface 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.container.TabsActionOrigin +import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRequest 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 @@ -79,7 +80,13 @@ class TabsHostViewManager : val navStateRequestMap = requireNotNull(value) { "[RNScreens] navStateRequest must not be nullish" } val selectedScreenKey = requireNotNull(navStateRequestMap.getString("selectedScreenKey")) val baseProvenance = requireNotNull(navStateRequestMap.getInt("baseProvenance")) - view.updateJSNavStateRequest(TabsNavState(selectedScreenKey, baseProvenance)) + view.updateJSNavStateRequest( + TabsNavStateUpdateRequest( + selectedScreenKey = selectedScreenKey, + baseProvenance = baseProvenance, + actionOrigin = TabsActionOrigin.PROGRAMMATIC_JS, + ), + ) } override fun setRejectStaleNavStateUpdates( 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 index b65ac57136..0407657122 100644 --- 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 @@ -27,7 +27,7 @@ class TabsHostTabSelectionPreventedEvent( override fun getEventData(): WritableMap? = Arguments.createMap().apply { - putString(EK_SELECTED_KEY, currentNavState.selectedKey) + putString(EK_SELECTED_KEY, currentNavState.selectedScreenKey) putInt(EK_PROVENANCE, currentNavState.provenance) putString(EK_PREVENTED_KEY, preventedScreenKey) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionRejectedEvent.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionRejectedEvent.kt index 7e714b1b89..3f98312dcf 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionRejectedEvent.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionRejectedEvent.kt @@ -6,19 +6,20 @@ import com.facebook.react.uimanager.events.Event import com.swmansion.rnscreens.gamma.common.event.NamingAwareEventType import com.swmansion.rnscreens.gamma.tabs.container.TabsNavState import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRejectionReason +import com.swmansion.rnscreens.gamma.tabs.container.TabsNavStateUpdateRequest /** * React Native event dispatched to JS when a tab selection request is rejected by the container. * - * Carries the currently active navigation state ([currentNavState]), the rejected update - * ([rejectedNavState]), and the [rejectionReason]. This event is never coalesced — every + * Carries the currently active navigation state ([currentNavState]), the rejected request + * ([rejectedRequest]), and the [rejectionReason]. This event is never coalesced — every * rejection is delivered individually so the JS side has a complete picture of state transitions. */ class TabsHostTabSelectionRejectedEvent( surfaceId: Int, viewId: Int, val currentNavState: TabsNavState, - val rejectedNavState: TabsNavState, + val rejectedRequest: TabsNavStateUpdateRequest, val rejectionReason: TabsNavStateUpdateRejectionReason, ) : Event(surfaceId, viewId), NamingAwareEventType { @@ -31,10 +32,10 @@ class TabsHostTabSelectionRejectedEvent( override fun getEventData(): WritableMap? = Arguments.createMap().apply { - putString(EK_SELECTED_KEY, currentNavState.selectedKey) + putString(EK_SELECTED_KEY, currentNavState.selectedScreenKey) putInt(EK_PROVENANCE, currentNavState.provenance) - putString(EK_REJECTED_KEY, rejectedNavState.selectedKey) - putInt(EK_REJECTED_PROVENANCE, rejectedNavState.provenance) + putString(EK_REJECTED_KEY, rejectedRequest.selectedScreenKey) + putInt(EK_REJECTED_PROVENANCE, rejectedRequest.baseProvenance) putString(EK_REJECTION_REASON, rejectionReason.toString()) } diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm index 253d7d5fbd..e674c84bf9 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm @@ -282,10 +282,11 @@ - (UIImage *)loadBackButtonImageInViewController:(UIViewController *)vc // in the image attribute not being updated. We manually set frame to the size of an image // in order to trigger proper reload that'd update the image attribute. RCTImageSource *imageSource = [RNSScreenStackHeaderConfig imageSourceFromImageView:imageView]; - [imageView reactSetFrame:CGRectMake(imageView.frame.origin.x, - imageView.frame.origin.y, - imageSource.size.width, - imageSource.size.height)]; + [imageView reactSetFrame:CGRectMake( + imageView.frame.origin.x, + imageView.frame.origin.y, + imageSource.size.width, + imageSource.size.height)]; } UIImage *image = imageView.image; @@ -847,12 +848,13 @@ - (void)mountChildComponentView:(UIView *)childCompone return; } - RCTAssert(childComponentView.superview == nil, - @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", - self, - childComponentView, - @(index), - @([childComponentView.superview tag])); + RCTAssert( + childComponentView.superview == nil, + @"Attempt to mount already mounted component view. (parent: %@, child: %@, index: %@, existing parent: %@)", + self, + childComponentView, + @(index), + @([childComponentView.superview tag])); // [_reactSubviews insertObject:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index]; [self insertReactSubview:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index]; @@ -1129,21 +1131,23 @@ @implementation RNSScreenStackHeaderConfigManager @implementation RCTConvert (RNSScreenStackHeader) -RCT_ENUM_CONVERTER(UISemanticContentAttribute, - (@{ - @"ltr" : @(UISemanticContentAttributeForceLeftToRight), - @"rtl" : @(UISemanticContentAttributeForceRightToLeft), - }), - UISemanticContentAttributeUnspecified, - integerValue) - -RCT_ENUM_CONVERTER(UINavigationItemBackButtonDisplayMode, - (@{ - @"default" : @(UINavigationItemBackButtonDisplayModeDefault), - @"generic" : @(UINavigationItemBackButtonDisplayModeGeneric), - @"minimal" : @(UINavigationItemBackButtonDisplayModeMinimal), - }), - UINavigationItemBackButtonDisplayModeDefault, - integerValue) +RCT_ENUM_CONVERTER( + UISemanticContentAttribute, + (@{ + @"ltr" : @(UISemanticContentAttributeForceLeftToRight), + @"rtl" : @(UISemanticContentAttributeForceRightToLeft), + }), + UISemanticContentAttributeUnspecified, + integerValue) + +RCT_ENUM_CONVERTER( + UINavigationItemBackButtonDisplayMode, + (@{ + @"default" : @(UINavigationItemBackButtonDisplayModeDefault), + @"generic" : @(UINavigationItemBackButtonDisplayModeGeneric), + @"minimal" : @(UINavigationItemBackButtonDisplayModeMinimal), + }), + UINavigationItemBackButtonDisplayModeDefault, + integerValue) @end diff --git a/ios/tabs/host/RNSTabBarController.h b/ios/tabs/host/RNSTabBarController.h index ad39e67865..b455c980a5 100644 --- a/ios/tabs/host/RNSTabBarController.h +++ b/ios/tabs/host/RNSTabBarController.h @@ -21,9 +21,9 @@ NS_ASSUME_NONNULL_BEGIN withContext:(nonnull RNSTabsNavigationStateUpdateContext *)context; - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController - rejectedStateUpdateTo:(nonnull RNSTabsNavigationState *)rejectedNavState - currentState:(nonnull RNSTabsNavigationState *)currentNavState - withReason:(RNSTabsNavigationStateRejectionReason)reasonCode; + rejectedStateUpdate:(nonnull RNSTabsNavigationStateUpdateRequest *)rejectedRequest + currentState:(nonnull RNSTabsNavigationState *)currentNavState + withReason:(RNSTabsNavigationStateRejectionReason)reasonCode; - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController preventedSelectionOf:(nonnull NSString *)screenKey @@ -48,7 +48,7 @@ NS_ASSUME_NONNULL_BEGIN * however. * * Updates made by this controller are synchronized by `RNSReactTransactionObserving` protocol, - * i.e. if you made changes through one of signals method, unless you flush them immediately (not needed atm), they will + * i.e. if you made changes through one of signals method, unless you flush them immediately, they will * be executed only after react finishes the transaction (from within transaction execution block). */ @interface RNSTabBarController : UITabBarController < @@ -219,7 +219,7 @@ NS_ASSUME_NONNULL_BEGIN * * If you want to execute multiple updates in sequence you must flush the container after each one separately. */ -- (void)setPendingNavigationStateUpdate:(nullable RNSTabsNavigationState *)navState; +- (void)setPendingNavigationStateUpdate:(nullable RNSTabsNavigationStateUpdateRequest *)stateUpdate; /** * Tell the controller that react provided tabs have changed (count / instances) & the child view controllers need to be diff --git a/ios/tabs/host/RNSTabBarController.mm b/ios/tabs/host/RNSTabBarController.mm index 0d2a18f773..b2909aeb89 100644 --- a/ios/tabs/host/RNSTabBarController.mm +++ b/ios/tabs/host/RNSTabBarController.mm @@ -73,7 +73,7 @@ @implementation RNSTabBarController { /// This property is nullable until first container update. Later it MUST NOT be nil. RNSTabsNavigationState *_Nullable _lastUINavigationState; - RNSTabsNavigationState *_Nullable _pendingOperation; + RNSTabsNavigationStateUpdateRequest *_Nullable _pendingStateUpdate; /// When YES, the controller is inside an explicit selection-changing code path (container update, /// delegate handling). Setter overrides skip reconciliation while this flag is set. @@ -91,7 +91,7 @@ - (instancetype)init _tabBarAppearanceCoordinator = [RNSTabBarAppearanceCoordinator new]; _tabsHostComponentView = nil; _navigationState = nil; - _pendingOperation = nil; + _pendingStateUpdate = nil; _shouldProgressStateOnMoreNavigationControllerPush = NO; // Delegate field retains weakly, no risk of cycle. @@ -146,9 +146,9 @@ - (void)setSelectedViewController:(__kindof UIViewController *)selectedViewContr #pragma mark - Signals -- (void)setPendingNavigationStateUpdate:(nullable RNSTabsNavigationState *)navState +- (void)setPendingNavigationStateUpdate:(nullable RNSTabsNavigationStateUpdateRequest *)stateUpdate { - _pendingOperation = navState; + _pendingStateUpdate = stateUpdate; } - (void)childViewControllersHaveChangedTo:(NSArray *)reactChildControllers @@ -438,32 +438,32 @@ - (void)updateReactChildrenControllers - (void)updateSelectedViewControllerIfNeeded { - if (_pendingOperation != nil) { + if (_pendingStateUpdate != nil) { [self updateSelectedViewController]; } } - (void)updateSelectedViewController { - if (_pendingOperation == nil || self.viewControllers.count == 0) { + if (_pendingStateUpdate == nil || self.viewControllers.count == 0) { return; } RNSLog(@"TabBarCtrl updateSelectedViewController"); [self updateSelectedViewControllerInner]; - _pendingOperation = nil; + _pendingStateUpdate = nil; } /** * NEVER call this method directly. Call the proper function `updateSelectedViewController` * - * The logic is extracted to an inner method to correctly manage _pendingOperation cleanup. + * The logic is extracted to an inner method to correctly manage `_pendingStateUpdate` cleanup. */ - (void)updateSelectedViewControllerInner { UIViewController *_Nonnull currSelectedViewController = self.selectedViewController; - NSString *_Nonnull nextSelectedViewControllerKey = _pendingOperation.selectedScreenKey; + NSString *_Nonnull nextSelectedViewControllerKey = _pendingStateUpdate.selectedScreenKey; UIViewController *nextSelectedViewController = [self findChildViewControllerForKey:nextSelectedViewControllerKey]; RCTAssert( @@ -477,9 +477,9 @@ - (void)updateSelectedViewControllerInner RNSTabsScreenViewController.class, nextSelectedViewController.class); - if (self.rejectStaleNavigationStateUpdates && [self isNavigationStateUpdateStale:_pendingOperation]) { + if (self.rejectStaleNavigationStateUpdates && [self isNavigationStateUpdateStale:_pendingStateUpdate]) { [self.tabsHostComponentView tabBarController:self - rejectedStateUpdateTo:_pendingOperation + rejectedStateUpdate:_pendingStateUpdate currentState:_navigationState withReason:RNSTabsNavigationStateRejectionReasonStale]; return; @@ -489,7 +489,7 @@ - (void)updateSelectedViewControllerInner // Nothing to do, we don't allow for programmatic repeat selection, unless // we're during first render. [self.tabsHostComponentView tabBarController:self - rejectedStateUpdateTo:_pendingOperation + rejectedStateUpdate:_pendingStateUpdate currentState:_navigationState withReason:RNSTabsNavigationStateRejectionReasonRepeated]; return; @@ -516,7 +516,7 @@ - (void)updateSelectedViewControllerInner [[RNSTabsNavigationStateUpdateContext alloc] initWithNavState:_navigationState isRepeated:NO hasTriggeredSpecialEffect:NO - actionOrigin:RNSTabsActionOriginProgrammaticJs]; + actionOrigin:_pendingStateUpdate.actionOrigin]; [self.tabsHostComponentView tabBarController:self didUpdateStateTo:_navigationState withContext:context]; } } @@ -676,9 +676,9 @@ - (void)reconcileNavigationStateWithUIKitState /** * This function assumes that the source of the state is NOT user. In current model, user update is never stale. */ -- (BOOL)isNavigationStateUpdateStale:(nullable RNSTabsNavigationState *)newState +- (BOOL)isNavigationStateUpdateStale:(nullable RNSTabsNavigationStateUpdateRequest *)stateUpdate { - if (newState == nil) { + if (stateUpdate == nil) { return YES; } @@ -686,7 +686,7 @@ - (BOOL)isNavigationStateUpdateStale:(nullable RNSTabsNavigationState *)newState return NO; } - return newState.provenance < _lastUINavigationState.provenance; + return stateUpdate.baseProvenance < _lastUINavigationState.provenance; } #pragma mark-- More Navigation Controller diff --git a/ios/tabs/host/RNSTabsHostComponentView.h b/ios/tabs/host/RNSTabsHostComponentView.h index b3991c0fca..aa60d58a0e 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.h +++ b/ios/tabs/host/RNSTabsHostComponentView.h @@ -48,9 +48,9 @@ NS_ASSUME_NONNULL_BEGIN @interface RNSTabsHostComponentView () /** - * Last navigation state requested by JS. Will be nonnull after first prop update. + * Last navigation state update requested by JS. Will be nonnull after first prop update. */ -@property (nonatomic, strong, readonly, nullable) RNSTabsNavigationState *navStateRequest; +@property (nonatomic, strong, readonly, nullable) RNSTabsNavigationStateUpdateRequest *navStateRequest; @property (nonatomic, readonly) BOOL rejectStaleNavStateUpdates; diff --git a/ios/tabs/host/RNSTabsHostComponentView.mm b/ios/tabs/host/RNSTabsHostComponentView.mm index f7a194e586..4d24164302 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.mm +++ b/ios/tabs/host/RNSTabsHostComponentView.mm @@ -53,7 +53,7 @@ @implementation RNSTabsHostComponentView { BOOL _hasModifiedBottomAccessoryInCurrentTransation; BOOL _needsTabBarAppearanceUpdate; - RNSTabsNavigationState *_Nullable _navStateRequest; + RNSTabsNavigationStateUpdateRequest *_Nullable _navStateRequest; } - (instancetype)initWithFrame:(CGRect)frame @@ -273,10 +273,11 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props NSString *selectedScreenKey = RCTNSStringFromStringNilIfEmpty(newComponentProps.navStateRequest.selectedScreenKey); RCTAssert(selectedScreenKey != nil, @"[RNScreens] selectedScreenKey MUST NOT be nil"); RCTAssert(newComponentProps.navStateRequest.baseProvenance >= 0, @"[RNScreens] baseProvenance MUST BE >= 0"); - _navStateRequest = - [RNSTabsNavigationState stateWithSelectedScreenKey:selectedScreenKey - provenance:newComponentProps.navStateRequest.baseProvenance]; - [_controller setPendingNavigationStateUpdate:[_navStateRequest cloneState]]; + _navStateRequest = [RNSTabsNavigationStateUpdateRequest + requestWithSelectedScreenKey:selectedScreenKey + baseProvenance:newComponentProps.navStateRequest.baseProvenance + actionOrigin:RNSTabsActionOriginProgrammaticJs]; + [_controller setPendingNavigationStateUpdate:[_navStateRequest cloneRequest]]; } if (newComponentProps.rejectStaleNavStateUpdates != oldComponentProps.rejectStaleNavStateUpdates) { @@ -613,15 +614,16 @@ - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController } - (void)tabBarController:(nonnull RNSTabBarController *)tabBarController - rejectedStateUpdateTo:(nonnull RNSTabsNavigationState *)rejectedNavState - currentState:(nonnull RNSTabsNavigationState *)currentNavState - withReason:(RNSTabsNavigationStateRejectionReason)reasonCode + rejectedStateUpdate:(nonnull RNSTabsNavigationStateUpdateRequest *)rejectedRequest + currentState:(nonnull RNSTabsNavigationState *)currentNavState + withReason:(RNSTabsNavigationStateRejectionReason)reasonCode { RCTAssert(currentNavState.selectedScreenKey != nil, @"[RNScreens] Current state screenKey MUST NOT be nil"); - RCTAssert(rejectedNavState.selectedScreenKey != nil, @"[RNScreens] Rejected state screenKey MUST NOT be nil"); + RCTAssert( + rejectedRequest.selectedScreenKey != nil, @"[RNScreens] Rejected request selectedScreenKey MUST NOT be nil"); [self.reactEventEmitter emitOnTabSelectionRejected:{.currentNavState = currentNavState, - .rejectedNavState = rejectedNavState, + .rejectedRequest = rejectedRequest, .rejectionReason = reasonCode}]; } diff --git a/ios/tabs/host/RNSTabsHostEventEmitter.h b/ios/tabs/host/RNSTabsHostEventEmitter.h index fe112ebc51..17c47f8c02 100644 --- a/ios/tabs/host/RNSTabsHostEventEmitter.h +++ b/ios/tabs/host/RNSTabsHostEventEmitter.h @@ -34,8 +34,8 @@ typedef struct { typedef struct { /** The currently active navigation state that was kept. */ RNSTabsNavigationState *_Nonnull currentNavState; - /** The navigation state update that was rejected. */ - RNSTabsNavigationState *_Nonnull rejectedNavState; + /** The navigation state update request that was rejected. */ + RNSTabsNavigationStateUpdateRequest *_Nonnull rejectedRequest; /** Reason the update was rejected. */ RNSTabsNavigationStateRejectionReason rejectionReason; } OnTabSelectionRejectedPayload; diff --git a/ios/tabs/host/RNSTabsHostEventEmitter.mm b/ios/tabs/host/RNSTabsHostEventEmitter.mm index 0977f48c8f..2dd16a95ef 100644 --- a/ios/tabs/host/RNSTabsHostEventEmitter.mm +++ b/ios/tabs/host/RNSTabsHostEventEmitter.mm @@ -61,8 +61,8 @@ - (BOOL)emitOnTabSelectionRejected:(OnTabSelectionRejectedPayload)payload _reactEventEmitter->onTabSelectionRejected( {.selectedScreenKey = RCTStringFromNSString(payload.currentNavState.selectedScreenKey), .provenance = payload.currentNavState.provenance, - .rejectedScreenKey = RCTStringFromNSString(payload.rejectedNavState.selectedScreenKey), - .rejectedProvenance = payload.rejectedNavState.provenance, + .rejectedScreenKey = RCTStringFromNSString(payload.rejectedRequest.selectedScreenKey), + .rejectedProvenance = payload.rejectedRequest.baseProvenance, .rejectionReason = convertedReason}); return YES; } else { diff --git a/ios/tabs/host/RNSTabsNavigationState.h b/ios/tabs/host/RNSTabsNavigationState.h index adf157dabf..e7c24aaa1f 100644 --- a/ios/tabs/host/RNSTabsNavigationState.h +++ b/ios/tabs/host/RNSTabsNavigationState.h @@ -4,6 +4,29 @@ NS_ASSUME_NONNULL_BEGIN +/** + * 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, +}; + +/** 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. */ + RNSTabsNavigationStateRejectionReasonStale = 0, + /** The requested tab is already selected. */ + RNSTabsNavigationStateRejectionReasonRepeated, +}; + /** * Describes navigation state of a tabs container. * @@ -17,7 +40,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, strong, readonly, nonnull) NSString *selectedScreenKey; /** Monotonically increasing number describing the generation of this state instance. - * Used for stale update detection: state A is stale iff A.provenance <= B.provenance. */ + * Used for stale update detection. */ @property (nonatomic, readonly) int provenance; - (instancetype)initWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey provenance:(int)provenance; @@ -29,19 +52,29 @@ NS_ASSUME_NONNULL_BEGIN @end /** - * Origin (actor) that requested a tab transition. Mirrors the public `actionOrigin` event field. + * A request to change navigation state. * - * - [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). + * Carries the target `selectedScreenKey`, the `baseProvenance` of the state the request was derived from, + * and the `actionOrigin` (actor) that initiated it. Mirrors the public + * `TabsHostNavStateRequest` TS type plus an `actionOrigin` carried internally. */ -typedef NS_ENUM(NSInteger, RNSTabsActionOrigin) { - RNSTabsActionOriginUser = 0, - RNSTabsActionOriginProgrammaticJs, - RNSTabsActionOriginImplicit, -}; +@interface RNSTabsNavigationStateUpdateRequest : NSObject + +@property (nonatomic, strong, readonly, nonnull) NSString *selectedScreenKey; +@property (nonatomic, readonly) int baseProvenance; +@property (nonatomic, readonly) RNSTabsActionOrigin actionOrigin; + +- (instancetype)initWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey + baseProvenance:(int)baseProvenance + actionOrigin:(RNSTabsActionOrigin)actionOrigin; + +- (instancetype)cloneRequest; + ++ (instancetype)requestWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey + baseProvenance:(int)baseProvenance + actionOrigin:(RNSTabsActionOrigin)actionOrigin; + +@end /** Bundles a navigation state change together with metadata about the selection context. */ @interface RNSTabsNavigationStateUpdateContext : NSObject @@ -62,12 +95,4 @@ typedef NS_ENUM(NSInteger, RNSTabsActionOrigin) { @end -/** 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. */ - RNSTabsNavigationStateRejectionReasonStale = 0, - /** The requested tab is already selected. */ - RNSTabsNavigationStateRejectionReasonRepeated, -}; - NS_ASSUME_NONNULL_END diff --git a/ios/tabs/host/RNSTabsNavigationState.mm b/ios/tabs/host/RNSTabsNavigationState.mm index f3911e1eca..246e865cf0 100644 --- a/ios/tabs/host/RNSTabsNavigationState.mm +++ b/ios/tabs/host/RNSTabsNavigationState.mm @@ -26,6 +26,39 @@ + (instancetype)stateWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey @end +@implementation RNSTabsNavigationStateUpdateRequest + +- (instancetype)initWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey + baseProvenance:(int)baseProvenance + actionOrigin:(RNSTabsActionOrigin)actionOrigin +{ + if (self = [super init]) { + _selectedScreenKey = selectedScreenKey; + _baseProvenance = baseProvenance; + _actionOrigin = actionOrigin; + } + return self; +} + +- (instancetype)cloneRequest +{ + return [[RNSTabsNavigationStateUpdateRequest alloc] + initWithSelectedScreenKey:[NSString stringWithString:self.selectedScreenKey] + baseProvenance:self.baseProvenance + actionOrigin:self.actionOrigin]; +} + ++ (instancetype)requestWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey + baseProvenance:(int)baseProvenance + actionOrigin:(RNSTabsActionOrigin)actionOrigin +{ + return [[RNSTabsNavigationStateUpdateRequest alloc] initWithSelectedScreenKey:selectedScreenKey + baseProvenance:baseProvenance + actionOrigin:actionOrigin]; +} + +@end + @implementation RNSTabsNavigationStateUpdateContext - (instancetype)initWithNavState:(nonnull RNSTabsNavigationState *)navState