Skip to content
Open
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
8 changes: 4 additions & 4 deletions apps/example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1748,7 +1748,7 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
- react-native-bottom-tabs (1.0.5):
- react-native-bottom-tabs (1.1.0):
- boost
- DoubleConversion
- fast_float
Expand All @@ -1766,7 +1766,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-bottom-tabs/common (= 1.0.5)
- react-native-bottom-tabs/common (= 1.1.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
Expand All @@ -1778,7 +1778,7 @@ PODS:
- SocketRocket
- SwiftUIIntrospect (~> 1.0)
- Yoga
- react-native-bottom-tabs/common (1.0.5):
- react-native-bottom-tabs/common (1.1.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2842,7 +2842,7 @@ SPEC CHECKSUMS:
React-logger: a3cb5b29c32b8e447b5a96919340e89334062b48
React-Mapbuffer: 9d2434a42701d6144ca18f0ca1c4507808ca7696
React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b
react-native-bottom-tabs: 8e918142554e3878f043b23bdf93049b34a78ca6
react-native-bottom-tabs: e33312fc663d163f0be73d3474dfb448ba38dad8
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d
Expand Down
21 changes: 15 additions & 6 deletions apps/example/src/Examples/FourTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Article } from '../Screens/Article';
import { Albums } from '../Screens/Albums';
import { Contacts } from '../Screens/Contacts';
import { Chat } from '../Screens/Chat';
import type { ColorValue } from 'react-native';
import { Platform, type ColorValue } from 'react-native';

interface Props {
disablePageAnimations?: boolean;
Expand Down Expand Up @@ -48,22 +48,31 @@ export default function FourTabs({
badge: '5',
hidden: hideOneTab,
},

{
key: 'chat',
focusedIcon: require('../../assets/icons/chat_dark.png'),
title: 'Chat',
},
{
key: 'contacts',
focusedIcon: require('../../assets/icons/person_dark.png'),
title: 'Contacts',
badge: ' ',
},
{
key: 'chat',
focusedIcon: require('../../assets/icons/chat_dark.png'),
title: 'Chat',
role: 'search',
searchable: true,
navigationBarToolbarStyle:
Platform.Version === 26 || Platform.Version === '26.0'
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The version check uses Platform.Version === 26 || Platform.Version === '26.0' which checks for both number and string. On iOS, Platform.Version is always a string, so the numeric comparison === 26 will never be true. Only the string comparison is needed.

Suggested change
Platform.Version === 26 || Platform.Version === '26.0'
Platform.Version === '26.0'

Copilot uses AI. Check for mistakes.
? 'hidden'
: 'visible',
},
]);

return (
<TabView
onSearchFocusChange={(isFocused) => console.log('isFocused', isFocused)}
sidebarAdaptable
onSearchTextChange={(text) => console.log(text)}
disablePageAnimations={disablePageAnimations}
scrollEdgeAppearance={scrollEdgeAppearance}
navigationState={{ index, routes }}
Expand Down
27 changes: 19 additions & 8 deletions apps/example/src/Examples/NativeBottomTabsEmbeddedStacks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Contacts } from '../Screens/Contacts';
import { Chat } from '../Screens/Chat';
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Platform } from 'react-native';

