diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt index 6289850aea..2e658c6c83 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt @@ -252,10 +252,14 @@ class TabsHost( bottomNavigationView.setOnItemSelectedListener { item -> RNSLog.d(TAG, "Item selected $item") val fragment = getFragmentForMenuItemId(item.itemId) - if (fragment != currentFocusedTab || !specialEffectsHandler.handleRepeatedTabSelection()) { - val tabKey = fragment?.tabScreen?.tabKey ?: "undefined" - eventEmitter.emitOnNativeFocusChange(tabKey) - } + val repeatedSelectionHandledBySpecialEffect = + if (fragment == currentFocusedTab) specialEffectsHandler.handleRepeatedTabSelection() else false + val tabKey = fragment?.tabScreen?.tabKey ?: "undefined" + eventEmitter.emitOnNativeFocusChange( + tabKey, + item.itemId, + repeatedSelectionHandledBySpecialEffect, + ) true } } @@ -352,8 +356,11 @@ class TabsHost( appearanceCoordinator.updateTabAppearance(this) - bottomNavigationView.selectedItemId = + val selectedTabScreenFragmentId = checkNotNull(getSelectedTabScreenFragmentId()) { "[RNScreens] A single selected tab must be present" } + if (bottomNavigationView.selectedItemId != selectedTabScreenFragmentId) { + bottomNavigationView.selectedItemId = selectedTabScreenFragmentId + } post { refreshLayout() diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHostEventEmitter.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHostEventEmitter.kt index 353cd39952..77de95dfa6 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHostEventEmitter.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHostEventEmitter.kt @@ -8,7 +8,19 @@ internal class TabsHostEventEmitter( reactContext: ReactContext, viewTag: Int, ) : BaseEventEmitter(reactContext, viewTag) { - fun emitOnNativeFocusChange(tabKey: String) { - reactEventDispatcher.dispatchEvent(TabsHostNativeFocusChangeEvent(surfaceId, viewTag, tabKey)) + fun emitOnNativeFocusChange( + tabKey: String, + tabNumber: Int, + repeatedSelectionHandledBySpecialEffect: Boolean, + ) { + reactEventDispatcher.dispatchEvent( + TabsHostNativeFocusChangeEvent( + surfaceId, + viewTag, + tabKey, + tabNumber, + repeatedSelectionHandledBySpecialEffect, + ), + ) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/event/TabsHostNativeFocusChangeEvent.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/event/TabsHostNativeFocusChangeEvent.kt index 4211fa0762..c9776bb9b1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/event/TabsHostNativeFocusChangeEvent.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/event/TabsHostNativeFocusChangeEvent.kt @@ -9,18 +9,27 @@ class TabsHostNativeFocusChangeEvent( surfaceId: Int, viewId: Int, val tabKey: String, + val tabNumber: Int, + val repeatedSelectionHandledBySpecialEffect: Boolean, ) : Event(surfaceId, viewId), NamingAwareEventType { override fun getEventName() = EVENT_NAME override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME - // All events for a given view can be coalesced. - override fun getCoalescingKey(): Short = 0 + // If the user taps currently selected tab 2 times and e.g. scroll to top effect can run, + // we should send 2 events [(tabKey, true), (tabKey, false)]. We don't want them to be coalesced + // as we would lose information about activation of special effect. That's why we take into + // account `repeatedSelectionHandledBySpecialEffect` for coalescingKey. + override fun getCoalescingKey(): Short = (tabNumber * 10 + if (repeatedSelectionHandledBySpecialEffect) 1 else 0).toShort() override fun getEventData(): WritableMap? = Arguments.createMap().apply { putString(EVENT_KEY_TAB_KEY, tabKey) + putBoolean( + EVENT_KEY_REPEATED_SELECTION_HANDLED_BY_SPECIAL_EFFECT, + repeatedSelectionHandledBySpecialEffect, + ) } companion object : NamingAwareEventType { @@ -28,6 +37,8 @@ class TabsHostNativeFocusChangeEvent( const val EVENT_REGISTRATION_NAME = "onNativeFocusChange" private const val EVENT_KEY_TAB_KEY = "tabKey" + private const val EVENT_KEY_REPEATED_SELECTION_HANDLED_BY_SPECIAL_EFFECT = + "repeatedSelectionHandledBySpecialEffect" override fun getEventName() = EVENT_NAME diff --git a/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h b/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h index c633d690fb..e7505bab1f 100644 --- a/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h +++ b/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h @@ -72,7 +72,8 @@ NS_ASSUME_NONNULL_BEGIN */ - (nonnull RNSBottomTabsHostEventEmitter *)reactEventEmitter; -- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen; +- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen + repeatedSelectionHandledBySpecialEffect:(BOOL)repeatedSelectionHandledBySpecialEffect; #if !RCT_NEW_ARCH_ENABLED #pragma mark - LEGACY Event blocks diff --git a/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm b/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm index 5ed820dd9a..77a248b860 100644 --- a/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm +++ b/ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm @@ -261,9 +261,13 @@ - (nonnull RNSBottomTabsHostEventEmitter *)reactEventEmitter return _reactEventEmitter; } -- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(RNSBottomTabsScreenComponentView *)tabScreen +- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen + repeatedSelectionHandledBySpecialEffect:(BOOL)repeatedSelectionHandledBySpecialEffect { - return [_reactEventEmitter emitOnNativeFocusChange:OnNativeFocusChangePayload{.tabKey = tabScreen.tabKey}]; + return [_reactEventEmitter + emitOnNativeFocusChange:OnNativeFocusChangePayload{ + .tabKey = tabScreen.tabKey, + .repeatedSelectionHandledBySpecialEffect = repeatedSelectionHandledBySpecialEffect}]; } #pragma mark - RCTComponentViewProtocol diff --git a/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h b/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h index b0f49f9bd4..09da2b0a69 100644 --- a/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h +++ b/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h @@ -18,10 +18,12 @@ NS_ASSUME_NONNULL_BEGIN #if defined(__cplusplus) struct OnNativeFocusChangePayload { NSString *_Nonnull tabKey; + BOOL repeatedSelectionHandledBySpecialEffect; }; #else typedef struct { NSString *_Nonnull tabKey; + BOOL repeatedSelectionHandledBySpecialEffect; } OnNativeFocusChangePayload; #endif diff --git a/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm b/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm index bf68c5334d..269895cb69 100644 --- a/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm +++ b/ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm @@ -37,7 +37,9 @@ - (BOOL)emitOnNativeFocusChange:(OnNativeFocusChangePayload)payload { #if RCT_NEW_ARCH_ENABLED if (_reactEventEmitter != nullptr) { - _reactEventEmitter->onNativeFocusChange({.tabKey = RCTStringFromNSString(payload.tabKey)}); + _reactEventEmitter->onNativeFocusChange( + {.tabKey = RCTStringFromNSString(payload.tabKey), + .repeatedSelectionHandledBySpecialEffect = payload.repeatedSelectionHandledBySpecialEffect}); return YES; } else { RCTLogWarn(@"[RNScreens] Skipped OnNativeFocusChange event emission due to nullish emitter"); @@ -45,7 +47,10 @@ - (BOOL)emitOnNativeFocusChange:(OnNativeFocusChangePayload)payload } #else if (self.onNativeFocusChange) { - self.onNativeFocusChange(@{@"tabKey" : payload.tabKey}); + self.onNativeFocusChange(@{ + @"tabKey" : payload.tabKey, + @"repeatedSelectionHandledBySpecialEffect" : @(payload.repeatedSelectionHandledBySpecialEffect) + }); return YES; } else { RCTLogWarn(@"[RNScreens] Skipped OnNativeFocusChange event emission due to nullish emitter"); diff --git a/ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm b/ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm index bf98c97176..71713b3953 100644 --- a/ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm +++ b/ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm @@ -32,26 +32,20 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController } #endif // !TARGET_OS_TV - bool repeatedSelectionHandledNatively = false; + // TODO: handle enforcing orientation with natively-driven tabs // Detect repeated selection and inform tabScreenController - if ([tabBarCtrl selectedViewController] == tabScreenCtrl) { - repeatedSelectionHandledNatively = [tabScreenCtrl tabScreenSelectedRepeatedly]; - } - - // TODO: send an event with information about event being handled natively - if (!repeatedSelectionHandledNatively) { - [tabBarCtrl.tabsHostComponentView - emitOnNativeFocusChangeRequestSelectedTabScreen:tabScreenCtrl.tabScreenComponentView]; + BOOL repeatedSelection = [tabBarCtrl selectedViewController] == tabScreenCtrl; + BOOL repeatedSelectionHandledBySpecialEffect = + repeatedSelection ? [tabScreenCtrl tabScreenSelectedRepeatedly] : false; - // TODO: handle overrideScrollViewBehaviorInFirstDescendantChainIfNeeded for natively-driven tabs - return ![self shouldPreventNativeTabChangeWithinTabBarController:tabBarCtrl]; - } - - // TODO: handle enforcing orientation with natively-driven tabs + [tabBarCtrl.tabsHostComponentView + emitOnNativeFocusChangeRequestSelectedTabScreen:tabScreenCtrl.tabScreenComponentView + repeatedSelectionHandledBySpecialEffect:repeatedSelectionHandledBySpecialEffect]; - // As we're selecting the same controller, returning both true and false works here. - return true; + // On repeated selection we return false to prevent native *pop to root* effect that works only starting from iOS 26 + // and interferes with our implementation (which is necessary for controlled tabs). + return repeatedSelection ? false : ![self shouldPreventNativeTabChangeWithinTabBarController:tabBarCtrl]; } - (void)tabBarController:(UITabBarController *)tabBarController diff --git a/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm b/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm index 0552adf709..537247bd84 100644 --- a/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm +++ b/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm @@ -649,6 +649,30 @@ - (void)setSystemItem:(RNSBottomTabsScreenSystemItem)systemItem _tabBarItemNeedsRecreation = YES; } +- (void)setSpecialEffects:(NSDictionary *)specialEffects +{ + if (specialEffects == nil || specialEffects[@"repeatedTabSelection"] == nil || + ![specialEffects[@"repeatedTabSelection"] isKindOfClass:[NSDictionary class]]) { + _shouldUseRepeatedTabSelectionPopToRootSpecialEffect = YES; + _shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = YES; + return; + } + + NSDictionary *repeatedTabSelection = specialEffects[@"repeatedTabSelection"]; + + if (repeatedTabSelection[@"popToRoot"] != nil) { + _shouldUseRepeatedTabSelectionPopToRootSpecialEffect = [RCTConvert BOOL:repeatedTabSelection[@"popToRoot"]]; + } else { + _shouldUseRepeatedTabSelectionPopToRootSpecialEffect = YES; + } + + if (repeatedTabSelection[@"scrollToTop"] != nil) { + _shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = [RCTConvert BOOL:repeatedTabSelection[@"scrollToTop"]]; + } else { + _shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = YES; + } +} + - (void)setOrientation:(RNSOrientation)orientation { _orientation = orientation; diff --git a/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentViewManager.mm b/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentViewManager.mm index 7a169059e5..91c4de1983 100644 --- a/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentViewManager.mm +++ b/ios/bottom-tabs/screen/RNSBottomTabsScreenComponentViewManager.mm @@ -33,9 +33,6 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(selectedIconImageSource, RCTImageSource); RCT_EXPORT_VIEW_PROPERTY(selectedIconSfSymbolName, NSString); -RCT_EXPORT_VIEW_PROPERTY(shouldUseRepeatedTabSelectionPopToRootSpecialEffect, BOOL); -RCT_EXPORT_VIEW_PROPERTY(shouldUseRepeatedTabSelectionScrollToTopSpecialEffect, BOOL); - RCT_EXPORT_VIEW_PROPERTY(overrideScrollViewContentInsetAdjustmentBehavior, BOOL); RCT_EXPORT_VIEW_PROPERTY(bottomScrollEdgeEffect, RNSScrollEdgeEffect); @@ -47,6 +44,8 @@ - (UIView *)view RCT_EXPORT_VIEW_PROPERTY(systemItem, RNSBottomTabsScreenSystemItem); +RCT_EXPORT_VIEW_PROPERTY(specialEffects, NSDictionary); + RCT_EXPORT_VIEW_PROPERTY(onWillAppear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onWillDisappear, RCTDirectEventBlock); RCT_EXPORT_VIEW_PROPERTY(onDidAppear, RCTDirectEventBlock); diff --git a/src/components/bottom-tabs/BottomTabs.types.ts b/src/components/bottom-tabs/BottomTabs.types.ts index 2a1a7cf4aa..d12d4f4f20 100644 --- a/src/components/bottom-tabs/BottomTabs.types.ts +++ b/src/components/bottom-tabs/BottomTabs.types.ts @@ -13,6 +13,7 @@ export type BottomAccessoryFn = ( export type NativeFocusChangeEvent = { tabKey: string; + repeatedSelectionHandledBySpecialEffect: boolean; }; // Android-specific @@ -49,11 +50,13 @@ export interface BottomTabsProps extends ViewProps { * @summary Hides the tab bar. * * @default false + * + * @platform android, ios */ tabBarHidden?: boolean; // #endregion General - // #region Android-only appearance + // #region Android-only /** * @summary Specifies the background color for the entire tab bar. * @@ -157,12 +160,13 @@ export interface BottomTabsProps extends ViewProps { * @see {@link https://github.com/material-components/material-components-android/blob/master/docs/components/BottomNavigation.md#making-navigation-bar-accessible|Material Components documentation} * * @default auto + * * @platform android */ tabBarItemLabelVisibilityMode?: TabBarItemLabelVisibilityMode; - // #endregion Android-only appearance + // #endregion Android-only - // #region iOS-only appearance + // #region iOS-only /** * @summary Specifies the color used for selected tab's text and icon color. * @@ -249,7 +253,7 @@ export interface BottomTabsProps extends ViewProps { * @supported iOS 18 or higher */ tabBarControllerMode?: TabBarControllerMode; - // #endregion iOS-only appearance + // #endregion iOS-only // #region Experimental support /** diff --git a/src/components/bottom-tabs/BottomTabsScreen.types.ts b/src/components/bottom-tabs/BottomTabsScreen.types.ts index c42ddec3f8..56a4123e6c 100644 --- a/src/components/bottom-tabs/BottomTabsScreen.types.ts +++ b/src/components/bottom-tabs/BottomTabsScreen.types.ts @@ -256,16 +256,6 @@ export interface BottomTabsScreenItemStateAppearance { } export interface BottomTabsScreenProps { - children?: ViewProps['children']; - /** - * @summary Defines what should be rendered when tab screen is frozen. - * - * @see {@link https://github.com/software-mansion/react-freeze|`react-freeze`'s GitHub repository} for more information about `react-freeze`. - * - * @platform android, ios - */ - placeholder?: React.ReactNode | undefined; - // #region Control /** * @summary Determines selected tab. @@ -289,6 +279,15 @@ export interface BottomTabsScreenProps { // #endregion // #region General + children?: ViewProps['children']; + /** + * @summary Defines what should be rendered when tab screen is frozen. + * + * @see {@link https://github.com/software-mansion/react-freeze|`react-freeze`'s GitHub repository} for more information about `react-freeze`. + * + * @platform android, ios + */ + placeholder?: React.ReactNode | undefined; /** * @summary Title of the tab screen, displayed in the tab bar item. * @@ -341,8 +340,66 @@ export interface BottomTabsScreenProps { * * On iOS, if no `selectedIcon` is provided, this icon will also * be used as the selected state icon. + * + * @platform android, ios */ icon?: PlatformIcon; + /** + * @summary Specifies which special effects (also known as microinteractions) + * are enabled for the tab screen. + * + * For repeated tab selection (selecting already focused tab bar item), + * there are 2 supported special effects: + * - `popToRoot` - when Stack is nested inside tab screen and repeated + * selection is detected, the Stack will pop to root screen, + * - `scrollToTop` - when there is a ScrollView in first descendant + * chain from tab screen and repeated selection is detected, ScrollView + * will be scrolled to top. + * + * `popToRoot` has priority over `scrollToTop`. + * + * @default All special effects are enabled by default. + * + * @platform android, ios + */ + specialEffects?: { + repeatedTabSelection?: { + /** + * @default true + */ + popToRoot?: boolean; + /** + * @default true + */ + scrollToTop?: boolean; + }; + }; + /** + * @summary Allows to control whether contents of a tab screen should be frozen or not. This overrides any default behavior. + * + * @default undefined + * + * @platform android, ios + */ + freezeContents?: boolean; + // #endregion General + + // #region Android-only + /** + * @summary Specifies the color of the text in the badge. + * + * @platform android + */ + tabBarItemBadgeTextColor?: ColorValue; + /** + * @summary Specifies the background color of the badge. + * + * @platform android + */ + tabBarItemBadgeBackgroundColor?: ColorValue; + // #endregion Android-only + + // #region iOS-only /** * @summary Specifies supported orientations for the tab screen. * @@ -392,22 +449,6 @@ export interface BottomTabsScreenProps { * @platform ios */ orientation?: BottomTabsScreenOrientation; - // #endregion General - /** - * @summary Specifies the color of the text in the badge. - * - * @platform android - */ - tabBarItemBadgeTextColor?: ColorValue; - /** - * @summary Specifies the background color of the badge. - * - * @platform android - */ - tabBarItemBadgeBackgroundColor?: ColorValue; - // #endregion Android-only appearance - - // #region iOS-only appearance /** * @summary Specifies the standard tab bar appearance. * @@ -449,43 +490,10 @@ export interface BottomTabsScreenProps { * be customized. * * @see {@link https://developer.apple.com/documentation/uikit/uitabbaritem/systemitem|UITabBarItem.SystemItem} + * * @platform ios */ systemItem?: BottomTabsSystemItem; - /** - * @summary Specifies which special effects (also known as microinteractions) - * are enabled for the tab screen. - * - * For repeated tab selection (selecting already focused tab bar item), - * there are 2 supported special effects: - * - `popToRoot` - when Stack is nested inside tab screen and repeated - * selection is detected, the Stack will pop to root screen, - * - `scrollToTop` - when there is a ScrollView in first descendant - * chain from tab screen and repeated selection is detected, ScrollView - * will be scrolled to top. - * - * `popToRoot` has priority over `scrollToTop`. - * - * @default All special effects are enabled by default. - */ - specialEffects?: { - repeatedTabSelection?: { - /** - * @default true - */ - popToRoot?: boolean; - /** - * @default true - */ - scrollToTop?: boolean; - }; - }; - /** - * @summary Allows to control whether contents of a tab screen should be frozen or not. This overrides any default behavior. - * - * @default `undefined` - */ - freezeContents?: boolean; /** * @summary Specifies if `contentInsetAdjustmentBehavior` of first ScrollView * in first descendant chain from tab screen should be overridden back from `never` @@ -550,10 +558,11 @@ export interface BottomTabsScreenProps { * @see {@link https://developer.apple.com/documentation/uikit/uiuserinterfacestyle|UIUserInterfaceStyle} * * @default unspecified + * * @platform ios */ experimental_userInterfaceStyle?: UserInterfaceStyle; - // #endregion iOS-only appearance + // #endregion iOS-only // #region Events /** diff --git a/src/fabric/bottom-tabs/BottomTabsNativeComponent.ts b/src/fabric/bottom-tabs/BottomTabsNativeComponent.ts index f56d3c91ba..ed1f74fa31 100644 --- a/src/fabric/bottom-tabs/BottomTabsNativeComponent.ts +++ b/src/fabric/bottom-tabs/BottomTabsNativeComponent.ts @@ -18,6 +18,7 @@ import type { type NativeFocusChangeEvent = { tabKey: string; + repeatedSelectionHandledBySpecialEffect: boolean; }; type TabBarItemLabelVisibilityMode =