@onekeyfe/react-native-bottom-tabs 6.7.0-19 → 6.7.0-21

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.
Files changed (30) hide show
  1. package/android/src/main/java/com/onekey/bottomtabs/BottomBarBlurRenderer.kt +50 -30
  2. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerManager.kt +55 -0
  3. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerView.kt +496 -0
  4. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +1 -1
  5. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsView.kt +24 -4
  6. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +3 -0
  7. package/docs/SPEC.md +184 -20
  8. package/ios/RCTBottomTabsComponentView.mm +37 -0
  9. package/ios/RCTBottomTabsContainerView.swift +192 -7
  10. package/ios/RCTBottomTabsDrawerComponentView.h +8 -0
  11. package/ios/RCTBottomTabsDrawerComponentView.mm +79 -0
  12. package/ios/RCTBottomTabsDrawerContainerView.swift +327 -0
  13. package/lib/module/BottomTabs.js +144 -87
  14. package/lib/module/BottomTabsDrawerNativeComponent.ts +19 -0
  15. package/lib/module/BottomTabsDrawerNativeComponent.web.js +13 -0
  16. package/lib/module/BottomTabsNativeComponent.ts +13 -0
  17. package/lib/typescript/src/BottomTabs.d.ts +36 -10
  18. package/lib/typescript/src/BottomTabsDrawerNativeComponent.d.ts +15 -0
  19. package/lib/typescript/src/BottomTabsDrawerNativeComponent.web.d.ts +4 -0
  20. package/lib/typescript/src/BottomTabsNativeComponent.d.ts +8 -0
  21. package/lib/typescript/src/index.d.ts +1 -1
  22. package/lib/typescript/src/types.d.ts +1 -0
  23. package/package.json +3 -2
  24. package/react-native.config.js +4 -1
  25. package/src/BottomTabs.tsx +235 -115
  26. package/src/BottomTabsDrawerNativeComponent.ts +19 -0
  27. package/src/BottomTabsDrawerNativeComponent.web.tsx +9 -0
  28. package/src/BottomTabsNativeComponent.ts +13 -0
  29. package/src/index.tsx +1 -1
  30. package/src/types.ts +3 -0
