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

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 (28) hide show
  1. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerManager.kt +55 -0
  2. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsDrawerView.kt +496 -0
  3. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +1 -1
  4. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +3 -0
  5. package/docs/SPEC.md +163 -2
  6. package/ios/RCTBottomTabsComponentView.mm +37 -0
  7. package/ios/RCTBottomTabsContainerView.swift +192 -7
  8. package/ios/RCTBottomTabsDrawerComponentView.h +8 -0
  9. package/ios/RCTBottomTabsDrawerComponentView.mm +79 -0
  10. package/ios/RCTBottomTabsDrawerContainerView.swift +327 -0
  11. package/lib/module/BottomTabs.js +144 -87
  12. package/lib/module/BottomTabsDrawerNativeComponent.ts +19 -0
  13. package/lib/module/BottomTabsDrawerNativeComponent.web.js +13 -0
  14. package/lib/module/BottomTabsNativeComponent.ts +13 -0
  15. package/lib/typescript/src/BottomTabs.d.ts +36 -10
  16. package/lib/typescript/src/BottomTabsDrawerNativeComponent.d.ts +15 -0
  17. package/lib/typescript/src/BottomTabsDrawerNativeComponent.web.d.ts +4 -0
  18. package/lib/typescript/src/BottomTabsNativeComponent.d.ts +8 -0
  19. package/lib/typescript/src/index.d.ts +1 -1
  20. package/lib/typescript/src/types.d.ts +1 -0
  21. package/package.json +3 -2
  22. package/react-native.config.js +4 -1
  23. package/src/BottomTabs.tsx +235 -115
  24. package/src/BottomTabsDrawerNativeComponent.ts +19 -0
  25. package/src/BottomTabsDrawerNativeComponent.web.tsx +9 -0
  26. package/src/BottomTabsNativeComponent.ts +13 -0
  27. package/src/index.tsx +1 -1
  28. package/src/types.ts +3 -0
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
@@ -142,12 +262,46 @@ boundary; `BottomTabsView.kt` owns scene geometry, appearance, and disposal;
142
262
  and runner instructions are in `native-tests/android`. The example's settings
143
263
  page exposes the switch and its scrolling scene reserves the reported bar height.
144
264
 