const headerOptions = {
headerShown: true,
Expand Down Expand Up @@ -67,7 +68,10 @@ function ChatStackScreen() {

function NativeBottomTabsEmbeddedStacks() {
return (
<Tab.Navigator sidebarAdaptable>
<Tab.Navigator
onSearchTextChange={(text) => console.log(text)}
onSearchFocusChange={(isFocused) => console.log('isFocused', isFocused)}
>
<Tab.Screen
name="Article"
component={ArticleStackScreen}
Expand All @@ -83,13 +87,7 @@ function NativeBottomTabsEmbeddedStacks() {
tabBarIcon: () => require('../../assets/icons/grid_dark.png'),
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsStackScreen}
options={{
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
}}
/>

<Tab.Screen
name="Chat"
component={ChatStackScreen}
Expand All @@ -98,6 +96,19 @@ function NativeBottomTabsEmbeddedStacks() {
require('../../assets/icons/message-circle-code.svg'),
}}
/>
<Tab.Screen
name="Contacts"
component={ContactsStackScreen}
options={{
role: 'search',
tabBarIcon: () => require('../../assets/icons/person_dark.png'),
searchable: true,
navigationBarToolbarStyle:
Platform.Version === 26 || Platform.Version === '26.0'
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The version check uses Platform.Version === 26 || Platform.Version === '26.0' which checks for both number and string. On iOS, Platform.Version is always a string, so the numeric comparison === 26 will never be true. Only the string comparison is needed.

Suggested change
Platform.Version === 26 || Platform.Version === '26.0'
Platform.Version === '26.0'

Copilot uses AI. Check for mistakes.
? 'hidden'
: 'visible',
}}
/>
</Tab.Navigator>
);
}
Expand Down
25 changes: 23 additions & 2 deletions packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &
hidden:item.hidden
testID:RCTNSStringFromStringNilIfEmpty(item.testID)
role:RCTNSStringFromStringNilIfEmpty(item.role)
preventsDefault:item.preventsDefault
preventsDefault:item.preventsDefault
searchable:item.searchable
navigationBarToolbarStyle:RCTNSStringFromStringNilIfEmpty(item.navigationBarToolbarStyle)
];

[result addObject:tabInfo];
Expand All @@ -210,7 +212,8 @@ - (void)updateState:(const facebook::react::State::Shared &)state oldState:(cons
}
}

// MARK: TabViewProviderDelegate

// MARK: TabViewProviderDelegate
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

Duplicate comment line with inconsistent formatting. The comment "// MARK: TabViewProviderDelegate" has extra spaces before "MARK:" compared to standard Swift conventions which typically use "// MARK:" with a single space.

Suggested change
// MARK: TabViewProviderDelegate
// MARK: TabViewProviderDelegate

Copilot uses AI. Check for mistakes.

- (void)onPageSelectedWithKey:(NSString *)key reactTag:(NSNumber *)reactTag {
auto eventEmitter = std::static_pointer_cast<const RNCTabViewEventEmitter>(_eventEmitter);
Expand All @@ -221,6 +224,24 @@ - (void)onPageSelectedWithKey:(NSString *)key reactTag:(NSNumber *)reactTag {
}
}

- (void)onSearchFocusChangeWithIsFocused:(BOOL)isFocused reactTag:(NSNumber *)reactTag{
auto eventEmitter = std::static_pointer_cast<const RNCTabViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
eventEmitter->onSearchFocusChange(RNCTabViewEventEmitter::OnSearchFocusChange{
.isFocused = isFocused
});
}
}
- (void)onSearchTextChangeWithText:(NSString * _Nonnull)text reactTag:(NSNumber * _Nullable)reactTag {
auto eventEmitter = std::static_pointer_cast<const RNCTabViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
eventEmitter->onSearchTextChange(RNCTabViewEventEmitter::OnSearchTextChange{
.text = [text cStringUsingEncoding:NSUTF8StringEncoding]
});
}
}


- (void)onLongPressWithKey:(NSString *)key reactTag:(NSNumber *)reactTag {
auto eventEmitter = std::static_pointer_cast<const RNCTabViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import Foundation
import SwiftUI

/**
Helper used to render UIViewController inside of SwiftUI.
This solves issues in some cases that can't found root UINavigationController.
*/
struct RepresentableViewController: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {

}


var view: PlatformView

#if os(macOS)

func makeNSView(context: Context) -> PlatformView {
let wrapper = NSView()
wrapper.addSubview(view)
return wrapper
}

func updateNSView(_ nsView: PlatformView, context: Context) {}

#else

func makeUIView(context: Context) -> PlatformView {
let wrapper = UIView()
wrapper.addSubview(view)
return wrapper
}
func makeUIViewController(context: Context) -> UIViewController {
let contentVC = UIViewController()
contentVC.view.backgroundColor = .clear
contentVC.view.addSubview(view)

return contentVC
}
func updateUIView(_ uiView: PlatformView, context: Context) {}
Comment on lines +28 to +40
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The makeUIView function is defined but never called for iOS builds. The protocol UIViewControllerRepresentable should only implement makeUIViewController and updateUIViewController on iOS. The makeUIView and updateUIView methods are for UIViewRepresentable, not UIViewControllerRepresentable.

Copilot uses AI. Check for mistakes.

#endif
}
Comment on lines +8 to +43
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

