@onekeyfe/react-native-bottom-tabs 0.0.0-stage → 6.7.0-8
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/LICENSE +46 -0
- package/README.md +94 -2
- 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/onekey/bottomtabs/BottomBarBlurRenderer.kt +92 -0
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsPackage.kt +16 -0
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsView.kt +1558 -0
- package/android/src/main/java/com/onekey/bottomtabs/BottomTabsViewManager.kt +220 -0
- package/android/src/main/java/com/onekey/bottomtabs/FloatingTabBarStyle.kt +50 -0
- package/android/src/main/java/com/onekey/bottomtabs/ImageSource.kt +86 -0
- package/android/src/main/java/com/onekey/bottomtabs/TabInfo.kt +13 -0
- package/android/src/main/java/com/onekey/bottomtabs/Utils.kt +31 -0
- package/android/src/main/java/com/onekey/bottomtabs/events/OnNativeLayoutEvent.kt +20 -0
- package/android/src/main/java/com/onekey/bottomtabs/events/OnTabBarMeasuredEvent.kt +19 -0
- package/android/src/main/java/com/onekey/bottomtabs/events/PageSelectedEvent.kt +21 -0
- package/android/src/main/java/com/onekey/bottomtabs/events/TabDoublePressEvent.kt +19 -0
- package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewComponentDescriptor.h +32 -0
- package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.cpp +18 -0
- package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewShadowNode.h +35 -0
- package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.cpp +15 -0
- package/common/cpp/react/renderer/components/RNBottomTabs/RNBottomTabsViewState.h +25 -0
- package/docs/SPEC.md +168 -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/RCTBottomTabsComponentView.h +12 -0
- package/ios/RCTBottomTabsComponentView.mm +338 -0
- package/ios/RCTBottomTabsContainerView.swift +777 -0
- package/ios/RCTBottomTabsLog.h +7 -0
- package/ios/RCTBottomTabsLog.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/BottomAccessoryViewNativeComponent.ts +27 -0
- package/lib/module/BottomTabs.js +325 -0
- package/lib/module/BottomTabsNativeComponent.ts +70 -0
- package/lib/module/DelayedFreeze.js +31 -0
- package/lib/module/NativeSVGDecoder.js +5 -0
- package/lib/module/SceneMap.js +28 -0
- package/lib/module/codegen-types.d.js +2 -0
- package/lib/module/index.js +20 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/utils/BottomTabBarHeightContext.js +5 -0
- package/lib/module/utils/useBottomTabBarHeight.js +12 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/BottomAccessoryView.d.ts +8 -0
- package/lib/typescript/src/BottomAccessoryViewNativeComponent.d.ts +16 -0
- package/lib/typescript/src/BottomTabs.d.ts +209 -0
- package/lib/typescript/src/BottomTabsNativeComponent.d.ts +59 -0
- package/lib/typescript/src/DelayedFreeze.d.ts +9 -0
- package/lib/typescript/src/NativeSVGDecoder.d.ts +6 -0
- package/lib/typescript/src/SceneMap.d.ts +10 -0
- package/lib/typescript/src/index.d.ts +16 -0
- package/lib/typescript/src/types.d.ts +29 -0
- package/lib/typescript/src/utils/BottomTabBarHeightContext.d.ts +3 -0
- package/lib/typescript/src/utils/useBottomTabBarHeight.d.ts +2 -0
- package/package.json +117 -4
- package/react-native-bottom-tabs.podspec +36 -0
- package/react-native.config.js +13 -0
- package/src/BottomAccessoryView.tsx +58 -0
- package/src/BottomAccessoryViewNativeComponent.ts +27 -0
- package/src/BottomTabs.tsx +559 -0
- package/src/BottomTabsNativeComponent.ts +70 -0
- package/src/DelayedFreeze.tsx +33 -0
- package/src/NativeSVGDecoder.ts +5 -0
- package/src/SceneMap.tsx +34 -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,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
|
+
}
|