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
5 changes: 5 additions & 0 deletions .changeset/cute-carrots-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-native-bottom-tabs': patch
---

Fix screen not rendering after tab change on iOS 27
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,6 @@ android/generated

# Codex
.codex

# Agent Device
tmp/
11 changes: 11 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ import NativeBottomTabsUnmounting from './Examples/NativeBottomTabsUnmounting';
import NativeBottomTabsCustomTabBar from './Examples/NativeBottomTabsCustomTabBar';
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';
import NativeBottomTabsScreenLayout from './Examples/NativeBottomTabsScreenLayout';
import NativeBottomTabsLazy from './Examples/NativeBottomTabsLazy';
import BottomAccessoryView from './Examples/BottomAccessoryView';
import { useLogger } from '@react-navigation/devtools';
import LazyTabs from './Examples/LazyTabs';
import { LogBox } from 'react-native';

LogBox.ignoreAllLogs();

const HiddenTab = () => {
return <FourTabs hideOneTab />;
Expand Down Expand Up @@ -74,6 +79,7 @@ const FourTabsActiveIndicatorColor = () => {
const UnlabeledTabs = () => {
return <LabeledTabs showLabels={false} />;
};

const FourTabsRightToLeft = () => {
return <FourTabsRTL layoutDirection={'rtl'} />;
};
Expand All @@ -96,6 +102,7 @@ const examples = [
name: 'Embedded stacks',
screenOptions: { headerShown: false },
},
{ component: LazyTabs, name: 'Lazy Tabs' },
{
component: FourTabsRippleColor,
name: 'Four Tabs with ripple Color',
Expand Down Expand Up @@ -156,6 +163,10 @@ const examples = [
component: NativeBottomTabsScreenLayout,
name: 'Native Bottom Tabs with screenLayout',
},
{
component: NativeBottomTabsLazy,
name: 'Native Bottom Tabs with Lazy',
},
{ component: NativeBottomTabs, name: 'Native Bottom Tabs' },
{ component: JSBottomTabs, name: 'JS Bottom Tabs' },
{
Expand Down
47 changes: 47 additions & 0 deletions apps/example/src/Examples/LazyTabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import TabView, { SceneMap } from 'react-native-bottom-tabs';
import { useState } from 'react';
import { Article } from '../Screens/Article';
import { Albums } from '../Screens/Albums';
import { Contacts } from '../Screens/Contacts';

const renderScene = SceneMap({
article: Article,
albums: Albums,
contacts: Contacts,
});

export default function LazyTabs() {
const [index, setIndex] = useState(0);
const [routes] = useState([
{
key: 'article',
title: 'Article',
focusedIcon: require('../../assets/icons/article_dark.png'),
unfocusedIcon: require('../../assets/icons/chat_dark.png'),
badge: '!',
testID: 'articleTestID',
},
{
key: 'albums',
title: 'Albums',
focusedIcon: require('../../assets/icons/grid_dark.png'),
badge: '5',
testID: 'albumsTestID',
lazy: false,
},
{
key: 'contacts',
focusedIcon: require('../../assets/icons/person_dark.png'),
title: 'Contacts',
testID: 'contactsTestID',
},
]);

return (
<TabView
navigationState={{ index, routes }}
onIndexChange={setIndex}
renderScene={renderScene}
/>
);
}
43 changes: 43 additions & 0 deletions apps/example/src/Examples/NativeBottomTabsLazy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Article } from '../Screens/Article';
import { Albums } from '../Screens/Albums';
import { Contacts } from '../Screens/Contacts';
import { Chat } from '../Screens/Chat';
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';

const Tab = createNativeBottomTabNavigator();

export default function NativeBottomTabsLazy() {
return (
<Tab.Navigator>
<Tab.Screen
name="Article"
component={Article}
options={{
tabBarIcon: () => require('../../assets/icons/article_dark.png'),
}}
/>
<Tab.Screen
name="Albums"
component={Albums}
options={{
tabBarIcon: () => require('../../assets/icons/grid_dark.png'),
lazy: false,
}}
/>
<Tab.Screen
name="Contacts"
component={Contacts}
options={{
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
}}
/>
<Tab.Screen
name="Chat"
component={Chat}
options={{
tabBarIcon: () => require('../../assets/icons/chat_dark.png'),
}}
/>
</Tab.Navigator>
);
}
49 changes: 41 additions & 8 deletions packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
#if !os(macOS) && !os(visionOS)

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

func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if #available(iOS 27.0, *) {
// iOS 27 routes SwiftUI TabView selection through shouldSelectTab.
return true
}

#if os(iOS)
// Handle "More" Tab
if tabBarController.moreNavigationController == viewController {
Expand All @@ -21,27 +26,55 @@

if isReselectingSameTab {
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
_ = onClick?(index)
_ = onClick?(index, nil)
}

return false
}

// Unfortunately, due to iOS 26 new tab switching animations, controlling state from JavaScript is causing significant delays when switching tabs.

Check warning on line 35 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 120 characters or less; currently it has 150 characters (line_length)
// See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383
// Due to this, whether the tab prevents default has to be defined statically.
if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
let defaultPrevented = onClick?(index) ?? false
let defaultPrevented = onClick?(index, nil) ?? false

return !defaultPrevented
}

return false
}

