@onekeyfe/react-native-bottom-tabs 6.7.0-alpha.385 → 6.7.0-alpha.386

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/SPEC.md CHANGED
@@ -260,6 +260,35 @@ boundary; `BottomTabsView.kt` owns scene geometry, appearance, and disposal;
260
260
  and runner instructions are in `native-tests/android`. The example's settings
261
261
  page exposes the switch and its scrolling scene reserves the reported bar height.
262
262
 
263
+ ## iOS vertical tab bar placement and side accessory
264
+
265
+ Status: Implemented. Runtime results are in Conformance.
266
+
267
+ On iOS 27.1+, devices such as iPhone Duo can place the `UITabBarController`
268
+ bar vertically in a side strip next to the status bar. The view reports that
269
+ placement and can draw one round accessory button above the vertical bar.
270
+
271
+ - **Placement:** `onTabBarPlacementChange({ placement })` fires with `bottom`,
272
+ `leading`, or `trailing`, read from `traitCollection.verticalBarEdge` of the
273
+ tab bar controller. It fires once after the bar is created and again on every
274
+ change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK
275
+ and earlier systems always report `bottom`. Android never fires it.
276
+ - **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the
277
+ React image loader and drawn as a template tinted with `inactiveTintColor`),
278
+ `sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The
279
+ button is 48 pt, a Liquid Glass capsule on iOS 26+, centered horizontally on
280
+ the vertical bar's item platter and 12 pt above it.
281
+ - The button shows only when all hold: the placement is `leading` or
282
+ `trailing`, the tab bar is not hidden, the icon has loaded, and the platter
283
+ is found. Otherwise it is hidden; a bottom bar never shows it.
284
+ - UIKit does not expose the platter frame. The view finds the platter by its
285
+ view class name below the tab bar controller (skipping tab content views),
286
+ reads its frame, and calls no private API. If a later system renames or
287
+ restructures it, the accessory hides; no other behavior depends on it.
288
+ - Layout re-runs, coalesced on the next main-queue turn, after container
289
+ layout, tab rebuilds, tab bar hiding, icon loads, and changes to the traits
290
+ affecting `verticalBarEdge`.
291
+
263
292
  ## Threading and ownership
264
293
 
265
294
  All gesture handling, animations, and timers run on the platform UI thread in
@@ -285,3 +314,10 @@ non-selected tab lifts the lens there; drag crossing tab centers does not change
285
314
  pages; release selects the nearest tab once; cancel restores; hidden bar, dark
286
315
  mode, and 3-button navigation insets lay out correctly. Source compilation is
287
316
  recorded separately from runtime acceptance.
317
+
318
+ Vertical tab bar: `RCTBottomTabsContainerView.swift` (`currentTabBarPlacement`,
319
+ `updateTabBarPlacementAndSideAccessory`, `verticalTabBarPlatterFrame`).
320
+ Required iOS runtime cases on iPhone Duo: the outer display and landscape inner
321
+ display report `trailing` and show the accessory above the platter; the
322
+ portrait inner display reports `bottom` and hides it; hiding the tab bar hides
323
+ it; a press emits `onSideAccessoryPress` once.
@@ -171,6 +171,28 @@ static NSArray<NSDictionary *>* convertItemsToArray(const std::vector<RNBottomTa
171
171
  }
172
172
  };
173
173
  [_containerView setValue:onNativeLayout forKey:@"onNativeLayout"];
174
+
175
+ EventBlock onTabBarPlacementChange = ^(NSDictionary *body) {
176
+ auto strongSelf = weakSelf;
177
+ if (!strongSelf) return;
178
+ auto emitter = std::static_pointer_cast<const RNBottomTabsViewEventEmitter>(strongSelf->_eventEmitter);
179
+ if (emitter) {
180
+ emitter->onTabBarPlacementChange({
181
+ .placement = std::string([[body objectForKey:@"placement"] UTF8String] ?: "bottom")
182
+ });
183
+ }
184
+ };
185
+ [_containerView setValue:onTabBarPlacementChange forKey:@"onTabBarPlacementChange"];
186
+
187
+ EventBlock onSideAccessoryPress = ^(NSDictionary *body) {
188
+ auto strongSelf = weakSelf;
189
+ if (!strongSelf) return;
190
+ auto emitter = std::static_pointer_cast<const RNBottomTabsViewEventEmitter>(strongSelf->_eventEmitter);
191
+ if (emitter) {
192
+ emitter->onSideAccessoryPress({});
193
+ }
194
+ };
195
+ [_containerView setValue:onSideAccessoryPress forKey:@"onSideAccessoryPress"];
174
196
  }
175
197
 
176
198
  return self;
@@ -308,6 +330,21 @@ static NSArray<NSDictionary *>* convertItemsToArray(const std::vector<RNBottomTa
308
330
  [_containerView setValue:@(newViewProps.tabBarHidden) forKey:@"tabBarHidden"];
309
331
  }
310
332
 
333
+ if (oldViewProps.sideAccessoryIcon != newViewProps.sideAccessoryIcon) {
334
+ const auto &source = newViewProps.sideAccessoryIcon;
335
+ NSDictionary *iconDict = source.uri.empty() ? nil : @{
336
+ @"uri": [NSString stringWithUTF8String:source.uri.c_str()],
337
+ @"width": @(source.size.width),
338
+ @"height": @(source.size.height),
339
+ @"scale": @(source.scale),
340
+ };
341
+ [_containerView setValue:iconDict forKey:@"sideAccessoryIcon"];
342
+ }
343
+
344
+ if (oldViewProps.sideAccessoryLabel != newViewProps.sideAccessoryLabel) {
345
+ [_containerView setValue:NSStringFromStdStringNilIfEmpty(newViewProps.sideAccessoryLabel) forKey:@"sideAccessoryLabel"];
346
+ }
347
+
311
348
  #undef SET_PROP
312
349
 
313
350
  [super updateProps:props oldProps:oldProps];
@@ -70,6 +70,14 @@ class RCTBottomTabsContainerView: UIView {
70
70
  private var lastPressedTabTime: CFTimeInterval = 0
71
71
  /// Cached view controllers keyed by tab key to avoid unnecessary reparenting
72
72
  private var cachedViewControllers: [String: UIViewController] = [:]
73
+ // Vertical tab bar (iPhone Duo side strip): last reported placement and the
74
+ // round accessory button drawn above the bar.
75
+ private var reportedTabBarPlacement: String?
76
+ private var sideAccessoryButton: UIButton?
77
+ private var loadedSideAccessoryIcon: UIImage?
78
+ private var isSideAccessoryLayoutScheduled = false
79
+ private static let sideAccessorySize: CGFloat = 48
80
+ private static let sideAccessorySpacing: CGFloat = 12
73
81
 
74
82
  // MARK: - Props (set by Fabric ComponentView via KVC)
75
83
 
@@ -128,7 +136,11 @@ class RCTBottomTabsContainerView: UIView {
128
136
 
129
137
  @objc var inactiveTintColor: UIColor? {
130
138
  // Rebuild so the baked unselected-image tint follows theme changes.
131
- didSet { updateTabBarAppearance(); rebuildViewControllers() }
139
+ didSet {
140
+ updateTabBarAppearance()
141
+ rebuildViewControllers()
142
+ sideAccessoryButton?.tintColor = inactiveTintColor
143
+ }
132
144
  }
133
145
 
134
146
  @objc var rippleColor: UIColor? // Android only
@@ -147,12 +159,22 @@ class RCTBottomTabsContainerView: UIView {
147
159
  didSet { updateTabBarAppearance() }
148
160
  }
149
161
 
162
+ @objc var sideAccessoryIcon: NSDictionary? {
163
+ didSet { loadSideAccessoryIcon() }
164
+ }
165
+
166
+ @objc var sideAccessoryLabel: String? {
167
+ didSet { sideAccessoryButton?.accessibilityLabel = sideAccessoryLabel }
168
+ }
169
+
150
170
  // MARK: - Events (RCTDirectEventBlock)
151
171
 
152
172
  @objc var onPageSelected: RCTDirectEventBlock?
153
173
  @objc var onTabDoublePress: RCTDirectEventBlock?
154
174
  @objc var onTabBarMeasured: RCTDirectEventBlock?
155
175
  @objc var onNativeLayout: RCTDirectEventBlock?
176
+ @objc var onTabBarPlacementChange: RCTDirectEventBlock?
177
+ @objc var onSideAccessoryPress: RCTDirectEventBlock?
156
178
 
157
179
  // MARK: - Init
158
180
 
@@ -188,6 +210,161 @@ class RCTBottomTabsContainerView: UIView {
188
210
  // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
189
211
  }
190
212
 
213
+ // MARK: - Vertical tab bar (iOS 27.1+)
214
+
215
+ /// Where the system draws the tab bar: "bottom", or "leading"/"trailing" for
216
+ /// the vertical bar some devices (iPhone Duo) place in a side strip.
217
+ private func currentTabBarPlacement() -> String {
218
+ #if compiler(>=6.4)
219
+ if #available(iOS 27.1, *), let tbc = tabBarController {
220
+ switch tbc.traitCollection.verticalBarEdge {
221
+ case .leading: return "leading"
222
+ case .trailing: return "trailing"
223
+ default: break
224
+ }
225
+ }
226
+ #endif
227
+ return "bottom"
228
+ }
229
+
230
+ private func registerVerticalBarTraitChanges(_ tbc: UITabBarController) {
231
+ #if compiler(>=6.4)
232
+ if #available(iOS 27.1, *) {
233
+ tbc.registerForTraitChanges(
234
+ UITraitCollection.systemTraitsAffectingVerticalBarEdge
235
+ ) { [weak self] (_: UITabBarController, _: UITraitCollection) in
236
+ self?.scheduleSideAccessoryLayout()
237
+ }
238
+ }
239
+ #endif
240
+ }
241
+
242
+ /// Coalesces requests and runs after UIKit has laid out the bar, whose
243
+ /// floating platter is positioned in UIKit's own layout pass.
244
+ private func scheduleSideAccessoryLayout() {
245
+ guard !isSideAccessoryLayoutScheduled else { return }
246
+ isSideAccessoryLayoutScheduled = true
247
+ DispatchQueue.main.async { [weak self] in
248
+ guard let self else { return }
249
+ self.isSideAccessoryLayoutScheduled = false
250
+ self.updateTabBarPlacementAndSideAccessory()
251
+ }
252
+ }
253
+
254
+ private func updateTabBarPlacementAndSideAccessory() {
255
+ guard let tbc = tabBarController else { return }
256
+ let placement = currentTabBarPlacement()
257
+ if placement != reportedTabBarPlacement {
258
+ reportedTabBarPlacement = placement
259
+ onTabBarPlacementChange?(["placement": placement])
260
+ }
261
+
262
+ guard placement != "bottom",
263
+ !tabBarHidden,
264
+ let icon = loadedSideAccessoryIcon,
265
+ let platterFrame = verticalTabBarPlatterFrame(in: tbc) else {
266
+ sideAccessoryButton?.isHidden = true
267
+ return
268
+ }
269
+
270
+ let button = ensureSideAccessoryButton(in: tbc.view)
271
+ button.configuration?.image = icon
272
+ let size = RCTBottomTabsContainerView.sideAccessorySize
273
+ button.frame = CGRect(
274
+ x: platterFrame.midX - size / 2,
275
+ y: platterFrame.minY - RCTBottomTabsContainerView.sideAccessorySpacing - size,
276
+ width: size,
277
+ height: size
278
+ )
279
+ button.isHidden = false
280
+ tbc.view.bringSubviewToFront(button)
281
+ }
282
+
283
+ /// UIKit draws the vertical bar items in a floating platter it does not
284
+ /// expose. Read that view's frame (no private API is called) and return nil
285
+ /// when it cannot be found, so the accessory hides instead of misplacing.
286
+ private func verticalTabBarPlatterFrame(in tbc: UITabBarController) -> CGRect? {
287
+ let root = tbc.view!
288
+ // Tab content can hold large React trees; the bar lives outside them.
289
+ let contentViews = Set((tbc.viewControllers ?? []).compactMap { vc in
290
+ vc.isViewLoaded ? ObjectIdentifier(vc.view) : nil
291
+ })
292
+ var queue: [UIView] = root.subviews
293
+ var index = 0
294
+ while index < queue.count {
295
+ let view = queue[index]
296
+ index += 1
297
+ if view === sideAccessoryButton || contentViews.contains(ObjectIdentifier(view)) {
298
+ continue
299
+ }
300
+ if NSStringFromClass(type(of: view)).contains("TabBarItemPlatterView") {
301
+ guard !view.isHidden, view.window != nil else { return nil }
302
+ let frame = view.convert(view.bounds, to: root)
303
+ return frame.isEmpty ? nil : frame
304
+ }
305
+ queue.append(contentsOf: view.subviews)
306
+ }
307
+ return nil
308
+ }
309
+
310
+ private func ensureSideAccessoryButton(in parent: UIView) -> UIButton {
311
+ if let button = sideAccessoryButton {
312
+ if button.superview !== parent { parent.addSubview(button) }
313
+ return button
314
+ }
315
+ var configuration: UIButton.Configuration
316
+ #if compiler(>=6.2)
317
+ if #available(iOS 26.0, *) {
318
+ configuration = .glass()
319
+ } else {
320
+ configuration = .gray()
321
+ }
322
+ #else
323
+ configuration = .gray()
324
+ #endif
325
+ configuration.cornerStyle = .capsule
326
+ configuration.contentInsets = .zero
327
+ let button = UIButton(configuration: configuration)
328
+ button.tintColor = inactiveTintColor
329
+ button.accessibilityLabel = sideAccessoryLabel
330
+ button.addAction(UIAction { [weak self] _ in
331
+ self?.onSideAccessoryPress?([:])
332
+ }, for: .primaryActionTriggered)
333
+ parent.addSubview(button)
334
+ sideAccessoryButton = button
335
+ return button
336
+ }
337
+
338
+ private func loadSideAccessoryIcon() {
339
+ let source = sideAccessoryIcon.map { IconSource(dict: $0) }
340
+ guard let source, let url = URL(string: source.uri) else {
341
+ loadedSideAccessoryIcon = nil
342
+ scheduleSideAccessoryLayout()
343
+ return
344
+ }
345
+ // The image loader arrives with the first state update; setImageLoader
346
+ // calls this again.
347
+ guard let imageLoader else { return }
348
+ imageLoader.loadImage(
349
+ with: URLRequest(url: url),
350
+ size: CGSize(width: source.width, height: source.height),
351
+ scale: CGFloat(source.scale),
352
+ clipped: true,
353
+ resizeMode: RCTResizeMode.contain,
354
+ progressBlock: { _, _ in },
355
+ partialLoad: { _ in },
356
+ completionBlock: { [weak self] error, image in
357
+ guard error == nil, let image else { return }
358
+ DispatchQueue.main.async {
359
+ guard let self else { return }
360
+ self.loadedSideAccessoryIcon = image
361
+ .resizeImageTo(size: self.iconSize)?
362
+ .withRenderingMode(.alwaysTemplate)
363
+ self.scheduleSideAccessoryLayout()
364
+ }
365
+ })
366
+ }
367
+
191
368
  // MARK: - Lifecycle
192
369
 
193
370
  private func handleLayout() {
@@ -197,6 +374,7 @@ class RCTBottomTabsContainerView: UIView {
197
374
  setupTabBarControllerIfNeeded()
198
375
  tabBarController?.view.frame = bounds
199
376
  onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
377
+ scheduleSideAccessoryLayout()
200
378
  }
201
379
 
202
380
  private func setupTabBarControllerIfNeeded() {
@@ -215,6 +393,7 @@ class RCTBottomTabsContainerView: UIView {
215
393
  tbc.didMove(toParent: parentVC)
216
394
 
217
395
  self.tabBarController = tbc
396
+ registerVerticalBarTraitChanges(tbc)
218
397
 
219
398
  rebuildViewControllers()
220
399
  updateTabBarAppearance()
@@ -356,6 +535,8 @@ class RCTBottomTabsContainerView: UIView {
356
535
  }
357
536
 
358
537
  updateSelectedTab()
538
+ // The platter height follows the number of visible tabs.
539
+ scheduleSideAccessoryLayout()
359
540
  }
360
541
 
361
542
  // MARK: - Selection
@@ -498,6 +679,7 @@ class RCTBottomTabsContainerView: UIView {
498
679
  tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
499
680
 
500
681
  onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
682
+ scheduleSideAccessoryLayout()
501
683
  }
502
684
 
503
685
  private func updateSidebarAdaptable() {
@@ -569,6 +751,7 @@ class RCTBottomTabsContainerView: UIView {
569
751
  self.imageLoader = loader
570
752
  loadIconsFromSources()
571
753
  loadSelectedIconsFromSources()
754
+ loadSideAccessoryIcon()
572
755
  }
573
756
 
574
757
  private func loadIconsFromSources() {
@@ -1,15 +1,15 @@
1
1
  "use strict";
2
2
 
3
- import React, { useLayoutEffect, useRef } from 'react';
4
- import { Image, Platform, StyleSheet, View, processColor } from 'react-native';
3
+ import React, { useLayoutEffect, useRef } from "react";
4
+ import { Image, Platform, StyleSheet, View, processColor } from "react-native";
5
5
  import { BottomTabBarHeightContext } from "./utils/BottomTabBarHeightContext.js";
6
6
 
7
7
  // eslint-disable-next-line @react-native/no-deep-imports
8
8
 
9
- import NativeBottomTabs from './BottomTabsNativeComponent';
10
- import useLatestCallback from 'use-latest-callback';
9
+ import NativeBottomTabs from "./BottomTabsNativeComponent";
10
+ import useLatestCallback from "use-latest-callback";
11
11
  import DelayedFreeze from "./DelayedFreeze.js";
12
- import NativeBottomTabsDrawer from './BottomTabsDrawerNativeComponent';
12
+ import NativeBottomTabsDrawer from "./BottomTabsDrawerNativeComponent";
13
13
  import { BottomAccessoryView } from "./BottomAccessoryView.js";
14
14
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  const isAppleSymbol = icon => icon?.sfSymbol;
@@ -65,7 +65,7 @@ const BottomTabs = ({
65
65
  route
66
66
  }) => route.preventsDefault,
67
67
  hapticFeedbackEnabled = false,
68
- labeled = Platform.OS !== 'android' ? true : undefined,
68
+ labeled = Platform.OS !== "android" ? true : undefined,
69
69
  getFreezeOnBlur = ({
70
70
  route
71
71
  }) => route.freezeOnBlur,
@@ -74,8 +74,12 @@ const BottomTabs = ({
74
74
  tabLabelStyle,
75
75
  renderBottomAccessoryView,
76
76
  activeIndicatorColor,
77
- androidBottomBlurEnabled = Platform.OS === 'android' && Number(Platform.Version) >= 36,
77
+ androidBottomBlurEnabled = Platform.OS === "android" && Number(Platform.Version) >= 36,
78
78
  delayedFreeze = false,
79
+ onTabBarPlacementChange,
80
+ sideAccessoryIcon,
81
+ sideAccessoryLabel,
82
+ onSideAccessoryPress,
79
83
  ...props
80
84
  }) => {
81
85
  // @ts-ignore
@@ -86,11 +90,11 @@ const BottomTabs = ({
86
90
  const [customTabBarHeight, setCustomTabBarHeight] = React.useState(0);
87
91
  const tabBarHeight = renderCustomTabBar ? customTabBarHeight : props.tabBarHidden ? 0 : nativeTabBarHeight;
88
92
  const [measuredDimensions, setMeasuredDimensions] = React.useState({
89
- width: '100%',
90
- height: '100%'
93
+ width: "100%",
94
+ height: "100%"
91
95
  });
92
96
  const trimmedRoutes = React.useMemo(() => {
93
- if (Platform.OS === 'android' && navigationState.routes.length > ANDROID_MAX_TABS) {
97
+ if (Platform.OS === "android" && navigationState.routes.length > ANDROID_MAX_TABS) {
94
98
  console.warn(`BottomTabs only supports up to ${ANDROID_MAX_TABS} tabs on Android`);
95
99
  return navigationState.routes.slice(0, ANDROID_MAX_TABS);
96
100
  }
@@ -100,7 +104,7 @@ const BottomTabs = ({
100
104
  if (!loaded.includes(focusedKey)) {
101
105
  setLoaded(loaded => [...loaded, focusedKey]);
102
106
  }
103
- const isIOS = Platform.OS === 'ios';
107
+ const isIOS = Platform.OS === "ios";
104
108
 
105
109
  // Unfocused (outline) icons — independent of focusedKey so the array stays
106
110
  // stable across tab switches and the native side never reloads on switch.
@@ -127,8 +131,8 @@ const BottomTabs = ({
127
131
  const items = React.useMemo(() => trimmedRoutes.map((route, index) => {
128
132
  const icon = icons[index];
129
133
  const isSfSymbol = isAppleSymbol(icon);
130
- if (Platform.OS === 'android' && isSfSymbol) {
131
- console.warn('SF Symbols are not supported on Android. Use require() or pass uri to load an image instead.');
134
+ if (Platform.OS === "android" && isSfSymbol) {
135
+ console.warn("SF Symbols are not supported on Android. Use require() or pass uri to load an image instead.");
132
136
  }
133
137
  const color = processColor(getActiveTintColor({
134
138
  route
@@ -148,9 +152,9 @@ const BottomTabs = ({
148
152
  badge: getBadge?.({
149
153
  route
150
154
  }),
151
- badgeBackgroundColor: typeof badgeBgColor === 'number' ? badgeBgColor : undefined,
152
- badgeTextColor: typeof badgeTxtColor === 'number' ? badgeTxtColor : undefined,
153
- activeTintColor: typeof color === 'number' ? color : undefined,
155
+ badgeBackgroundColor: typeof badgeBgColor === "number" ? badgeBgColor : undefined,
156
+ badgeTextColor: typeof badgeTxtColor === "number" ? badgeTxtColor : undefined,
157
+ activeTintColor: typeof color === "number" ? color : undefined,
154
158
  hidden: getHidden?.({
155
159
  route
156
160
  }),
@@ -170,14 +174,14 @@ const BottomTabs = ({
170
174
  // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
171
175
  const resolved = Image.resolveAssetSource(icon);
172
176
  return {
173
- uri: resolved?.uri ?? '',
177
+ uri: resolved?.uri ?? "",
174
178
  width: resolved?.width ?? 0,
175
179
  height: resolved?.height ?? 0,
176
180
  scale: resolved?.scale ?? 1
177
181
  };
178
182
  }
179
183
  return {
180
- uri: '',
184
+ uri: "",
181
185
  width: 0,
182
186
  height: 0,
183
187
  scale: 1
@@ -191,14 +195,14 @@ const BottomTabs = ({
191
195
  // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
192
196
  const resolved = Image.resolveAssetSource(icon);
193
197
  return {
194
- uri: resolved?.uri ?? '',
198
+ uri: resolved?.uri ?? "",
195
199
  width: resolved?.width ?? 0,
196
200
  height: resolved?.height ?? 0,
197
201
  scale: resolved?.scale ?? 1
198
202
  };
199
203
  }
200
204
  return {
201
- uri: '',
205
+ uri: "",
202
206
  width: 0,
203
207
  height: 0,
204
208
  scale: 1
@@ -244,6 +248,25 @@ const BottomTabs = ({
244
248
  height
245
249
  });
246
250
  }, [setMeasuredDimensions]);
251
+ const handleTabBarPlacementChange = React.useCallback(event => {
252
+ onTabBarPlacementChange?.(event.nativeEvent.placement);
253
+ }, [onTabBarPlacementChange]);
254
+ const handleSideAccessoryPress = React.useCallback(() => {
255
+ onSideAccessoryPress?.();
256
+ }, [onSideAccessoryPress]);
257
+ const resolvedSideAccessoryIcon = React.useMemo(() => {
258
+ if (!isIOS || !sideAccessoryIcon) {
259
+ return undefined;
260
+ }
261
+ // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
262
+ const resolved = Image.resolveAssetSource(sideAccessoryIcon);
263
+ return resolved?.uri ? {
264
+ uri: resolved.uri,
265
+ width: resolved.width ?? 0,
266
+ height: resolved.height ?? 0,
267
+ scale: resolved.scale ?? 1
268
+ } : undefined;
269
+ }, [sideAccessoryIcon]);
247
270
  useLayoutEffect(() => {
248
271
  if (renderCustomTabBar && customTabBarWrapperRef.current) {
249
272
  customTabBarWrapperRef.current.measure((_x, _y, _width, height) => {
@@ -264,7 +287,7 @@ const BottomTabs = ({
264
287
  children: [/*#__PURE__*/_jsx(View, {
265
288
  collapsable: false,
266
289
  style: styles.drawerMenu,
267
- children: Platform.OS !== 'web' ? renderDrawerContent?.() : null
290
+ children: Platform.OS !== "web" ? renderDrawerContent?.() : null
268
291
  }), /*#__PURE__*/_jsxs(View, {
269
292
  collapsable: false,
270
293
  style: styles.fullWidth,
@@ -282,13 +305,17 @@ const BottomTabs = ({
282
305
  onPageSelected: handlePageSelected,
283
306
  onTabBarMeasured: handleTabBarMeasured,
284
307
  onNativeLayout: handleNativeLayout,
308
+ onTabBarPlacementChange: handleTabBarPlacementChange,
309
+ sideAccessoryIcon: renderCustomTabBar ? undefined : resolvedSideAccessoryIcon,
310
+ sideAccessoryLabel: sideAccessoryLabel,
311
+ onSideAccessoryPress: handleSideAccessoryPress,
285
312
  hapticFeedbackEnabled: hapticFeedbackEnabled,
286
313
  activeTintColor: activeTintColor,
287
314
  inactiveTintColor: inactiveTintColor,
288
315
  barTintColor: tabBarStyle?.backgroundColor,
289
316
  rippleColor: rippleColor,
290
317
  activeIndicatorColor: activeIndicatorColor,
291
- androidBottomBlurEnabled: Platform.OS === 'android' && !renderCustomTabBar && androidBottomBlurEnabled,
318
+ androidBottomBlurEnabled: Platform.OS === "android" && !renderCustomTabBar && androidBottomBlurEnabled,
292
319
  labeled: labeled,
293
320
  children: [trimmedRoutes.map(route => {
294
321
  if (getLazy({
@@ -312,9 +339,9 @@ const BottomTabs = ({
312
339
  return /*#__PURE__*/_jsx(View, {
313
340
  style: [styles.screen, renderCustomTabBar ? styles.fullWidth : measuredDimensions, customStyle],
314
341
  collapsable: false,
315
- pointerEvents: focused ? 'auto' : 'none',
342
+ pointerEvents: focused ? "auto" : "none",
316
343
  accessibilityElementsHidden: !focused,
317
- importantForAccessibility: focused ? 'auto' : 'no-hide-descendants',
344
+ importantForAccessibility: focused ? "auto" : "no-hide-descendants",
318
345
  children: /*#__PURE__*/_jsx(DelayedFreeze, {
319
346
  freeze: !!freeze,
320
347
  delayedFreeze: delayedFreeze,
@@ -324,7 +351,7 @@ const BottomTabs = ({
324
351
  })
325
352
  })
326
353
  }, route.key);
327
- }), Platform.OS === 'ios' && parseFloat(Platform.Version) >= 26 && renderBottomAccessoryView && !renderCustomTabBar ? /*#__PURE__*/_jsx(BottomAccessoryView, {
354
+ }), Platform.OS === "ios" && parseFloat(Platform.Version) >= 26 && renderBottomAccessoryView && !renderCustomTabBar ? /*#__PURE__*/_jsx(BottomAccessoryView, {
328
355
  renderBottomAccessoryView: renderBottomAccessoryView
329
356
  }) : null]
330
357
  }), renderCustomTabBar ? /*#__PURE__*/_jsx(View, {
@@ -338,17 +365,17 @@ const BottomTabs = ({
338
365
  };
339
366
  const styles = StyleSheet.create({
340
367
  fullWidth: {
341
- width: '100%',
342
- height: '100%',
368
+ width: "100%",
369
+ height: "100%",
343
370
  flex: 1
344
371
  },
345
372
  drawerMenu: {
346
- position: 'absolute',
347
- width: '78%',
348
- height: '100%'
373
+ position: "absolute",
374
+ width: "78%",
375
+ height: "100%"
349
376
  },
350
377
  screen: {
351
- position: 'absolute'
378
+ position: "absolute"
352
379
  }
353
380
  });
354
381
  export default BottomTabs;
@@ -22,6 +22,14 @@ export type OnNativeLayout = Readonly<{
22
22
  height: Double;
23
23
  }>;
24
24
 
25
+ // Where the system draws the tab bar: 'bottom', or 'leading' / 'trailing' for
26
+ // a vertical bar (the iPhone Duo side strip, iOS 27.1+).
27
+ export type OnTabBarPlacementChange = Readonly<{
28
+ placement: string;
29
+ }>;
30
+
31
+ export type OnSideAccessoryPress = Readonly<{}>;
32
+
25
33
  export type BottomTabsItems = ReadonlyArray<{
26
34
  key: string;
27
35
  title: string;
@@ -65,6 +73,11 @@ export interface BottomTabsNativeProps extends ViewProps {
65
73
  ignoreBottomInsets?: boolean;
66
74
  androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
67
75
  ignoreKeyboardInsets?: WithDefault<boolean, true>;
76
+ onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
77
+ // A round button drawn above a vertical tab bar; iOS only.
78
+ sideAccessoryIcon?: ImageSource;
79
+ sideAccessoryLabel?: string;
80
+ onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
68
81
  }
69
82
 
70
83
  export default codegenNativeComponent<BottomTabsNativeProps>('RNBottomTabsView');
@@ -1,8 +1,8 @@
1
- import React from 'react';
2
- import { type ColorValue, type StyleProp, type ViewStyle } from 'react-native';
3
- import type { ImageSource } from 'react-native/Libraries/Image/ImageSource';
4
- import type { AppleIcon, BaseRoute, NavigationState, TabRole } from './types';
5
- import { type RNBottomTabsAccessoryViewProps } from './BottomAccessoryView';
1
+ import React from "react";
2
+ import { type ColorValue, type ImageSourcePropType, type StyleProp, type ViewStyle } from "react-native";
3
+ import type { ImageSource } from "react-native/Libraries/Image/ImageSource";
4
+ import type { AppleIcon, BaseRoute, NavigationState, TabBarPlacement, TabRole } from "./types";
5
+ import { type RNBottomTabsAccessoryViewProps } from "./BottomAccessoryView";
6
6
  interface Props<Route extends BaseRoute> {
7
7
  labeled?: boolean;
8
8
  /** Optional physical-left menu; all motion and input are owned by native. */
@@ -35,11 +35,11 @@ interface Props<Route extends BaseRoute> {
35
35
  /**
36
36
  * Describes the appearance attributes for the tabBar to use when an observable scroll view is scrolled to the bottom. (iOS only)
37
37
  */
38
- scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent';
38
+ scrollEdgeAppearance?: "default" | "opaque" | "transparent";
39
39
  /**
40
40
  * Behavior for minimizing the tab bar. (iOS 26+)
41
41
  */
42
- minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never';
42
+ minimizeBehavior?: "automatic" | "onScrollDown" | "onScrollUp" | "never";
43
43
  /**
44
44
  * Active tab color.
45
45
  */
@@ -186,7 +186,7 @@ interface Props<Route extends BaseRoute> {
186
186
  * A function that returns a React element to display as bottom accessory view.
187
187
  * iOS 26+ only.
188
188
  */
189
- renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps['renderBottomAccessoryView'];
189
+ renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps["renderBottomAccessoryView"];
190
190
  /**
191
191
  * Whether the tab bar is hidden.
192
192
  */
@@ -198,7 +198,7 @@ interface Props<Route extends BaseRoute> {
198
198
  /**
199
199
  * Visual style of the Android tab bar. Android only.
200
200
  */
201
- androidTabBarStyle?: 'default' | 'floating';
201
+ androidTabBarStyle?: "default" | "floating";
202
202
  /**
203
203
  * Whether the tab bar ignores the soft keyboard. Defaults to true, so the
204
204
  * keyboard covers the tab bar as on iOS. Set to false to pad the tab bar by
@@ -211,7 +211,25 @@ interface Props<Route extends BaseRoute> {
211
211
  * original delayed behavior.
212
212
  */
213
213
  delayedFreeze?: boolean;
214
+ /**
215
+ * Called when the system moves the tab bar between the bottom edge and a
216
+ * vertical bar on the leading or trailing edge (iPhone Duo). iOS only.
217
+ */
218
+ onTabBarPlacementChange?: (placement: TabBarPlacement) => void;
219
+ /**
220
+ * Icon of a round button shown above a vertical tab bar. The button only
221
+ * appears while the tab bar is vertical and visible. iOS only.
222
+ */
223
+ sideAccessoryIcon?: ImageSourcePropType;
224
+ /**
225
+ * Accessibility label of the side accessory button.
226
+ */
227
+ sideAccessoryLabel?: string;
228
+ /**
229
+ * Called when the side accessory button is pressed.
230
+ */
231
+ onSideAccessoryPress?: () => void;
214
232
  }
215
- declare const BottomTabs: <Route extends BaseRoute>({ navigationState, renderScene, renderLazyPlaceholder, onIndexChange, onTabDoublePress, renderDrawerContent, drawerOpen, drawerSwipeEnabled, onDrawerOpenChange, rippleColor, tabBarActiveTintColor: activeTintColor, tabBarInactiveTintColor: inactiveTintColor, getBadge, getBadgeBackgroundColor, getBadgeTextColor, getLazy, getLabelText, getIcon, getHidden, getActiveTintColor, getTestID, getRole, getSceneStyle, getPreventsDefault, hapticFeedbackEnabled, labeled, getFreezeOnBlur, tabBar: renderCustomTabBar, tabBarStyle, tabLabelStyle, renderBottomAccessoryView, activeIndicatorColor, androidBottomBlurEnabled, delayedFreeze, ...props }: Props<Route>) => import("react/jsx-runtime").JSX.Element;
233
+ declare const BottomTabs: <Route extends BaseRoute>({ navigationState, renderScene, renderLazyPlaceholder, onIndexChange, onTabDoublePress, renderDrawerContent, drawerOpen, drawerSwipeEnabled, onDrawerOpenChange, rippleColor, tabBarActiveTintColor: activeTintColor, tabBarInactiveTintColor: inactiveTintColor, getBadge, getBadgeBackgroundColor, getBadgeTextColor, getLazy, getLabelText, getIcon, getHidden, getActiveTintColor, getTestID, getRole, getSceneStyle, getPreventsDefault, hapticFeedbackEnabled, labeled, getFreezeOnBlur, tabBar: renderCustomTabBar, tabBarStyle, tabLabelStyle, renderBottomAccessoryView, activeIndicatorColor, androidBottomBlurEnabled, delayedFreeze, onTabBarPlacementChange, sideAccessoryIcon, sideAccessoryLabel, onSideAccessoryPress, ...props }: Props<Route>) => import("react/jsx-runtime").JSX.Element;
216
234
  export default BottomTabs;
217
235
  //# sourceMappingURL=BottomTabs.d.ts.map
@@ -11,6 +11,10 @@ export type OnNativeLayout = Readonly<{
11
11
  width: Double;
12
12
  height: Double;
13
13
  }>;
14
+ export type OnTabBarPlacementChange = Readonly<{
15
+ placement: string;
16
+ }>;
17
+ export type OnSideAccessoryPress = Readonly<{}>;
14
18
  export type BottomTabsItems = ReadonlyArray<{
15
19
  key: string;
16
20
  title: string;
@@ -53,6 +57,10 @@ export interface BottomTabsNativeProps extends ViewProps {
53
57
  ignoreBottomInsets?: boolean;
54
58
  androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
55
59
  ignoreKeyboardInsets?: WithDefault<boolean, true>;
60
+ onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
61
+ sideAccessoryIcon?: ImageSource;
62
+ sideAccessoryLabel?: string;
63
+ onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
56
64
  }
57
65
  declare const _default: import("react-native").HostComponent<BottomTabsNativeProps>;
58
66
  export default _default;
@@ -12,5 +12,5 @@ export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
12
12
  /**
13
13
  * Types
14
14
  */
15
- export type { AppleIcon, TabRole } from './types';
15
+ export type { AppleIcon, TabBarPlacement, TabRole } from './types';
16
16
  //# sourceMappingURL=index.d.ts.map
@@ -5,6 +5,7 @@ export type AppleIcon = {
5
5
  sfSymbol: SFSymbol;
6
6
  };
7
7
  export type TabRole = 'search';
8
+ export type TabBarPlacement = 'bottom' | 'leading' | 'trailing';
8
9
  export type BaseRoute = {
9
10
  key: string;
10
11
  title?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-bottom-tabs",
3
- "version": "6.7.0-alpha.385",
3
+ "version": "6.7.0-alpha.386",
4
4
  "description": "Native bottom tabs (tab bar + scene container) for React Native on iOS and Android",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
@@ -1,31 +1,38 @@
1
- import React, { useLayoutEffect, useRef } from 'react';
2
- import type { NativeSyntheticEvent } from 'react-native';
1
+ import React, { useLayoutEffect, useRef } from "react";
2
+ import type { NativeSyntheticEvent } from "react-native";
3
3
  import {
4
4
  type ColorValue,
5
5
  type DimensionValue,
6
6
  Image,
7
+ type ImageSourcePropType,
7
8
  Platform,
8
9
  type StyleProp,
9
10
  StyleSheet,
10
11
  View,
11
12
  type ViewStyle,
12
13
  processColor,
13
- } from 'react-native';
14
- import { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
14
+ } from "react-native";
15
+ import { BottomTabBarHeightContext } from "./utils/BottomTabBarHeightContext";
15
16
 
16
17
  // eslint-disable-next-line @react-native/no-deep-imports
17
- import type { ImageSource } from 'react-native/Libraries/Image/ImageSource';
18
+ import type { ImageSource } from "react-native/Libraries/Image/ImageSource";
18
19
  import NativeBottomTabs, {
19
20
  type BottomTabsItems,
20
- } from './BottomTabsNativeComponent';
21
- import useLatestCallback from 'use-latest-callback';
22
- import type { AppleIcon, BaseRoute, NavigationState, TabRole } from './types';
23
- import DelayedFreeze from './DelayedFreeze';
24
- import NativeBottomTabsDrawer from './BottomTabsDrawerNativeComponent';
21
+ } from "./BottomTabsNativeComponent";
22
+ import useLatestCallback from "use-latest-callback";
23
+ import type {
24
+ AppleIcon,
25
+ BaseRoute,
26
+ NavigationState,
27
+ TabBarPlacement,
28
+ TabRole,
29
+ } from "./types";
30
+ import DelayedFreeze from "./DelayedFreeze";
31
+ import NativeBottomTabsDrawer from "./BottomTabsDrawerNativeComponent";
25
32
  import {
26
33
  BottomAccessoryView,
27
34
  type RNBottomTabsAccessoryViewProps,
28
- } from './BottomAccessoryView';
35
+ } from "./BottomAccessoryView";
29
36
 
30
37
  const isAppleSymbol = (icon: any): icon is { sfSymbol: string } =>
31
38
  icon?.sfSymbol;
@@ -65,12 +72,12 @@ interface Props<Route extends BaseRoute> {
65
72
  /**
66
73
  * Describes the appearance attributes for the tabBar to use when an observable scroll view is scrolled to the bottom. (iOS only)
67
74
  */
68
- scrollEdgeAppearance?: 'default' | 'opaque' | 'transparent';
75
+ scrollEdgeAppearance?: "default" | "opaque" | "transparent";
69
76
 
70
77
  /**
71
78
  * Behavior for minimizing the tab bar. (iOS 26+)
72
79
  */
73
- minimizeBehavior?: 'automatic' | 'onScrollDown' | 'onScrollUp' | 'never';
80
+ minimizeBehavior?: "automatic" | "onScrollDown" | "onScrollUp" | "never";
74
81
  /**
75
82
  * Active tab color.
76
83
  */
@@ -199,7 +206,7 @@ interface Props<Route extends BaseRoute> {
199
206
  * A function that returns a React element to display as bottom accessory view.
200
207
  * iOS 26+ only.
201
208
  */
202
- renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps['renderBottomAccessoryView'];
209
+ renderBottomAccessoryView?: RNBottomTabsAccessoryViewProps["renderBottomAccessoryView"];
203
210
  /**
204
211
  * Whether the tab bar is hidden.
205
212
  */
@@ -211,7 +218,7 @@ interface Props<Route extends BaseRoute> {
211
218
  /**
212
219
  * Visual style of the Android tab bar. Android only.
213
220
  */
214
- androidTabBarStyle?: 'default' | 'floating';
221
+ androidTabBarStyle?: "default" | "floating";
215
222
  /**
216
223
  * Whether the tab bar ignores the soft keyboard. Defaults to true, so the
217
224
  * keyboard covers the tab bar as on iOS. Set to false to pad the tab bar by
@@ -224,6 +231,24 @@ interface Props<Route extends BaseRoute> {
224
231
  * original delayed behavior.
225
232
  */
226
233
  delayedFreeze?: boolean;
234
+ /**
235
+ * Called when the system moves the tab bar between the bottom edge and a
236
+ * vertical bar on the leading or trailing edge (iPhone Duo). iOS only.
237
+ */
238
+ onTabBarPlacementChange?: (placement: TabBarPlacement) => void;
239
+ /**
240
+ * Icon of a round button shown above a vertical tab bar. The button only
241
+ * appears while the tab bar is vertical and visible. iOS only.
242
+ */
243
+ sideAccessoryIcon?: ImageSourcePropType;
244
+ /**
245
+ * Accessibility label of the side accessory button.
246
+ */
247
+ sideAccessoryLabel?: string;
248
+ /**
249
+ * Called when the side accessory button is pressed.
250
+ */
251
+ onSideAccessoryPress?: () => void;
227
252
  }
228
253
 
229
254
  const ANDROID_MAX_TABS = 100;
@@ -260,34 +285,41 @@ const BottomTabs = <Route extends BaseRoute>({
260
285
  getSceneStyle = ({ route }: { route: Route }) => route.style,
261
286
  getPreventsDefault = ({ route }: { route: Route }) => route.preventsDefault,
262
287
  hapticFeedbackEnabled = false,
263
- labeled = Platform.OS !== 'android' ? true : undefined,
288
+ labeled = Platform.OS !== "android" ? true : undefined,
264
289
  getFreezeOnBlur = ({ route }: { route: Route }) => route.freezeOnBlur,
265
290
  tabBar: renderCustomTabBar,
266
291
  tabBarStyle,
267
292
  tabLabelStyle,
268
293
  renderBottomAccessoryView,
269
294
  activeIndicatorColor,
270
- androidBottomBlurEnabled = Platform.OS === 'android' &&
295
+ androidBottomBlurEnabled = Platform.OS === "android" &&
271
296
  Number(Platform.Version) >= 36,
272
297
  delayedFreeze = false,
298
+ onTabBarPlacementChange,
299
+ sideAccessoryIcon,
300
+ sideAccessoryLabel,
301
+ onSideAccessoryPress,
273
302
  ...props
274
303
  }: Props<Route>) => {
275
304
  // @ts-ignore
276
305
  const focusedKey = navigationState.routes[navigationState.index].key;
277
306
  const customTabBarWrapperRef = useRef<View>(null) as React.RefObject<any>;
278
- const nativeBottomTabsRef = useRef<React.ElementRef<typeof NativeBottomTabs>>(null);
307
+ const nativeBottomTabsRef =
308
+ useRef<React.ElementRef<typeof NativeBottomTabs>>(null);
279
309
  const [nativeTabBarHeight, setNativeTabBarHeight] = React.useState(0);
280
310
  const [customTabBarHeight, setCustomTabBarHeight] = React.useState(0);
281
311
  const tabBarHeight = renderCustomTabBar
282
312
  ? customTabBarHeight
283
- : props.tabBarHidden ? 0 : nativeTabBarHeight;
313
+ : props.tabBarHidden
314
+ ? 0
315
+ : nativeTabBarHeight;
284
316
  const [measuredDimensions, setMeasuredDimensions] = React.useState<
285
317
  { width: DimensionValue; height: DimensionValue } | undefined
286
- >({ width: '100%', height: '100%' });
318
+ >({ width: "100%", height: "100%" });
287
319
 
288
320
  const trimmedRoutes = React.useMemo(() => {
289
321
  if (
290
- Platform.OS === 'android' &&
322
+ Platform.OS === "android" &&
291
323
  navigationState.routes.length > ANDROID_MAX_TABS
292
324
  ) {
293
325
  console.warn(
@@ -304,7 +336,7 @@ const BottomTabs = <Route extends BaseRoute>({
304
336
  setLoaded((loaded) => [...loaded, focusedKey]);
305
337
  }
306
338
 
307
- const isIOS = Platform.OS === 'ios';
339
+ const isIOS = Platform.OS === "ios";
308
340
 
309
341
  // Unfocused (outline) icons — independent of focusedKey so the array stays
310
342
  // stable across tab switches and the native side never reloads on switch.
@@ -341,9 +373,9 @@ const BottomTabs = <Route extends BaseRoute>({
341
373
  const icon = icons[index];
342
374
  const isSfSymbol = isAppleSymbol(icon);
343
375
 
344
- if (Platform.OS === 'android' && isSfSymbol) {
376
+ if (Platform.OS === "android" && isSfSymbol) {
345
377
  console.warn(
346
- 'SF Symbols are not supported on Android. Use require() or pass uri to load an image instead.'
378
+ "SF Symbols are not supported on Android. Use require() or pass uri to load an image instead."
347
379
  );
348
380
  }
349
381
 
@@ -357,10 +389,10 @@ const BottomTabs = <Route extends BaseRoute>({
357
389
  sfSymbol: isSfSymbol ? icon.sfSymbol : undefined,
358
390
  badge: getBadge?.({ route }),
359
391
  badgeBackgroundColor:
360
- typeof badgeBgColor === 'number' ? badgeBgColor : undefined,
392
+ typeof badgeBgColor === "number" ? badgeBgColor : undefined,
361
393
  badgeTextColor:
362
- typeof badgeTxtColor === 'number' ? badgeTxtColor : undefined,
363
- activeTintColor: typeof color === 'number' ? color : undefined,
394
+ typeof badgeTxtColor === "number" ? badgeTxtColor : undefined,
395
+ activeTintColor: typeof color === "number" ? color : undefined,
364
396
  hidden: getHidden?.({ route }),
365
397
  testID: getTestID?.({ route }),
366
398
  role: getRole?.({ route }),
@@ -389,13 +421,13 @@ const BottomTabs = <Route extends BaseRoute>({
389
421
  // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
390
422
  const resolved = Image.resolveAssetSource(icon);
391
423
  return {
392
- uri: resolved?.uri ?? '',
424
+ uri: resolved?.uri ?? "",
393
425
  width: resolved?.width ?? 0,
394
426
  height: resolved?.height ?? 0,
395
427
  scale: resolved?.scale ?? 1,
396
428
  };
397
429
  }
398
- return { uri: '', width: 0, height: 0, scale: 1 };
430
+ return { uri: "", width: 0, height: 0, scale: 1 };
399
431
  }),
400
432
  [icons]
401
433
  );
@@ -409,13 +441,13 @@ const BottomTabs = <Route extends BaseRoute>({
409
441
  // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
410
442
  const resolved = Image.resolveAssetSource(icon);
411
443
  return {
412
- uri: resolved?.uri ?? '',
444
+ uri: resolved?.uri ?? "",
413
445
  width: resolved?.width ?? 0,
414
446
  height: resolved?.height ?? 0,
415
447
  scale: resolved?.scale ?? 1,
416
448
  };
417
449
  }
418
- return { uri: '', width: 0, height: 0, scale: 1 };
450
+ return { uri: "", width: 0, height: 0, scale: 1 };
419
451
  }),
420
452
  [focusedIcons]
421
453
  );
@@ -444,10 +476,12 @@ const BottomTabs = <Route extends BaseRoute>({
444
476
  const { key } = event.nativeEvent;
445
477
  const accepted = jumpTo(key);
446
478
  if (accepted === false) {
447
- nativeBottomTabsRef.current?.setNativeProps({ selectedPage: focusedKey });
479
+ nativeBottomTabsRef.current?.setNativeProps({
480
+ selectedPage: focusedKey,
481
+ });
448
482
  }
449
483
  },
450
- [focusedKey, jumpTo],
484
+ [focusedKey, jumpTo]
451
485
  );
452
486
 
453
487
  const handleTabBarMeasured = React.useCallback(
@@ -465,11 +499,40 @@ const BottomTabs = <Route extends BaseRoute>({
465
499
  [setMeasuredDimensions]
466
500
  );
467
501
 
502
+ const handleTabBarPlacementChange = React.useCallback(
503
+ (event: NativeSyntheticEvent<{ placement: string }>) => {
504
+ onTabBarPlacementChange?.(event.nativeEvent.placement as TabBarPlacement);
505
+ },
506
+ [onTabBarPlacementChange]
507
+ );
508
+
509
+ const handleSideAccessoryPress = React.useCallback(() => {
510
+ onSideAccessoryPress?.();
511
+ }, [onSideAccessoryPress]);
512
+
513
+ const resolvedSideAccessoryIcon = React.useMemo(() => {
514
+ if (!isIOS || !sideAccessoryIcon) {
515
+ return undefined;
516
+ }
517
+ // @ts-ignore - resolveAssetSource accepts ImageSourcePropType
518
+ const resolved = Image.resolveAssetSource(sideAccessoryIcon);
519
+ return resolved?.uri
520
+ ? {
521
+ uri: resolved.uri,
522
+ width: resolved.width ?? 0,
523
+ height: resolved.height ?? 0,
524
+ scale: resolved.scale ?? 1,
525
+ }
526
+ : undefined;
527
+ }, [sideAccessoryIcon]);
528
+
468
529
  useLayoutEffect(() => {
469
530
  if (renderCustomTabBar && customTabBarWrapperRef.current) {
470
- customTabBarWrapperRef.current.measure((_x: number, _y: number, _width: number, height: number) => {
471
- setCustomTabBarHeight(height);
472
- });
531
+ customTabBarWrapperRef.current.measure(
532
+ (_x: number, _y: number, _width: number, height: number) => {
533
+ setCustomTabBarHeight(height);
534
+ }
535
+ );
473
536
  }
474
537
  }, [renderCustomTabBar]);
475
538
 
@@ -487,7 +550,7 @@ const BottomTabs = <Route extends BaseRoute>({
487
550
  }
488
551
  >
489
552
  <View collapsable={false} style={styles.drawerMenu}>
490
- {Platform.OS !== 'web' ? renderDrawerContent?.() : null}
553
+ {Platform.OS !== "web" ? renderDrawerContent?.() : null}
491
554
  </View>
492
555
  <View collapsable={false} style={styles.fullWidth}>
493
556
  <NativeBottomTabs
@@ -498,7 +561,9 @@ const BottomTabs = <Route extends BaseRoute>({
498
561
  items={items}
499
562
  icons={renderCustomTabBar ? undefined : resolvedIconAssets}
500
563
  selectedIcons={
501
- isIOS && !renderCustomTabBar ? resolvedSelectedIconAssets : undefined
564
+ isIOS && !renderCustomTabBar
565
+ ? resolvedSelectedIconAssets
566
+ : undefined
502
567
  }
503
568
  selectedPage={focusedKey}
504
569
  tabBarHidden={props.tabBarHidden ?? !!renderCustomTabBar}
@@ -506,6 +571,12 @@ const BottomTabs = <Route extends BaseRoute>({
506
571
  onPageSelected={handlePageSelected}
507
572
  onTabBarMeasured={handleTabBarMeasured}
508
573
  onNativeLayout={handleNativeLayout}
574
+ onTabBarPlacementChange={handleTabBarPlacementChange}
575
+ sideAccessoryIcon={
576
+ renderCustomTabBar ? undefined : resolvedSideAccessoryIcon
577
+ }
578
+ sideAccessoryLabel={sideAccessoryLabel}
579
+ onSideAccessoryPress={handleSideAccessoryPress}
509
580
  hapticFeedbackEnabled={hapticFeedbackEnabled}
510
581
  activeTintColor={activeTintColor}
511
582
  inactiveTintColor={inactiveTintColor}
@@ -513,7 +584,7 @@ const BottomTabs = <Route extends BaseRoute>({
513
584
  rippleColor={rippleColor}
514
585
  activeIndicatorColor={activeIndicatorColor}
515
586
  androidBottomBlurEnabled={
516
- Platform.OS === 'android' &&
587
+ Platform.OS === "android" &&
517
588
  !renderCustomTabBar &&
518
589
  androidBottomBlurEnabled
519
590
  }
@@ -546,13 +617,16 @@ const BottomTabs = <Route extends BaseRoute>({
546
617
  customStyle,
547
618
  ]}
548
619
  collapsable={false}
549
- pointerEvents={focused ? 'auto' : 'none'}
620
+ pointerEvents={focused ? "auto" : "none"}
550
621
  accessibilityElementsHidden={!focused}
551
622
  importantForAccessibility={
552
- focused ? 'auto' : 'no-hide-descendants'
623
+ focused ? "auto" : "no-hide-descendants"
553
624
  }
554
625
  >
555
- <DelayedFreeze freeze={!!freeze} delayedFreeze={delayedFreeze}>
626
+ <DelayedFreeze
627
+ freeze={!!freeze}
628
+ delayedFreeze={delayedFreeze}
629
+ >
556
630
  {renderScene({
557
631
  route,
558
632
  jumpTo,
@@ -561,7 +635,7 @@ const BottomTabs = <Route extends BaseRoute>({
561
635
  </View>
562
636
  );
563
637
  })}
564
- {Platform.OS === 'ios' &&
638
+ {Platform.OS === "ios" &&
565
639
  parseFloat(Platform.Version) >= 26 &&
566
640
  renderBottomAccessoryView &&
567
641
  !renderCustomTabBar ? (
@@ -573,7 +647,9 @@ const BottomTabs = <Route extends BaseRoute>({
573
647
  {renderCustomTabBar ? (
574
648
  <View
575
649
  ref={customTabBarWrapperRef}
576
- onLayout={(event) => setCustomTabBarHeight(event.nativeEvent.layout.height)}
650
+ onLayout={(event) =>
651
+ setCustomTabBarHeight(event.nativeEvent.layout.height)
652
+ }
577
653
  >
578
654
  {renderCustomTabBar()}
579
655
  </View>
@@ -586,17 +662,17 @@ const BottomTabs = <Route extends BaseRoute>({
586
662
 
587
663
  const styles = StyleSheet.create({
588
664
  fullWidth: {
589
- width: '100%',
590
- height: '100%',
665
+ width: "100%",
666
+ height: "100%",
591
667
  flex: 1,
592
668
  },
593
669
  drawerMenu: {
594
- position: 'absolute',
595
- width: '78%',
596
- height: '100%',
670
+ position: "absolute",
671
+ width: "78%",
672
+ height: "100%",
597
673
  },
598
674
  screen: {
599
- position: 'absolute',
675
+ position: "absolute",
600
676
  },
601
677
  });
602
678
 
@@ -22,6 +22,14 @@ export type OnNativeLayout = Readonly<{
22
22
  height: Double;
23
23
  }>;
24
24
 
25
+ // Where the system draws the tab bar: 'bottom', or 'leading' / 'trailing' for
26
+ // a vertical bar (the iPhone Duo side strip, iOS 27.1+).
27
+ export type OnTabBarPlacementChange = Readonly<{
28
+ placement: string;
29
+ }>;
30
+
31
+ export type OnSideAccessoryPress = Readonly<{}>;
32
+
25
33
  export type BottomTabsItems = ReadonlyArray<{
26
34
  key: string;
27
35
  title: string;
@@ -65,6 +73,11 @@ export interface BottomTabsNativeProps extends ViewProps {
65
73
  ignoreBottomInsets?: boolean;
66
74
  androidTabBarStyle?: WithDefault<'default' | 'floating', 'default'>;
67
75
  ignoreKeyboardInsets?: WithDefault<boolean, true>;
76
+ onTabBarPlacementChange?: DirectEventHandler<OnTabBarPlacementChange>;
77
+ // A round button drawn above a vertical tab bar; iOS only.
78
+ sideAccessoryIcon?: ImageSource;
79
+ sideAccessoryLabel?: string;
80
+ onSideAccessoryPress?: DirectEventHandler<OnSideAccessoryPress>;
68
81
  }
69
82
 
70
83
  export default codegenNativeComponent<BottomTabsNativeProps>('RNBottomTabsView');
package/src/index.tsx CHANGED
@@ -15,4 +15,4 @@ export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext';
15
15
  /**
16
16
  * Types
17
17
  */
18
- export type { AppleIcon, TabRole } from './types';
18
+ export type { AppleIcon, TabBarPlacement, TabRole } from './types';
package/src/types.ts CHANGED
@@ -7,6 +7,9 @@ export type AppleIcon = { sfSymbol: SFSymbol };
7
7
 
8
8
  export type TabRole = 'search';
9
9
 
10
+ // 'leading' and 'trailing' mean a vertical tab bar on that edge.
11
+ export type TabBarPlacement = 'bottom' | 'leading' | 'trailing';
12
+
10
13
  export type BaseRoute = {
11
14
  key: string;
12
15
  title?: string;