Skip to content

Commit 257a53c

Browse files
authored
feat(Tabs): special effects refactor (#3440)
## Description Refactors special effects on both platforms: - fixes disabling `popToRoot` on iOS 26, - fixes special effects running on regular (not repeated) tab change on Android, - adds `repeatedSelectionHandledBySpecialEffect` to `NativeFocusChangeEvent` on both platforms (now the event is sent on every tab press with appropriate flag value), - adds support for `specialEffects` on Paper+iOS. Closes software-mansion/react-native-screens-labs#579. Closes software-mansion/react-native-screens-labs#574. ### Recordings Pay attention to logs. #### Android https://github.com/user-attachments/assets/54887d06-f6fa-4bb9-a9bd-2642a8af31b2 #### iOS https://github.com/user-attachments/assets/c06505dc-e4e6-44b9-90f3-5d18c3345c01 ### Native pop to root on iOS 26 UIKit natively has "pop to root" effect in `UITabBarController` but prior to iOS 26, it worked only when `UINavigationController` was direct child of `UITabBarController`. In `screens`, `RNSTabBarController` has `RNSTabsScreenViewController` children view controllers and `RNSScreenStack` can be a child VC of `RNSTabsScreenViewController`. This prevents native effect from working on iOS versions prior to 26. Starting from iOS 26, `UITabBarController` also detects nested `UINavigationController`s. This interaction happens when we return `true` from `tabBarController:shouldSelectViewController:` delegate method. This would work for natively-driven tabs but for controlled tabs, we need to return `false` to prevent tab change on the native side -> that's why we decided to use only our own implementation and disable native effect by always returning `false` from `tabBarController:shouldSelectViewController:` on repeated tab selection. ## Changes - handle `specialEffects` prop on iOS, Paper, - add `repeatedSelectionHandledBySpecialEffect` to `NativeFocusChangeEvent` - update docs (move special effects and freeze to general section, clean up) - return `false` from `tabBarController:(UITabBarController *)tabBarController shouldSelectViewController:(UIViewController *)viewController` on repeated tab selection on iOS (disables native pop to root interaction which is available starting from iOS 26) - prevent running `onItemSelectedListener` after menu selection on Android (it runs on click for the first time and it would run for the second time after update from JS which would activate special effect erroneously) ## Test code and steps to reproduce Run `TestBottomTabs`. Test repeated selection with different special effects enabled on `Tab4`. Make sure that special effects don't run when changing from other tab to `Tab4`. ## Checklist - [x] Included code example that can be used to test this change - [x] Updated TS types - [x] Updated documentation - [x] Ensured that CI passes
1 parent 9e8bf52 commit 257a53c

13 files changed

+171
-98
lines changed

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHost.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,14 @@ class TabsHost(
252252
bottomNavigationView.setOnItemSelectedListener { item ->
253253
RNSLog.d(TAG, "Item selected $item")
254254
val fragment = getFragmentForMenuItemId(item.itemId)
255-
if (fragment != currentFocusedTab || !specialEffectsHandler.handleRepeatedTabSelection()) {
256-
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
257-
eventEmitter.emitOnNativeFocusChange(tabKey)
258-
}
255+
val repeatedSelectionHandledBySpecialEffect =
256+
if (fragment == currentFocusedTab) specialEffectsHandler.handleRepeatedTabSelection() else false
257+
val tabKey = fragment?.tabScreen?.tabKey ?: "undefined"
258+
eventEmitter.emitOnNativeFocusChange(
259+
tabKey,
260+
item.itemId,
261+
repeatedSelectionHandledBySpecialEffect,
262+
)
259263
true
260264
}
261265
}
@@ -352,8 +356,11 @@ class TabsHost(
352356

353357
appearanceCoordinator.updateTabAppearance(this)
354358

355-
bottomNavigationView.selectedItemId =
359+
val selectedTabScreenFragmentId =
356360
checkNotNull(getSelectedTabScreenFragmentId()) { "[RNScreens] A single selected tab must be present" }
361+
if (bottomNavigationView.selectedItemId != selectedTabScreenFragmentId) {
362+
bottomNavigationView.selectedItemId = selectedTabScreenFragmentId
363+
}
357364

358365
post {
359366
refreshLayout()

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/TabsHostEventEmitter.kt

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,19 @@ internal class TabsHostEventEmitter(
88
reactContext: ReactContext,
99
viewTag: Int,
1010
) : BaseEventEmitter(reactContext, viewTag) {
11-
fun emitOnNativeFocusChange(tabKey: String) {
12-
reactEventDispatcher.dispatchEvent(TabsHostNativeFocusChangeEvent(surfaceId, viewTag, tabKey))
11+
fun emitOnNativeFocusChange(
12+
tabKey: String,
13+
tabNumber: Int,
14+
repeatedSelectionHandledBySpecialEffect: Boolean,
15+
) {
16+
reactEventDispatcher.dispatchEvent(
17+
TabsHostNativeFocusChangeEvent(
18+
surfaceId,
19+
viewTag,
20+
tabKey,
21+
tabNumber,
22+
repeatedSelectionHandledBySpecialEffect,
23+
),
24+
)
1325
}
1426
}

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/event/TabsHostNativeFocusChangeEvent.kt

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,36 @@ class TabsHostNativeFocusChangeEvent(
99
surfaceId: Int,
1010
viewId: Int,
1111
val tabKey: String,
12+
val tabNumber: Int,
13+
val repeatedSelectionHandledBySpecialEffect: Boolean,
1214
) : Event<TabScreenDidAppearEvent>(surfaceId, viewId),
1315
NamingAwareEventType {
1416
override fun getEventName() = EVENT_NAME
1517

1618
override fun getEventRegistrationName() = EVENT_REGISTRATION_NAME
1719

18-
// All events for a given view can be coalesced.
19-
override fun getCoalescingKey(): Short = 0
20+
// If the user taps currently selected tab 2 times and e.g. scroll to top effect can run,
21+
// we should send 2 events [(tabKey, true), (tabKey, false)]. We don't want them to be coalesced
22+
// as we would lose information about activation of special effect. That's why we take into
23+
// account `repeatedSelectionHandledBySpecialEffect` for coalescingKey.
24+
override fun getCoalescingKey(): Short = (tabNumber * 10 + if (repeatedSelectionHandledBySpecialEffect) 1 else 0).toShort()
2025

2126
override fun getEventData(): WritableMap? =
2227
Arguments.createMap().apply {
2328
putString(EVENT_KEY_TAB_KEY, tabKey)
29+
putBoolean(
30+
EVENT_KEY_REPEATED_SELECTION_HANDLED_BY_SPECIAL_EFFECT,
31+
repeatedSelectionHandledBySpecialEffect,
32+
)
2433
}
2534

2635
companion object : NamingAwareEventType {
2736
const val EVENT_NAME = "topNativeFocusChange"
2837
const val EVENT_REGISTRATION_NAME = "onNativeFocusChange"
2938

3039
private const val EVENT_KEY_TAB_KEY = "tabKey"
40+
private const val EVENT_KEY_REPEATED_SELECTION_HANDLED_BY_SPECIAL_EFFECT =
41+
"repeatedSelectionHandledBySpecialEffect"
3142

3243
override fun getEventName() = EVENT_NAME
3344

ios/bottom-tabs/host/RNSBottomTabsHostComponentView.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ NS_ASSUME_NONNULL_BEGIN
7272
*/
7373
- (nonnull RNSBottomTabsHostEventEmitter *)reactEventEmitter;
7474

75-
- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen;
75+
- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen
76+
repeatedSelectionHandledBySpecialEffect:(BOOL)repeatedSelectionHandledBySpecialEffect;
7677

7778
#if !RCT_NEW_ARCH_ENABLED
7879
#pragma mark - LEGACY Event blocks

ios/bottom-tabs/host/RNSBottomTabsHostComponentView.mm

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,9 +261,13 @@ - (nonnull RNSBottomTabsHostEventEmitter *)reactEventEmitter
261261
return _reactEventEmitter;
262262
}
263263

264-
- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(RNSBottomTabsScreenComponentView *)tabScreen
264+
- (BOOL)emitOnNativeFocusChangeRequestSelectedTabScreen:(nonnull RNSBottomTabsScreenComponentView *)tabScreen
265+
repeatedSelectionHandledBySpecialEffect:(BOOL)repeatedSelectionHandledBySpecialEffect
265266
{
266-
return [_reactEventEmitter emitOnNativeFocusChange:OnNativeFocusChangePayload{.tabKey = tabScreen.tabKey}];
267+
return [_reactEventEmitter
268+
emitOnNativeFocusChange:OnNativeFocusChangePayload{
269+
.tabKey = tabScreen.tabKey,
270+
.repeatedSelectionHandledBySpecialEffect = repeatedSelectionHandledBySpecialEffect}];
267271
}
268272

269273
#pragma mark - RCTComponentViewProtocol

ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ NS_ASSUME_NONNULL_BEGIN
1818
#if defined(__cplusplus)
1919
struct OnNativeFocusChangePayload {
2020
NSString *_Nonnull tabKey;
21+
BOOL repeatedSelectionHandledBySpecialEffect;
2122
};
2223
#else
2324
typedef struct {
2425
NSString *_Nonnull tabKey;
26+
BOOL repeatedSelectionHandledBySpecialEffect;
2527
} OnNativeFocusChangePayload;
2628
#endif
2729

ios/bottom-tabs/host/RNSBottomTabsHostEventEmitter.mm

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,20 @@ - (BOOL)emitOnNativeFocusChange:(OnNativeFocusChangePayload)payload
3737
{
3838
#if RCT_NEW_ARCH_ENABLED
3939
if (_reactEventEmitter != nullptr) {
40-
_reactEventEmitter->onNativeFocusChange({.tabKey = RCTStringFromNSString(payload.tabKey)});
40+
_reactEventEmitter->onNativeFocusChange(
41+
{.tabKey = RCTStringFromNSString(payload.tabKey),
42+
.repeatedSelectionHandledBySpecialEffect = payload.repeatedSelectionHandledBySpecialEffect});
4143
return YES;
4244
} else {
4345
RCTLogWarn(@"[RNScreens] Skipped OnNativeFocusChange event emission due to nullish emitter");
4446
return NO;
4547
}
4648
#else
4749
if (self.onNativeFocusChange) {
48-
self.onNativeFocusChange(@{@"tabKey" : payload.tabKey});
50+
self.onNativeFocusChange(@{
51+
@"tabKey" : payload.tabKey,
52+
@"repeatedSelectionHandledBySpecialEffect" : @(payload.repeatedSelectionHandledBySpecialEffect)
53+
});
4954
return YES;
5055
} else {
5156
RCTLogWarn(@"[RNScreens] Skipped OnNativeFocusChange event emission due to nullish emitter");

ios/bottom-tabs/host/RNSTabBarControllerDelegate.mm

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -32,26 +32,20 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController
3232
}
3333
#endif // !TARGET_OS_TV
3434

35-
bool repeatedSelectionHandledNatively = false;
35+
// TODO: handle enforcing orientation with natively-driven tabs
3636

3737
// Detect repeated selection and inform tabScreenController
38-
if ([tabBarCtrl selectedViewController] == tabScreenCtrl) {
39-
repeatedSelectionHandledNatively = [tabScreenCtrl tabScreenSelectedRepeatedly];
40-
}
41-
42-
// TODO: send an event with information about event being handled natively
43-
if (!repeatedSelectionHandledNatively) {
44-
[tabBarCtrl.tabsHostComponentView
45-
emitOnNativeFocusChangeRequestSelectedTabScreen:tabScreenCtrl.tabScreenComponentView];
38+
BOOL repeatedSelection = [tabBarCtrl selectedViewController] == tabScreenCtrl;
39+
BOOL repeatedSelectionHandledBySpecialEffect =
40+
repeatedSelection ? [tabScreenCtrl tabScreenSelectedRepeatedly] : false;
4641

47-
// TODO: handle overrideScrollViewBehaviorInFirstDescendantChainIfNeeded for natively-driven tabs
48-
return ![self shouldPreventNativeTabChangeWithinTabBarController:tabBarCtrl];
49-
}
50-
51-
// TODO: handle enforcing orientation with natively-driven tabs
42+
[tabBarCtrl.tabsHostComponentView
43+
emitOnNativeFocusChangeRequestSelectedTabScreen:tabScreenCtrl.tabScreenComponentView
44+
repeatedSelectionHandledBySpecialEffect:repeatedSelectionHandledBySpecialEffect];
5245

53-
// As we're selecting the same controller, returning both true and false works here.
54-
return true;
46+
// On repeated selection we return false to prevent native *pop to root* effect that works only starting from iOS 26
47+
// and interferes with our implementation (which is necessary for controlled tabs).
48+
return repeatedSelection ? false : ![self shouldPreventNativeTabChangeWithinTabBarController:tabBarCtrl];
5549
}
5650

5751
- (void)tabBarController:(UITabBarController *)tabBarController

ios/bottom-tabs/screen/RNSBottomTabsScreenComponentView.mm

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,30 @@ - (void)setSystemItem:(RNSBottomTabsScreenSystemItem)systemItem
649649
_tabBarItemNeedsRecreation = YES;
650650
}
651651

652+
- (void)setSpecialEffects:(NSDictionary *)specialEffects
653+
{
654+
if (specialEffects == nil || specialEffects[@"repeatedTabSelection"] == nil ||
655+
![specialEffects[@"repeatedTabSelection"] isKindOfClass:[NSDictionary class]]) {
656+
_shouldUseRepeatedTabSelectionPopToRootSpecialEffect = YES;
657+
_shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = YES;
658+
return;
659+
}
660+
661+
NSDictionary *repeatedTabSelection = specialEffects[@"repeatedTabSelection"];
662+
663+
if (repeatedTabSelection[@"popToRoot"] != nil) {
664+
_shouldUseRepeatedTabSelectionPopToRootSpecialEffect = [RCTConvert BOOL:repeatedTabSelection[@"popToRoot"]];
665+
} else {
666+
_shouldUseRepeatedTabSelectionPopToRootSpecialEffect = YES;
667+
}
668+
669+
if (repeatedTabSelection[@"scrollToTop"] != nil) {
670+
_shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = [RCTConvert BOOL:repeatedTabSelection[@"scrollToTop"]];
671+
} else {
672+
_shouldUseRepeatedTabSelectionScrollToTopSpecialEffect = YES;
673+
}
674+
}
675+
652676
- (void)setOrientation:(RNSOrientation)orientation
653677
{
654678
_orientation = orientation;

ios/bottom-tabs/screen/RNSBottomTabsScreenComponentViewManager.mm

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,6 @@ - (UIView *)view
3333
RCT_EXPORT_VIEW_PROPERTY(selectedIconImageSource, RCTImageSource);
3434
RCT_EXPORT_VIEW_PROPERTY(selectedIconSfSymbolName, NSString);
3535

36-
RCT_EXPORT_VIEW_PROPERTY(shouldUseRepeatedTabSelectionPopToRootSpecialEffect, BOOL);
37-
RCT_EXPORT_VIEW_PROPERTY(shouldUseRepeatedTabSelectionScrollToTopSpecialEffect, BOOL);
38-
3936
RCT_EXPORT_VIEW_PROPERTY(overrideScrollViewContentInsetAdjustmentBehavior, BOOL);
4037

4138
RCT_EXPORT_VIEW_PROPERTY(bottomScrollEdgeEffect, RNSScrollEdgeEffect);
@@ -47,6 +44,8 @@ - (UIView *)view
4744

4845
RCT_EXPORT_VIEW_PROPERTY(systemItem, RNSBottomTabsScreenSystemItem);
4946

47+
RCT_EXPORT_VIEW_PROPERTY(specialEffects, NSDictionary);
48+
5049
RCT_EXPORT_VIEW_PROPERTY(onWillAppear, RCTDirectEventBlock);
5150
RCT_EXPORT_VIEW_PROPERTY(onWillDisappear, RCTDirectEventBlock);
5251
RCT_EXPORT_VIEW_PROPERTY(onDidAppear, RCTDirectEventBlock);

0 commit comments

Comments
 (0)