@onekeyfe/react-native-tab-view 1.1.31

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 (94) hide show
  1. package/android/build.gradle +119 -0
  2. package/android/gradle.properties +4 -0
  3. package/android/src/main/AndroidManifest.xml +3 -0
  4. package/android/src/main/AndroidManifestNew.xml +2 -0
  5. package/android/src/main/java/com/rcttabview/ImageSource.kt +86 -0
  6. package/android/src/main/java/com/rcttabview/RCTTabView.kt +529 -0
  7. package/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +204 -0
  8. package/android/src/main/java/com/rcttabview/RCTTabViewPackage.kt +16 -0
  9. package/android/src/main/java/com/rcttabview/TabInfo.kt +12 -0
  10. package/android/src/main/java/com/rcttabview/Utils.kt +31 -0
  11. package/android/src/main/java/com/rcttabview/events/OnNativeLayoutEvent.kt +20 -0
  12. package/android/src/main/java/com/rcttabview/events/OnTabBarMeasuredEvent.kt +19 -0
  13. package/android/src/main/java/com/rcttabview/events/PageSelectedEvent.kt +21 -0
  14. package/android/src/main/java/com/rcttabview/events/TabLongPressedEvent.kt +19 -0
  15. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewComponentDescriptor.h +32 -0
  16. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.cpp +18 -0
  17. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.h +35 -0
  18. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.cpp +15 -0
  19. package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.h +25 -0
  20. package/ios/Extensions.swift +46 -0
  21. package/ios/RCTBottomAccessoryComponentView.h +12 -0
  22. package/ios/RCTBottomAccessoryComponentView.mm +67 -0
  23. package/ios/RCTBottomAccessoryContainerView.swift +51 -0
  24. package/ios/RCTTabViewComponentView.h +12 -0
  25. package/ios/RCTTabViewComponentView.mm +325 -0
  26. package/ios/RCTTabViewContainerView.swift +768 -0
  27. package/ios/RCTTabViewLog.h +7 -0
  28. package/ios/RCTTabViewLog.m +32 -0
  29. package/ios/SVG/CoreSVG.h +13 -0
  30. package/ios/SVG/CoreSVG.mm +177 -0
  31. package/ios/SVG/SvgDecoder.h +10 -0
  32. package/ios/SVG/SvgDecoder.mm +32 -0
  33. package/ios/TabBarFontSize.swift +55 -0
  34. package/lib/module/BottomAccessoryView.js +45 -0
  35. package/lib/module/BottomAccessoryView.js.map +1 -0
  36. package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
  37. package/lib/module/DelayedFreeze.js +26 -0
  38. package/lib/module/DelayedFreeze.js.map +1 -0
  39. package/lib/module/NativeSVGDecoder.js +5 -0
  40. package/lib/module/NativeSVGDecoder.js.map +1 -0
  41. package/lib/module/SceneMap.js +28 -0
  42. package/lib/module/SceneMap.js.map +1 -0
  43. package/lib/module/TabView.js +263 -0
  44. package/lib/module/TabView.js.map +1 -0
  45. package/lib/module/TabViewNativeComponent.ts +68 -0
  46. package/lib/module/codegen-types.d.js +2 -0
  47. package/lib/module/codegen-types.d.js.map +1 -0
  48. package/lib/module/index.js +20 -0
  49. package/lib/module/index.js.map +1 -0
  50. package/lib/module/package.json +1 -0
  51. package/lib/module/types.js +4 -0
  52. package/lib/module/types.js.map +1 -0
  53. package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
  54. package/lib/module/utils/BottomTabBarHeightContext.js.map +1 -0
  55. package/lib/module/utils/useBottomTabBarHeight.js +12 -0
  56. package/lib/module/utils/useBottomTabBarHeight.js.map +1 -0
  57. package/lib/typescript/package.json +1 -0
  58. package/lib/typescript/src/BottomAccessoryView.d.ts +8 -0
  59. package/lib/typescript/src/BottomAccessoryView.d.ts.map +1 -0
  60. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +16 -0
  61. package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts.map +1 -0
  62. package/lib/typescript/src/DelayedFreeze.d.ts +8 -0
  63. package/lib/typescript/src/DelayedFreeze.d.ts.map +1 -0
  64. package/lib/typescript/src/NativeSVGDecoder.d.ts +6 -0
  65. package/lib/typescript/src/NativeSVGDecoder.d.ts.map +1 -0
  66. package/lib/typescript/src/SceneMap.d.ts +10 -0
  67. package/lib/typescript/src/SceneMap.d.ts.map +1 -0
  68. package/lib/typescript/src/TabView.d.ts +178 -0
  69. package/lib/typescript/src/TabView.d.ts.map +1 -0
  70. package/lib/typescript/src/TabViewNativeComponent.d.ts +55 -0
  71. package/lib/typescript/src/TabViewNativeComponent.d.ts.map +1 -0
  72. package/lib/typescript/src/index.d.ts +16 -0
  73. package/lib/typescript/src/index.d.ts.map +1 -0
  74. package/lib/typescript/src/types.d.ts +29 -0
  75. package/lib/typescript/src/types.d.ts.map +1 -0
  76. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +3 -0
  77. package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts.map +1 -0
  78. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +2 -0
  79. package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts.map +1 -0
  80. package/package.json +114 -0
  81. package/react-native-tab-view.podspec +36 -0
  82. package/react-native.config.js +13 -0
  83. package/src/BottomAccessoryView.tsx +58 -0
  84. package/src/BottomAccessoryViewNativeComponent.ts +27 -0
  85. package/src/DelayedFreeze.tsx +27 -0
  86. package/src/NativeSVGDecoder.ts +5 -0
  87. package/src/SceneMap.tsx +34 -0
  88. package/src/TabView.tsx +466 -0
  89. package/src/TabViewNativeComponent.ts +68 -0
  90. package/src/codegen-types.d.ts +28 -0
  91. package/src/index.tsx +18 -0
  92. package/src/types.ts +31 -0
  93. package/src/utils/BottomTabBarHeightContext.ts +5 -0
  94. package/src/utils/useBottomTabBarHeight.ts +15 -0
