@onekeyfe/react-native-tab-view 3.0.66 → 3.0.68
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/src/main/java/com/rcttabview/RCTTabViewManager.kt +3 -0
- package/ios/RCTTabViewComponentView.mm +13 -0
- package/ios/RCTTabViewContainerView.swift +98 -3
- package/lib/module/TabView.js +43 -1
- package/lib/module/TabViewNativeComponent.ts +1 -0
- package/lib/typescript/src/TabViewNativeComponent.d.ts +1 -0
- package/package.json +1 -1
- package/src/TabView.tsx +44 -1
- package/src/TabViewNativeComponent.ts +1 -0
|
@@ -193,6 +193,9 @@ class RCTTabViewManager(context: ReactApplicationContext) :
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
// iOS-only props (no-ops on Android)
|
|
196
|
+
// selectedIcons feeds UITabBarItem.selectedImage on iOS; Android keeps
|
|
197
|
+
// swapping the icon in JS via the focusedKey-driven `icons` array.
|
|
198
|
+
override fun setSelectedIcons(view: ReactBottomNavigationView?, value: ReadableArray?) {}
|
|
196
199
|
override fun setTranslucent(view: ReactBottomNavigationView?, value: Boolean) {}
|
|
197
200
|
override fun setSidebarAdaptable(view: ReactBottomNavigationView?, value: Boolean) {}
|
|
198
201
|
override fun setScrollEdgeAppearance(view: ReactBottomNavigationView?, value: String?) {}
|
|
@@ -239,6 +239,19 @@ static NSArray<NSDictionary *>* convertItemsToArray(const std::vector<RNCTabView
|
|
|
239
239
|
[_containerView setValue:iconsArray forKey:@"icons"];
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
if (oldViewProps.selectedIcons != newViewProps.selectedIcons) {
|
|
243
|
+
auto selectedIconsArray = [[NSMutableArray alloc] init];
|
|
244
|
+
for (auto &source: newViewProps.selectedIcons) {
|
|
245
|
+
NSMutableDictionary *iconDict = [NSMutableDictionary dictionary];
|
|
246
|
+
iconDict[@"uri"] = [NSString stringWithUTF8String:source.uri.c_str()];
|
|
247
|
+
iconDict[@"width"] = @(source.size.width);
|
|
248
|
+
iconDict[@"height"] = @(source.size.height);
|
|
249
|
+
iconDict[@"scale"] = @(source.scale);
|
|
250
|
+
[selectedIconsArray addObject:iconDict];
|
|
251
|
+
}
|
|
252
|
+
[_containerView setValue:selectedIconsArray forKey:@"selectedIcons"];
|
|
253
|
+
}
|
|
254
|
+
|
|
242
255
|
if (oldViewProps.sidebarAdaptable != newViewProps.sidebarAdaptable) {
|
|
243
256
|
[_containerView setValue:@(newViewProps.sidebarAdaptable) forKey:@"sidebarAdaptable"];
|
|
244
257
|
}
|
|
@@ -56,6 +56,11 @@ class RCTTabViewContainerView: UIView {
|
|
|
56
56
|
private var childViews: [UIView] = []
|
|
57
57
|
private var bottomAccessoryView: UIView?
|
|
58
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] = [:]
|
|
59
64
|
private var imageLoader: RCTImageLoaderProtocol?
|
|
60
65
|
private let iconSize = CGSize(width: 27, height: 27)
|
|
61
66
|
private var longPressHandler: LongPressGestureHandler?
|
|
@@ -76,6 +81,10 @@ class RCTTabViewContainerView: UIView {
|
|
|
76
81
|
didSet { loadIconsFromSources() }
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
@objc var selectedIcons: [NSDictionary]? {
|
|
85
|
+
didSet { loadSelectedIconsFromSources() }
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
@objc var labeled: NSNumber? {
|
|
80
89
|
didSet { rebuildViewControllers() }
|
|
81
90
|
}
|
|
@@ -109,11 +118,13 @@ class RCTTabViewContainerView: UIView {
|
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
@objc var activeTintColor: UIColor? {
|
|
112
|
-
|
|
121
|
+
// Rebuild so the baked selectedImage tint follows light/dark theme changes.
|
|
122
|
+
didSet { updateTintColors(); rebuildViewControllers() }
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
@objc var inactiveTintColor: UIColor? {
|
|
116
|
-
|
|
126
|
+
// Rebuild so the baked unselected-image tint follows theme changes.
|
|
127
|
+
didSet { updateTabBarAppearance(); rebuildViewControllers() }
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
@objc var rippleColor: UIColor? // Android only
|
|
@@ -291,6 +302,7 @@ class RCTTabViewContainerView: UIView {
|
|
|
291
302
|
|
|
292
303
|
// Configure tab bar item
|
|
293
304
|
let icon = loadedIcons[originalIndex]
|
|
305
|
+
let selectedIcon = loadedSelectedIcons[originalIndex]
|
|
294
306
|
let sfSymbol = tabData.sfSymbol ?? ""
|
|
295
307
|
|
|
296
308
|
var tabImage: UIImage?
|
|
@@ -300,9 +312,29 @@ class RCTTabViewContainerView: UIView {
|
|
|
300
312
|
tabImage = UIImage(systemName: sfSymbol)
|
|
301
313
|
}
|
|
302
314
|
|
|
315
|
+
// Focused (solid) artwork for the selected state. Fall back to the
|
|
316
|
+
// unfocused image when no selected icon was provided so the selected
|
|
317
|
+
// tab still renders something.
|
|
318
|
+
var selectedTabImage: UIImage?
|
|
319
|
+
if let selectedIcon {
|
|
320
|
+
selectedTabImage = selectedIcon
|
|
321
|
+
} else if !sfSymbol.isEmpty {
|
|
322
|
+
selectedTabImage = UIImage(systemName: sfSymbol)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Bake the inactive/active colors directly into the images as
|
|
326
|
+
// .alwaysOriginal. iOS 26 Liquid Glass ignores appearance.normal.iconColor
|
|
327
|
+
// and washes every icon to a single near-white tint, which is why the
|
|
328
|
+
// unselected tabs never render the gray `iconSubdued`. Pre-tinting the
|
|
329
|
+
// artwork and marking it .alwaysOriginal stops the system from re-tinting,
|
|
330
|
+
// restoring the gray(unselected) → active(selected) color change natively.
|
|
331
|
+
let normalImage = bakeTint(tabImage, inactiveTintColor)
|
|
332
|
+
let selectedImage = bakeTint(selectedTabImage ?? tabImage, activeTintColor)
|
|
333
|
+
|
|
303
334
|
let isLabeled = labeled?.boolValue ?? true
|
|
304
335
|
let title = isLabeled ? tabData.title : nil
|
|
305
|
-
vc.tabBarItem = UITabBarItem(title: title, image:
|
|
336
|
+
vc.tabBarItem = UITabBarItem(title: title, image: normalImage, selectedImage: selectedImage)
|
|
337
|
+
vc.tabBarItem.tag = originalIndex
|
|
306
338
|
vc.tabBarItem.badgeValue = tabData.badge
|
|
307
339
|
vc.tabBarItem.accessibilityIdentifier = tabData.testID
|
|
308
340
|
|
|
@@ -359,6 +391,21 @@ class RCTTabViewContainerView: UIView {
|
|
|
359
391
|
let filtered = filteredItems
|
|
360
392
|
guard let index = filtered.firstIndex(where: { $0.key == selectedPage }) else { return }
|
|
361
393
|
|
|
394
|
+
// Common case: the user tapped the tab. UIKit's delegate already selected
|
|
395
|
+
// it optimistically and started the iOS 26 Liquid Glass capsule animation.
|
|
396
|
+
// Re-assigning selectedIndex here — especially wrapped in
|
|
397
|
+
// performWithoutAnimation — would cancel that in-flight animation, which is
|
|
398
|
+
// what made the icon/color change lag a beat behind the glass. When the
|
|
399
|
+
// selection is already correct, just refresh tint and leave the native
|
|
400
|
+
// animation alone.
|
|
401
|
+
if tbc.selectedIndex == index {
|
|
402
|
+
updateTintColors()
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Programmatic selection (deep link, external navigation, preventsDefault
|
|
407
|
+
// tabs): keep the existing no-animation behavior to avoid the historically
|
|
408
|
+
// reverted tab-switch animation regression.
|
|
362
409
|
if disablePageAnimations {
|
|
363
410
|
UIView.performWithoutAnimation {
|
|
364
411
|
tbc.selectedIndex = index
|
|
@@ -525,11 +572,25 @@ class RCTTabViewContainerView: UIView {
|
|
|
525
572
|
}
|
|
526
573
|
}
|
|
527
574
|
|
|
575
|
+
// MARK: - Icon tinting
|
|
576
|
+
|
|
577
|
+
/// Returns a copy of `image` recolored to `color` and marked
|
|
578
|
+
/// `.alwaysOriginal`, so iOS 26 Liquid Glass won't override the icon color.
|
|
579
|
+
/// Falls back to the untinted image when no color is provided.
|
|
580
|
+
private func bakeTint(_ image: UIImage?, _ color: UIColor?) -> UIImage? {
|
|
581
|
+
guard let image else { return nil }
|
|
582
|
+
guard let color else { return image }
|
|
583
|
+
return image
|
|
584
|
+
.withRenderingMode(.alwaysTemplate)
|
|
585
|
+
.withTintColor(color, renderingMode: .alwaysOriginal)
|
|
586
|
+
}
|
|
587
|
+
|
|
528
588
|
// MARK: - Icon loading
|
|
529
589
|
|
|
530
590
|
@objc func setImageLoader(_ loader: RCTImageLoaderProtocol) {
|
|
531
591
|
self.imageLoader = loader
|
|
532
592
|
loadIconsFromSources()
|
|
593
|
+
loadSelectedIconsFromSources()
|
|
533
594
|
}
|
|
534
595
|
|
|
535
596
|
private func loadIconsFromSources() {
|
|
@@ -566,6 +627,40 @@ class RCTTabViewContainerView: UIView {
|
|
|
566
627
|
}
|
|
567
628
|
}
|
|
568
629
|
|
|
630
|
+
private func loadSelectedIconsFromSources() {
|
|
631
|
+
guard let imageLoader, let iconDicts = selectedIcons else { return }
|
|
632
|
+
|
|
633
|
+
let iconSources = iconDicts.map { IconSource(dict: $0) }
|
|
634
|
+
|
|
635
|
+
for (index, source) in iconSources.enumerated() {
|
|
636
|
+
guard !source.uri.isEmpty else { continue }
|
|
637
|
+
|
|
638
|
+
let url = URL(string: source.uri)
|
|
639
|
+
guard let url else { continue }
|
|
640
|
+
|
|
641
|
+
let request = URLRequest(url: url)
|
|
642
|
+
let size = CGSize(width: source.width, height: source.height)
|
|
643
|
+
let scale = CGFloat(source.scale)
|
|
644
|
+
|
|
645
|
+
imageLoader.loadImage(
|
|
646
|
+
with: request,
|
|
647
|
+
size: size,
|
|
648
|
+
scale: scale,
|
|
649
|
+
clipped: true,
|
|
650
|
+
resizeMode: RCTResizeMode.contain,
|
|
651
|
+
progressBlock: { _, _ in },
|
|
652
|
+
partialLoad: { _ in },
|
|
653
|
+
completionBlock: { [weak self] error, image in
|
|
654
|
+
if error != nil { return }
|
|
655
|
+
guard let image, let self else { return }
|
|
656
|
+
DispatchQueue.main.async {
|
|
657
|
+
self.loadedSelectedIcons[index] = image.resizeImageTo(size: self.iconSize)
|
|
658
|
+
self.rebuildViewControllers()
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
569
664
|
// MARK: - Tab delegate callbacks
|
|
570
665
|
|
|
571
666
|
func handleTabSelected(at index: Int) {
|
package/lib/module/TabView.js
CHANGED
|
@@ -90,10 +90,30 @@ const TabView = ({
|
|
|
90
90
|
if (!loaded.includes(focusedKey)) {
|
|
91
91
|
setLoaded(loaded => [...loaded, focusedKey]);
|
|
92
92
|
}
|
|
93
|
-
const
|
|
93
|
+
const isIOS = Platform.OS === 'ios';
|
|
94
|
+
|
|
95
|
+
// Unfocused (outline) icons — independent of focusedKey so the array stays
|
|
96
|
+
// stable across tab switches and the native side never reloads on switch.
|
|
97
|
+
const unfocusedIcons = React.useMemo(() => trimmedRoutes.map(route => getIcon({
|
|
98
|
+
route,
|
|
99
|
+
focused: false
|
|
100
|
+
})), [getIcon, trimmedRoutes]);
|
|
101
|
+
// Focused (solid) icons — also stable; handed to iOS as selectedImage.
|
|
102
|
+
const focusedIcons = React.useMemo(() => trimmedRoutes.map(route => getIcon({
|
|
103
|
+
route,
|
|
104
|
+
focused: true
|
|
105
|
+
})), [getIcon, trimmedRoutes]);
|
|
106
|
+
// Legacy single array driven by focusedKey. Android still swaps the icon in
|
|
107
|
+
// JS (it has no selectedImage equivalent).
|
|
108
|
+
const focusKeyedIcons = React.useMemo(() => trimmedRoutes.map(route => getIcon({
|
|
94
109
|
route,
|
|
95
110
|
focused: route.key === focusedKey
|
|
96
111
|
})), [focusedKey, getIcon, trimmedRoutes]);
|
|
112
|
+
|
|
113
|
+
// iOS hands both states to UIKit (image + selectedImage) so the focused
|
|
114
|
+
// artwork swaps natively, in sync with the selection animation, instead of
|
|
115
|
+
// waiting for a JS round-trip. Android keeps the focusedKey-driven array.
|
|
116
|
+
const icons = isIOS ? unfocusedIcons : focusKeyedIcons;
|
|
97
117
|
const items = React.useMemo(() => trimmedRoutes.map((route, index) => {
|
|
98
118
|
const icon = icons[index];
|
|
99
119
|
const isSfSymbol = isAppleSymbol(icon);
|
|
@@ -153,6 +173,27 @@ const TabView = ({
|
|
|
153
173
|
scale: 1
|
|
154
174
|
};
|
|
155
175
|
}), [icons]);
|
|
176
|
+
|
|
177
|
+
// Focused (solid) assets passed to iOS as selectedImage. iOS only — Android
|
|
178
|
+
// ignores selectedIcons and keeps swapping via the focusedKey array above.
|
|
179
|
+
const resolvedSelectedIconAssets = React.useMemo(() => focusedIcons.map(icon => {
|
|
180
|
+
if (icon && !isAppleSymbol(icon)) {
|
|
181
|
+
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
182
|
+
const resolved = Image.resolveAssetSource(icon);
|
|
183
|
+
return {
|
|
184
|
+
uri: resolved?.uri ?? '',
|
|
185
|
+
width: resolved?.width ?? 0,
|
|
186
|
+
height: resolved?.height ?? 0,
|
|
187
|
+
scale: resolved?.scale ?? 1
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
uri: '',
|
|
192
|
+
width: 0,
|
|
193
|
+
height: 0,
|
|
194
|
+
scale: 1
|
|
195
|
+
};
|
|
196
|
+
}), [focusedIcons]);
|
|
156
197
|
const jumpTo = useLatestCallback(key => {
|
|
157
198
|
const index = trimmedRoutes.findIndex(route => route.key === key);
|
|
158
199
|
if (index === -1) {
|
|
@@ -203,6 +244,7 @@ const TabView = ({
|
|
|
203
244
|
style: styles.fullWidth,
|
|
204
245
|
items: items,
|
|
205
246
|
icons: renderCustomTabBar ? undefined : resolvedIconAssets,
|
|
247
|
+
selectedIcons: isIOS && !renderCustomTabBar ? resolvedSelectedIconAssets : undefined,
|
|
206
248
|
selectedPage: focusedKey,
|
|
207
249
|
tabBarHidden: props.tabBarHidden ?? !!renderCustomTabBar,
|
|
208
250
|
onTabLongPress: handleTabLongPress,
|
|
@@ -44,6 +44,7 @@ export interface TabViewProps extends ViewProps {
|
|
|
44
44
|
onTabBarMeasured?: DirectEventHandler<OnTabBarMeasured>;
|
|
45
45
|
onNativeLayout?: DirectEventHandler<OnNativeLayout>;
|
|
46
46
|
icons?: ReadonlyArray<ImageSource>;
|
|
47
|
+
selectedIcons?: ReadonlyArray<ImageSource>;
|
|
47
48
|
tabBarHidden?: boolean;
|
|
48
49
|
labeled?: boolean;
|
|
49
50
|
sidebarAdaptable?: boolean;
|
|
@@ -32,6 +32,7 @@ export interface TabViewProps extends ViewProps {
|
|
|
32
32
|
onTabBarMeasured?: DirectEventHandler<OnTabBarMeasured>;
|
|
33
33
|
onNativeLayout?: DirectEventHandler<OnNativeLayout>;
|
|
34
34
|
icons?: ReadonlyArray<ImageSource>;
|
|
35
|
+
selectedIcons?: ReadonlyArray<ImageSource>;
|
|
35
36
|
tabBarHidden?: boolean;
|
|
36
37
|
labeled?: boolean;
|
|
37
38
|
sidebarAdaptable?: boolean;
|
package/package.json
CHANGED
package/src/TabView.tsx
CHANGED
|
@@ -260,7 +260,22 @@ const TabView = <Route extends BaseRoute>({
|
|
|
260
260
|
setLoaded((loaded) => [...loaded, focusedKey]);
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
const
|
|
263
|
+
const isIOS = Platform.OS === 'ios';
|
|
264
|
+
|
|
265
|
+
// Unfocused (outline) icons — independent of focusedKey so the array stays
|
|
266
|
+
// stable across tab switches and the native side never reloads on switch.
|
|
267
|
+
const unfocusedIcons = React.useMemo(
|
|
268
|
+
() => trimmedRoutes.map((route) => getIcon({ route, focused: false })),
|
|
269
|
+
[getIcon, trimmedRoutes]
|
|
270
|
+
);
|
|
271
|
+
// Focused (solid) icons — also stable; handed to iOS as selectedImage.
|
|
272
|
+
const focusedIcons = React.useMemo(
|
|
273
|
+
() => trimmedRoutes.map((route) => getIcon({ route, focused: true })),
|
|
274
|
+
[getIcon, trimmedRoutes]
|
|
275
|
+
);
|
|
276
|
+
// Legacy single array driven by focusedKey. Android still swaps the icon in
|
|
277
|
+
// JS (it has no selectedImage equivalent).
|
|
278
|
+
const focusKeyedIcons = React.useMemo(
|
|
264
279
|
() =>
|
|
265
280
|
trimmedRoutes.map((route) =>
|
|
266
281
|
getIcon({
|
|
@@ -271,6 +286,11 @@ const TabView = <Route extends BaseRoute>({
|
|
|
271
286
|
[focusedKey, getIcon, trimmedRoutes]
|
|
272
287
|
);
|
|
273
288
|
|
|
289
|
+
// iOS hands both states to UIKit (image + selectedImage) so the focused
|
|
290
|
+
// artwork swaps natively, in sync with the selection animation, instead of
|
|
291
|
+
// waiting for a JS round-trip. Android keeps the focusedKey-driven array.
|
|
292
|
+
const icons = isIOS ? unfocusedIcons : focusKeyedIcons;
|
|
293
|
+
|
|
274
294
|
const items: TabViewItems[number][] = React.useMemo(
|
|
275
295
|
() =>
|
|
276
296
|
trimmedRoutes.map((route, index) => {
|
|
@@ -336,6 +356,26 @@ const TabView = <Route extends BaseRoute>({
|
|
|
336
356
|
[icons]
|
|
337
357
|
);
|
|
338
358
|
|
|
359
|
+
// Focused (solid) assets passed to iOS as selectedImage. iOS only — Android
|
|
360
|
+
// ignores selectedIcons and keeps swapping via the focusedKey array above.
|
|
361
|
+
const resolvedSelectedIconAssets = React.useMemo(
|
|
362
|
+
() =>
|
|
363
|
+
focusedIcons.map((icon) => {
|
|
364
|
+
if (icon && !isAppleSymbol(icon)) {
|
|
365
|
+
// @ts-ignore - resolveAssetSource accepts ImageSourcePropType
|
|
366
|
+
const resolved = Image.resolveAssetSource(icon);
|
|
367
|
+
return {
|
|
368
|
+
uri: resolved?.uri ?? '',
|
|
369
|
+
width: resolved?.width ?? 0,
|
|
370
|
+
height: resolved?.height ?? 0,
|
|
371
|
+
scale: resolved?.scale ?? 1,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
return { uri: '', width: 0, height: 0, scale: 1 };
|
|
375
|
+
}),
|
|
376
|
+
[focusedIcons]
|
|
377
|
+
);
|
|
378
|
+
|
|
339
379
|
const jumpTo = useLatestCallback((key: string) => {
|
|
340
380
|
const index = trimmedRoutes.findIndex((route) => route.key === key);
|
|
341
381
|
if (index === -1) {
|
|
@@ -394,6 +434,9 @@ const TabView = <Route extends BaseRoute>({
|
|
|
394
434
|
style={styles.fullWidth}
|
|
395
435
|
items={items}
|
|
396
436
|
icons={renderCustomTabBar ? undefined : resolvedIconAssets}
|
|
437
|
+
selectedIcons={
|
|
438
|
+
isIOS && !renderCustomTabBar ? resolvedSelectedIconAssets : undefined
|
|
439
|
+
}
|
|
397
440
|
selectedPage={focusedKey}
|
|
398
441
|
tabBarHidden={props.tabBarHidden ?? !!renderCustomTabBar}
|
|
399
442
|
onTabLongPress={handleTabLongPress}
|
|
@@ -44,6 +44,7 @@ export interface TabViewProps extends ViewProps {
|
|
|
44
44
|
onTabBarMeasured?: DirectEventHandler<OnTabBarMeasured>;
|
|
45
45
|
onNativeLayout?: DirectEventHandler<OnNativeLayout>;
|
|
46
46
|
icons?: ReadonlyArray<ImageSource>;
|
|
47
|
+
selectedIcons?: ReadonlyArray<ImageSource>;
|
|
47
48
|
tabBarHidden?: boolean;
|
|
48
49
|
labeled?: boolean;
|
|
49
50
|
sidebarAdaptable?: boolean;
|