@onekeyfe/react-native-tab-view 3.0.67 → 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.
@@ -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
- didSet { updateTintColors() }
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
- didSet { updateTabBarAppearance() }
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: tabImage, tag: originalIndex)
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) {
@@ -90,10 +90,30 @@ const TabView = ({
90
90
  if (!loaded.includes(focusedKey)) {
91
91
  setLoaded(loaded => [...loaded, focusedKey]);
92
92
  }
93
- const icons = React.useMemo(() => trimmedRoutes.map(route => getIcon({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-tab-view",
3
- "version": "3.0.67",
3
+ "version": "3.0.68",
4
4
  "description": "Native Bottom Tabs for React Native (UIKit implementation)",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/module/index.js",
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 icons = React.useMemo(
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;