@onekeyfe/react-native-bottom-tabs 0.0.0-stage → 6.7.0-11

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 (78) hide show
  1. package/LICENSE +46 -0
  2. package/README.md +94 -2
  3. package/android/build.gradle +119 -0
  4. package/android/gradle.properties +4 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/AndroidManifestNew.xml +2 -0
  7. package/android/src/main/java/com/onekey/bottomtabs/BottomBarBlurRenderer.kt +92 -0
  8. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +16 -0
  9. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsView.kt +1558 -0
  10. package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +220 -0
  11. package/android/src/main/java/com/onekey/bottomtabs/FloatingTabBarStyle.kt +50 -0
  12. package/android/src/main/java/com/onekey/bottomtabs/ImageSource.kt +86 -0
  13. package/android/src/main/java/com/onekey/bottomtabs/TabInfo.kt +13 -0
  14. package/android/src/main/java/com/onekey/bottomtabs/Utils.kt +31 -0
  15. package/android/src/main/java/com/onekey/bottomtabs/events/OnNativeLayoutEvent.kt +20 -0
  16. package/android/src/main/java/com/onekey/bottomtabs/events/OnTabBarMeasuredEvent.kt +19 -0
  17. package/android/src/main/java/com/onekey/bottomtabs/events/PageSelectedEvent.kt +21 -0
  18. package/android/src/main/java/com/onekey/bottomtabs/events/TabDoublePressEvent.kt +19 -0
  19. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewComponentDescriptor.h +32 -0
  20. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.cpp +18 -0
  21. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.h +35 -0
  22. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.cpp +15 -0
  23. package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.h +25 -0
  24. package/docs/SPEC.md +168 -0
  25. package/ios/Extensions.swift +46 -0
  26. package/ios/RCTBottomAccessoryComponentView.h +12 -0
  27. package/ios/RCTBottomAccessoryComponentView.mm +67 -0
  28. package/ios/RCTBottomAccessoryContainerView.swift +51 -0
  29. package/ios/RCTBottomTabsComponentView.h +12 -0
  30. package/ios/RCTBottomTabsComponentView.mm +338 -0
  31. package/ios/RCTBottomTabsContainerView.swift +777 -0
  32. package/ios/RCTBottomTabsLog.h +7 -0
  33. package/ios/RCTBottomTabsLog.m +32 -0
  34. package/ios/SVG/CoreSVG.h +13 -0
  35. package/ios/SVG/CoreSVG.mm +177 -0
  36. package/ios/SVG/SvgDecoder.h +10 -0
  37. package/ios/SVG/SvgDecoder.mm +32 -0
  38. package/ios/TabBarFontSize.swift +55 -0
  39. package/lib/module/BottomAccessoryView.js +45 -0
  40. package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
  41. package/lib/module/BottomTabs.js +325 -0
  42. package/lib/module/BottomTabsNativeComponent.ts +70 -0
  43. package/lib/module/DelayedFreeze.js +31 -0
  44. package/lib/module/NativeSVGDecoder.js +5 -0
  45. package/lib/module/SceneMap.js +28 -0
  46. package/lib/module/codegen-types.d.js +2 -0
  47. package/lib/module/index.js +20 -0
  48. package/lib/module/package.json +1 -0
  49. package/lib/module/types.js +4 -0
  50. package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
  51. package/lib/module/utils/useBottomTabBarHeight.js +12 -0
  52. package/lib/typescript/package.json +1 -0
  53. package/lib/typescript/src/BottomAccessoryView.d.ts +8 -0
  54. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +16 -0
  55. package/lib/typescript/src/BottomTabs.d.ts +209 -0
  56. package/lib/typescript/src/BottomTabsNativeComponent.d.ts +59 -0
  57. package/lib/typescript/src/DelayedFreeze.d.ts +9 -0
  58. package/lib/typescript/src/NativeSVGDecoder.d.ts +6 -0
  59. package/lib/typescript/src/SceneMap.d.ts +10 -0
  60. package/lib/typescript/src/index.d.ts +16 -0
  61. package/lib/typescript/src/types.d.ts +29 -0
  62. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +3 -0
  63. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +2 -0
  64. package/package.json +117 -4
  65. package/react-native-bottom-tabs.podspec +36 -0
  66. package/react-native.config.js +13 -0
  67. package/src/BottomAccessoryView.tsx +58 -0
  68. package/src/BottomAccessoryViewNativeComponent.ts +27 -0
  69. package/src/BottomTabs.tsx +559 -0
  70. package/src/BottomTabsNativeComponent.ts +70 -0
  71. package/src/DelayedFreeze.tsx +33 -0
  72. package/src/NativeSVGDecoder.ts +5 -0
  73. package/src/SceneMap.tsx +34 -0
  74. package/src/codegen-types.d.ts +28 -0
  75. package/src/index.tsx +18 -0
  76. package/src/types.ts +31 -0
  77. package/src/utils/BottomTabBarHeightContext.ts +5 -0
  78. package/src/utils/useBottomTabBarHeight.ts +15 -0
