@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.
- package/android/build.gradle +119 -0
- package/android/gradle.properties +4 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/AndroidManifestNew.xml +2 -0
- package/android/src/main/java/com/rcttabview/ImageSource.kt +86 -0
- package/android/src/main/java/com/rcttabview/RCTTabView.kt +529 -0
- package/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +204 -0
- package/android/src/main/java/com/rcttabview/RCTTabViewPackage.kt +16 -0
- package/android/src/main/java/com/rcttabview/TabInfo.kt +12 -0
- package/android/src/main/java/com/rcttabview/Utils.kt +31 -0
- package/android/src/main/java/com/rcttabview/events/OnNativeLayoutEvent.kt +20 -0
- package/android/src/main/java/com/rcttabview/events/OnTabBarMeasuredEvent.kt +19 -0
- package/android/src/main/java/com/rcttabview/events/PageSelectedEvent.kt +21 -0
- package/android/src/main/java/com/rcttabview/events/TabLongPressedEvent.kt +19 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewComponentDescriptor.h +32 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.cpp +18 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewShadowNode.h +35 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.cpp +15 -0
- package/common/cpp/react/renderer/components/RNCTabView/RNCTabViewState.h +25 -0
- package/ios/Extensions.swift +46 -0
- package/ios/RCTBottomAccessoryComponentView.h +12 -0
- package/ios/RCTBottomAccessoryComponentView.mm +67 -0
- package/ios/RCTBottomAccessoryContainerView.swift +51 -0
- package/ios/RCTTabViewComponentView.h +12 -0
- package/ios/RCTTabViewComponentView.mm +325 -0
- package/ios/RCTTabViewContainerView.swift +768 -0
- package/ios/RCTTabViewLog.h +7 -0
- package/ios/RCTTabViewLog.m +32 -0
- package/ios/SVG/CoreSVG.h +13 -0
- package/ios/SVG/CoreSVG.mm +177 -0
- package/ios/SVG/SvgDecoder.h +10 -0
- package/ios/SVG/SvgDecoder.mm +32 -0
- package/ios/TabBarFontSize.swift +55 -0
- package/lib/module/BottomAccessoryView.js +45 -0
- package/lib/module/BottomAccessoryView.js.map +1 -0
- package/lib/module/BottomAccessoryViewNativeComponent.ts +27 -0
- package/lib/module/DelayedFreeze.js +26 -0
- package/lib/module/DelayedFreeze.js.map +1 -0
- package/lib/module/NativeSVGDecoder.js +5 -0
- package/lib/module/NativeSVGDecoder.js.map +1 -0
- package/lib/module/SceneMap.js +28 -0
- package/lib/module/SceneMap.js.map +1 -0
- package/lib/module/TabView.js +263 -0
- package/lib/module/TabView.js.map +1 -0
- package/lib/module/TabViewNativeComponent.ts +68 -0
- package/lib/module/codegen-types.d.js +2 -0
- package/lib/module/codegen-types.d.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
- package/lib/module/utils/BottomTabBarHeightContext.js.map +1 -0
- package/lib/module/utils/useBottomTabBarHeight.js +12 -0
- package/lib/module/utils/useBottomTabBarHeight.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/BottomAccessoryView.d.ts +8 -0
- package/lib/typescript/src/BottomAccessoryView.d.ts.map +1 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +16 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/DelayedFreeze.d.ts +8 -0
- package/lib/typescript/src/DelayedFreeze.d.ts.map +1 -0
- package/lib/typescript/src/NativeSVGDecoder.d.ts +6 -0
- package/lib/typescript/src/NativeSVGDecoder.d.ts.map +1 -0
- package/lib/typescript/src/SceneMap.d.ts +10 -0
- package/lib/typescript/src/SceneMap.d.ts.map +1 -0
- package/lib/typescript/src/TabView.d.ts +178 -0
- package/lib/typescript/src/TabView.d.ts.map +1 -0
- package/lib/typescript/src/TabViewNativeComponent.d.ts +55 -0
- package/lib/typescript/src/TabViewNativeComponent.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +16 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +29 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +3 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts.map +1 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +2 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts.map +1 -0
- package/package.json +114 -0
- package/react-native-tab-view.podspec +36 -0
- package/react-native.config.js +13 -0
- package/src/BottomAccessoryView.tsx +58 -0
- package/src/BottomAccessoryViewNativeComponent.ts +27 -0
- package/src/DelayedFreeze.tsx +27 -0
- package/src/NativeSVGDecoder.ts +5 -0
- package/src/SceneMap.tsx +34 -0
- package/src/TabView.tsx +466 -0
- package/src/TabViewNativeComponent.ts +68 -0
- package/src/codegen-types.d.ts +28 -0
- package/src/index.tsx +18 -0
- package/src/types.ts +31 -0
- package/src/utils/BottomTabBarHeightContext.ts +5 -0
- 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
|
+
}
|