Skip to content

Commit 6797d32

Browse files
authored
fix(iOS): screen not rendering after tab change on iOS 27 (#530)
* fix(iOS): fix screens not rendering on tab change on iOS 27 * chore: reorder blocks * fix(iOS): implement tabBarController(_:shouldSelectTab:) for iOS 27 * chore: restore TabAppearModifier * chore: simplify solution * chore: cleanup * chore: cleanup * chore: add changeset
1 parent 942e441 commit 6797d32

7 files changed

Lines changed: 153 additions & 10 deletions

File tree

.changeset/cute-carrots-switch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'react-native-bottom-tabs': patch
3+
---
4+
5+
Fix screen not rendering after tab change on iOS 27

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,6 @@ android/generated
8282

8383
# Codex
8484
.codex
85+
86+
# Agent Device
87+
tmp/

apps/example/src/App.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,13 @@ import NativeBottomTabsUnmounting from './Examples/NativeBottomTabsUnmounting';
3636
import NativeBottomTabsCustomTabBar from './Examples/NativeBottomTabsCustomTabBar';
3737
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';
3838
import NativeBottomTabsScreenLayout from './Examples/NativeBottomTabsScreenLayout';
39+
import NativeBottomTabsLazy from './Examples/NativeBottomTabsLazy';
3940
import BottomAccessoryView from './Examples/BottomAccessoryView';
4041
import { useLogger } from '@react-navigation/devtools';
42+
import LazyTabs from './Examples/LazyTabs';
43+
import { LogBox } from 'react-native';
44+
45+
LogBox.ignoreAllLogs();
4146

4247
const HiddenTab = () => {
4348
return <FourTabs hideOneTab />;
@@ -74,6 +79,7 @@ const FourTabsActiveIndicatorColor = () => {
7479
const UnlabeledTabs = () => {
7580
return <LabeledTabs showLabels={false} />;
7681
};
82+
7783
const FourTabsRightToLeft = () => {
7884
return <FourTabsRTL layoutDirection={'rtl'} />;
7985
};
@@ -96,6 +102,7 @@ const examples = [
96102
name: 'Embedded stacks',
97103
screenOptions: { headerShown: false },
98104
},
105+
{ component: LazyTabs, name: 'Lazy Tabs' },
99106
{
100107
component: FourTabsRippleColor,
101108
name: 'Four Tabs with ripple Color',
@@ -156,6 +163,10 @@ const examples = [
156163
component: NativeBottomTabsScreenLayout,
157164
name: 'Native Bottom Tabs with screenLayout',
158165
},
166+
{
167+
component: NativeBottomTabsLazy,
168+
name: 'Native Bottom Tabs with Lazy',
169+
},
159170
{ component: NativeBottomTabs, name: 'Native Bottom Tabs' },
160171
{ component: JSBottomTabs, name: 'JS Bottom Tabs' },
161172
{
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import TabView, { SceneMap } from 'react-native-bottom-tabs';
2+
import { useState } from 'react';
3+
import { Article } from '../Screens/Article';
4+
import { Albums } from '../Screens/Albums';
5+
import { Contacts } from '../Screens/Contacts';
6+
7+
const renderScene = SceneMap({
8+
article: Article,
9+
albums: Albums,
10+
contacts: Contacts,
11+
});
12+
13+
export default function LazyTabs() {
14+
const [index, setIndex] = useState(0);
15+
const [routes] = useState([
16+
{
17+
key: 'article',
18+
title: 'Article',
19+
focusedIcon: require('../../assets/icons/article_dark.png'),
20+
unfocusedIcon: require('../../assets/icons/chat_dark.png'),
21+
badge: '!',
22+
testID: 'articleTestID',
23+
},
24+
{
25+
key: 'albums',
26+
title: 'Albums',
27+
focusedIcon: require('../../assets/icons/grid_dark.png'),
28+
badge: '5',
29+
testID: 'albumsTestID',
30+
lazy: false,
31+
},
32+
{
33+
key: 'contacts',
34+
focusedIcon: require('../../assets/icons/person_dark.png'),
35+
title: 'Contacts',
36+
testID: 'contactsTestID',
37+
},
38+
]);
39+
40+
return (
41+
<TabView
42+
navigationState={{ index, routes }}
43+
onIndexChange={setIndex}
44+
renderScene={renderScene}
45+
/>
46+
);
47+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Article } from '../Screens/Article';
2+
import { Albums } from '../Screens/Albums';
3+
import { Contacts } from '../Screens/Contacts';
4+
import { Chat } from '../Screens/Chat';
5+
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
6+
7+
const Tab = createNativeBottomTabNavigator();
8+
9+
export default function NativeBottomTabsLazy() {
10+
return (
11+
<Tab.Navigator>
12+
<Tab.Screen
13+
name="Article"
14+
component={Article}
15+
options={{
16+
tabBarIcon: () => require('../../assets/icons/article_dark.png'),
17+
}}
18+
/>
19+
<Tab.Screen
20+
name="Albums"
21+
component={Albums}
22+
options={{
23+
tabBarIcon: () => require('../../assets/icons/grid_dark.png'),
24+
lazy: false,
25+
}}
26+
/>
27+
<Tab.Screen
28+
name="Contacts"
29+
component={Contacts}
30+
options={{
31+
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
32+
}}
33+
/>
34+
<Tab.Screen
35+
name="Chat"
36+
component={Chat}
37+
options={{
38+
tabBarIcon: () => require('../../assets/icons/chat_dark.png'),
39+
}}
40+
/>
41+
</Tab.Navigator>
42+
);
43+
}

packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ import UIKit
77
#if !os(macOS) && !os(visionOS)
88

99
private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
10-
var onClick: ((_ index: Int) -> Bool)?
10+
var onClick: ((_ index: Int?, _ identifier: String?) -> Bool)?
1111

1212
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
13+
if #available(iOS 27.0, *) {
14+
// iOS 27 routes SwiftUI TabView selection through shouldSelectTab.
15+
return true
16+
}
17+
1318
#if os(iOS)
1419
// Handle "More" Tab
1520
if tabBarController.moreNavigationController == viewController {
@@ -21,7 +26,7 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
2126

2227
if isReselectingSameTab {
2328
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
24-
_ = onClick?(index)
29+
_ = onClick?(index, nil)
2530
}
2631

2732
return false
@@ -31,17 +36,45 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate {
3136
// See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383
3237
// Due to this, whether the tab prevents default has to be defined statically.
3338
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
34-
let defaultPrevented = onClick?(index) ?? false
39+
let defaultPrevented = onClick?(index, nil) ?? false
3540

3641
return !defaultPrevented
3742
}
3843

3944
return false
4045
}
46+
47+
@available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
48+
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
49+
guard #available(iOS 27.0, *) else {
50+
return true
51+
}
52+
53+
let isReselectingSameTab =
54+
tabBarController.selectedTab === tab ||
55+
tabBarController.selectedTab?.identifier == tab.identifier
56+
57+
// Unfortunately, due to iOS 26 new tab switching animations, controlling state from JavaScript is causing significant delays when switching tabs.
58+
// See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383
59+
// Due to this, whether the tab prevents default has to be defined statically.
60+
let defaultPrevented = onClick?(
61+
tabIndex(for: tab, in: tabBarController),
62+
tab.identifier
63+
) ?? false
64+
65+
return isReselectingSameTab ? false : !defaultPrevented
66+
}
67+
68+
@available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
69+
private func tabIndex(for tab: UITab, in tabBarController: UITabBarController) -> Int? {
70+
tabBarController.tabs.firstIndex {
71+
$0 === tab || $0.identifier == tab.identifier
72+
}
73+
}
4174
}
4275

4376
struct TabItemEventModifier: ViewModifier {
44-
let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Bool
77+
let onTabEvent: (_ index: Int?, _ identifier: String?, _ isLongPress: Bool) -> Bool
4578
private let delegate = TabBarDelegate()
4679

4780
func body(content: Content) -> some View {
@@ -52,8 +85,8 @@ struct TabItemEventModifier: ViewModifier {
5285
}
5386

5487
func handle(tabController: UITabBarController) {
55-
delegate.onClick = { index in
56-
onTabEvent(index, false)
88+
delegate.onClick = { index, identifier in
89+
onTabEvent(index, identifier, false)
5790
}
5891
tabController.delegate = delegate
5992

@@ -70,7 +103,7 @@ struct TabItemEventModifier: ViewModifier {
70103
}
71104

72105
// Create gesture handler
73-
let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { key, isLongPress in _ = onTabEvent(key, isLongPress) }
106+
let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { index, isLongPress in _ = onTabEvent(index, nil, isLongPress) }
74107
let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:)))
75108
gesture.minimumPressDuration = 0.5
76109

@@ -122,7 +155,7 @@ extension View {
122155
/**
123156
Event for tab items. Returns true if should prevent default (switching tabs).
124157
*/
125-
func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View {
158+
func onTabItemEvent(_ handler: @escaping (Int?, String?, Bool) -> Bool) -> some View {
126159
modifier(TabItemEventModifier(onTabEvent: handler))
127160
}
128161
}

packages/react-native-bottom-tabs/ios/TabViewImpl.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ struct TabViewImpl: View {
4646
tabContent
4747
.tabBarMinimizeBehavior(props.minimizeBehavior)
4848
#if !os(tvOS) && !os(macOS) && !os(visionOS)
49-
.onTabItemEvent { index, isLongPress in
50-
let item = props.filteredItems[safe: index]
49+
.onTabItemEvent { index, identifier, isLongPress in
50+
let item = identifier.flatMap { props.filteredItems.findByKey($0) }
51+
?? index.flatMap { props.filteredItems[safe: $0] }
5152
guard let key = item?.key else { return false }
5253

5354
if isLongPress {

0 commit comments

Comments
 (0)