@@ -154,7 +154,14 @@ class BottomTabsView(context: Context) : LinearLayout(context) {
154
154
  private val floatingRoot = object : FrameLayout(context) {
155
155
  override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
156
156
  if (child === floatingBar && usesBottomBlur) {
157
- drawBlurBackdrop(canvas, width, floatingBar.top, height) { recording ->
157
+ drawBlurBackdrop(
158
+ canvas,
159
+ floatingBar.left,
160
+ floatingBar.top,
161
+ floatingBar.right,
162
+ floatingBar.bottom,
163
+ floatingBar.height / 2f,
164
+ ) { recording ->
158
165
  super.drawChild(recording, layoutHolder, drawingTime)
159
166
  }
160
167
  }
@@ -994,18 +1001,31 @@ class BottomTabsView(context: Context) : LinearLayout(context) {
994
1001
  }
995
1002
 
996
1003
  private fun drawBlurBackdrop(
997
- canvas: Canvas, width: Int, barTop: Int, bottom: Int, drawScenes: (Canvas) -> Unit,
1004
+ canvas: Canvas,
1005
+ left: Int,
1006
+ top: Int,
1007
+ right: Int,
1008
+ bottom: Int,
1009
+ cornerRadius: Float,
1010
+ drawScenes: (Canvas) -> Unit,
998
1011
  ) {
999
1012
  if (!usesBottomBlur || !canvas.isHardwareAccelerated || Build.VERSION.SDK_INT < 31) return
1000
1013
  val blur = bottomBarBlur ?: BottomBarBlurRenderer(resources.displayMetrics.density).also {
1001
1014
  bottomBarBlur = it
1002
1015
  }
1003
- blur.draw(canvas, width, barTop, bottom, blurSurfaceColor, drawScenes)
1016
+ blur.draw(canvas, left, top, right, bottom, cornerRadius, blurSurfaceColor, drawScenes)
1004
1017
  }
1005
1018
 
1006
1019
  override fun drawChild(canvas: Canvas, child: View, drawingTime: Long): Boolean {
1007
1020
  if (child === bottomNavigation && androidTabBarStyle == AndroidTabBarStyle.DEFAULT) {
1008
- drawBlurBackdrop(canvas, width, bottomNavigation.top, bottomNavigation.bottom) { recording ->
1021
+ drawBlurBackdrop(
1022
+ canvas,
1023
+ bottomNavigation.left,
1024
+ bottomNavigation.top,
1025
+ bottomNavigation.right,
1026
+ bottomNavigation.bottom,
1027
+ 0f,
1028
+ ) { recording ->
1009
1029
  super.drawChild(recording, layoutHolder, drawingTime)
1010
1030
  }
1011
1031
  }
@@ -5,6 +5,7 @@ import android.view.View
5
5
  import android.view.ViewGroup
6
6
  import com.facebook.react.bridge.ReactApplicationContext
7
7
  import com.facebook.react.bridge.ReadableArray
8
+ import com.facebook.react.bridge.ReadableMap
8
9
  import com.facebook.react.module.annotations.ReactModule
9
10
  import com.facebook.react.uimanager.ThemedReactContext
10
11
  import com.facebook.react.uimanager.UIManagerHelper
@@ -213,6 +214,8 @@ class BottomTabsViewManager(context: ReactApplicationContext) :
213
214
  override fun setSidebarAdaptable(view: BottomTabsView?, value: Boolean) {}
214
215
  override fun setScrollEdgeAppearance(view: BottomTabsView?, value: String?) {}
215
216
  override fun setMinimizeBehavior(view: BottomTabsView?, value: String?) {}
217
+ override fun setSideAccessoryIcon(view: BottomTabsView?, value: ReadableMap?) {}
218
+ override fun setSideAccessoryLabel(view: BottomTabsView?, value: String?) {}
216
219
 
217
220
  companion object {
218
221
  const val NAME = "RNBottomTabsView"
package/docs/SPEC.md CHANGED
@@ -1,5 +1,125 @@
1
1
  # Native Tab View behavioral contract
2
2
 
3
+ ## Native left drawer
4
+
5
+ Status: implemented in module source. Package TypeScript, iOS/Android Fabric
6
+ code generation, iOS Objective-C++ Fabric bridge compilation against RN 0.86.2,
7
+ 13 React wrapper checks, twelve Android API 36 native tests and four real-Fabric
8
+ iOS review cases passed (shared-header/body arbitration and bar-height switches).
9
+ The static Home example also passed a focused TypeScript check.
10
+ Detailed iOS runtime evidence is recorded separately; physical-device and exact
11
+ reference-video acceptance remain pending.
12
+
13
+ `renderDrawerContent?: () => React.ReactNode` supplies an optional menu slot.
14
+ `drawerOpen?: boolean` defaults to false and requests its stable open state.
15
+ `onDrawerOpenChange?: (open: boolean) => void` reports a completed user transition;
16
+ controlled consumers MUST mirror it into `drawerOpen`. Prop-driven transitions,
17
+ initial layout and cancellation emit no event. A user transition returning to
18
+ the previous stable state emits only when it overrides an in-flight external
19
+ target, so the consumer can reconcile that target. `drawerSwipeEnabled?: boolean`
20
+ defaults to false; callers MUST enable it only where drawer navigation is appropriate (normally a root
21
+ screen). Existing `hapticFeedbackEnabled` enables one light native haptic after
22
+ a completed user transition changes the stable state. Programmatic updates and
23
+ cancelled gestures do not vibrate.
24
+
25
+ The drawer is physically left on both iOS and Android, including RTL. Its width
26
+ is 78 percent of the host. The foreground is the full tab container, including
27
+ the native tab bar and an optional custom JS bar. It keeps its full width and
28
+ height while moving right, without scaling, scene reparenting or remounting.
29
+ The native host and two slots remain mounted when the drawer is disabled or its
30
+ content changes. Missing content disables the drawer, resets it closed without
31
+ an event, and passes foreground input through. Web omits the drawer host/menu
32
+ and passes the existing tab content through; it implements no drawer gestures.
33
+ The existing tab view's other Web support is unchanged.
34
+
35
+ For normalized progress `p` in [0, 1], foreground translation is `.78 * width * p`,
36
+ its left circular corner radius is `64 * p` points/dp and its neutral gray
37
+ dim surface (RGB 28/28/28) has opacity `.7 * p`.
38
+ The menu translates from `-.12 * drawerWidth` to zero; a black menu shade fades
39
+ from .12 to zero. These are initial reference-video tuning values, not a claim
40
+ of pixel-perfect parity. Closed menus are hidden from touch/accessibility;
41
+ while visible, the foreground is blocked by a native dim surface. Tapping that
42
+ surface closes the drawer. iOS accessibility escape and Android Back close an
43
+ open drawer; the dim surface exposes an accessible close action.
44
+ On Android the dim surface MUST retain full foreground bounds even while hidden
45
+ and after React/Fabric ancestors suppress `requestLayout`. Internal containers
46
+ and both slots MUST be measured and laid out on host layout and slot changes,
47
+ without relying on a later ancestor layout pass. Visible dim input MUST prevent
48
+ native foreground controls from receiving the same touch.
49
+ While closed, that retained dim surface MUST also be excluded from React Native
50
+ touch-target lookup, so JS Pressable taps reach the foreground. Visibility alone
51
+ is insufficient for React Native's native target traversal.
52
+
53
+ The reported native tab-bar height MUST become zero when hidden and restore the
54
+ current height when shown. The context and drawer exclusion use one effective
55
+ height: a rendered custom bar's own measured height takes precedence; otherwise
56
+ `tabBarHidden` forces zero. Native hide measurements MUST NOT overwrite a custom
57
+ bar's height, including across native/custom presentation switches.
58
+
59
+ With opening swipes enabled, a one-finger rightward gesture may start anywhere
60
+ in the foreground content. At the first confirmed direction, the actual native
61
+ touch target and its ancestor chain determine ownership. A horizontal scroll
62
+ view, list or pager which can consume that physical direction takes priority;
63
+ only when every such ancestor is at its boundary may the drawer open. Vertical
64
+ scroll views do not block horizontal drawer intent. Once selected, the owner is
65
+ locked until up/cancel: reaching a boundary mid-gesture does not hand off.
66
+ Disabled pager input does not claim a gesture based on its retained scroll
67
+ range; an independently enabled horizontal child inside that page still can.
68
+ An ancestor pager which explicitly excludes the touched shared header MUST NOT
69
+ claim that header gesture solely because its horizontal offset is nonzero.
70
+ The header's own nested horizontal content retains its normal boundary priority.
71
+ On iOS this uses the repository's existing `excludedHeaderView` Objective-C
72
+ getter on the native smooth pager and the real touch target's ancestry. It does
73
+ not invoke another scroll view's UIKit recognition lifecycle hook, add a Pager
74
+ API, or infer a header exclusion from a pager's selected page.
75
+ The iOS UIPageViewController boundary uses its public data-source query once per
76
+ decision, skipping its internally centered scroll offset. Android observes
77
+ pending direction even when a nested pager initially disallows interception,
78
+ but stops overriding that request after the child wins. Sliders and editing controls,
79
+ web/graphics surfaces, system back gestures and starts in the bottom tab-bar
80
+ footprint take priority. When open, closing gestures
81
+ remain enabled even if opening swipes are disabled. Only the exposed
82
+ foreground accepts a closing pan; menu scrolling remains the consumer's own.
83
+ Vertical gestures fail before taking ownership. Active touches interrupted by
84
+ a second finger, system cancellation, disabling, detach or a newer prop update
85
+ cannot later emit an event. Cancellation returns to the previous stable state,
86
+ or resumes an external target whose programmatic animation the gesture interrupted.
87
+ No gesture progress crosses the JS bridge.
88
+
89
+ Both platforms settle on their UI thread with the same critically damped curve:
90
+ `p(t) = target + (a + b*t) * exp(-22*t)`, where `a = p0-target`,
91
+ `b = v0 + 22*a`, and normalized velocity `v0` is clamped to [-8, 8] per second.
92
+ Presentation is clamped to [0, 1], and the transition ends at or before 500 ms.
93
+ Release targets open when `p + v0*.12 >= .5`. A newer animation or touch starts
94
+ from the current displayed progress and cancels the previous completion.
95
+ Resize snaps a pending settlement to its target, emitting its user event once
96
+ if appropriate; an active drag follows the same cancellation rule. Detach cancels
97
+ animations and callbacks; reattachment reapplies the latest external target.
98
+ The host retains at most one display-link/animator, one touch
99
+ sequence, two React child slots, and native dim surfaces. It owns no snapshots,
100
+ workers, persistence, JS animation objects, RNGH or Reanimated dependency.
101
+
102
+ Implementation boundaries: `src/BottomTabs.tsx` owns slots and settled callbacks;
103
+ `src/BottomTabsDrawerNativeComponent.ts` defines the Fabric boundary;
104
+ `ios/RCTBottomTabsDrawerContainerView.swift` and
105
+ `android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerView.kt` own native
106
+ input, layout, animation and cleanup. The iOS Fabric component and Android manager only forward children,
107
+ props and the settled event. Required acceptance includes both bar styles,
108
+ custom bars, content/vertical gestures, dim close, rapid reversal, cancellation,
109
+ disable/remove-content, resize, detach, accessibility, RTL and bottom-bar
110
+ gesture ownership, including a real pager, nested horizontal list and vertical
111
+ scrolling in the complete Fabric example. Source checks and emulator checks do not establish physical
112
+ device smoothness or exact reference-video fidelity.
113
+ The Android native suite also exercises generated drawer prop forwarding in RTL,
114
+ physical-left placement, and closing pans while opening swipes are disabled.
115
+
116
+ The standalone `example/react-native/pages/BottomTabsHomeDrawerExample.tsx`
117
+ reproduces Home’s wallet toolbar, balance/actions/banner shared header and
118
+ Spot/Perps/Earn/NFT/History hierarchy using static data, native CollapsiblePager
119
+ and standard React Native scroll views. It is an equivalent interaction fixture;
120
+ the application’s existing JS collapsible-header implementation and business
121
+ NativeList pages are not migrated or exercised by this fixture.
122
+
3
123
  ## Tab presses and double press
4
124
 
5
125
  Status: implemented in module source. Runtime verification status is recorded
@@ -52,10 +172,10 @@ bottom gap, or 0 while hidden.
52
172
 
53
173
  Floating appearance with background blur disabled: the bar surface is `barTintColor` (or the theme background)
54
174
  at 95% opacity (lightened slightly for dark colors), with
55
- a hairline rim and an outline shadow. When background blur is enabled (see below), the bottom strip extends from
56
- 24 dp above the capsule to the bottom of the scene, including the gap beneath
57
- the capsule. The existing capsule keeps its rim/shadow and uses a 12 percent
58
- surface fill over the blurred strip; its lens and navigation controls remain
175
+ a hairline rim and an outline shadow. When background blur is enabled (see below), the live blur and translucent
176
+ surface tint are confined and clipped to the capsule bounds; the region above,
177
+ beside, and beneath the capsule remains unblurred. The existing capsule keeps its rim/shadow and uses a 12 percent
178
+ surface fill over the blurred capsule backdrop; its lens and navigation controls remain
59
179
  sharp. The selected tab sits on a capsule pill inset 4 dp
60
180
  (`activeIndicatorColor` when provided). The Material ripple is disabled; the
61
181
  lens provides press feedback.
@@ -91,7 +211,7 @@ coverage. Complete consumer-app and physical-device acceptance remain pending;
91
211
  API 31-35 and visual dark-mode acceptance have not been reverified for this port.
92
212
 
93
213
  `androidBottomBlurEnabled?: boolean` controls the native Android tab bar's live
94
- background blur, translucent surface tint, and 24 dp fade into the page above it.
214
+ background blur and translucent surface tint behind the bar.
95
215
  Omitting it (including `undefined`) MUST enable the effect only on Android 16
96
216
  (API 36) and newer. `false` MUST restore the existing appearance and layout for the current
97
217
  `androidTabBarStyle`: docked scenes reserve bar height; floating scenes retain
@@ -109,19 +229,22 @@ report zero bar height. Toggling, resizing, theme changes, and system/keyboard
109
229
  insets MUST refresh geometry and appearance without remounting scenes.
110
230
 
111
231
  The module owns the GPU resources on the UI thread. It MUST sample only the
112
- scene layer, excluding the tab bar and its own output. It MUST use hardware
113
- RenderNode/RenderEffect rendering of a bottom strip at one-quarter resolution
114
- in each dimension, with a 20 dp blur radius and 40 dp sampling margin above the
115
- fade. It MUST NOT capture bitmaps, read pixels back to the CPU, emit scroll
116
- events to JS, or run a recurring frame callback for this effect. Rendering MUST
117
- follow the normal view invalidation cycle, using the scene's existing display
118
- lists. The translucent tint MUST follow `tabBarStyle.backgroundColor` when set,
119
- otherwise the docked Material surface color or floating theme background,
120
- with opacity capped at 78 percent.
121
-
122
- Only one blur RenderNode and cached paints/shaders MAY be retained per mounted
123
- tab view. Its offscreen dimensions MUST be bounded by one-quarter of the view
124
- width and the smaller of the view height or reported bar footprint plus 64 dp (rounded up).
232
+ scene layer directly behind the active tab bar bounds (the docked bar rectangle
233
+ in `default` style or the capsule bounds in `floating` style), excluding the
234
+ tab bar and its own output, and clip the blurred backdrop and tint to those
235
+ bounds (including the capsule corner radius in `floating` style) with no blur
236
+ or fade bleeding above or outside the bar. It MUST use hardware
237
+ RenderNode/RenderEffect rendering of the bar bounds at one-quarter resolution
238
+ in each dimension, with a 20 dp blur radius. It MUST NOT capture bitmaps, read
239
+ pixels back to the CPU, emit scroll events to JS, or run a recurring frame
240
+ callback for this effect. Rendering MUST follow the normal view invalidation
241
+ cycle, using the scene's existing display lists. The translucent tint MUST
242
+ follow `tabBarStyle.backgroundColor` when set, otherwise the docked Material
243
+ surface color or floating theme background, with opacity capped at 78 percent.
244
+
245
+ Only one blur RenderNode and cached paints/paths MAY be retained per mounted
246
+ tab view. Its offscreen dimensions MUST be bounded by one-quarter of the bar
247
+ width and one-quarter of the bar height (rounded up).
125
248
  Disabling, hiding, detaching, and dropping the native view MUST discard the
126
249
  display list; no scene snapshots, disk cache, timers, or background workers are
127
250
  owned. SurfaceView content is outside the normal View display list and is not
@@ -142,12 +265,46 @@ boundary; `BottomTabsView.kt` owns scene geometry, appearance, and disposal;
142
265
  and runner instructions are in `native-tests/android`. The example's settings
143
266
  page exposes the switch and its scrolling scene reserves the reported bar height.
144
267
 
268
+ ## iOS vertical tab bar placement and side accessory
269
+
270
+ Status: Implemented. Runtime results are in Conformance.
271
+
272
+ On iOS 27.1+, devices such as iPhone Duo can place the `UITabBarController`
273
+ bar vertically in a side strip next to the status bar. The view reports that
274
+ placement and can draw one round accessory button above the vertical bar.
275
+
276
+ - **Placement:** `onTabBarPlacementChange({ placement })` fires with `bottom`,
277
+ `leading`, or `trailing`, read from `traitCollection.verticalBarEdge` of the
278
+ tab bar controller. It fires once after the bar is created and again on every
279
+ change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK
280
+ and earlier systems always report `bottom`. Android never fires it.
281
+ - **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the
282
+ React image loader and drawn as a template tinted with `inactiveTintColor`),
283
+ `sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The
284
+ button is 48 pt, a Liquid Glass capsule on iOS 26+, centered horizontally on
285
+ the vertical bar's item platter and 12 pt above it.
286
+ - Android MUST accept `sideAccessoryIcon` and `sideAccessoryLabel`, including
287
+ null resets, as no-ops through its generated ViewManager interface. It MUST
288
+ NOT create a side accessory or emit placement/accessory events. Shared Fabric
289
+ props still require Android setters even when the feature is iOS-only.
290
+ - The button shows only when all hold: the placement is `leading` or
291
+ `trailing`, the tab bar is not hidden, the icon has loaded, and the platter
292
+ is found. Otherwise it is hidden; a bottom bar never shows it.
293
+ - UIKit does not expose the platter frame. The view finds the platter by its
294
+ view class name below the tab bar controller (skipping tab content views),
295
+ reads its frame, and calls no private API. If a later system renames or
296
+ restructures it, the accessory hides; no other behavior depends on it.
297
+ - Layout re-runs, coalesced on the next main-queue turn, after container
298
+ layout, tab rebuilds, tab bar hiding, icon loads, and changes to the traits
299
+ affecting `verticalBarEdge`.
300
+
145
301
  ## Threading and ownership
146
302
 
147
303
  All gesture handling, animations, and timers run on the platform UI thread in
148
304
  the main-runtime native view. The view owns no background-runtime or shared JS
149
- objects. Retained state is bounded to the current touch sequence, the last
150
- pressed tab and timestamp, and at most two running animators.
305
+ objects. The tab bar retains the current touch sequence, the last pressed tab
306
+ and timestamp, and at most two running animators. The optional drawer's additional
307
+ single animation and touch ownership are bounded in the Native left drawer section.
151
308
 
152
309
  ## Conformance and acceptance
153
310
 
@@ -166,3 +323,10 @@ non-selected tab lifts the lens there; drag crossing tab centers does not change
166
323
  pages; release selects the nearest tab once; cancel restores; hidden bar, dark
167
324
  mode, and 3-button navigation insets lay out correctly. Source compilation is
168
325
  recorded separately from runtime acceptance.
326
+
327
+ Vertical tab bar: `RCTBottomTabsContainerView.swift` (`currentTabBarPlacement`,
328
+ `updateTabBarPlacementAndSideAccessory`, `verticalTabBarPlatterFrame`).
329
+ Required iOS runtime cases on iPhone Duo: the outer display and landscape inner
330
+ display report `trailing` and show the accessory above the platter; the
331
+ portrait inner display reports `bottom` and hides it; hiding the tab bar hides
332
+ 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,16 @@ 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
+ // Increments per icon load; a completion applies only to the latest one.
79
+ private var sideAccessoryIconRequest = 0
80
+ private var isSideAccessoryLayoutScheduled = false
81
+ private static let sideAccessorySize: CGFloat = 48
82
+ private static let sideAccessorySpacing: CGFloat = 12
73
83
 
74
84
  // MARK: - Props (set by Fabric ComponentView via KVC)
75
85
 
@@ -128,7 +138,11 @@ class RCTBottomTabsContainerView: UIView {
128
138
 
129
139
  @objc var inactiveTintColor: UIColor? {
130
140
  // Rebuild so the baked unselected-image tint follows theme changes.
131
- didSet { updateTabBarAppearance(); rebuildViewControllers() }
141
+ didSet {
142
+ updateTabBarAppearance()
143
+ rebuildViewControllers()
144
+ sideAccessoryButton?.tintColor = inactiveTintColor
145
+ }
132
146
  }
133
147
 
134
148
  @objc var rippleColor: UIColor? // Android only
@@ -147,12 +161,22 @@ class RCTBottomTabsContainerView: UIView {
147
161
  didSet { updateTabBarAppearance() }
148
162
  }
149
163
 
164
+ @objc var sideAccessoryIcon: NSDictionary? {
165
+ didSet { loadSideAccessoryIcon() }
166
+ }
167
+
168
+ @objc var sideAccessoryLabel: String? {
169
+ didSet { sideAccessoryButton?.accessibilityLabel = sideAccessoryLabel }
170
+ }
171
+
150
172
  // MARK: - Events (RCTDirectEventBlock)
151
173
 
152
174
  @objc var onPageSelected: RCTDirectEventBlock?
153
175
  @objc var onTabDoublePress: RCTDirectEventBlock?
154
176
  @objc var onTabBarMeasured: RCTDirectEventBlock?
155
177
  @objc var onNativeLayout: RCTDirectEventBlock?
178
+ @objc var onTabBarPlacementChange: RCTDirectEventBlock?
179
+ @objc var onSideAccessoryPress: RCTDirectEventBlock?
156
180
 
157
181
  // MARK: - Init
158
182
 
@@ -188,6 +212,165 @@ class RCTBottomTabsContainerView: UIView {
188
212
  // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
189
213
  }
190
214
 
215
+ // MARK: - Vertical tab bar (iOS 27.1+)
216
+
217
+ /// Where the system draws the tab bar: "bottom", or "leading"/"trailing" for
218
+ /// the vertical bar some devices (iPhone Duo) place in a side strip.
219
+ private func currentTabBarPlacement() -> String {
220
+ #if compiler(>=6.4)
221
+ if #available(iOS 27.1, *), let tbc = tabBarController {
222
+ switch tbc.traitCollection.verticalBarEdge {
223
+ case .leading: return "leading"
224
+ case .trailing: return "trailing"
225
+ default: break
226
+ }
227
+ }
228
+ #endif
229
+ return "bottom"
230
+ }
231
+
232
+ private func registerVerticalBarTraitChanges(_ tbc: UITabBarController) {
233
+ #if compiler(>=6.4)
234
+ if #available(iOS 27.1, *) {
235
+ tbc.registerForTraitChanges(
236
+ UITraitCollection.systemTraitsAffectingVerticalBarEdge
237
+ ) { [weak self] (_: UITabBarController, _: UITraitCollection) in
238
+ self?.scheduleSideAccessoryLayout()
239
+ }
240
+ }
241
+ #endif
242
+ }
243
+
244
+ /// Coalesces requests and runs after UIKit has laid out the bar, whose
245
+ /// floating platter is positioned in UIKit's own layout pass.
246
+ private func scheduleSideAccessoryLayout() {
247
+ guard !isSideAccessoryLayoutScheduled else { return }
248
+ isSideAccessoryLayoutScheduled = true
249
+ DispatchQueue.main.async { [weak self] in
250
+ guard let self else { return }
251
+ self.isSideAccessoryLayoutScheduled = false
252
+ self.updateTabBarPlacementAndSideAccessory()
253
+ }
254
+ }
255
+
256
+ private func updateTabBarPlacementAndSideAccessory() {
257
+ guard let tbc = tabBarController else { return }
258
+ let placement = currentTabBarPlacement()
259
+ if placement != reportedTabBarPlacement {
260
+ reportedTabBarPlacement = placement
261
+ onTabBarPlacementChange?(["placement": placement])
262
+ }
263
+
264
+ guard placement != "bottom",
265
+ !tabBarHidden,
266
+ let icon = loadedSideAccessoryIcon,
267
+ let platterFrame = verticalTabBarPlatterFrame(in: tbc) else {
268
+ sideAccessoryButton?.isHidden = true
269
+ return
270
+ }
271
+
272
+ let button = ensureSideAccessoryButton(in: tbc.view)
273
+ button.configuration?.image = icon
274
+ let size = RCTBottomTabsContainerView.sideAccessorySize
275
+ button.frame = CGRect(
276
+ x: platterFrame.midX - size / 2,
277
+ y: platterFrame.minY - RCTBottomTabsContainerView.sideAccessorySpacing - size,
278
+ width: size,
279
+ height: size
280
+ )
281
+ button.isHidden = false
282
+ tbc.view.bringSubviewToFront(button)
283
+ }
284
+
285
+ /// UIKit draws the vertical bar items in a floating platter it does not
286
+ /// expose. Read that view's frame (no private API is called) and return nil
287
+ /// when it cannot be found, so the accessory hides instead of misplacing.
288
+ private func verticalTabBarPlatterFrame(in tbc: UITabBarController) -> CGRect? {
289
+ let root = tbc.view!
290
+ // Tab content can hold large React trees; the bar lives outside them.
291
+ let contentViews = Set((tbc.viewControllers ?? []).compactMap { vc in
292
+ vc.isViewLoaded ? ObjectIdentifier(vc.view) : nil
293
+ })
294
+ var queue: [UIView] = root.subviews
295
+ var index = 0
296
+ while index < queue.count {
297
+ let view = queue[index]
298
+ index += 1
299
+ if view === sideAccessoryButton || contentViews.contains(ObjectIdentifier(view)) {
300
+ continue
301
+ }
302
+ if NSStringFromClass(type(of: view)).contains("TabBarItemPlatterView") {
303
+ guard !view.isHidden, view.window != nil else { return nil }
304
+ let frame = view.convert(view.bounds, to: root)
305
+ return frame.isEmpty ? nil : frame
306
+ }
307
+ queue.append(contentsOf: view.subviews)
308
+ }
309
+ return nil
310
+ }
311
+
312
+ private func ensureSideAccessoryButton(in parent: UIView) -> UIButton {
313
+ if let button = sideAccessoryButton {
314
+ if button.superview !== parent { parent.addSubview(button) }
315
+ return button
316
+ }
317
+ var configuration: UIButton.Configuration
318
+ #if compiler(>=6.2)
319
+ if #available(iOS 26.0, *) {
320
+ configuration = .glass()
321
+ } else {
322
+ configuration = .gray()
323
+ }
324
+ #else
325
+ configuration = .gray()
326
+ #endif
327
+ configuration.cornerStyle = .capsule
328
+ configuration.contentInsets = .zero
329
+ let button = UIButton(configuration: configuration)
330
+ button.tintColor = inactiveTintColor
331
+ button.accessibilityLabel = sideAccessoryLabel
332
+ button.addAction(UIAction { [weak self] _ in
333
+ self?.onSideAccessoryPress?([:])
334
+ }, for: .primaryActionTriggered)
335
+ parent.addSubview(button)
336
+ sideAccessoryButton = button
337
+ return button
338
+ }
339
+
340
+ private func loadSideAccessoryIcon() {
341
+ sideAccessoryIconRequest += 1
342
+ let request = sideAccessoryIconRequest
343
+ // Drop the previous icon so a slower or failed load never leaves it shown
344
+ // for the new source.
345
+ if loadedSideAccessoryIcon != nil {
346
+ loadedSideAccessoryIcon = nil
347
+ scheduleSideAccessoryLayout()
348
+ }
349
+ let source = sideAccessoryIcon.map { IconSource(dict: $0) }
350
+ guard let source, let url = URL(string: source.uri) else { return }
351
+ // The image loader arrives with the first state update; setImageLoader
352
+ // calls this again.
353
+ guard let imageLoader else { return }
354
+ imageLoader.loadImage(
355
+ with: URLRequest(url: url),
356
+ size: CGSize(width: source.width, height: source.height),
357
+ scale: CGFloat(source.scale),
358
+ clipped: true,
359
+ resizeMode: RCTResizeMode.contain,
360
+ progressBlock: { _, _ in },
361
+ partialLoad: { _ in },
362
+ completionBlock: { [weak self] error, image in
363
+ guard error == nil, let image else { return }
364
+ DispatchQueue.main.async {
365
+ guard let self, request == self.sideAccessoryIconRequest else { return }
366
+ self.loadedSideAccessoryIcon = image
367
+ .resizeImageTo(size: self.iconSize)?
368
+ .withRenderingMode(.alwaysTemplate)
369
+ self.scheduleSideAccessoryLayout()
370
+ }
371
+ })
372
+ }
373
+
191
374
  // MARK: - Lifecycle
192
375
 
193
376
  private func handleLayout() {
@@ -197,6 +380,7 @@ class RCTBottomTabsContainerView: UIView {
197
380
  setupTabBarControllerIfNeeded()
198
381
  tabBarController?.view.frame = bounds
199
382
  onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
383
+ scheduleSideAccessoryLayout()
200
384
  }
201
385
 
202
386
  private func setupTabBarControllerIfNeeded() {
@@ -215,6 +399,7 @@ class RCTBottomTabsContainerView: UIView {
215
399
  tbc.didMove(toParent: parentVC)
216
400
 
217
401
  self.tabBarController = tbc
402
+ registerVerticalBarTraitChanges(tbc)
218
403
 
219
404
  rebuildViewControllers()
220
405
  updateTabBarAppearance()
@@ -225,9 +410,7 @@ class RCTBottomTabsContainerView: UIView {
225
410
 
226
411
  DispatchQueue.main.async { [weak self] in
227
412
  guard let self, let tbc = self.tabBarController else { return }
228
- if !tbc.tabBar.isHidden {
229
- self.onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
230
- }
413
+ self.onTabBarMeasured?(["height": self.tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
231
414
  }
232
415
  }
233
416
 
@@ -358,6 +541,8 @@ class RCTBottomTabsContainerView: UIView {
358
541
  }
359
542
 
360
543
  updateSelectedTab()
544
+ // The platter height follows the number of visible tabs.
545
+ scheduleSideAccessoryLayout()
361
546
  }
362
547
 
363
548
  // MARK: - Selection
@@ -499,9 +684,8 @@ class RCTBottomTabsContainerView: UIView {
499
684
  #endif
500
685
  tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
501
686
 
502
- if !tabBarHidden {
503
- onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
504
- }
687
+ onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)])
688
+ scheduleSideAccessoryLayout()
505
689
  }
506
690
 
507
691
  private func updateSidebarAdaptable() {
@@ -573,6 +757,7 @@ class RCTBottomTabsContainerView: UIView {
573
757
  self.imageLoader = loader
574
758
  loadIconsFromSources()
575
759
  loadSelectedIconsFromSources()
760
+ loadSideAccessoryIcon()
576
761
  }
577
762
 
578
763
  private func loadIconsFromSources() {
@@ -0,0 +1,8 @@
1
+ #ifdef RCT_NEW_ARCH_ENABLED
2
+ #import <React/RCTViewComponentView.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+ @interface RCTBottomTabsDrawerComponentView : RCTViewComponentView
6
+ @end
7
+ NS_ASSUME_NONNULL_END
8
+ #endif