Skip to content

Commit e419b58

Browse files
feat(iOS): implement bottom accessory view (#446)
* create RCTBottomAccessoryComponentView * create BottomAccessoryRepresentableView * emitOnPlacementChanged * remove some comments * create BottomAccessoryView and proper prop types * create example * add dimensions * hide header in example * add docs * cleanup example * lint * swiftlint * syntax change * add a wrapper view in BottomAccessoryRepresentableView * refactor to a BottomAccessoryProvider * swiftlint * make BottomAccessoryProvider to a NSObject and have it as prop * props observedobject in ConditionalBottomAccessoryModifier * always render bottomaccessoryview * chore: add changes * chore: changes after latest review --------- Co-authored-by: Oskar Kwaśniewski <[email protected]>
1 parent 9673d46 commit e419b58

File tree

14 files changed

+390
-5
lines changed

14 files changed

+390
-5
lines changed

.changeset/brown-clocks-worry.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"react-native-bottom-tabs": patch
3+
"react-native-bottom-tabs-example": patch
4+
"react-native-bottom-tabs-docs": patch
5+
---
6+
7+
feat(iOS): [experimental] implement bottom accessory view

apps/example/ios/Podfile.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,7 +1748,7 @@ PODS:
17481748
- React-RCTFBReactNativeSpec
17491749
- ReactCommon/turbomodule/core
17501750
- SocketRocket
1751-
- react-native-bottom-tabs (0.11.2):
1751+
- react-native-bottom-tabs (0.12.0):
17521752
- boost
17531753
- DoubleConversion
17541754
- fast_float
@@ -1766,7 +1766,7 @@ PODS:
17661766
- React-graphics
17671767
- React-ImageManager
17681768
- React-jsi
1769-
- react-native-bottom-tabs/common (= 0.11.2)
1769+
- react-native-bottom-tabs/common (= 0.12.0)
17701770
- React-NativeModulesApple
17711771
- React-RCTFabric
17721772
- React-renderercss
@@ -1778,7 +1778,7 @@ PODS:
17781778
- SocketRocket
17791779
- SwiftUIIntrospect (~> 1.0)
17801780
- Yoga
1781-
- react-native-bottom-tabs/common (0.11.2):
1781+
- react-native-bottom-tabs/common (0.12.0):
17821782
- boost
17831783
- DoubleConversion
17841784
- fast_float
@@ -2842,7 +2842,7 @@ SPEC CHECKSUMS:
28422842
React-logger: a3cb5b29c32b8e447b5a96919340e89334062b48
28432843
React-Mapbuffer: 9d2434a42701d6144ca18f0ca1c4507808ca7696
28442844
React-microtasksnativemodule: 75b6604b667d297292345302cc5bfb6b6aeccc1b
2845-
react-native-bottom-tabs: d71dd2e1b69f11d3ed2da2db23016ebdc77f4ba1
2845+
react-native-bottom-tabs: f068aaf76d89f04627dd80af56dde68efa6dd507
28462846
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
28472847
React-NativeModulesApple: 879fbdc5dcff7136abceb7880fe8a2022a1bd7c3
28482848
React-oscompat: 93b5535ea7f7dff46aaee4f78309a70979bdde9d

apps/example/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import NativeBottomTabsRemoteIcons from './Examples/NativeBottomTabsRemoteIcons'
3030
import NativeBottomTabsUnmounting from './Examples/NativeBottomTabsUnmounting';
3131
import NativeBottomTabsCustomTabBar from './Examples/NativeBottomTabsCustomTabBar';
3232
import NativeBottomTabsFreezeOnBlur from './Examples/NativeBottomTabsFreezeOnBlur';
33+
import BottomAccessoryView from './Examples/BottomAccessoryView';
3334

3435
const HiddenTab = () => {
3536
return <FourTabs hideOneTab />;
@@ -150,6 +151,11 @@ const examples = [
150151
},
151152
{ component: MaterialBottomTabs, name: 'Material (JS) Bottom Tabs' },
152153
{ component: TintColorsExample, name: 'Tint Colors' },
154+
{
155+
component: BottomAccessoryView,
156+
name: 'Bottom Accessory View',
157+
screenOptions: { headerShown: false },
158+
},
153159
];
154160

155161
function App() {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
import { Text, View, type TextStyle, type ViewStyle } from 'react-native';
7+
8+
const bottomAccessoryViewStyle: ViewStyle = {
9+
width: '100%',
10+
height: '100%',
11+
justifyContent: 'center',
12+
alignItems: 'center',
13+
};
14+
15+
const textStyle: TextStyle = { textAlign: 'center' };
16+
17+
const renderScene = SceneMap({
18+
article: Article,
19+
albums: Albums,
20+
contacts: Contacts,
21+
});
22+
23+
export default function BottomAccessoryView() {
24+
const [index, setIndex] = useState(0);
25+
const [routes] = useState([
26+
{
27+
key: 'article',
28+
title: 'Article',
29+
focusedIcon: require('../../assets/icons/article_dark.png'),
30+
badge: '!',
31+
},
32+
{
33+
key: 'albums',
34+
title: 'Albums',
35+
focusedIcon: require('../../assets/icons/grid_dark.png'),
36+
badge: '5',
37+
},
38+
{
39+
key: 'contacts',
40+
focusedIcon: require('../../assets/icons/person_dark.png'),
41+
title: 'Contacts',
42+
role: 'search',
43+
},
44+
]);
45+
46+
const [bottomAccessoryDimensions, setBottomAccessoryDimensions] = useState({
47+
width: 0,
48+
height: 0,
49+
});
50+
51+
return (
52+
<TabView
53+
sidebarAdaptable
54+
minimizeBehavior="onScrollDown"
55+
navigationState={{ index, routes }}
56+
onIndexChange={setIndex}
57+
renderScene={renderScene}
58+
renderBottomAccessoryView={({ placement }) => (
59+
<View
60+
style={bottomAccessoryViewStyle}
61+
onLayout={(e) => setBottomAccessoryDimensions(e.nativeEvent.layout)}
62+
>
63+
<Text style={textStyle}>
64+
Placement: {placement}. Dimensions:{' '}
65+
{bottomAccessoryDimensions.width}x{bottomAccessoryDimensions.height}
66+
</Text>
67+
</View>
68+
)}
69+
/>
70+
);
71+
}

docs/docs/docs/guides/standalone-usage.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ Color of tab indicator.
200200

201201
- Type: `ColorValue`
202202

203+
#### `renderBottomAccessoryView` <Badge text="iOS" type="info" /> <Badge text="experimental" type="danger"/>
204+
205+
Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory).
206+
207+
:::note
208+
This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored.
209+
:::
210+
203211
### Route Configuration
204212

205213
Each route in the `routes` array can have the following properties:

docs/docs/docs/guides/usage-with-react-navigation.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,13 @@ function MyTabs() {
216216
);
217217
}
218218
```
219+
#### `renderBottomAccessoryView` <Badge text="iOS" type="info" /> <Badge text="experimental" type="danger"/>
219220

221+
Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory).
222+
223+
:::note
224+
This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored.
225+
:::
220226

221227
### Options
222228

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import SwiftUI
2+
3+
@objc public class BottomAccessoryProvider: NSObject {
4+
private weak var delegate: BottomAccessoryProviderDelegate?
5+
6+
@objc public convenience init(delegate: BottomAccessoryProviderDelegate) {
7+
self.init()
8+
self.delegate = delegate
9+
}
10+
11+
@available(iOS 26.0, *)
12+
public func emitPlacementChanged(_ placement: TabViewBottomAccessoryPlacement?) {
13+
var placementValue = "none"
14+
if placement == .inline {
15+
placementValue = "inline"
16+
} else if placement == .expanded {
17+
placementValue = "expanded"
18+
}
19+
self.delegate?.onPlacementChanged(placement: placementValue)
20+
}
21+
}
22+
23+
@objc public protocol BottomAccessoryProviderDelegate {
24+
func onPlacementChanged(placement: String)
25+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#ifdef RCT_NEW_ARCH_ENABLED
2+
#import <React/RCTViewComponentView.h>
3+
#if TARGET_OS_OSX
4+
#import <AppKit/AppKit.h>
5+
#else
6+
#import <UIKit/UIKit.h>
7+
#endif
8+
9+
NS_ASSUME_NONNULL_BEGIN
10+
11+
@interface RCTBottomAccessoryComponentView: RCTViewComponentView
12+
13+
@end
14+
15+
NS_ASSUME_NONNULL_END
16+
17+
#endif /* RCTBottomAccessoryComponentView_h */
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#ifdef RCT_NEW_ARCH_ENABLED
2+
#import "RCTBottomAccessoryComponentView.h"
3+
4+
#import <react/renderer/components/RNCTabView/ComponentDescriptors.h>
5+
#import <react/renderer/components/RNCTabView/EventEmitters.h>
6+
#import <react/renderer/components/RNCTabView/Props.h>
7+
#import <react/renderer/components/RNCTabView/RCTComponentViewHelpers.h>
8+
9+
#import <React/RCTFabricComponentsPlugins.h>
10+
11+
#if __has_include("react_native_bottom_tabs/react_native_bottom_tabs-Swift.h")
12+
#import "react_native_bottom_tabs/react_native_bottom_tabs-Swift.h"
13+
#else
14+
#import "react_native_bottom_tabs-Swift.h"
15+
#endif
16+
17+
using namespace facebook::react;
18+
19+
@interface RCTBottomAccessoryComponentView () <BottomAccessoryProviderDelegate> {
20+
BottomAccessoryProvider* bottomAccessoryProvider;
21+
}
22+
@end
23+
24+
@implementation RCTBottomAccessoryComponentView
25+
26+
+ (ComponentDescriptorProvider)componentDescriptorProvider
27+
{
28+
return concreteComponentDescriptorProvider<BottomAccessoryViewComponentDescriptor>();
29+
}
30+
31+
- (instancetype)initWithFrame:(CGRect)frame
32+
{
33+
if (self = [super initWithFrame:frame]) {
34+
static const auto defaultProps = std::make_shared<const BottomAccessoryViewProps>();
35+
if (@available(iOS 26.0, *)) {
36+
bottomAccessoryProvider = [[BottomAccessoryProvider alloc] initWithDelegate:self];
37+
}
38+
}
39+
40+
return self;
41+
}
42+
43+
- (void)setFrame:(CGRect)frame
44+
{
45+
[super setFrame:frame];
46+
auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter);
47+
if (eventEmitter) {
48+
TODO: Rewrite this to emit synchronous layout events using shadow nodes
49+
eventEmitter->onNativeLayout(BottomAccessoryViewEventEmitter::OnNativeLayout {
50+
.height = frame.size.height,
51+
.width = frame.size.width
52+
});
53+
}
54+
}
55+
56+
// MARK: BottomAccessoryProviderDelegate
57+
58+
- (void)onPlacementChangedWithPlacement:(NSString *)placement
59+
{
60+
auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter);
61+
if (eventEmitter) {
62+
eventEmitter->onPlacementChanged(BottomAccessoryViewEventEmitter::OnPlacementChanged {
63+
.placement = std::string([placement UTF8String])
64+
});
65+
}
66+
}
67+
68+
69+
Class<RCTComponentViewProtocol> BottomAccessoryViewCls(void)
70+
{
71+
return RCTBottomAccessoryComponentView.class;
72+
}
73+
74+
@end
75+
76+
#endif

packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import React
12
import SwiftUI
23

34
@available(iOS 18, macOS 15, visionOS 2, tvOS 18, *)
@@ -51,5 +52,66 @@ struct NewTabView: AnyTabView {
5152
.measureView { size in
5253
onLayout(size)
5354
}
55+
.modifier(ConditionalBottomAccessoryModifier(props: props))
56+
}
57+
}
58+
59+
struct ConditionalBottomAccessoryModifier: ViewModifier {
60+
@ObservedObject var props: TabViewProps
61+
62+
private var bottomAccessoryView: PlatformView? {
63+
props.children.first { child in
64+
let className = String(describing: type(of: child.view))
65+
return className == "RCTBottomAccessoryComponentView"
66+
}?.view
67+
}
68+
69+
func body(content: Content) -> some View {
70+
if #available(iOS 26.0, macOS 26.0, tvOS 26.0, visionOS 3.0, *) {
71+
content
72+
.tabViewBottomAccessory {
73+
renderBottomAccessoryView()
74+
}
75+
} else {
76+
content
77+
}
78+
}
79+
80+
@ViewBuilder
81+
private func renderBottomAccessoryView() -> some View {
82+
if let bottomAccessoryView {
83+
if #available(iOS 26.0, *) {
84+
BottomAccessoryRepresentableView(view: bottomAccessoryView)
85+
}
86+
}
87+
}
88+
}
89+
90+
@available(iOS 26.0, *)
91+
struct BottomAccessoryRepresentableView: PlatformViewRepresentable {
92+
@Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement
93+
var view: PlatformView
94+
95+
func makeUIView(context: Context) -> PlatformView {
96+
let wrapper = UIView()
97+
wrapper.addSubview(view)
98+
99+
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
100+
101+
emitPlacementChanged(for: view)
102+
return wrapper
103+
}
104+
105+
func updateUIView(_ uiView: PlatformView, context: Context) {
106+
if let subview = uiView.subviews.first {
107+
subview.frame = uiView.bounds
108+
}
109+
emitPlacementChanged(for: view)
110+
}
111+
112+
private func emitPlacementChanged(for uiView: PlatformView) {
113+
if let contentView = uiView.value(forKey: "bottomAccessoryProvider") as? BottomAccessoryProvider {
114+
contentView.emitPlacementChanged(tabViewBottomAccessoryPlacement)
115+
}
54116
}
55117
}

0 commit comments

Comments
 (0)