@@ -0,0 +1,768 @@
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: - RCTTabViewContainerView
50
+
51
+ class RCTTabViewContainerView: UIView {
52
+
53
+ // MARK: - Debug logging
54
+
55
+ private static let debugLog = true
56
+
57
+ private func log(_ message: String, function: String = #function) {
58
+ guard Self.debugLog else { return }
59
+ RCTTabViewLog.debug("RCTTabView", message: "[\(function)] \(message)")
60
+ }
61
+
62
+ // MARK: - Internal State
63
+
64
+ private var tabBarController: UITabBarController?
65
+ private var childViews: [UIView] = []
66
+ private var bottomAccessoryView: UIView?
67
+ private var loadedIcons: [Int: UIImage] = [:]
68
+ private var imageLoader: RCTImageLoaderProtocol?
69
+ private let iconSize = CGSize(width: 27, height: 27)
70
+ private var longPressHandler: LongPressGestureHandler?
71
+ private var tabBarHiddenObservation: NSKeyValueObservation?
72
+ private var tabBarAlphaObservation: NSKeyValueObservation?
73
+ private var tabBarFrameObservation: NSKeyValueObservation?
74
+ /// Cached view controllers keyed by tab key to avoid unnecessary reparenting
75
+ private var cachedViewControllers: [String: UIViewController] = [:]
76
+
77
+ // MARK: - Props (set by Fabric ComponentView via KVC)
78
+
79
+ @objc var items: [NSDictionary]? {
80
+ didSet {
81
+ log("items prop changed: \(oldValue?.count ?? 0) -> \(items?.count ?? 0) items")
82
+ rebuildViewControllers()
83
+ }
84
+ }
85
+
86
+ @objc var selectedPage: String? {
87
+ didSet {
88
+ log("selectedPage prop changed: \(oldValue ?? "nil") -> \(selectedPage ?? "nil")")
89
+ updateSelectedTab()
90
+ }
91
+ }
92
+
93
+ @objc var icons: [NSDictionary]? {
94
+ didSet { loadIconsFromSources() }
95
+ }
96
+
97
+ @objc var labeled: NSNumber? {
98
+ didSet { rebuildViewControllers() }
99
+ }
100
+
101
+ @objc var sidebarAdaptable: Bool = false {
102
+ didSet { updateSidebarAdaptable() }
103
+ }
104
+
105
+ @objc var disablePageAnimations: Bool = false
106
+
107
+ @objc var hapticFeedbackEnabled: Bool = false
108
+
109
+ @objc var scrollEdgeAppearance: String? {
110
+ didSet { updateTabBarAppearance() }
111
+ }
112
+
113
+ @objc var minimizeBehavior: String? {
114
+ didSet { updateMinimizeBehavior() }
115
+ }
116
+
117
+ @objc var tabBarHidden: Bool = false {
118
+ didSet {
119
+ log("tabBarHidden prop changed: \(oldValue) -> \(tabBarHidden)")
120
+ updateTabBarHidden()
121
+ }
122
+ }
123
+
124
+ @objc var translucent: NSNumber? {
125
+ didSet { updateTabBarAppearance() }
126
+ }
127
+
128
+ @objc var barTintColor: UIColor? {
129
+ didSet { updateTabBarAppearance() }
130
+ }
131
+
132
+ @objc var activeTintColor: UIColor? {
133
+ didSet { updateTintColors() }
134
+ }
135
+
136
+ @objc var inactiveTintColor: UIColor? {
137
+ didSet { updateTabBarAppearance() }
138
+ }
139
+
140
+ @objc var rippleColor: UIColor? // Android only
141
+
142
+ @objc var activeIndicatorColor: UIColor? // Android only
143
+
144
+ @objc var fontFamily: String? {
145
+ didSet { updateTabBarAppearance() }
146
+ }
147
+
148
+ @objc var fontWeight: String? {
149
+ didSet { updateTabBarAppearance() }
150
+ }
151
+
152
+ @objc var fontSize: NSNumber? {
153
+ didSet { updateTabBarAppearance() }
154
+ }
155
+
156
+ // MARK: - Events (RCTDirectEventBlock)
157
+
158
+ @objc var onPageSelected: RCTDirectEventBlock?
159
+ @objc var onTabLongPress: RCTDirectEventBlock?
160
+ @objc var onTabBarMeasured: RCTDirectEventBlock?
161
+ @objc var onNativeLayout: RCTDirectEventBlock?
162
+
163
+ // MARK: - Init
164
+
165
+ override init(frame: CGRect) {
166
+ super.init(frame: frame)
167
+ }
168
+
169
+ required init?(coder: NSCoder) {
170
+ fatalError("init(coder:) has not been implemented")
171
+ }
172
+
173
+ override func didAddSubview(_ subview: UIView) {
174
+ super.didAddSubview(subview)
175
+ if let tbcView = tabBarController?.view, subview !== tbcView {
176
+ bringSubviewToFront(tbcView)
177
+ }
178
+ }
179
+
180
+ // MARK: - Layout
181
+
182
+ override func layoutSubviews() {
183
+ super.layoutSubviews()
184
+ if let tbc = tabBarController {
185
+ log("layoutSubviews: bounds=\(bounds), tabBar.isHidden=\(tbc.tabBar.isHidden), tabBar.frame=\(tbc.tabBar.frame), tabBar.alpha=\(tbc.tabBar.alpha)")
186
+ }
187
+ handleLayout()
188
+ }
189
+
190
+ // MARK: - Bottom Accessory (iOS 26+)
191
+
192
+ private func attachBottomAccessory() {
193
+ // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
194
+ }
195
+
196
+ private func detachBottomAccessory() {
197
+ // TODO: Re-enable when UITabBar.bottomAccessoryView is available in the SDK
198
+ }
199
+
200
+ // MARK: - Lifecycle
201
+
202
+ private func handleLayout() {
203
+ let bounds = self.bounds
204
+ guard bounds.width > 0 && bounds.height > 0 else { return }
205
+
206
+ setupTabBarControllerIfNeeded()
207
+ tabBarController?.view.frame = bounds
208
+
209
+ // Log container's direct subviews
210
+ if let tbc = tabBarController {
211
+ let strayCount = self.subviews.filter { sv in sv !== tbc.view && childViews.contains(where: { $0 === sv }) }.count
212
+ if strayCount > 0 {
213
+ log("⚠️ handleLayout: \(strayCount) stray childViews found as direct subviews of self!")
214
+ }
215
+ log("handleLayout: self.subviews=\(self.subviews.count), strayChildren=\(strayCount)")
216
+ }
217
+
218
+ onNativeLayout?(["width": Double(bounds.width), "height": Double(bounds.height)])
219
+ }
220
+
221
+ private func setupTabBarControllerIfNeeded() {
222
+ guard tabBarController == nil else { return }
223
+ guard let parentVC = self.reactViewController() else {
224
+ log("setupTabBarControllerIfNeeded: no parent view controller found")
225
+ return
226
+ }
227
+ log("setupTabBarControllerIfNeeded: creating UITabBarController, parentVC=\(type(of: parentVC))")
228
+
229
+ let tbc = UITabBarController()
230
+ tbc.delegate = TabViewDelegateProxy.shared
231
+ TabViewDelegateProxy.shared.register(self, for: tbc)
232
+ tbc.view.backgroundColor = .clear
233
+
234
+ parentVC.addChild(tbc)
235
+ self.addSubview(tbc.view)
236
+ tbc.view.frame = self.bounds
237
+ tbc.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
238
+ tbc.didMove(toParent: parentVC)
239
+
240
+ self.tabBarController = tbc
241
+
242
+ // KVO observers to detect external changes to tab bar visibility
243
+ if Self.debugLog {
244
+ tabBarHiddenObservation = tbc.tabBar.observe(\.isHidden, options: [.old, .new]) { [weak self] tabBar, change in
245
+ self?.log("KVO tabBar.isHidden changed: \(change.oldValue ?? false) -> \(change.newValue ?? false), frame=\(tabBar.frame)")
246
+ if change.newValue == true && self?.tabBarHidden == false {
247
+ self?.log("⚠️ TAB BAR WAS HIDDEN EXTERNALLY! tabBarHidden prop is false but isHidden became true")
248
+ Thread.callStackSymbols.prefix(15).forEach { self?.log(" \($0)") }
249
+ }
250
+ }
251
+ tabBarAlphaObservation = tbc.tabBar.observe(\.alpha, options: [.old, .new]) { [weak self] tabBar, change in
252
+ if change.oldValue != change.newValue {
253
+ self?.log("KVO tabBar.alpha changed: \(change.oldValue ?? 0) -> \(change.newValue ?? 0), frame=\(tabBar.frame)")
254
+ }
255
+ }
256
+ tabBarFrameObservation = tbc.tabBar.observe(\.frame, options: [.old, .new]) { [weak self] tabBar, change in
257
+ let oldFrame = change.oldValue ?? .zero
258
+ let newFrame = change.newValue ?? .zero
259
+ if oldFrame != newFrame {
260
+ self?.log("KVO tabBar.frame changed: \(oldFrame) -> \(newFrame), isHidden=\(tabBar.isHidden), alpha=\(tabBar.alpha)")
261
+ }
262
+ }
263
+ }
264
+
265
+ setupLongPressGesture()
266
+ rebuildViewControllers()
267
+ updateTabBarAppearance()
268
+ updateTintColors()
269
+ updateTabBarHidden()
270
+ updateSidebarAdaptable()
271
+ updateMinimizeBehavior()
272
+
273
+ DispatchQueue.main.async { [weak self] in
274
+ guard let self, let tbc = self.tabBarController else { return }
275
+ if !tbc.tabBar.isHidden {
276
+ self.onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
277
+ }
278
+ }
279
+ }
280
+
281
+ // MARK: - Long press
282
+
283
+ private func setupLongPressGesture() {
284
+ guard let tbc = tabBarController else { return }
285
+
286
+ if let existingGestures = tbc.tabBar.gestureRecognizers {
287
+ for gesture in existingGestures where gesture is UILongPressGestureRecognizer {
288
+ tbc.tabBar.removeGestureRecognizer(gesture)
289
+ }
290
+ }
291
+
292
+ let handler = LongPressGestureHandler(tabBar: tbc.tabBar) { [weak self] index, _ in
293
+ guard let self else { return }
294
+ let filtered = self.filteredItems
295
+ guard let tabData = filtered[safe: index] else { return }
296
+ self.onTabLongPress?(["key": tabData.key])
297
+ self.emitHapticFeedback(longPress: true)
298
+ }
299
+
300
+ let gesture = UILongPressGestureRecognizer(target: handler, action: #selector(LongPressGestureHandler.handleLongPress(_:)))
301
+ gesture.minimumPressDuration = 0.5
302
+ tbc.tabBar.addGestureRecognizer(gesture)
303
+ self.longPressHandler = handler
304
+ }
305
+
306
+ // MARK: - Tab management
307
+
308
+ private var parsedItems: [TabItem] {
309
+ (items ?? []).map { TabItem(dict: $0) }
310
+ }
311
+
312
+ private var filteredItems: [TabItem] {
313
+ parsedItems.filter { !$0.hidden || $0.key == selectedPage }
314
+ }
315
+
316
+ private func rebuildViewControllers() {
317
+ guard let tbc = tabBarController else {
318
+ log("rebuildViewControllers: no tabBarController yet")
319
+ return
320
+ }
321
+ log("rebuildViewControllers: tabBar.isHidden=\(tbc.tabBar.isHidden), tabBarHidden=\(tabBarHidden), items=\(parsedItems.count), childViews=\(childViews.count)")
322
+
323
+ let filtered = filteredItems
324
+ let allItems = parsedItems
325
+ var viewControllers: [UIViewController] = []
326
+ var usedKeys = Set<String>()
327
+
328
+ for (_, tabData) in filtered.enumerated() {
329
+ guard let originalIndex = allItems.firstIndex(where: { $0.key == tabData.key }) else { continue }
330
+ usedKeys.insert(tabData.key)
331
+
332
+ // Reuse cached VC or create a new one
333
+ let vc: UIViewController
334
+ if let cached = cachedViewControllers[tabData.key] {
335
+ vc = cached
336
+ log("rebuildViewControllers: reusing cached VC for tab[\(tabData.key)]")
337
+ } else {
338
+ vc = UIViewController()
339
+ vc.view.backgroundColor = .clear
340
+ vc.view.clipsToBounds = true
341
+ cachedViewControllers[tabData.key] = vc
342
+ log("rebuildViewControllers: created new VC for tab[\(tabData.key)]")
343
+ }
344
+
345
+ // Attach child view if not already attached to this VC
346
+ if originalIndex < childViews.count {
347
+ let childView = childViews[originalIndex]
348
+ let currentSuperview = childView.superview
349
+ let superviewType = currentSuperview.map { String(describing: type(of: $0)) } ?? "nil"
350
+ let isSelf = currentSuperview === self
351
+ let isVcView = currentSuperview === vc.view
352
+ log("rebuildViewControllers: tab[\(tabData.key)] childView superview=\(superviewType), isSelf=\(isSelf), isVcView=\(isVcView), userInteraction=\(childView.isUserInteractionEnabled)")
353
+ if childView.superview !== vc.view {
354
+ childView.removeFromSuperview()
355
+ vc.view.addSubview(childView)
356
+ // Use autoresizingMask instead of Auto Layout constraints — Fabric sets
357
+ // frames directly and constraints conflict with that on subsequent mounts.
358
+ childView.translatesAutoresizingMaskIntoConstraints = true
359
+ childView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
360
+ childView.frame = vc.view.bounds
361
+ log("rebuildViewControllers: tab[\(tabData.key)] MOVED childView to vc.view (was \(superviewType))")
362
+ }
363
+ }
364
+
365
+ // Configure tab bar item
366
+ let icon = loadedIcons[originalIndex]
367
+ let sfSymbol = tabData.sfSymbol ?? ""
368
+
369
+ var tabImage: UIImage?
370
+ if let icon {
371
+ tabImage = icon
372
+ } else if !sfSymbol.isEmpty {
373
+ tabImage = UIImage(systemName: sfSymbol)
374
+ }
375
+
376
+ let isLabeled = labeled?.boolValue ?? true
377
+ let title = isLabeled ? tabData.title : nil
378
+ vc.tabBarItem = UITabBarItem(title: title, image: tabImage, tag: originalIndex)
379
+ vc.tabBarItem.badgeValue = tabData.badge
380
+ vc.tabBarItem.accessibilityIdentifier = tabData.testID
381
+
382
+ // Badge colors (iOS 16+)
383
+ if let badgeBgColor = tabData.badgeBackgroundColor {
384
+ vc.tabBarItem.badgeColor = UIColor(rgb: badgeBgColor)
385
+ }
386
+ if #available(iOS 16.0, *), let badgeTxtColor = tabData.badgeTextColor {
387
+ let badge = vc.tabBarItem
388
+ badge?.setBadgeTextAttributes(
389
+ [.foregroundColor: UIColor(rgb: badgeTxtColor)],
390
+ for: .normal
391
+ )
392
+ }
393
+
394
+ let inactiveColor = inactiveTintColor
395
+ let attributes = TabBarFontSize.createNormalStateAttributes(
396
+ fontSize: fontSize?.intValue,
397
+ fontFamily: fontFamily,
398
+ fontWeight: fontWeight,
399
+ inactiveColor: inactiveColor
400
+ )
401
+ vc.tabBarItem.setTitleTextAttributes(attributes, for: .normal)
402
+
403
+ viewControllers.append(vc)
404
+ }
405
+
406
+ // Clean up cached VCs for removed tabs
407
+ for key in cachedViewControllers.keys where !usedKeys.contains(key) {
408
+ cachedViewControllers.removeValue(forKey: key)
409
+ }
410
+
411
+ // Only call setViewControllers if the VC list actually changed
412
+ let currentVCs = tbc.viewControllers ?? []
413
+ if currentVCs.count != viewControllers.count || !zip(currentVCs, viewControllers).allSatisfy({ $0 === $1 }) {
414
+ log("rebuildViewControllers: updating VCs (old=\(currentVCs.count), new=\(viewControllers.count))")
415
+ if disablePageAnimations {
416
+ UIView.performWithoutAnimation {
417
+ tbc.setViewControllers(viewControllers, animated: false)
418
+ }
419
+ } else {
420
+ tbc.setViewControllers(viewControllers, animated: false)
421
+ }
422
+ } else {
423
+ log("rebuildViewControllers: VCs unchanged, skipping setViewControllers")
424
+ }
425
+
426
+ // Log the view hierarchy after setting VCs
427
+ log("rebuildViewControllers: tbc.view.subviews=\(tbc.view.subviews.map { "\(type(of: $0)):\(String(format: "%.0f", $0.frame.width))x\(String(format: "%.0f", $0.frame.height))" })")
428
+ if let selectedVC = tbc.selectedViewController {
429
+ log("rebuildViewControllers: selectedVC.view.frame=\(selectedVC.view.frame), subviews=\(selectedVC.view.subviews.count)")
430
+ }
431
+
432
+ updateSelectedTab()
433
+ }
434
+
435
+ // MARK: - Selection
436
+
437
+ private func updateSelectedTab() {
438
+ guard let tbc = tabBarController,
439
+ let selectedPage else {
440
+ log("updateSelectedTab: skipped (tbc=\(tabBarController != nil), selectedPage=\(selectedPage ?? "nil"))")
441
+ return
442
+ }
443
+ log("updateSelectedTab: selectedPage=\(selectedPage), tabBar.isHidden=\(tbc.tabBar.isHidden)")
444
+
445
+ let filtered = filteredItems
446
+ guard let index = filtered.firstIndex(where: { $0.key == selectedPage }) else { return }
447
+
448
+ if disablePageAnimations {
449
+ UIView.performWithoutAnimation {
450
+ tbc.selectedIndex = index
451
+ }
452
+ } else {
453
+ tbc.selectedIndex = index
454
+ }
455
+
456
+ updateTintColors()
457
+ }
458
+
459
+ // MARK: - Appearance
460
+
461
+ private func updateTabBarAppearance() {
462
+ guard let tbc = tabBarController else { return }
463
+ let tabBar = tbc.tabBar
464
+
465
+ if scrollEdgeAppearance == "transparent" {
466
+ configureTransparentAppearance(tabBar: tabBar)
467
+ return
468
+ }
469
+
470
+ configureStandardAppearance(tabBar: tabBar)
471
+ }
472
+
473
+ private func configureTransparentAppearance(tabBar: UITabBar) {
474
+ tabBar.barTintColor = barTintColor
475
+ tabBar.isTranslucent = translucent?.boolValue ?? true
476
+ tabBar.unselectedItemTintColor = inactiveTintColor
477
+
478
+ guard let tabBarItems = tabBar.items else { return }
479
+
480
+ let attributes = TabBarFontSize.createNormalStateAttributes(
481
+ fontSize: fontSize?.intValue,
482
+ fontFamily: fontFamily,
483
+ fontWeight: fontWeight,
484
+ inactiveColor: nil
485
+ )
486
+
487
+ tabBarItems.forEach { item in
488
+ item.setTitleTextAttributes(attributes, for: .normal)
489
+ }
490
+ }
491
+
492
+ private func configureStandardAppearance(tabBar: UITabBar) {
493
+ let appearance = UITabBarAppearance()
494
+
495
+ switch scrollEdgeAppearance {
496
+ case "opaque":
497
+ appearance.configureWithOpaqueBackground()
498
+ default:
499
+ appearance.configureWithDefaultBackground()
500
+ }
501
+
502
+ if translucent?.boolValue == false {
503
+ appearance.configureWithOpaqueBackground()
504
+ }
505
+
506
+ if let bgColor = barTintColor {
507
+ appearance.backgroundColor = bgColor
508
+ }
509
+
510
+ let itemAppearance = UITabBarItemAppearance()
511
+ let inactiveColor = inactiveTintColor
512
+
513
+ let attributes = TabBarFontSize.createNormalStateAttributes(
514
+ fontSize: fontSize?.intValue,
515
+ fontFamily: fontFamily,
516
+ fontWeight: fontWeight,
517
+ inactiveColor: inactiveColor
518
+ )
519
+
520
+ if let inactiveColor {
521
+ itemAppearance.normal.iconColor = inactiveColor
522
+ }
523
+
524
+ itemAppearance.normal.titleTextAttributes = attributes
525
+
526
+ appearance.stackedLayoutAppearance = itemAppearance
527
+ appearance.inlineLayoutAppearance = itemAppearance
528
+ appearance.compactInlineLayoutAppearance = itemAppearance
529
+
530
+ tabBar.standardAppearance = appearance
531
+ tabBar.scrollEdgeAppearance = appearance.copy()
532
+ }
533
+
534
+ private func updateTintColors() {
535
+ guard let tbc = tabBarController else { return }
536
+
537
+ var tintColor = activeTintColor
538
+ if let selectedPage,
539
+ let tabData = parsedItems.first(where: { $0.key == selectedPage }),
540
+ let perTabColor = tabData.activeTintColor {
541
+ tintColor = UIColor(rgb: perTabColor)
542
+ }
543
+
544
+ if let tintColor {
545
+ tbc.tabBar.tintColor = tintColor
546
+ }
547
+ }
548
+
549
+ private func updateTabBarHidden() {
550
+ guard let tbc = tabBarController else {
551
+ log("updateTabBarHidden: no tabBarController yet")
552
+ return
553
+ }
554
+ log("updateTabBarHidden: setting tabBar.isHidden=\(tabBarHidden), current tabBar.isHidden=\(tbc.tabBar.isHidden), tabBar.frame=\(tbc.tabBar.frame)")
555
+ tbc.tabBar.isHidden = tabBarHidden
556
+ tbc.tabBar.isUserInteractionEnabled = !tabBarHidden
557
+
558
+ if !tabBarHidden {
559
+ onTabBarMeasured?(["height": Double(tbc.tabBar.frame.size.height)])
560
+ }
561
+ }
562
+
563
+ private func updateSidebarAdaptable() {
564
+ guard let tbc = tabBarController else { return }
565
+ if #available(iOS 18.0, *) {
566
+ #if compiler(>=6.0)
567
+ tbc.mode = sidebarAdaptable ? .tabSidebar : .tabBar
568
+ #endif
569
+ }
570
+ }
571
+
572
+ private func updateMinimizeBehavior() {
573
+ #if compiler(>=6.2)
574
+ if #available(iOS 26.0, *) {
575
+ guard let tbc = tabBarController,
576
+ let behavior = minimizeBehavior else { return }
577
+ switch behavior {
578
+ case "automatic":
579
+ tbc.tabBarMinimizeBehavior = .automatic
580
+ case "never":
581
+ tbc.tabBarMinimizeBehavior = .never
582
+ case "onScrollUp":
583
+ tbc.tabBarMinimizeBehavior = .onScrollUp
584
+ case "onScrollDown":
585
+ tbc.tabBarMinimizeBehavior = .onScrollDown
586
+ default:
587
+ break
588
+ }
589
+ }
590
+ #endif
591
+ }
592
+
593
+ // MARK: - Haptic feedback
594
+
595
+ private func emitHapticFeedback(longPress: Bool = false) {
596
+ guard hapticFeedbackEnabled else { return }
597
+
598
+ if longPress {
599
+ UINotificationFeedbackGenerator().notificationOccurred(.success)
600
+ } else {
601
+ UISelectionFeedbackGenerator().selectionChanged()
602
+ }
603
+ }
604
+
605
+ // MARK: - Icon loading
606
+
607
+ func setImageLoader(_ loader: RCTImageLoaderProtocol) {
608
+ self.imageLoader = loader
609
+ loadIconsFromSources()
610
+ }
611
+
612
+ private func loadIconsFromSources() {
613
+ guard let imageLoader, let iconDicts = icons 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.loadedIcons[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
+ if isReselection {
660
+ onPageSelected?(["key": tabData.key])
661
+ emitHapticFeedback()
662
+ return false
663
+ }
664
+
665
+ onPageSelected?(["key": tabData.key])
666
+ emitHapticFeedback()
667
+ return !tabData.preventsDefault
668
+ }
669
+
670
+ // MARK: - Bottom accessory placement
671
+
672
+ func handlePlacementChanged(_ placement: String) {
673
+ guard bottomAccessoryView != nil else { return }
674
+ NotificationCenter.default.post(
675
+ name: .bottomAccessoryPlacementChanged,
676
+ object: nil,
677
+ userInfo: ["placement": placement]
678
+ )
679
+ }
680
+
681
+ // MARK: - Fabric child management
682
+
683
+ @objc func insertChild(_ child: UIView, atIndex index: Int) {
684
+ if child is RCTBottomAccessoryContainerView {
685
+ self.bottomAccessoryView = child
686
+ attachBottomAccessory()
687
+ return
688
+ }
689
+ guard index >= 0 && index <= childViews.count else { return }
690
+ childViews.insert(child, at: index)
691
+ rebuildViewControllers()
692
+ }
693
+
694
+ @objc func removeChild(atIndex index: Int) {
695
+ guard index >= 0 && index < childViews.count else { return }
696
+ let child = childViews[index]
697
+ if child is RCTBottomAccessoryContainerView {
698
+ detachBottomAccessory()
699
+ self.bottomAccessoryView = nil
700
+ return
701
+ }
702
+ childViews.remove(at: index)
703
+ rebuildViewControllers()
704
+ }
705
+ }
706
+
707
+ // MARK: - UITabBarControllerDelegate proxy
708
+
709
+ class TabViewDelegateProxy: NSObject, UITabBarControllerDelegate {
710
+ static let shared = TabViewDelegateProxy()
711
+ private var mapping: [ObjectIdentifier: RCTTabViewContainerView] = [:]
712
+
713
+ func register(_ view: RCTTabViewContainerView, for controller: UITabBarController) {
714
+ mapping[ObjectIdentifier(controller)] = view
715
+ }
716
+
717
+ func unregister(for controller: UITabBarController) {
718
+ mapping.removeValue(forKey: ObjectIdentifier(controller))
719
+ }
720
+
721
+ func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
722
+ guard let containerView = mapping[ObjectIdentifier(tabBarController)] else {
723
+ RCTTabViewLog.debug("RCTTabView", message: "[DelegateProxy] shouldSelect: no container view found!")
724
+ return false
725
+ }
726
+ let isReselection = tabBarController.selectedViewController == viewController
727
+ RCTTabViewLog.debug("RCTTabView", message: "[DelegateProxy] shouldSelect: isReselection=\(isReselection), tabBar.isHidden=\(tabBarController.tabBar.isHidden)")
728
+
729
+ if let index = tabBarController.viewControllers?.firstIndex(of: viewController) {
730
+ let result = containerView.shouldSelectTab(at: index, isReselection: isReselection)
731
+ RCTTabViewLog.debug("RCTTabView", message: "[DelegateProxy] shouldSelect result=\(result)")
732
+ return result
733
+ }
734
+
735
+ return false
736
+ }
737
+ }
738
+
739
+ // MARK: - Long press gesture handler
740
+
741
+ private class LongPressGestureHandler: NSObject {
742
+ private weak var tabBar: UITabBar?
743
+ private let handler: (Int, Bool) -> Void
744
+
745
+ init(tabBar: UITabBar, handler: @escaping (Int, Bool) -> Void) {
746
+ self.tabBar = tabBar
747
+ self.handler = handler
748
+ super.init()
749
+ }
750
+
751
+ @objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
752
+ guard recognizer.state == .began,
753
+ let tabBar else { return }
754
+
755
+ let location = recognizer.location(in: tabBar)
756
+
757
+ let tabBarButtons = tabBar.subviews
758
+ .filter { String(describing: type(of: $0)).contains("UITabBarButton") }
759
+ .sorted { $0.frame.minX < $1.frame.minX }
760
+
761
+ for (index, button) in tabBarButtons.enumerated() {
762
+ if button.frame.contains(location) {
763
+ handler(index, true)
764
+ break
765
+ }
766
+ }
767
+ }
768
+ }