Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,36 @@ class TabsHostNativeFocusChangeEvent(
surfaceId: Int,
viewId: Int,
val tabKey: String,
val tabNumber: Int,
val repeatedSelectionHandledBySpecialEffect: Boolean,
) : Event<TabScreenDidAppearEvent>(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()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reasoning here? Why different key when repeatedSelectionHandledBySpecialEffect?

FYI: I don't think this works anymore, as recently discovered by @t0maboro.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user taps currently selected tab 2 times and e.g. scroll to top effect can run, we should send [(tabKey, true), (tabKey, false)]. If I understand correctly, coalescing key is used to combine events to prevent spamming them. If those 2 events were combined to only one, we would lose some information that somebody can rely on (e.g. running some action at the same time as special effect or after special effect).

FYI: I don't think this works anymore, as recently discovered by @t0maboro.

Just wanted to make sure that it works correctly when it gets fixed 😅

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This convinces me. I think this deserves code comment. Let's make one, please.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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 {
const val EVENT_NAME = "topNativeFocusChange"
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

Expand Down
3 changes: 2 additions & 1 deletion ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,20 @@ - (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");
return NO;
}
#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");
Expand Down
26 changes: 10 additions & 16 deletions ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 8 additions & 4 deletions src/components/bottom-tabs/BottomTabs.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type BottomAccessoryFn = (

export type NativeFocusChangeEvent = {
tabKey: string;
repeatedSelectionHandledBySpecialEffect: boolean;
};

// Android-specific
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
/**
Expand Down
Loading
Loading