Missing implementation for macOS. The struct declares itself as UIViewControllerRepresentable which doesn't exist on macOS (should be NSViewControllerRepresentable). The macOS section implements view-related methods but the struct protocol is for view controllers.

Suggested change
struct RepresentableViewController: UIViewControllerRepresentable {
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
var view: PlatformView
#if os(macOS)
func makeNSView(context: Context) -> PlatformView {
let wrapper = NSView()
wrapper.addSubview(view)
return wrapper
}
func updateNSView(_ nsView: PlatformView, context: Context) {}
#else
func makeUIView(context: Context) -> PlatformView {
let wrapper = UIView()
wrapper.addSubview(view)
return wrapper
}
func makeUIViewController(context: Context) -> UIViewController {
let contentVC = UIViewController()
contentVC.view.backgroundColor = .clear
contentVC.view.addSubview(view)
return contentVC
}
func updateUIView(_ uiView: PlatformView, context: Context) {}
#endif
}
#if os(macOS)
struct RepresentableViewController: NSViewRepresentable {
var view: PlatformView
func makeNSView(context: Context) -> PlatformView {
let wrapper = NSView()
wrapper.addSubview(view)
return wrapper
}
func updateNSView(_ nsView: PlatformView, context: Context) {}
}
#else
struct RepresentableViewController: UIViewControllerRepresentable {
var view: PlatformView
func makeUIView(context: Context) -> PlatformView {
let wrapper = UIView()
wrapper.addSubview(view)
return wrapper
}
func updateUIView(_ uiView: PlatformView, context: Context) {}
func makeUIViewController(context: Context) -> UIViewController {
let contentVC = UIViewController()
contentVC.view.backgroundColor = .clear
contentVC.view.addSubview(view)
return contentVC
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
}
}
#endif

Copilot uses AI. Check for mistakes.
38 changes: 32 additions & 6 deletions packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import React
import SwiftUI