@available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
func tabBarController(_ tabBarController: UITabBarController, shouldSelectTab tab: UITab) -> Bool {
guard #available(iOS 27.0, *) else {
return true
}

let isReselectingSameTab =
tabBarController.selectedTab === tab ||
tabBarController.selectedTab?.identifier == tab.identifier

// Unfortunately, due to iOS 26 new tab switching animations, controlling state from JavaScript is causing significant delays when switching tabs.

Check warning on line 57 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 120 characters or less; currently it has 150 characters (line_length)
// See: https://github.com/callstackincubator/react-native-bottom-tabs/issues/383
// Due to this, whether the tab prevents default has to be defined statically.
let defaultPrevented = onClick?(
tabIndex(for: tab, in: tabBarController),
tab.identifier
) ?? false

return isReselectingSameTab ? false : !defaultPrevented
}

@available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
private func tabIndex(for tab: UITab, in tabBarController: UITabBarController) -> Int? {
tabBarController.tabs.firstIndex {
$0 === tab || $0.identifier == tab.identifier

Check warning on line 71 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Anonymous Argument in Multiline Closure Violation: Use named arguments in multiline closures (anonymous_argument_in_multiline_closure)

Check warning on line 71 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Anonymous Argument in Multiline Closure Violation: Use named arguments in multiline closures (anonymous_argument_in_multiline_closure)
}
}
}

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

func body(content: Content) -> some View {
Expand All @@ -52,8 +85,8 @@
}

func handle(tabController: UITabBarController) {
delegate.onClick = { index in
onTabEvent(index, false)
delegate.onClick = { index, identifier in
onTabEvent(index, identifier, false)
}
tabController.delegate = delegate

Expand All @@ -70,8 +103,8 @@
}

// Create gesture handler
let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { key, isLongPress in _ = onTabEvent(key, isLongPress) }
let handler = LongPressGestureHandler(tabBar: tabController.tabBar) { index, isLongPress in _ = onTabEvent(index, nil, isLongPress) }

Check warning on line 106 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 120 characters or less; currently it has 137 characters (line_length)
let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:)))

Check warning on line 107 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Line Length Violation: Line should be 120 characters or less; currently it has 127 characters (line_length)
gesture.minimumPressDuration = 0.5

objc_setAssociatedObject(tabController.tabBar, &AssociatedKeys.gestureHandler, handler, .OBJC_ASSOCIATION_RETAIN)
Expand All @@ -80,7 +113,7 @@
}
}

private struct AssociatedKeys {

Check warning on line 116 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Convenience Type Violation: Types used for hosting only static members should be implemented as a caseless enum to avoid instantiation (convenience_type)
static var gestureHandler: UInt8 = 0
}

Expand All @@ -104,7 +137,7 @@
let tabBarButtons = tabBar.subviews.filter { String(describing: type(of: $0)).contains("UITabBarButton") }.sorted { $0.frame.minX < $1.frame.minX }

for (index, button) in tabBarButtons.enumerated() {
if button.frame.contains(location) {

Check warning on line 140 in packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Prefer For-Where Violation: `where` clauses are preferred over a single `if` inside a `for` (for_where)
handler(index, true)
break
}
Expand All @@ -122,7 +155,7 @@
/**
Event for tab items. Returns true if should prevent default (switching tabs).
*/
func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View {
func onTabItemEvent(_ handler: @escaping (Int?, String?, Bool) -> Bool) -> some View {
modifier(TabItemEventModifier(onTabEvent: handler))
}
}
Expand Down
5 changes: 3 additions & 2 deletions packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ struct TabViewImpl: View {
tabContent
.tabBarMinimizeBehavior(props.minimizeBehavior)
#if !os(tvOS) && !os(macOS) && !os(visionOS)
.onTabItemEvent { index, isLongPress in
let item = props.filteredItems[safe: index]
.onTabItemEvent { index, identifier, isLongPress in
let item = identifier.flatMap { props.filteredItems.findByKey($0) }
?? index.flatMap { props.filteredItems[safe: $0] }
guard let key = item?.key else { return false }

if isLongPress {
Expand Down
Loading