Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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 (0.11.2):
- react-native-bottom-tabs (0.12.0):
- boost
- DoubleConversion
- fast_float
Expand All @@ -1766,7 +1766,7 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-bottom-tabs/common (= 0.11.2)
- react-native-bottom-tabs/common (= 0.12.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
Expand All @@ -1778,7 +1778,7 @@ PODS:
- SocketRocket
- SwiftUIIntrospect (~> 1.0)
- Yoga
- react-native-bottom-tabs/common (0.11.2):
- react-native-bottom-tabs/common (0.12.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: d71dd2e1b69f11d3ed2da2db23016ebdc77f4ba1
react-native-bottom-tabs: f068aaf76d89f04627dd80af56dde68efa6dd507
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d
Expand Down
6 changes: 6 additions & 0 deletions apps/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import NativeBottomTabsRemoteIcons from './Examples/NativeBottomTabsRemoteIcons'
import NativeBottomTabsUnmounting from './Examples/NativeBottomTabsUnmounting';
import NativeBottomTabsCustomTabBar from './Examples/NativeBottomTabsCustomTabBar';
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';
import BottomAccessoryView from './Examples/BottomAccessoryView';

const HiddenTab = () => {
return <FourTabs hideOneTab />;
Expand Down Expand Up @@ -150,6 +151,11 @@ const examples = [
},
{ component: MaterialBottomTabs, name: 'Material (JS) Bottom Tabs' },
{ component: TintColorsExample, name: 'Tint Colors' },
{
component: BottomAccessoryView,
name: 'Bottom Accessory View',
screenOptions: { headerShown: false },
},
];

function App() {
Expand Down
71 changes: 71 additions & 0 deletions apps/example/src/Examples/BottomAccessoryView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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';
import { Text, View, type TextStyle, type ViewStyle } from 'react-native';

const bottomAccessoryViewStyle: ViewStyle = {
width: '100%',
height: '100%',
justifyContent: 'center',
alignItems: 'center',
};

const textStyle: TextStyle = { textAlign: 'center' };

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

export default function BottomAccessoryView() {
const [index, setIndex] = useState(0);
const [routes] = useState([
{
key: 'article',
title: 'Article',
focusedIcon: require('../../assets/icons/article_dark.png'),
badge: '!',
},
{
key: 'albums',
title: 'Albums',
focusedIcon: require('../../assets/icons/grid_dark.png'),
badge: '5',
},
{
key: 'contacts',
focusedIcon: require('../../assets/icons/person_dark.png'),
title: 'Contacts',
role: 'search',
},
]);

const [bottomAccessoryDimensions, setBottomAccessoryDimensions] = useState({
width: 0,
height: 0,
});

return (
<TabView
sidebarAdaptable
minimizeBehavior="onScrollDown"
navigationState={{ index, routes }}
onIndexChange={setIndex}
renderScene={renderScene}
renderBottomAccessoryView={({ placement }) => (
<View
style={bottomAccessoryViewStyle}
onLayout={(e) => setBottomAccessoryDimensions(e.nativeEvent.layout)}
>
<Text style={textStyle}>
Placement: {placement}. Dimensions:{' '}
{bottomAccessoryDimensions.width}x{bottomAccessoryDimensions.height}
</Text>
</View>
)}
/>
);
}
8 changes: 8 additions & 0 deletions docs/docs/docs/guides/standalone-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ Color of tab indicator.

- Type: `ColorValue`

#### `renderBottomAccessoryView` <Badge text="iOS" type="info" />
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's add <Badge text="experimental" type="danger"/> here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link

Choose a reason for hiding this comment

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

Bug: Experimental Badge Missing

Missing experimental badge in documentation. The PR reviewer explicitly requested adding <Badge text="experimental" type="danger"/> to this section, but only the iOS badge was added. The feature was intended to be marked as experimental.

Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

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

Bug: Experimental Badge Missing

Missing experimental badge. According to the user instructions, this should include <Badge text="experimental" type="danger"/> in addition to the iOS badge, but only the iOS badge is present.

Fix in Cursor Fix in Web


Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory).

:::note
This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored.
:::

### Route Configuration

Each route in the `routes` array can have the following properties:
Expand Down
6 changes: 6 additions & 0 deletions docs/docs/docs/guides/usage-with-react-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,13 @@ function MyTabs() {
);
}
```
#### `renderBottomAccessoryView` <Badge text="iOS" type="info" />
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here let's add here

Copy link
Contributor Author

Choose a reason for hiding this comment

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


Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory).

:::note
This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored.
:::

### Options

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#ifdef RCT_NEW_ARCH_ENABLED
#import <React/RCTViewComponentView.h>
#if TARGET_OS_OSX
#import <AppKit/AppKit.h>
#else
#import <UIKit/UIKit.h>
#endif

NS_ASSUME_NONNULL_BEGIN

@interface RCTBottomAccessoryComponentView: RCTViewComponentView

- (void)emitOnPlacementChanged:(NSString *)placement;

@end

NS_ASSUME_NONNULL_END

#endif /* RCTBottomAccessoryComponentView_h */
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#ifdef RCT_NEW_ARCH_ENABLED
#import "RCTBottomAccessoryComponentView.h"

#import <react/renderer/components/RNCTabView/ComponentDescriptors.h>
#import <react/renderer/components/RNCTabView/EventEmitters.h>
#import <react/renderer/components/RNCTabView/Props.h>
#import <react/renderer/components/RNCTabView/RCTComponentViewHelpers.h>

#import <React/RCTFabricComponentsPlugins.h>

#if __has_include("react_native_bottom_tabs/react_native_bottom_tabs-Swift.h")
#import "react_native_bottom_tabs/react_native_bottom_tabs-Swift.h"
#else
#import "react_native_bottom_tabs-Swift.h"
#endif

using namespace facebook::react;

@implementation RCTBottomAccessoryComponentView

+ (ComponentDescriptorProvider)componentDescriptorProvider
{
return concreteComponentDescriptorProvider<BottomAccessoryViewComponentDescriptor>();
}

- (instancetype)initWithFrame:(CGRect)frame
{
if (self = [super initWithFrame:frame]) {
static const auto defaultProps = std::make_shared<const BottomAccessoryViewProps>();
}

return self;
}

- (void)setFrame:(CGRect)frame
{
[super setFrame:frame];
auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
eventEmitter->onNativeLayout(BottomAccessoryViewEventEmitter::OnNativeLayout {
.height = frame.size.height,
.width = frame.size.width
});
Comment on lines +49 to +52
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's add a TODO here, TODO: Rewrite this to emit synchronous layout events using shadow nodes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}
}

- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
{
[super updateProps:props oldProps:oldProps];
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is not necessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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


- (void)emitOnPlacementChanged:(NSString *)placement {
auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
eventEmitter->onPlacementChanged(BottomAccessoryViewEventEmitter::OnPlacementChanged {
.placement = std::string([placement UTF8String])
});
}
}


Class<RCTComponentViewProtocol> BottomAccessoryViewCls(void)
{
return RCTBottomAccessoryComponentView.class;
}

@end

#endif
71 changes: 71 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React
import SwiftUI

@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
Expand Down Expand Up @@ -51,5 +52,75 @@ struct NewTabView: AnyTabView {
.measureView { size in
onLayout(size)
}
.modifier(ConditionalBottomAccessoryModifier(props: props))
}
}

struct ConditionalBottomAccessoryModifier: ViewModifier {
let props: TabViewProps

// Check if there's a bottom accessory component view
private var hasBottomAccessory: Bool {
props.children.contains { child in
let className = String(describing: type(of: child.view))
return className == "RCTBottomAccessoryComponentView"
}
}

// Find the bottom accessory view
private var bottomAccessoryView: PlatformView? {
props.children.first { child in
let className = String(describing: type(of: child.view))
return className == "RCTBottomAccessoryComponentView"
}?.view
}

func body(content: Content) -> some View {
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, visionOS 3.0, *), hasBottomAccessory {
content
.tabViewBottomAccessory {
renderBottomAccessoryView()
}
} else {
content
}
}

@ViewBuilder
private func renderBottomAccessoryView() -> some View {
if let bottomAccessoryView {
if #available(iOS 26.0, *) {
BottomAccessoryRepresentableView(view: bottomAccessoryView)
}
}
}
}

@available(iOS 26.0, *)
struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
var view: PlatformView

func makeUIView(context: Context) -> PlatformView {
emitPlacementChanged(for: view)
return view
}

func updateUIView(_ uiView: PlatformView, context: Context) {
emitPlacementChanged(for: view)
}

private func emitPlacementChanged(for uiView: PlatformView) {
let selectorString = "emitOnPlacementChanged:"
let selector = NSSelectorFromString(selectorString)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this could be solved by a shared protocol between RCTBottomAccessoryView and NewTabView this way we would have type safety

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure! I will do that asap

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The commit above introduced a bug making it impossible to press pressable items in the accessory view. So I need to look into that. Do you know what could have caused this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It is adding the BottomAccessoryProvider as contentView to RCTBottomAccessoryComponentView that causes this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Solved it in 8dfd717

if uiView.responds(to: selector) {
var placementValue = "none"
if tabViewBottomAccessoryPlacement == .inline {
placementValue = "inline"
} else if tabViewBottomAccessoryPlacement == .expanded {
placementValue = "expanded"
}
uiView.perform(selector, with: placementValue)
}
Copy link

Choose a reason for hiding this comment

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

Bug: KVC Access Fails for Private Variable

The BottomAccessoryRepresentableView tries to access bottomAccessoryProvider via KVC, but the underlying Objective-C view's bottomAccessoryProvider is a private instance variable, not a KVC-compliant property. This prevents emitPlacementChanged from being called, so placement change events are not emitted.

Fix in Cursor Fix in Web

}
}
3 changes: 2 additions & 1 deletion packages/react-native-bottom-tabs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@
},
"ios": {
"componentProvider": {
"RNCTabView": "RCTTabViewComponentView"
"RNCTabView": "RCTTabViewComponentView",
"BottomAccessoryView": "RCTBottomAccessoryComponentView"
},
"modulesConformingToProtocol": {
"RCTImageDataDecoder": [
Expand Down
59 changes: 59 additions & 0 deletions packages/react-native-bottom-tabs/src/BottomAccessoryView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import React from 'react';
import type { DimensionValue, ViewStyle } from 'react-native';
import BottomAccessoryViewNativeComponent, {
type OnNativeLayout,
type OnPlacementChanged,
} from './BottomAccessoryViewNativeComponent';

const defaultStyle: ViewStyle = {
position: 'absolute',
top: 0,
left: 0,
};

export interface BottomAccessoryViewProps {
renderBottomAccessoryView: (props: {
placement: 'inline' | 'expanded' | 'none';
}) => React.ReactNode;
}

export const BottomAccessoryView = (props: BottomAccessoryViewProps) => {
const { renderBottomAccessoryView } = props;
const [bottomAccessoryDimensions, setBottomAccessoryDimensions] =
React.useState<
{ width: DimensionValue; height: DimensionValue } | undefined
>({ width: '100%', height: '100%' });
const [placement, setPlacement] = React.useState<
'inline' | 'expanded' | 'none'
>('none');

const handleNativeLayout = React.useCallback(
({ nativeEvent: { width, height } }: { nativeEvent: OnNativeLayout }) => {
setBottomAccessoryDimensions({ width, height });
},
[setBottomAccessoryDimensions]
);

const handlePlacementChanged = React.useCallback(
({ nativeEvent }: { nativeEvent: OnPlacementChanged }) => {
if (
nativeEvent.placement === 'inline' ||
nativeEvent.placement === 'expanded' ||
nativeEvent.placement === 'none'
) {
setPlacement(nativeEvent.placement);
}
},
[setPlacement]
);

return (
<BottomAccessoryViewNativeComponent
style={[defaultStyle, bottomAccessoryDimensions]}
onNativeLayout={handleNativeLayout}
onPlacementChanged={handlePlacementChanged}
>
{renderBottomAccessoryView({ placement })}
</BottomAccessoryViewNativeComponent>
);
};
Loading
Loading