@@ -0,0 +1,777 @@
1
+ import Foundation
2
+ import UIKit
3
+ import React
4
+
5
+ // MARK: - Data types for props from JS
6
+
7
+ struct TabItem {
8
+ let key: String
9
+ let title: String
10
+ let sfSymbol: String?
11
+ let badge: String?
12
+ let badgeBackgroundColor: Int?
13
+ let badgeTextColor: Int?
14
+ let activeTintColor: Int?
15
+ let hidden: Bool
16
+ let testID: String?
17
+ let role: String?
18
+ let preventsDefault: Bool
19
+
20
+ init(dict: NSDictionary) {
21
+ self.key = dict["key"] as? String ?? ""
22
+ self.title = dict["title"] as? String ?? ""
23
+ self.sfSymbol = dict["sfSymbol"] as? String
24
+ self.badge = dict["badge"] as? String
25
+ self.badgeBackgroundColor = dict["badgeBackgroundColor"] as? Int
26
+ self.badgeTextColor = dict["badgeTextColor"] as? Int
27
+ self.activeTintColor = dict["activeTintColor"] as? Int
28
+ self.hidden = dict["hidden"] as? Bool ?? false
29
+ self.testID = dict["testID"] as? String
30
+ self.role = dict["role"] as? String
31
+ self.preventsDefault = dict["preventsDefault"] as? Bool ?? false
32
+ }
33
+ }
34
+
35
+ struct IconSource {
36
+ let uri: String
37
+ let width: Double
38
+ let height: Double
39
+ let scale: Double
40
+
41
+ init(dict: NSDictionary) {
42
+ self.uri = dict["uri"] as? String ?? ""
43
+ self.width = dict["width"] as? Double ?? 0
44
+ self.height = dict["height"] as? Double ?? 0
45
+ self.scale = dict["scale"] as? Double ?? 1
46
+ }
47
+ }
48
+
49
+ // MARK: - RCTBottomTabsContainerView
50
+
51
+ class RCTBottomTabsContainerView: UIView {
52
+
53
+ // MARK: - Internal State
54
+
55
+ private var tabBarController: UITabBarController?
56
+ private var childViews: [UIView] = []
57
+ private var bottomAccessoryView: UIView?
58
+ private var loadedIcons: [Int: UIImage] = [:]
59
+ // Solid/focused icons shown for the selected tab via UITabBarItem.selectedImage.
60
+ // Loaded once and swapped natively by UIKit on selection, so the focused
61
+ // artwork rides the system selection animation instead of waiting for a JS
62
+ // round-trip + async reload.
63
+ private var loadedSelectedIcons: [Int: UIImage] = [:]
64
+ private var imageLoader: RCTImageLoaderProtocol?
65
+ private let iconSize = CGSize(width: 27, height: 27)
66
+ // Double press is derived from consecutive selection requests so no extra
67
+ // recognizer competes with the system tab bar gestures (iOS 26 drag-to-switch).
68
+ private static let doublePressInterval: CFTimeInterval = 0.3
69
+ private var lastPressedTabKey: String?
70
+ private var lastPressedTabTime: CFTimeInterval = 0
71
+ /// Cached view controllers keyed by tab key to avoid unnecessary reparenting
72
+ private var cachedViewControllers: [String: UIViewController] = [:]
73
+
74
+ // MARK: - Props (set by Fabric ComponentView via KVC)
75
+
76
+ @objc var items: [NSDictionary]? {
77
+ didSet { rebuildViewControllers() }
78
+ }
79
+
80
+ @objc var selectedPage: String? {
81
+ didSet { updateSelectedTab() }
82
+ }
83
+
84
+ @objc var icons: [NSDictionary]? {
85
+ didSet { loadIconsFromSources() }
86
+ }
87
+
88
+ @objc var selectedIcons: [NSDictionary]? {
89
+ didSet { loadSelectedIconsFromSources() }
90
+ }
91
+
92
+ @objc var labeled: NSNumber? {
93
+ didSet { rebuildViewControllers() }
94
+ }
95
+
96
+ @objc var sidebarAdaptable: Bool = false {
97
+ didSet { updateSidebarAdaptable() }
98
+ }
99
+
100
+ @objc var disablePageAnimations: Bool = false
101
+
102
+ @objc var hapticFeedbackEnabled: Bool = false
103
+
104
+ @objc var scrollEdgeAppearance: String? {
105
+ didSet { updateTabBarAppearance() }
106
+ }
107
+
108
+ @objc var minimizeBehavior: String? {
109
+ didSet { updateMinimizeBehavior() }
110
+ }
111
+
112
+ @objc var tabBarHidden: Bool = false {
113
+ didSet { updateTabBarHidden() }
114
+ }
115
+
116
+ @objc var translucent: NSNumber? {
117
+ didSet { updateTabBarAppearance() }
118
+ }
119
+
120
+ @objc var barTintColor: UIColor? {
121
+ didSet { updateTabBarAppearance() }
122
+ }
123
+
124
+ @objc var activeTintColor: UIColor? {
125
+ // Rebuild so the baked selectedImage tint follows light/dark theme changes.
126
+ didSet { updateTintColors(); rebuildViewControllers() }
127
+ }
128
+
129
+ @objc var inactiveTintColor: UIColor? {
130
+ // Rebuild so the baked unselected-image tint follows theme changes.
131
+ didSet { updateTabBarAppearance(); rebuildViewControllers() }
132
+ }
133
+
134
+ @objc var rippleColor: UIColor? // Android only
135
+
136
+ @objc var activeIndicatorColor: UIColor? // Android only
137
+
138
+ @objc var fontFamily: String? {
139
+ didSet { updateTabBarAppearance() }
140
+ }
141
+
142
+ @objc var fontWeight: String? {
143
+ didSet { updateTabBarAppearance() }
144
+ }
145
+
146
+ @objc var fontSize: NSNumber? {
147
+ didSet { updateTabBarAppearance() }
148
+ }
149
+
150
+ // MARK: - Events (RCTDirectEventBlock)
151
+
152
+ @objc var onPageSelected: RCTDirectEventBlock?
153
+ @objc var onTabDoublePress: RCTDirectEventBlock?
154
+ @objc var onTabBarMeasured: RCTDirectEventBlock?
155
+ @objc var onNativeLayout: RCTDirectEventBlock?
156
+
157
+ // MARK: - Init
158
+
159
+ override init(frame: CGRect) {
160
+ super.init(frame: frame)
161
+ }
162
+
163
+ required init?(coder: NSCoder) {
164
+ fatalError("init(coder:) has not been implemented")
165
+ }
166
+
167
+ override func didAddSubview(_ subview: UIView) {
168
+ super.didAddSubview(subview)
169
+ if let tbcView = tabBarController?.view, subview !== tbcView {
170
+ bringSubviewToFront(tbcView)
171
+ }
172
+ }
173
+
174
+ // MARK: - Layout
175
+
176
+ override func layoutSubviews() {
177
+ super.layoutSubviews()
178
+ handleLayout()
179
+ }
180
+
181
+ // MARK: - Bottom Accessory (iOS 26+)
182
+
183
+ private func attachBottomAccessory() {
184
+ // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
185
+ }
186
+
187
+ private func detachBottomAccessory() {
188
+ // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
189
+ }
190
+
191
+ // MARK: - Lifecycle
192
+
193
+ private func handleLayout() {
194
+ let bounds = self.bounds
195
+ guard bounds.width > 0 && bounds.height > 0 else { return }
196
+
197
+ setupTabBarControllerIfNeeded()
198
+ tabBarController?.view.frame = bounds
199
+ onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
200
+ }
201
+
202
+ private func setupTabBarControllerIfNeeded() {
203
+ guard tabBarController == nil else { return }
204
+ guard let parentVC = self.reactViewController() else { return }
205
+
206
+ let tbc = UITabBarController()
207
+ tbc.delegate = BottomTabsDelegateProxy.shared
208
+ BottomTabsDelegateProxy.shared.register(self, for: tbc)
209
+ tbc.view.backgroundColor = .clear
210
+
211
+ parentVC.addChild(tbc)
212
+ self.addSubview(tbc.view)
213
+ tbc.view.frame = self.bounds
214
+ tbc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
215
+ tbc.didMove(toParent: parentVC)
216
+
217
+ self.tabBarController = tbc
218
+
219
+ rebuildViewControllers()
220
+ updateTabBarAppearance()
221
+ updateTintColors()
222
+ updateTabBarHidden()
223
+ updateSidebarAdaptable()
224
+ updateMinimizeBehavior()
225
+
226
+ DispatchQueue.main.async { [weak self] in
227
+ 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
+ }
231
+ }
232
+ }
233
+
234
+ // MARK: - Tab management
235
+
236
+ private var parsedItems: [TabItem] {
237
+ (items ?? []).map { TabItem(dict: $0) }
238
+ }
239
+
240
+ private var filteredItems: [TabItem] {
241
+ parsedItems.filter { !$0.hidden || $0.key == selectedPage }
242
+ }
243
+
244
+ private func rebuildViewControllers() {
245
+ guard let tbc = tabBarController else { return }
246
+
247
+ let filtered = filteredItems
248
+ let allItems = parsedItems
249
+ var viewControllers: [UIViewController] = []
250
+ var usedKeys = Set<String>()
251
+
252
+ for (_, tabData) in filtered.enumerated() {
253
+ guard let originalIndex = allItems.firstIndex(where: { $0.key == tabData.key }) else { continue }
254
+ usedKeys.insert(tabData.key)
255
+
256
+ // Reuse cached VC or create a new one
257
+ let vc: UIViewController
258
+ if let cached = cachedViewControllers[tabData.key] {
259
+ vc = cached
260
+ } else {
261
+ vc = UIViewController()
262
+ vc.view.backgroundColor = .clear
263
+ vc.view.clipsToBounds = true
264
+ cachedViewControllers[tabData.key] = vc
265
+ }
266
+
267
+ // Attach child view if not already attached to this VC
268
+ if originalIndex < childViews.count {
269
+ let childView = childViews[originalIndex]
270
+ if childView.superview !== vc.view {
271
+ childView.removeFromSuperview()
272
+ vc.view.addSubview(childView)
273
+ // Use autoresizingMask instead of Auto Layout constraints — Fabric sets
274
+ // frames directly and constraints conflict with that on subsequent mounts.
275
+ childView.translatesAutoresizingMaskIntoConstraints = true
276
+ childView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
277
+ childView.frame = vc.view.bounds
278
+ }
279
+ }
280
+
281
+ // Configure tab bar item
282
+ let icon = loadedIcons[originalIndex]
283
+ let selectedIcon = loadedSelectedIcons[originalIndex]
284
+ let sfSymbol = tabData.sfSymbol ?? ""
285
+
286
+ var tabImage: UIImage?
287
+ if let icon {
288
+ tabImage = icon
289
+ } else if !sfSymbol.isEmpty {
290
+ tabImage = UIImage(systemName: sfSymbol)
291
+ }
292
+
293
+ // Focused (solid) artwork for the selected state. Fall back to the
294
+ // unfocused image when no selected icon was provided so the selected
295
+ // tab still renders something.
296
+ var selectedTabImage: UIImage?
297
+ if let selectedIcon {
298
+ selectedTabImage = selectedIcon
299
+ } else if !sfSymbol.isEmpty {
300
+ selectedTabImage = UIImage(systemName: sfSymbol)
301
+ }
302
+
303
+ // Bake the inactive/active colors directly into the images as
304
+ // .alwaysOriginal. iOS 26 Liquid Glass ignores appearance.normal.iconColor
305
+ // and washes every icon to a single near-white tint, which is why the
306
+ // unselected tabs never render the gray `iconSubdued`. Pre-tinting the
307
+ // artwork and marking it .alwaysOriginal stops the system from re-tinting,
308
+ // restoring the gray(unselected) → active(selected) color change natively.
309
+ let normalImage = bakeTint(tabImage, inactiveTintColor)
310
+ let selectedImage = bakeTint(selectedTabImage ?? tabImage, activeTintColor)
311
+
312
+ let isLabeled = labeled?.boolValue ?? true
313
+ let title = isLabeled ? tabData.title : nil
314
+ vc.tabBarItem = UITabBarItem(title: title, image: normalImage, selectedImage: selectedImage)
315
+ vc.tabBarItem.tag = originalIndex
316
+ vc.tabBarItem.badgeValue = tabData.badge
317
+ vc.tabBarItem.accessibilityIdentifier = tabData.testID
318
+
319
+ // Badge colors (iOS 16+)
320
+ if let badgeBgColor = tabData.badgeBackgroundColor {
321
+ vc.tabBarItem.badgeColor = UIColor(rgb: badgeBgColor)
322
+ }
323
+ if #available(iOS 16.0, *), let badgeTxtColor = tabData.badgeTextColor {
324
+ let badge = vc.tabBarItem
325
+ badge?.setBadgeTextAttributes(
326
+ [.foregroundColor: UIColor(rgb: badgeTxtColor)],
327
+ for: .normal
328
+ )
329
+ }
330
+
331
+ let inactiveColor = inactiveTintColor
332
+ let attributes = TabBarFontSize.createNormalStateAttributes(
333
+ fontSize: fontSize?.intValue,
334
+ fontFamily: fontFamily,
335
+ fontWeight: fontWeight,
336
+ inactiveColor: inactiveColor
337
+ )
338
+ vc.tabBarItem.setTitleTextAttributes(attributes, for: .normal)
339
+
340
+ viewControllers.append(vc)
341
+ }
342
+
343
+ // Clean up cached VCs for removed tabs
344
+ for key in cachedViewControllers.keys where !usedKeys.contains(key) {
345
+ cachedViewControllers.removeValue(forKey: key)
346
+ }
347
+
348
+ // Only call setViewControllers if the VC list actually changed
349
+ let currentVCs = tbc.viewControllers ?? []
350
+ if currentVCs.count != viewControllers.count || !zip(currentVCs, viewControllers).allSatisfy({ $0 === $1 }) {
351
+ if disablePageAnimations {
352
+ UIView.performWithoutAnimation {
353
+ tbc.setViewControllers(viewControllers, animated: false)
354
+ }
355
+ } else {
356
+ tbc.setViewControllers(viewControllers, animated: false)
357
+ }
358
+ }
359
+
360
+ updateSelectedTab()
361
+ }
362
+
363
+ // MARK: - Selection
364
+
365
+ private func updateSelectedTab() {
366
+ guard let tbc = tabBarController,
367
+ let selectedPage else { return }
368
+
369
+ let filtered = filteredItems
370
+ guard let index = filtered.firstIndex(where: { $0.key == selectedPage }) else { return }
371
+
372
+ // Common case: the user tapped the tab. UIKit's delegate already selected
373
+ // it optimistically and started the iOS 26 Liquid Glass capsule animation.
374
+ // Re-assigning selectedIndex here — especially wrapped in
375
+ // performWithoutAnimation — would cancel that in-flight animation, which is
376
+ // what made the icon/color change lag a beat behind the glass. When the
377
+ // selection is already correct, just refresh tint and leave the native
378
+ // animation alone.
379
+ if tbc.selectedIndex == index {
380
+ updateTintColors()
381
+ return
382
+ }
383
+
384
+ // Programmatic selection (deep link, external navigation, preventsDefault
385
+ // tabs): keep the existing no-animation behavior to avoid the historically
386
+ // reverted tab-switch animation regression.
387
+ if disablePageAnimations {
388
+ UIView.performWithoutAnimation {
389
+ tbc.selectedIndex = index
390
+ }
391
+ } else {
392
+ tbc.selectedIndex = index
393
+ }
394
+
395
+ updateTintColors()
396
+ }
397
+
398
+ // MARK: - Appearance
399
+
400
+ private func updateTabBarAppearance() {
401
+ guard let tbc = tabBarController else { return }
402
+ let tabBar = tbc.tabBar
403
+
404
+ if scrollEdgeAppearance == "transparent" {
405
+ configureTransparentAppearance(tabBar: tabBar)
406
+ return
407
+ }
408
+
409
+ configureStandardAppearance(tabBar: tabBar)
410
+ }
411
+
412
+ private func configureTransparentAppearance(tabBar: UITabBar) {
413
+ tabBar.barTintColor = barTintColor
414
+ tabBar.isTranslucent = translucent?.boolValue ?? true
415
+ tabBar.unselectedItemTintColor = inactiveTintColor
416
+
417
+ guard let tabBarItems = tabBar.items else { return }
418
+
419
+ let attributes = TabBarFontSize.createNormalStateAttributes(
420
+ fontSize: fontSize?.intValue,
421
+ fontFamily: fontFamily,
422
+ fontWeight: fontWeight,
423
+ inactiveColor: nil
424
+ )
425
+
426
+ tabBarItems.forEach { item in
427
+ item.setTitleTextAttributes(attributes, for: .normal)
428
+ }
429
+ }
430
+
431
+ private func configureStandardAppearance(tabBar: UITabBar) {
432
+ let appearance = UITabBarAppearance()
433
+
434
+ switch scrollEdgeAppearance {
435
+ case "opaque":
436
+ appearance.configureWithOpaqueBackground()
437
+ default:
438
+ appearance.configureWithDefaultBackground()
439
+ }
440
+
441
+ if translucent?.boolValue == false {
442
+ appearance.configureWithOpaqueBackground()
443
+ }
444
+
445
+ if let bgColor = barTintColor {
446
+ appearance.backgroundColor = bgColor
447
+ }
448
+
449
+ let itemAppearance = UITabBarItemAppearance()
450
+ let inactiveColor = inactiveTintColor
451
+
452
+ let attributes = TabBarFontSize.createNormalStateAttributes(
453
+ fontSize: fontSize?.intValue,
454
+ fontFamily: fontFamily,
455
+ fontWeight: fontWeight,
456
+ inactiveColor: inactiveColor
457
+ )
458
+
459
+ if let inactiveColor {
460
+ itemAppearance.normal.iconColor = inactiveColor
461
+ }
462
+
463
+ itemAppearance.normal.titleTextAttributes = attributes
464
+
465
+ appearance.stackedLayoutAppearance = itemAppearance
466
+ appearance.inlineLayoutAppearance = itemAppearance
467
+ appearance.compactInlineLayoutAppearance = itemAppearance
468
+
469
+ tabBar.standardAppearance = appearance
470
+ tabBar.scrollEdgeAppearance = appearance.copy()
471
+ }
472
+
473
+ private func updateTintColors() {
474
+ guard let tbc = tabBarController else { return }
475
+
476
+ var tintColor = activeTintColor
477
+ if let selectedPage,
478
+ let tabData = parsedItems.first(where: { $0.key == selectedPage }),
479
+ let perTabColor = tabData.activeTintColor {
480
+ tintColor = UIColor(rgb: perTabColor)
481
+ }
482
+
483
+ if let tintColor {
484
+ tbc.tabBar.tintColor = tintColor
485
+ }
486
+ }
487
+
488
+ private func updateTabBarHidden() {
489
+ guard let tbc = tabBarController else { return }
490
+ // Keep UIKit's tab bar visibility state in sync on iOS 18+.
491
+ #if compiler(>=6.0)
492
+ if #available(iOS 18.0, *) {
493
+ tbc.setTabBarHidden(tabBarHidden, animated: false)
494
+ } else {
495
+ tbc.tabBar.isHidden = tabBarHidden
496
+ }
497
+ #else
498
+ tbc.tabBar.isHidden = tabBarHidden
499
+ #endif
500
+ tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
501
+
502
+ if !tabBarHidden {
503
+ onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
504
+ }
505
+ }
506
+
507
+ private func updateSidebarAdaptable() {
508
+ guard let tbc = tabBarController else { return }
509
+ if #available(iOS 18.0, *) {
510
+ #if compiler(>=6.0)
511
+ if sidebarAdaptable {
512
+ tbc.mode = .tabSidebar
513
+ // Remove compact override so sidebar can use the natural size class
514
+ if UIDevice.current.userInterfaceIdiom == .pad {
515
+ tbc.traitOverrides.remove(UITraitHorizontalSizeClass.self)
516
+ }
517
+ } else {
518
+ tbc.mode = .tabBar
519
+ // Force compact horizontal size class on iPad to keep TabBar at bottom
520
+ // iPad defaults to .regular which causes iOS 18+ to show tabs at top
521
+ if UIDevice.current.userInterfaceIdiom == .pad {
522
+ tbc.traitOverrides.horizontalSizeClass = .compact
523
+ }
524
+ }
525
+ #endif
526
+ }
527
+ }
528
+
529
+ private func updateMinimizeBehavior() {
530
+ #if compiler(>=6.2)
531
+ if #available(iOS 26.0, *) {
532
+ guard let tbc = tabBarController,
533
+ let behavior = minimizeBehavior else { return }
534
+ switch behavior {
535
+ case "automatic":
536
+ tbc.tabBarMinimizeBehavior = .automatic
537
+ case "never":
538
+ tbc.tabBarMinimizeBehavior = .never
539
+ case "onScrollUp":
540
+ tbc.tabBarMinimizeBehavior = .onScrollUp
541
+ case "onScrollDown":
542
+ tbc.tabBarMinimizeBehavior = .onScrollDown
543
+ default:
544
+ break
545
+ }
546
+ }
547
+ #endif
548
+ }
549
+
550
+ // MARK: - Haptic feedback
551
+
552
+ private func emitHapticFeedback() {
553
+ guard hapticFeedbackEnabled else { return }
554
+ UISelectionFeedbackGenerator().selectionChanged()
555
+ }
556
+
557
+ // MARK: - Icon tinting
558
+
559
+ /// Returns a copy of `image` recolored to `color` and marked
560
+ /// `.alwaysOriginal`, so iOS 26 Liquid Glass won't override the icon color.
561
+ /// Falls back to the untinted image when no color is provided.
562
+ private func bakeTint(_ image: UIImage?, _ color: UIColor?) -> UIImage? {
563
+ guard let image else { return nil }
564
+ guard let color else { return image }
565
+ return image
566
+ .withRenderingMode(.alwaysTemplate)
567
+ .withTintColor(color, renderingMode: .alwaysOriginal)
568
+ }
569
+
570
+ // MARK: - Icon loading
571
+
572
+ @objc func setImageLoader(_ loader: RCTImageLoaderProtocol) {
573
+ self.imageLoader = loader
574
+ loadIconsFromSources()
575
+ loadSelectedIconsFromSources()
576
+ }
577
+
578
+ private func loadIconsFromSources() {
579
+ guard let imageLoader, let iconDicts = icons else { return }
580
+
581
+ let iconSources = iconDicts.map { IconSource(dict: $0) }
582
+
583
+ for (index, source) in iconSources.enumerated() {
584
+ guard !source.uri.isEmpty else { continue }
585
+
586
+ let url = URL(string: source.uri)
587
+ guard let url else { continue }
588
+
589
+ let request = URLRequest(url: url)
590
+ let size = CGSize(width: source.width, height: source.height)
591
+ let scale = CGFloat(source.scale)
592
+
593
+ imageLoader.loadImage(
594
+ with: request,
595
+ size: size,
596
+ scale: scale,
597
+ clipped: true,
598
+ resizeMode: RCTResizeMode.contain,
599
+ progressBlock: { _, _ in },
600
+ partialLoad: { _ in },
601
+ completionBlock: { [weak self] error, image in
602
+ if error != nil { return }
603
+ guard let image, let self else { return }
604
+ DispatchQueue.main.async {
605
+ self.loadedIcons[index] = image.resizeImageTo(size: self.iconSize)
606
+ self.rebuildViewControllers()
607
+ }
608
+ })
609
+ }
610
+ }
611
+
612
+ private func loadSelectedIconsFromSources() {
613
+ guard let imageLoader, let iconDicts = selectedIcons else { return }
614
+
615
+ let iconSources = iconDicts.map { IconSource(dict: $0) }
616
+
617
+ for (index, source) in iconSources.enumerated() {
618
+ guard !source.uri.isEmpty else { continue }
619
+
620
+ let url = URL(string: source.uri)
621
+ guard let url else { continue }
622
+
623
+ let request = URLRequest(url: url)
624
+ let size = CGSize(width: source.width, height: source.height)
625
+ let scale = CGFloat(source.scale)
626
+
627
+ imageLoader.loadImage(
628
+ with: request,
629
+ size: size,
630
+ scale: scale,
631
+ clipped: true,
632
+ resizeMode: RCTResizeMode.contain,
633
+ progressBlock: { _, _ in },
634
+ partialLoad: { _ in },
635
+ completionBlock: { [weak self] error, image in
636
+ if error != nil { return }
637
+ guard let image, let self else { return }
638
+ DispatchQueue.main.async {
639
+ self.loadedSelectedIcons[index] = image.resizeImageTo(size: self.iconSize)
640
+ self.rebuildViewControllers()
641
+ }
642
+ })
643
+ }
644
+ }
645
+
646
+ // MARK: - Tab delegate callbacks
647
+
648
+ func handleTabSelected(at index: Int) {
649
+ let filtered = filteredItems
650
+ guard let tabData = filtered[safe: index] else { return }
651
+ onPageSelected?(["key": tabData.key])
652
+ emitHapticFeedback()
653
+ }
654
+
655
+ func shouldSelectTab(at index: Int, isReselection: Bool) -> Bool {
656
+ let filtered = filteredItems
657
+ guard let tabData = filtered[safe: index] else { return false }
658
+
659
+ onPageSelected?(["key": tabData.key])
660
+ emitHapticFeedback()
661
+ emitDoublePressIfNeeded(key: tabData.key)
662
+ return isReselection ? false : !tabData.preventsDefault
663
+ }
664
+
665
+ private func emitDoublePressIfNeeded(key: String) {
666
+ let now = CACurrentMediaTime()
667
+ if lastPressedTabKey == key && now - lastPressedTabTime <= Self.doublePressInterval {
668
+ lastPressedTabKey = nil
669
+ onTabDoublePress?(["key": key])
670
+ return
671
+ }
672
+ lastPressedTabKey = key
673
+ lastPressedTabTime = now
674
+ }
675
+
676
+ // MARK: - Bottom accessory placement
677
+
678
+ func handlePlacementChanged(_ placement: String) {
679
+ guard bottomAccessoryView != nil else { return }
680
+ NotificationCenter.default.post(
681
+ name: .bottomAccessoryPlacementChanged,
682
+ object: nil,
683
+ userInfo: ["placement": placement]
684
+ )
685
+ }
686
+
687
+ // MARK: - Fabric child management
688
+
689
+ @objc func insertChild(_ child: UIView, atIndex index: Int) {
690
+ if child is RCTBottomAccessoryContainerView {
691
+ self.bottomAccessoryView = child
692
+ attachBottomAccessory()
693
+ return
694
+ }
695
+ guard index >= 0 && index <= childViews.count else { return }
696
+ childViews.insert(child, at: index)
697
+ rebuildViewControllers()
698
+ }
699
+
700
+ @objc func removeChild(atIndex index: Int) {
701
+ guard index >= 0 && index < childViews.count else { return }
702
+ let child = childViews[index]
703
+ if child is RCTBottomAccessoryContainerView {
704
+ detachBottomAccessory()
705
+ self.bottomAccessoryView = nil
706
+ return
707
+ }
708
+ // Fabric recycles this view for unrelated <View>s once it is unmounted.
709
+ // Leaving the flexible mask on would let UIKit autoresize it to whatever
710
+ // superview it lands in next (it showed up as page-sized "flakes").
711
+ child.autoresizingMask = []
712
+ childViews.remove(at: index)
713
+ rebuildViewControllers()
714
+ }
715
+ }
716
+
717
+ // OneKey patch: keep native tab selection immediate while suppressing UIKit's content crossfade.
718
+ private final class ImmediateTabTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning {
719
+ static let shared = ImmediateTabTransitionAnimator()
720
+
721
+ func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
722
+ 0
723
+ }
724
+
725
+ func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
726
+ guard let toViewController = transitionContext.viewController(forKey: .to),
727
+ let toView = transitionContext.view(forKey: .to) ?? toViewController.view else {
728
+ transitionContext.completeTransition(false)
729
+ return
730
+ }
731
+
732
+ toView.frame = transitionContext.finalFrame(for: toViewController)
733
+ transitionContext.containerView.addSubview(toView)
734
+ transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
735
+ }
736
+ }
737
+
738
+ // MARK: - UITabBarControllerDelegate proxy
739
+
740
+ class BottomTabsDelegateProxy: NSObject, UITabBarControllerDelegate {
741
+ static let shared = BottomTabsDelegateProxy()
742
+ private var mapping: [ObjectIdentifier: RCTBottomTabsContainerView] = [:]
743
+
744
+ func register(_ view: RCTBottomTabsContainerView, for controller: UITabBarController) {
745
+ mapping[ObjectIdentifier(controller)] = view
746
+ }
747
+
748
+ func unregister(for controller: UITabBarController) {
749
+ mapping.removeValue(forKey: ObjectIdentifier(controller))
750
+ }
751
+
752
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
753
+ guard let containerView = mapping[ObjectIdentifier(tabBarController)] else {
754
+ return false
755
+ }
756
+ let isReselection = tabBarController.selectedViewController == viewController
757
+
758
+ if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
759
+ return containerView.shouldSelectTab(at: index, isReselection: isReselection)
760
+ }
761
+
762
+ return false
763
+ }
764
+
765
+ func tabBarController(
766
+ _ tabBarController: UITabBarController,
767
+ animationControllerForTransitionFrom fromViewController: UIViewController,
768
+ to toViewController: UIViewController
769
+ ) -> UIViewControllerAnimatedTransitioning? {
770
+ guard let containerView = mapping[ObjectIdentifier(tabBarController)],
771
+ containerView.disablePageAnimations else {
772
+ return nil
773
+ }
774
+
775
+ return ImmediateTabTransitionAnimator.shared
776
+ }
777
+ }