@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
struct NewTabView: AnyTabView {
struct NewTabView: AnyTabView {
@ObservedObject var props: TabViewProps

var onLayout: (CGSize) -> Void
var onSelect: (String) -> Void
var onSearchTextChange: ((String) -> Void)
var onSearchFocusChange: ((Bool) -> Void)
var updateTabBarAppearance: () -> Void
@FocusState var focused: Bool
@State var query = ""
Comment on lines +12 to +13
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The @State variable query and @FocusState variable focused are shared across all tabs in the TabView. This means when switching between tabs, the search query and focus state will persist across different tabs rather than being tab-specific. Each searchable tab should maintain its own search state.

Copilot uses AI. Check for mistakes.

@ViewBuilder
var body: some View {
Expand All @@ -29,10 +32,33 @@ struct NewTabView: AnyTabView {
)

Tab(value: tabData.key, role: tabData.role?.convert()) {
RepresentableView(view: child.view)
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
.hideTabBar(props.tabBarHidden)
//Have to wrap in NavigationView to use searchable
if(tabData.searchable){
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

Missing space after if keyword. Should be if (tabData.searchable) have a space as if (tabData.searchable) to match Swift formatting conventions (though Swift typically doesn't require parentheses around conditions).

Suggested change
if(tabData.searchable){
if tabData.searchable {

Copilot uses AI. Check for mistakes.
NavigationView{
//If it is not wrapped in UIViewController, it will crash.
Comment on lines +35 to +38
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The comment states "Have to wrap in NavigationView to use searchable" but doesn't explain why wrapping in UIViewController is required. The comment on line 38 mentions it will crash but doesn't provide context about what causes the crash or under what conditions.

Suggested change
//Have to wrap in NavigationView to use searchable
if(tabData.searchable){
NavigationView{
//If it is not wrapped in UIViewController, it will crash.
// `.searchable` renders the search field in the navigation bar, so the content
// must be placed inside a `NavigationView` for the search UI to appear correctly.
if(tabData.searchable){
NavigationView{
// The React Native root view is a UIKit `UIView`. When used as the root content
// of a `NavigationView` with `.searchable`, embedding the `UIView` directly
// (without wrapping it in a `UIViewController`) causes a runtime crash on iOS.
// `RepresentableViewController` wraps the `UIView` in a `UIViewController`
// to satisfy SwiftUI's expectations for the navigation/search container.

Copilot uses AI. Check for mistakes.
RepresentableViewController(view: child.view)
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
.hideTabBar(props.tabBarHidden)
.toolbar(tabData.navigationBarToolbarStyle.convert(), for: .navigationBar)

.searchFocused($focused)
.onChange(of: focused){ newValue in
onSearchFocusChange(newValue)
}
.onChange(of: query) { newValue in
onSearchTextChange(newValue)
}

}.navigationViewStyle(StackNavigationViewStyle())
.searchable(text: $query)
Comment on lines +44 to +54
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

The .searchFocused($focused) modifier is applied to RepresentableViewController but should be applied to the NavigationView or a level that encompasses the searchable content. The current placement may not work as expected with the search functionality.

Suggested change
.searchFocused($focused)
.onChange(of: focused){ newValue in
onSearchFocusChange(newValue)
}
.onChange(of: query) { newValue in
onSearchTextChange(newValue)
}
}.navigationViewStyle(StackNavigationViewStyle())
.searchable(text: $query)
}
.navigationViewStyle(StackNavigationViewStyle())
.searchable(text: $query)
.searchFocused($focused)
.onChange(of: focused){ newValue in
onSearchFocusChange(newValue)
}
.onChange(of: query) { newValue in
onSearchTextChange(newValue)
}

Copilot uses AI. Check for mistakes.
}else{
Copy link

Copilot AI Dec 23, 2025

Choose a reason for hiding this comment

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

Missing space after else keyword. Should format as } else { on a single line or properly indent the else block to match Swift formatting conventions.

Suggested change
}else{
} else {

Copilot uses AI. Check for mistakes.
RepresentableView(view: child.view)
.ignoresSafeArea(.container, edges: .all)
.tabAppear(using: context)
.hideTabBar(props.tabBarHidden)
}

} label: {
TabItem(
title: tabData.title,
Expand Down
8 changes: 6 additions & 2 deletions packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ struct TabViewImpl: View {
NewTabView(
props: props,
onLayout: onLayout,
onSelect: onSelect
onSelect: onSelect,
onSearchTextChange: onSearchTextChange,
onSearchFocusChange: onSearchFocusChange,
) {
#if !os(macOS)
updateTabBarAppearance(props: props, tabBar: tabBar)
Expand All @@ -36,11 +38,13 @@ struct TabViewImpl: View {
}
}
}

var onSelect: (_ key: String) -> Void
var onLongPress: (_ key: String) -> Void
var onLayout: (_ size: CGSize) -> Void
var onTabBarMeasured: (_ height: Int) -> Void
var onSearchTextChange: (_ text: String) -> Void
var onSearchFocusChange: (_ focused: Bool) -> Void

var body: some View {
tabContent
Expand Down
18 changes: 18 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabViewProps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,24 @@ public enum TabBarRole: String {
}
}

public enum ToolbarStyle: String {
case automatic
case hidden
case visible

@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
func convert() -> Visibility {
switch self {
case .automatic:
return .automatic
case .hidden:
return .hidden
case .visible:
return .visible
}
}
}

struct IdentifiablePlatformView: Identifiable, Equatable {
let id = UUID()
let view: PlatformView
Expand Down
Loading