265
+ ## iOS vertical tab bar placement and side accessory
266
+
267
+ Status: Implemented. Runtime results are in Conformance.
268
+
269
+ On iOS 27.1+, devices such as iPhone Duo can place the `UITabBarController`
270
+ bar vertically in a side strip next to the status bar. The view reports that
271
+ placement and can draw one round accessory button above the vertical bar.
272
+
273
+ - **Placement:** `onTabBarPlacementChange({ placement })` fires with `bottom`,
274
+ `leading`, or `trailing`, read from `traitCollection.verticalBarEdge` of the
275
+ tab bar controller. It fires once after the bar is created and again on every
276
+ change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK
277
+ and earlier systems always report `bottom`. Android never fires it.
278
+ - **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the
279
+ React image loader and drawn as a template tinted with `inactiveTintColor`),
280
+ `sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The
281
+ button is 48 pt, a Liquid Glass capsule on iOS 26+, centered horizontally on
282
+ the vertical bar's item platter and 12 pt above it.
283
+ - Android MUST accept `sideAccessoryIcon` and `sideAccessoryLabel`, including
284
+ null resets, as no-ops through its generated ViewManager interface. It MUST
285
+ NOT create a side accessory or emit placement/accessory events. Shared Fabric
286
+ props still require Android setters even when the feature is iOS-only.
287
+ - The button shows only when all hold: the placement is `leading` or
288
+ `trailing`, the tab bar is not hidden, the icon has loaded, and the platter
289
+ is found. Otherwise it is hidden; a bottom bar never shows it.
290
+ - UIKit does not expose the platter frame. The view finds the platter by its
291
+ view class name below the tab bar controller (skipping tab content views),
292
+ reads its frame, and calls no private API. If a later system renames or
293
+ restructures it, the accessory hides; no other behavior depends on it.
294
+ - Layout re-runs, coalesced on the next main-queue turn, after container
295
+ layout, tab rebuilds, tab bar hiding, icon loads, and changes to the traits
296
+ affecting `verticalBarEdge`.
297
+
145
298
  ## Threading and ownership
146
299
 
147
300
  All gesture handling, animations, and timers run on the platform UI thread in
148
301
  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.
302
+ objects. The tab bar retains the current touch sequence, the last pressed tab
303
+ and timestamp, and at most two running animators. The optional drawer's additional
304
+ single animation and touch ownership are bounded in the Native left drawer section.
151
305
 
152
306
  ## Conformance and acceptance
153
307
 
@@ -166,3 +320,10 @@ non-selected tab lifts the lens there; drag crossing tab centers does not change
166
320
  pages; release selects the nearest tab once; cancel restores; hidden bar, dark
167
321
  mode, and 3-button navigation insets lay out correctly. Source compilation is
168
322
  recorded separately from runtime acceptance.
323
+
324
+ Vertical tab bar: `RCTBottomTabsContainerView.swift` (`currentTabBarPlacement`,
325
+ `updateTabBarPlacementAndSideAccessory`, `verticalTabBarPlatterFrame`).
326
+ Required iOS runtime cases on iPhone Duo: the outer display and landscape inner
327
+ display report `trailing` and show the accessory above the platter; the
328
+ portrait inner display reports `bottom` and hides it; hiding the tab bar hides
329
+ 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
@@ -0,0 +1,79 @@
1
+ #import "RCTBottomTabsDrawerComponentView.h"
2
+ #import <react/renderer/components/RNBottomTabs/ComponentDescriptors.h>
3
+ #import <react/renderer/components/RNBottomTabs/EventEmitters.h>
4
+ #import <react/renderer/components/RNBottomTabs/Props.h>
5
+ #import <React/RCTFabricComponentsPlugins.h>
6
+
7
+ // Swift keeps the host internal to this module, so its framework compatibility
8
+ // header does not export the class. Its explicit @objc name and selectors form
9
+ // this private boundary, matching the existing tab container's runtime lookup.
10
+ @protocol RNBottomTabsDrawerContainer <NSObject>
11
+ @property(nonatomic) BOOL enabled;
12
+ @property(nonatomic) BOOL open;
13
+ @property(nonatomic) BOOL swipeEnabled;
14
+ @property(nonatomic) BOOL hapticFeedbackEnabled;
15
+ @property(nonatomic) double bottomBarHeight;
16
+ @property(nonatomic, copy) void (^onDrawerOpenChange)(BOOL open);
17
+ - (void)insertChild:(UIView *)child atIndex:(NSInteger)index;
18
+ - (void)removeChildAtIndex:(NSInteger)index;
19
+ @end
20
+
21
+ using namespace facebook::react;
22
+
23
+ @implementation RCTBottomTabsDrawerComponentView {
24
+ UIView<RNBottomTabsDrawerContainer> *_drawer;
25
+ }
26
+
27
+ + (ComponentDescriptorProvider)componentDescriptorProvider
28
+ {
29
+ return concreteComponentDescriptorProvider<RNBottomTabsDrawerComponentDescriptor>();
30
+ }
31
+
32
+ + (BOOL)shouldBeRecycled { return NO; }
33
+
34
+ - (instancetype)initWithFrame:(CGRect)frame
35
+ {
36
+ if (self = [super initWithFrame:frame]) {
37
+ _props = std::make_shared<const RNBottomTabsDrawerProps>();
38
+ Class containerClass = NSClassFromString(@"RCTBottomTabsDrawerContainerView");
39
+ _drawer = (UIView<RNBottomTabsDrawerContainer> *)[[containerClass alloc] initWithFrame:frame];
40
+ self.contentView = _drawer;
41
+ __weak RCTBottomTabsDrawerComponentView *weakSelf = self;
42
+ _drawer.onDrawerOpenChange = ^(BOOL open) {
43
+ auto strongSelf = weakSelf;
44
+ if (!strongSelf) return;
45
+ auto emitter = std::static_pointer_cast<const RNBottomTabsDrawerEventEmitter>(strongSelf->_eventEmitter);
46
+ if (emitter) emitter->onDrawerOpenChange({.open = (bool)open});
47
+ };
48
+ }
49
+ return self;
50
+ }
51
+
52
+ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index
53
+ {
54
+ [_drawer insertChild:child atIndex:index];
55
+ }
56
+
57
+ - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index
58
+ {
59
+ [_drawer removeChildAtIndex:index];
60
+ }
61
+
62
+ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
63
+ {
64
+ const auto &next = *std::static_pointer_cast<const RNBottomTabsDrawerProps>(props);
65
+ const auto &previous = *std::static_pointer_cast<const RNBottomTabsDrawerProps>(_props);
66
+ if (next.enabled != previous.enabled) _drawer.enabled = next.enabled;
67
+ if (next.open != previous.open) _drawer.open = next.open;
68
+ if (next.swipeEnabled != previous.swipeEnabled) _drawer.swipeEnabled = next.swipeEnabled;
69
+ if (next.hapticFeedbackEnabled != previous.hapticFeedbackEnabled)
70
+ _drawer.hapticFeedbackEnabled = next.hapticFeedbackEnabled;
71
+ if (next.bottomBarHeight != previous.bottomBarHeight) _drawer.bottomBarHeight = next.bottomBarHeight;
72
+ [super updateProps:props oldProps:oldProps];
73
+ }
74
+ @end
75
+
76
+ Class<RCTComponentViewProtocol> RNBottomTabsDrawerCls(void)
77
+ {
78
+ return RCTBottomTabsDrawerComponentView.class;
79
+ }