From f5888995543d22f95238c88930b27a24bf67fedd Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Wed, 29 Apr 2026 09:18:11 +0200 Subject: [PATCH 1/2] begin: @kkafar/tabs-allow-for-programmatic-native-tab-changes-2 From 5bc649e0d715629e23de607fcb46e111bd87ce63 Mon Sep 17 00:00:00 2001 From: Kacper Kafara Date: Wed, 29 Apr 2026 11:48:20 +0200 Subject: [PATCH 2/2] feat(tabs): add 'programmatic-native' actionOrigin variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth value to the public `TabSelectedEvent.actionOrigin` union: `'user' | 'programmatic-js' | 'programmatic-native' | 'implicit'`. The new origin is reserved for downstream libraries that integrate directly against `TabsContainer` (Android) or `RNSTabBarController` (iOS) — this library itself does not produce it. Plumbed end-to-end: - Public TS contract + both Android/iOS codegen specs. - iOS: `RNSTabsActionOriginProgrammaticNative` enum case and the matching arm in the conversion helper that maps the Obj-C enum to the codegen C++ scoped enum. - Android: `TabsActionOrigin.PROGRAMMATIC_NATIVE` + `toString()` mapping. Android visibility widening to make the new origin reachable from outside the library (iOS already exposes the equivalent surface): - `TabsContainer` class is now public; constructor stays `internal` because it takes the still-`internal` `TabsContainerDelegate`. Downstream is expected to obtain the container reference via its own integration path (TabsHost.container stays `private`). - `setContainerOperation(TabsContainerOp)` is replaced with a public `setPendingNavigationStateUpdate(TabsNavStateUpdateRequest)` that takes the request directly and wraps in `TabSelectOp` internally. Naming mirrors `RNSTabBarController.setPendingNavigationStateUpdate:`. - `performContainerUpdateIfNeeded()` is now public — the flush trigger downstream calls after setting a pending update. - Internal sealed-class machinery (`TabsContainerOp`, `TabSelectOp`) stays internal. Also reformats iOS doc comments in `RNSTabsNavigationState.h` to per-case style and the `/**\n * ...\n */` form consistently. --- .../gamma/tabs/container/TabsActionOrigin.kt | 4 ++ .../gamma/tabs/container/TabsContainer.kt | 8 +-- .../rnscreens/gamma/tabs/host/TabsHost.kt | 3 +- ios/conversion/RNSConversions-Tabs.mm | 2 + ios/tabs/host/RNSTabsNavigationState.h | 64 ++++++++++++++----- src/components/tabs/host/TabsHost.types.ts | 16 +++-- .../tabs/TabsHostAndroidNativeComponent.ts | 2 +- src/fabric/tabs/TabsHostIOSNativeComponent.ts | 2 +- 8 files changed, 69 insertions(+), 32 deletions(-) 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 index 951671d60f..e63ed6c786 100644 --- 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 @@ -5,6 +5,8 @@ package com.swmansion.rnscreens.gamma.tabs.container * * - [USER] — direct native UI interaction (tab bar tap). * - [PROGRAMMATIC_JS] — JS-initiated request delivered via the `navStateRequest` prop. + * - [PROGRAMMATIC_NATIVE] — request initiated from the native side by a downstream library + * integrating directly against [TabsContainer] (not produced by this library itself). * * The `implicit` origin defined on the public TS API is iOS-only at the moment; * Android does not currently produce it. @@ -12,11 +14,13 @@ package com.swmansion.rnscreens.gamma.tabs.container enum class TabsActionOrigin { USER, PROGRAMMATIC_JS, + PROGRAMMATIC_NATIVE, ; override fun toString(): String = when (this) { USER -> "user" PROGRAMMATIC_JS -> "programmatic-js" + PROGRAMMATIC_NATIVE -> "programmatic-native" } } 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 438b8e2cc0..b906b38a98 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 @@ -37,7 +37,7 @@ import com.swmansion.rnscreens.utils.RNSLog import kotlin.properties.Delegates @SuppressLint("ViewConstructor") // Created only by us. Should never be restored. -internal class TabsContainer( +class TabsContainer internal constructor( private val context: Context, private val delegate: TabsContainerDelegate, ) : FrameLayout(context), @@ -196,8 +196,8 @@ internal class TabsContainer( return insets } - internal fun setContainerOperation(op: TabsContainerOp) { - pendingOperation = op + fun setPendingNavigationStateUpdate(request: TabsNavStateUpdateRequest) { + pendingOperation = TabSelectOp(request) invalidationFlags.isSelectedTabInvalidated = true } @@ -224,7 +224,7 @@ internal class TabsContainer( invalidationFlags.invalidateAll() } - internal fun performContainerUpdateIfNeeded() { + fun performContainerUpdateIfNeeded() { if (invalidationFlags.any() && isAttachedToWindow) { performContainerUpdate() } 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 08e005eaea..8885d0fdb2 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 @@ -12,7 +12,6 @@ import com.facebook.react.uimanager.ThemedReactContext 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 @@ -113,7 +112,7 @@ class TabsHost( internal fun updateJSNavStateRequest(navStateRequest: TabsNavStateUpdateRequest) { jsNavStateRequest = navStateRequest - container.setContainerOperation(TabSelectOp(navStateRequest.copy())) + container.setPendingNavigationStateUpdate(navStateRequest.copy()) } private val layoutCallback = diff --git a/ios/conversion/RNSConversions-Tabs.mm b/ios/conversion/RNSConversions-Tabs.mm index 4ec65e9e2b..0c725ad1e1 100644 --- a/ios/conversion/RNSConversions-Tabs.mm +++ b/ios/conversion/RNSConversions-Tabs.mm @@ -242,6 +242,8 @@ UITabBarControllerMode UITabBarControllerModeFromRNSTabBarControllerMode(RNSTabB return User; case RNSTabsActionOriginProgrammaticJs: return ProgrammaticJs; + case RNSTabsActionOriginProgrammaticNative: + return ProgrammaticNative; case RNSTabsActionOriginImplicit: return Implicit; default: diff --git a/ios/tabs/host/RNSTabsNavigationState.h b/ios/tabs/host/RNSTabsNavigationState.h index e7c24aaa1f..a68eda8e4a 100644 --- a/ios/tabs/host/RNSTabsNavigationState.h +++ b/ios/tabs/host/RNSTabsNavigationState.h @@ -6,24 +6,40 @@ 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) { + /** + * Direct native UI interaction (tab bar tap, drag-and-drop). + */ RNSTabsActionOriginUser = 0, + /** + * JS-initiated request delivered via the `navStateRequest` prop. + */ RNSTabsActionOriginProgrammaticJs, + /** + * Request initiated from the native side by some actor, e.g. a downstream + * library integrating directly against `RNSTabBarController`. + */ + RNSTabsActionOriginProgrammaticNative, + /** + * 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). + */ RNSTabsActionOriginImplicit, }; -/** Reason why a navigation state update was rejected by the container. */ +/** + * 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. */ + /** + * The update's provenance is based on a stale state. + */ RNSTabsNavigationStateRejectionReasonStale = 0, - /** The requested tab is already selected. */ + /** + * The requested tab is already selected. + */ RNSTabsNavigationStateRejectionReasonRepeated, }; @@ -36,11 +52,15 @@ typedef NS_ENUM(NSInteger, RNSTabsNavigationStateRejectionReason) { */ @interface RNSTabsNavigationState : NSObject -/** Screen key of the currently selected tab. */ +/** + * Screen key of the currently selected tab. + */ @property (nonatomic, strong, readonly, nonnull) NSString *selectedScreenKey; -/** Monotonically increasing number describing the generation of this state instance. - * Used for stale update detection. */ +/** + * Monotonically increasing number describing the generation of this state instance. + * Used for stale update detection. + */ @property (nonatomic, readonly) int provenance; - (instancetype)initWithSelectedScreenKey:(nonnull NSString *)selectedScreenKey provenance:(int)provenance; @@ -76,16 +96,26 @@ typedef NS_ENUM(NSInteger, RNSTabsNavigationStateRejectionReason) { @end -/** Bundles a navigation state change together with metadata about the selection context. */ +/** + * Bundles a navigation state change together with metadata about the selection context. + */ @interface RNSTabsNavigationStateUpdateContext : NSObject -/** The navigation state after the change. */ +/** + * The navigation state after the change. + */ @property (nonatomic, readonly, strong, nonnull) RNSTabsNavigationState *navState; -/** Whether the same tab that was already selected has been selected again. */ +/** + * Whether the same tab that was already selected has been selected again. + */ @property (nonatomic, readonly) BOOL isRepeated; -/** Whether a special effect (e.g. scroll-to-top) was triggered by the selection. */ +/** + * Whether a special effect (e.g. scroll-to-top) was triggered by the selection. + */ @property (nonatomic, readonly) BOOL hasTriggeredSpecialEffect; -/** Origin (actor) that requested this transition. */ +/** + * Origin (actor) that requested this transition. + */ @property (nonatomic, readonly) RNSTabsActionOrigin actionOrigin; - (instancetype)initWithNavState:(nonnull RNSTabsNavigationState *)navState diff --git a/src/components/tabs/host/TabsHost.types.ts b/src/components/tabs/host/TabsHost.types.ts index 20dd2bdf70..6d8f5c3ed8 100644 --- a/src/components/tabs/host/TabsHost.types.ts +++ b/src/components/tabs/host/TabsHost.types.ts @@ -67,11 +67,13 @@ export type TabSelectedEvent = { * @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. + * - `programmatic-native` — request initiated from the native side by an actor + * integrating directly against the native container. * - `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. */ - actionOrigin: 'user' | 'programmatic-js' | 'implicit'; + actionOrigin: 'user' | 'programmatic-js' | 'programmatic-native' | 'implicit'; }; /** @@ -241,8 +243,8 @@ export interface TabsHostPropsBase { * @platform android, ios */ onTabSelected?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + | ((event: NativeSyntheticEvent) => void) + | undefined; /** * @summary @@ -251,8 +253,8 @@ export interface TabsHostPropsBase { * @see {@link TabSelectionRejectedEvent} */ onTabSelectionRejected?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + | ((event: NativeSyntheticEvent) => void) + | undefined; /** * @summary @@ -262,8 +264,8 @@ export interface TabsHostPropsBase { * @see {@link TabSelectionPreventedEvent} */ onTabSelectionPrevented?: - | ((event: NativeSyntheticEvent) => void) - | undefined; + | ((event: NativeSyntheticEvent) => void) + | undefined; } export interface TabsHostProps extends TabsHostPropsBase { diff --git a/src/fabric/tabs/TabsHostAndroidNativeComponent.ts b/src/fabric/tabs/TabsHostAndroidNativeComponent.ts index bab550d0c8..1a53fd2cef 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; - actionOrigin: 'user' | 'programmatic-js' | 'implicit'; + actionOrigin: 'user' | 'programmatic-js' | 'programmatic-native' | 'implicit'; }; type NavigationStateRequest = { diff --git a/src/fabric/tabs/TabsHostIOSNativeComponent.ts b/src/fabric/tabs/TabsHostIOSNativeComponent.ts index 72e37315fd..f50446f468 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; - actionOrigin: 'user' | 'programmatic-js' | 'implicit'; + actionOrigin: 'user' | 'programmatic-js' | 'programmatic-native' | 'implicit'; }>; type NavigationStateRequest = Readonly<{