@capgo/capacitor-native-navigation 8.1.0 → 8.2.0

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.
@@ -42,6 +42,8 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
42
42
  private var tabContainer: UIView?
43
43
  private var tabEffectView: UIVisualEffectView?
44
44
  private var tabBar: UITabBar?
45
+ private var floatingTabBar: NativeNavigationFloatingTabBar?
46
+ private var tabbarStyle = NativeNavigationTabbarStyleConfig()
45
47
  private var tabBarController: NativeNavigationTabController?
46
48
  private var tabViewControllers: [UIViewController] = []
47
49
  private weak var systemTabRootContainer: UIView?
@@ -51,10 +53,7 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
51
53
  private var liftedWebViewOverlays: [NativeNavigationWeakView] = []
52
54
  private var isWebViewHostedInSystemTabController = false
53
55
  private var navbarHeight: CGFloat = 44
54
- private var tabbarHeight: CGFloat = 64
55
- private let floatingTabbarHorizontalMargin: CGFloat = 24
56
- private let floatingTabbarMaxWidth: CGFloat = 430
57
- private let floatingTabbarBottomGap: CGFloat = 10
56
+ private var tabbarHeight: CGFloat = NativeNavigationTabbarStyleConfig().totalHeight
58
57
  private var navbarVisible = false
59
58
  private var tabbarVisible = false
60
59
  private var contentInsetMode = "css"
@@ -70,6 +69,10 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
70
69
  private var activeTransitionDirection = "forward"
71
70
  private var activeZoomSourceFrame: CGRect?
72
71
  private var activeZoomCornerRadius: CGFloat = 0
72
+ private weak var activeTransitionContainer: UIView?
73
+ private var activeTransitionContainerBackgroundColor: UIColor?
74
+ private var activeTransitionContainerWasOpaque = false
75
+ private var activeTransitionContainerBackgroundCaptured = false
73
76
 
74
77
  private var usesSystemLiquidGlass: Bool {
75
78
  if #available(iOS 26.0, *) {
@@ -183,33 +186,56 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
183
186
  return
184
187
  }
185
188
 
186
- let tabBar = self.ensureTabBar()
189
+ self.tabbarStyle = self.makeTabbarStyle(from: call)
190
+ self.tabbarHeight = self.tabbarStyle.totalHeight
191
+
187
192
  let tabs = call.getArray("tabs") as? [[String: Any]] ?? []
188
193
  let selectedId = call.getString("selectedId")
189
194
  let labels = call.getBool("labels", true)
190
195
  let labelVisibilityMode = call.getString("labelVisibilityMode") ?? (labels ? "labeled" : "unlabeled")
191
196
  let icons = call.getBool("icons", true)
192
197
 
193
- let (items, selectedIndex) = self.makeTabBarItems(
194
- tabs,
195
- selectedId: selectedId,
196
- labelVisibilityMode: labelVisibilityMode,
197
- icons: icons
198
- )
199
-
200
- if self.usesSystemLiquidGlass {
198
+ if self.usesSystemLiquidGlass && self.tabbarStyle.shape != .curve {
199
+ let tabBar = self.ensureTabBar()
200
+ let (items, selectedIndex) = self.makeTabBarItems(
201
+ tabs,
202
+ selectedId: selectedId,
203
+ labelVisibilityMode: labelVisibilityMode,
204
+ icons: icons
205
+ )
206
+ self.floatingTabBar?.isHidden = true
207
+ self.tabContainer?.isHidden = true
201
208
  self.applySystemTabBarItems(items, selectedIndex: selectedIndex, animated: call.getBool("animated", false))
209
+ self.applyTabBarAppearance(tabBar: tabBar, options: call)
210
+ self.showTabBarChrome(tabBar)
202
211
  } else {
203
- tabBar.items = items
204
- if let selectedIndex = selectedIndex, selectedIndex < items.count {
205
- tabBar.selectedItem = items[selectedIndex]
206
- } else if tabBar.selectedItem == nil {
207
- tabBar.selectedItem = items.first
212
+ self.restoreWebViewFromSystemTabController()
213
+ self.setSystemTabBarHidden(true)
214
+ self.tabBarController?.view.isHidden = true
215
+ let tabBar = self.ensureFloatingTabBar()
216
+ let (items, selectedIndex) = self.makeFloatingTabBarItems(
217
+ tabs,
218
+ selectedId: selectedId,
219
+ icons: icons
220
+ )
221
+ self.applyFloatingTabBarAppearance(tabBar: tabBar, options: call)
222
+ let resolvedSelectedIndex = selectedIndex ?? (items.indices.contains(tabBar.selectedIndex) ? tabBar.selectedIndex : 0)
223
+ tabBar.configure(
224
+ items: items,
225
+ selectedIndex: resolvedSelectedIndex,
226
+ labelVisibilityMode: labelVisibilityMode,
227
+ icons: icons,
228
+ style: self.tabbarStyle
229
+ )
230
+ tabBar.onSelect = { [weak self] index, item in
231
+ self?.notifyListeners("tabSelect", data: [
232
+ "id": item.id,
233
+ "index": index,
234
+ "title": item.title
235
+ ])
208
236
  }
237
+ self.showFloatingTabBarChrome(tabBar)
209
238
  }
210
-
211
- self.applyTabBarAppearance(tabBar: tabBar, options: call)
212
- self.showTabBarChrome(tabBar)
213
239
  self.layoutChrome()
214
240
  self.updateInsetsAndNotify()
215
241
  call.resolve(self.insetsResult())
@@ -232,9 +258,14 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
232
258
  let cornerRadius = CGFloat(call.getDouble("cornerRadius") ?? 0)
233
259
 
234
260
  self.transitionSnapshot?.removeFromSuperview()
261
+ self.restoreTransitionContainerBackground()
262
+ let transitionSurface = nativeNavigationFallbackBackground(for: webView).withAlphaComponent(1.0)
263
+ self.prepareTransitionContainerBackground(transitionContainer, surface: transitionSurface)
235
264
  let snapshot = self.transitionSnapshotView(from: webView, sourceRect: zoomSourceRect)
236
265
  snapshot.frame = zoomSourceFrame ?? webView.frame
237
266
  snapshot.autoresizingMask = zoomSourceFrame == nil ? [.flexibleWidth, .flexibleHeight] : []
267
+ snapshot.backgroundColor = transitionSurface
268
+ snapshot.isOpaque = true
238
269
  snapshot.layer.cornerRadius = cornerRadius
239
270
  snapshot.clipsToBounds = cornerRadius > 0
240
271
  transitionContainer.insertSubview(snapshot, aboveSubview: webView)
@@ -292,8 +323,9 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
292
323
  private func finishStandardTransition(_ transition: NativeNavigationTransitionContext) {
293
324
  let width = transition.webView.bounds.width
294
325
  let transforms = standardTransitionTransforms(direction: transition.direction, width: width)
326
+ let usesStationaryCrossfade = nativeNavigationUsesStationaryTransitionCrossfade(direction: transition.direction)
295
327
  transition.webView.transform = transforms.start
296
- transition.webView.alpha = transition.direction == "none" ? 1 : 0.01
328
+ transition.webView.alpha = usesStationaryCrossfade ? 1 : 0.01
297
329
 
298
330
  UIView.animate(
299
331
  withDuration: max(transition.duration, 0),
@@ -303,7 +335,7 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
303
335
  transition.webView.transform = .identity
304
336
  transition.webView.alpha = 1
305
337
  transition.snapshot?.transform = transforms.snapshotEnd
306
- transition.snapshot?.alpha = transition.direction == "none" ? 0 : 0.75
338
+ transition.snapshot?.alpha = usesStationaryCrossfade ? 0 : 0.75
307
339
  },
308
340
  completion: { _ in
309
341
  self.finishTransitionCleanup(transition)
@@ -407,6 +439,7 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
407
439
  transition.webView.layer.cornerRadius = 0
408
440
  transition.webView.clipsToBounds = false
409
441
  transitionSnapshot = nil
442
+ restoreTransitionContainerBackground()
410
443
  activeTransitionId = nil
411
444
  activeZoomSourceFrame = nil
412
445
  let event: [String: Any] = ["id": transition.id, "direction": transition.direction, "duration": transition.durationMs]
@@ -414,6 +447,29 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
414
447
  transition.resolve(event)
415
448
  }
416
449
 
450
+ private func prepareTransitionContainerBackground(_ container: UIView, surface: UIColor) {
451
+ activeTransitionContainer = container
452
+ activeTransitionContainerBackgroundColor = container.backgroundColor
453
+ activeTransitionContainerWasOpaque = container.isOpaque
454
+ activeTransitionContainerBackgroundCaptured = true
455
+ if nativeNavigationNeedsTransitionSurface(container.backgroundColor) {
456
+ container.backgroundColor = surface
457
+ container.isOpaque = true
458
+ }
459
+ }
460
+
461
+ private func restoreTransitionContainerBackground() {
462
+ guard activeTransitionContainerBackgroundCaptured else {
463
+ return
464
+ }
465
+ activeTransitionContainer?.backgroundColor = activeTransitionContainerBackgroundColor
466
+ activeTransitionContainer?.isOpaque = activeTransitionContainerWasOpaque
467
+ activeTransitionContainer = nil
468
+ activeTransitionContainerBackgroundColor = nil
469
+ activeTransitionContainerWasOpaque = false
470
+ activeTransitionContainerBackgroundCaptured = false
471
+ }
472
+
417
473
  @objc func getPluginVersion(_ call: CAPPluginCall) {
418
474
  call.resolve([
419
475
  "version": implementation.getPluginVersion()
@@ -549,6 +605,40 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
549
605
  return bar
550
606
  }
551
607
 
608
+ private func ensureFloatingTabBar() -> NativeNavigationFloatingTabBar {
609
+ if let floatingTabBar = floatingTabBar {
610
+ return floatingTabBar
611
+ }
612
+
613
+ let container = NativeNavigationChromeContainer()
614
+ container.hitSlop = UIEdgeInsets(top: 32, left: 0, bottom: 24, right: 0)
615
+ container.isUserInteractionEnabled = true
616
+ container.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
617
+ container.backgroundColor = .clear
618
+ container.clipsToBounds = false
619
+
620
+ container.layer.shadowColor = UIColor.black.cgColor
621
+ container.layer.shadowOpacity = 0.14
622
+ container.layer.shadowRadius = 18
623
+ container.layer.shadowOffset = CGSize(width: 0, height: 10)
624
+
625
+ let effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemChromeMaterial))
626
+ effectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
627
+ effectView.isUserInteractionEnabled = false
628
+ effectView.clipsToBounds = true
629
+ container.addSubview(effectView)
630
+ self.tabEffectView = effectView
631
+
632
+ let bar = NativeNavigationFloatingTabBar()
633
+ bar.autoresizingMask = [.flexibleWidth, .flexibleTopMargin]
634
+ bar.clipsToBounds = false
635
+ container.addSubview(bar)
636
+ bridge?.viewController?.view.addSubview(container)
637
+ self.tabContainer = container
638
+ self.floatingTabBar = bar
639
+ return bar
640
+ }
641
+
552
642
  private func ensureSystemTabBar() -> UITabBar {
553
643
  if let tabBarController = tabBarController {
554
644
  hostWebViewInSelectedSystemTab()
@@ -676,20 +766,22 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
676
766
  }
677
767
 
678
768
  private func hideTabBarChrome() {
679
- if usesSystemLiquidGlass {
769
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
680
770
  setSystemTabBarHidden(true)
681
771
  tabBarController?.view.isHidden = false
682
772
  hostWebViewInSelectedSystemTab()
683
773
  } else {
684
774
  tabContainer?.isHidden = true
775
+ floatingTabBar?.isHidden = true
685
776
  tabBar?.isHidden = true
686
777
  }
687
778
  }
688
779
 
689
780
  private func showTabBarChrome(_ tabBar: UITabBar) {
690
- tabContainer?.isHidden = false
781
+ tabContainer?.isHidden = true
782
+ floatingTabBar?.isHidden = true
691
783
  tabBarController?.view.isHidden = false
692
- if usesSystemLiquidGlass {
784
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
693
785
  setSystemTabBarHidden(false)
694
786
  liftWebViewOverlaysAboveSystemTabs()
695
787
  hostWebViewInSelectedSystemTab()
@@ -698,6 +790,13 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
698
790
  }
699
791
  }
700
792
 
793
+ private func showFloatingTabBarChrome(_ tabBar: NativeNavigationFloatingTabBar) {
794
+ restoreWebViewFromSystemTabController()
795
+ tabBarController?.view.isHidden = true
796
+ tabContainer?.isHidden = false
797
+ tabBar.isHidden = false
798
+ }
799
+
701
800
  private func captureOriginalWebViewPlacementIfNeeded(_ webView: UIView) {
702
801
  guard originalWebViewSuperview == nil, let superview = webView.superview else {
703
802
  return
@@ -879,6 +978,33 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
879
978
  return (items, selectedIndex)
880
979
  }
881
980
 
981
+ private func makeFloatingTabBarItems(
982
+ _ tabs: [[String: Any]],
983
+ selectedId: String?,
984
+ icons: Bool
985
+ ) -> ([NativeNavigationFloatingTabItem], Int?) {
986
+ var selectedIndex: Int?
987
+ let items = tabs.enumerated().map { index, tab -> NativeNavigationFloatingTabItem in
988
+ let id = tab["id"] as? String ?? "tab-\(index)"
989
+ let title = tab["title"] as? String ?? ""
990
+ let image = icons ? self.image(from: tab["icon"] as? [String: Any]) : nil
991
+ let selectedImage = icons ? self.image(from: tab["selectedIcon"] as? [String: Any]) : nil
992
+ if id == selectedId {
993
+ selectedIndex = index
994
+ }
995
+ return NativeNavigationFloatingTabItem(
996
+ id: id,
997
+ title: title,
998
+ accessibilityTitle: title.isEmpty ? id : title,
999
+ image: image,
1000
+ selectedImage: selectedImage,
1001
+ badge: tab["badge"].map { String(describing: $0) },
1002
+ enabled: tab["enabled"] as? Bool ?? true
1003
+ )
1004
+ }
1005
+ return (items, selectedIndex)
1006
+ }
1007
+
882
1008
  private func tabTitle(
883
1009
  _ title: String?,
884
1010
  id: String,
@@ -1044,6 +1170,35 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1044
1170
  return imageView
1045
1171
  }
1046
1172
 
1173
+ private func makeTabbarStyle(from call: CAPPluginCall) -> NativeNavigationTabbarStyleConfig {
1174
+ let rawStyle = call.getObject("style") ?? [:]
1175
+ let requestedShape = (rawStyle["shape"] as? String)?.lowercased()
1176
+ let shape: NativeNavigationTabbarShape = requestedShape == "curve" ? .curve : .floating
1177
+ let isCurve = shape == .curve
1178
+ let centerDiameter = max(number(from: rawStyle["centerButtonDiameter"]) ?? 76, 44)
1179
+ let height = max(number(from: rawStyle["height"]) ?? (isCurve ? 76 : 64), 44)
1180
+ let centerLift = max(number(from: rawStyle["centerButtonLift"]) ?? (centerDiameter / 2), 0)
1181
+ let bottomGap = max(number(from: rawStyle["bottomGap"]) ?? (isCurve ? 0 : 10), 0)
1182
+ let horizontalMargin = max(number(from: rawStyle["horizontalMargin"]) ?? (isCurve ? 0 : 24), 0)
1183
+ let maxWidth = max(number(from: rawStyle["maxWidth"]) ?? (isCurve ? 0 : 430), 0)
1184
+ let cornerRadius = max(number(from: rawStyle["cornerRadius"]) ?? (isCurve ? 24 : height / 2), 0)
1185
+ let centerButtonColor = (rawStyle["centerButtonColor"] as? String).flatMap { UIColor(hexString: $0) }
1186
+ let centerButtonIconColor = (rawStyle["centerButtonIconColor"] as? String).flatMap { UIColor(hexString: $0) } ?? .white
1187
+
1188
+ return NativeNavigationTabbarStyleConfig(
1189
+ shape: shape,
1190
+ height: height,
1191
+ horizontalMargin: horizontalMargin,
1192
+ maxWidth: maxWidth,
1193
+ bottomGap: bottomGap,
1194
+ cornerRadius: cornerRadius,
1195
+ centerItemId: rawStyle["centerItemId"] as? String,
1196
+ centerButtonDiameter: centerDiameter,
1197
+ centerButtonLift: centerLift,
1198
+ centerButtonColor: centerButtonColor,
1199
+ centerButtonIconColor: centerButtonIconColor
1200
+ )
1201
+ }
1047
1202
  private func applyNavBarAppearance(navBar: UINavigationBar, options call: CAPPluginCall) {
1048
1203
  let appearance = UINavigationBarAppearance()
1049
1204
  let transparent = call.getBool("transparent", false)
@@ -1190,6 +1345,37 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1190
1345
  }
1191
1346
  }
1192
1347
 
1348
+ private func applyFloatingTabBarAppearance(tabBar: NativeNavigationFloatingTabBar, options call: CAPPluginCall) {
1349
+ let colors = call.getObject("colors")
1350
+ let backgroundTint = colorValue(colors?["background"])
1351
+ let backgroundColor = (backgroundTint ?? .systemBackground).withAlphaComponent(tabbarStyle.shape == .curve ? 0.96 : 0.46)
1352
+
1353
+ if tabbarStyle.shape == .curve {
1354
+ tabEffectView?.isHidden = true
1355
+ } else if let effect = blurEffect(from: call.getString("blurEffect"), fallback: .systemChromeMaterial) {
1356
+ tabEffectView?.effect = effect
1357
+ tabEffectView?.isHidden = false
1358
+ tabEffectView?.contentView.backgroundColor = backgroundTint?.withAlphaComponent(0.12) ?? backgroundColor
1359
+ } else {
1360
+ tabEffectView?.isHidden = true
1361
+ }
1362
+
1363
+ tabBar.backgroundFillColor = backgroundColor
1364
+ if let color = colorValue(colors?["tint"]) {
1365
+ tabBar.selectedTintColor = color
1366
+ }
1367
+ if let color = colorValue(colors?["inactiveTint"]) {
1368
+ tabBar.inactiveTintColor = color
1369
+ }
1370
+ if let color = colorValue(call.getString("badgeBackgroundColor")) ?? colorValue(colors?["badgeBackground"]) {
1371
+ tabBar.badgeBackgroundColor = color
1372
+ }
1373
+ if let color = colorValue(call.getString("badgeTextColor")) ?? colorValue(colors?["badgeText"]) {
1374
+ tabBar.badgeTextColor = color
1375
+ }
1376
+ tabBar.setNeedsLayout()
1377
+ }
1378
+
1193
1379
  private func applyTabBarBadgeOptions(_ appearance: UITabBarAppearance, options call: CAPPluginCall) {
1194
1380
  if let color = colorValue(call.getString("badgeBackgroundColor")) {
1195
1381
  applyTabItemAppearances(appearance) { itemAppearance in
@@ -1230,17 +1416,18 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1230
1416
  }
1231
1417
 
1232
1418
  if let container = tabContainer {
1233
- let availableWidth = max(0, width - (floatingTabbarHorizontalMargin * 2))
1234
- let tabbarWidth = min(availableWidth, floatingTabbarMaxWidth)
1419
+ let availableWidth = max(0, width - (tabbarStyle.horizontalMargin * 2))
1420
+ let maxWidth = tabbarStyle.maxWidth > 0 ? tabbarStyle.maxWidth : availableWidth
1421
+ let tabbarWidth = min(availableWidth, maxWidth)
1235
1422
  let originX = (width - tabbarWidth) / 2
1236
- let originY = height - safeInsets.bottom - floatingTabbarBottomGap - tabbarHeight
1237
- container.frame = CGRect(x: originX, y: originY, width: tabbarWidth, height: tabbarHeight)
1238
- container.layer.cornerRadius = tabbarHeight / 2
1239
- container.layer.shadowPath = UIBezierPath(roundedRect: container.bounds, cornerRadius: tabbarHeight / 2).cgPath
1423
+ let originY = height - safeInsets.bottom - tabbarStyle.bottomGap - tabbarStyle.totalHeight
1424
+ container.frame = CGRect(x: originX, y: originY, width: tabbarWidth, height: tabbarStyle.totalHeight)
1425
+ container.layer.cornerRadius = tabbarStyle.shape == .floating ? tabbarStyle.cornerRadius : 0
1426
+ container.layer.shadowPath = NativeNavigationTabbarBackgroundPath.path(in: container.bounds, style: tabbarStyle).cgPath
1240
1427
  tabEffectView?.frame = container.bounds
1241
- tabEffectView?.layer.cornerRadius = tabbarHeight / 2
1242
- tabBar?.frame = container.bounds
1243
- tabBar?.layer.cornerRadius = tabbarHeight / 2
1428
+ tabEffectView?.layer.cornerRadius = tabbarStyle.cornerRadius
1429
+ floatingTabBar?.frame = container.bounds
1430
+ floatingTabBar?.layer.cornerRadius = 0
1244
1431
  }
1245
1432
 
1246
1433
  if let tabBarController = tabBarController {
@@ -1253,7 +1440,7 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1253
1440
  }
1254
1441
 
1255
1442
  private func bringChromeToFront() {
1256
- if usesSystemLiquidGlass {
1443
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
1257
1444
  if let navContainer = navContainer {
1258
1445
  bridge?.viewController?.view.bringSubviewToFront(navContainer)
1259
1446
  }
@@ -1352,10 +1539,10 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1352
1539
  private func currentInsets() -> [String: Any] {
1353
1540
  let safeInsets = bridge?.viewController?.view.safeAreaInsets ?? .zero
1354
1541
  let navHeight = isEnabled && navbarVisible ? navbarHeight + safeInsets.top : 0
1542
+ let usesSystemTabbar = usesSystemLiquidGlass && tabbarStyle.shape != .curve
1355
1543
  let nativeTabHeight = max(tabBar?.frame.height ?? 0, 49 + safeInsets.bottom)
1356
- let tabHeight = isEnabled && tabbarVisible
1357
- ? (usesSystemLiquidGlass ? nativeTabHeight : tabbarHeight + safeInsets.bottom + floatingTabbarBottomGap)
1358
- : 0
1544
+ let customTabHeight = tabbarHeight + safeInsets.bottom + tabbarStyle.bottomGap
1545
+ let tabHeight = isEnabled && tabbarVisible ? (usesSystemTabbar ? nativeTabHeight : customTabHeight) : 0
1359
1546
  return [
1360
1547
  "top": navHeight,
1361
1548
  "right": safeInsets.right,
@@ -1455,6 +1642,428 @@ private struct SVGRenderStyle {
1455
1642
  }
1456
1643
  }
1457
1644
 
1645
+ private struct NativeNavigationFloatingTabItem {
1646
+ let id: String
1647
+ let title: String
1648
+ let accessibilityTitle: String
1649
+ let image: UIImage?
1650
+ let selectedImage: UIImage?
1651
+ let badge: String?
1652
+ let enabled: Bool
1653
+ }
1654
+
1655
+ private enum NativeNavigationTabbarShape {
1656
+ case floating
1657
+ case curve
1658
+ }
1659
+
1660
+ private struct NativeNavigationTabbarStyleConfig {
1661
+ var shape: NativeNavigationTabbarShape = .floating
1662
+ var height: CGFloat = 64
1663
+ var horizontalMargin: CGFloat = 24
1664
+ var maxWidth: CGFloat = 430
1665
+ var bottomGap: CGFloat = 10
1666
+ var cornerRadius: CGFloat = 32
1667
+ var centerItemId: String?
1668
+ var centerButtonDiameter: CGFloat = 76
1669
+ var centerButtonLift: CGFloat = 38
1670
+ var centerButtonColor: UIColor?
1671
+ var centerButtonIconColor: UIColor = .white
1672
+
1673
+ var barTop: CGFloat {
1674
+ shape == .curve ? centerButtonLift : 0
1675
+ }
1676
+
1677
+ var totalHeight: CGFloat {
1678
+ height + barTop
1679
+ }
1680
+ }
1681
+
1682
+ private enum NativeNavigationTabbarBackgroundPath {
1683
+ static func path(in bounds: CGRect, style: NativeNavigationTabbarStyleConfig) -> UIBezierPath {
1684
+ guard style.shape == .curve else {
1685
+ return UIBezierPath(roundedRect: bounds, cornerRadius: style.cornerRadius)
1686
+ }
1687
+
1688
+ let barRect = CGRect(x: 0, y: style.barTop, width: bounds.width, height: max(style.height, 1))
1689
+ let cornerRadius = min(style.cornerRadius, barRect.height / 2)
1690
+ let centerX = bounds.midX
1691
+ let notchRadius = (style.centerButtonDiameter / 2) + 7
1692
+ let notchDepth = min(barRect.height * 0.78, notchRadius)
1693
+ let leftShoulder = max(barRect.minX + cornerRadius, centerX - notchRadius)
1694
+ let rightShoulder = min(barRect.maxX - cornerRadius, centerX + notchRadius)
1695
+ let control = notchDepth * 0.55228475
1696
+ let path = UIBezierPath()
1697
+
1698
+ path.move(to: CGPoint(x: barRect.minX + cornerRadius, y: barRect.minY))
1699
+ path.addLine(to: CGPoint(x: leftShoulder, y: barRect.minY))
1700
+ path.addCurve(
1701
+ to: CGPoint(x: centerX, y: barRect.minY + notchDepth),
1702
+ controlPoint1: CGPoint(x: leftShoulder, y: barRect.minY + control),
1703
+ controlPoint2: CGPoint(x: centerX - control, y: barRect.minY + notchDepth)
1704
+ )
1705
+ path.addCurve(
1706
+ to: CGPoint(x: rightShoulder, y: barRect.minY),
1707
+ controlPoint1: CGPoint(x: centerX + control, y: barRect.minY + notchDepth),
1708
+ controlPoint2: CGPoint(x: rightShoulder, y: barRect.minY + control)
1709
+ )
1710
+ path.addLine(to: CGPoint(x: barRect.maxX - cornerRadius, y: barRect.minY))
1711
+ path.addQuadCurve(to: CGPoint(x: barRect.maxX, y: barRect.minY + cornerRadius), controlPoint: CGPoint(x: barRect.maxX, y: barRect.minY))
1712
+ path.addLine(to: CGPoint(x: barRect.maxX, y: barRect.maxY - cornerRadius))
1713
+ path.addQuadCurve(to: CGPoint(x: barRect.maxX - cornerRadius, y: barRect.maxY), controlPoint: CGPoint(x: barRect.maxX, y: barRect.maxY))
1714
+ path.addLine(to: CGPoint(x: barRect.minX + cornerRadius, y: barRect.maxY))
1715
+ path.addQuadCurve(to: CGPoint(x: barRect.minX, y: barRect.maxY - cornerRadius), controlPoint: CGPoint(x: barRect.minX, y: barRect.maxY))
1716
+ path.addLine(to: CGPoint(x: barRect.minX, y: barRect.minY + cornerRadius))
1717
+ path.addQuadCurve(to: CGPoint(x: barRect.minX + cornerRadius, y: barRect.minY), controlPoint: CGPoint(x: barRect.minX, y: barRect.minY))
1718
+ path.close()
1719
+ return path
1720
+ }
1721
+ }
1722
+
1723
+ private struct NativeNavigationFloatingTabStyle {
1724
+ let selected: Bool
1725
+ let labels: Bool
1726
+ let icons: Bool
1727
+ let isCenter: Bool
1728
+ let selectedTint: UIColor
1729
+ let inactiveTint: UIColor
1730
+ let centerButtonColor: UIColor
1731
+ let centerButtonIconColor: UIColor
1732
+ let badgeBackgroundColor: UIColor
1733
+ let badgeTextColor: UIColor
1734
+ }
1735
+
1736
+ private final class NativeNavigationFloatingTabBar: UIView {
1737
+ private var items: [NativeNavigationFloatingTabItem] = []
1738
+ private var buttons: [NativeNavigationFloatingTabButton] = []
1739
+ private var labelVisibilityMode = "labeled"
1740
+ private var iconsVisible = true
1741
+ private let backgroundShapeView = NativeNavigationTabbarBackgroundView()
1742
+ private var tabbarStyle = NativeNavigationTabbarStyleConfig()
1743
+
1744
+ var selectedIndex = 0
1745
+ var selectedTintColor = UIColor.systemBlue {
1746
+ didSet { updateButtons() }
1747
+ }
1748
+ var inactiveTintColor = UIColor.secondaryLabel {
1749
+ didSet { updateButtons() }
1750
+ }
1751
+ var badgeBackgroundColor = UIColor.systemRed {
1752
+ didSet { updateButtons() }
1753
+ }
1754
+ var badgeTextColor = UIColor.white {
1755
+ didSet { updateButtons() }
1756
+ }
1757
+ var backgroundFillColor = UIColor.systemBackground.withAlphaComponent(0.46) {
1758
+ didSet {
1759
+ backgroundShapeView.fillColor = backgroundFillColor
1760
+ }
1761
+ }
1762
+ var onSelect: ((Int, NativeNavigationFloatingTabItem) -> Void)?
1763
+
1764
+ override init(frame: CGRect) {
1765
+ super.init(frame: frame)
1766
+ isOpaque = false
1767
+ backgroundShapeView.isUserInteractionEnabled = false
1768
+ backgroundShapeView.fillColor = backgroundFillColor
1769
+ addSubview(backgroundShapeView)
1770
+ }
1771
+
1772
+ required init?(coder: NSCoder) {
1773
+ fatalError("init(coder:) has not been implemented")
1774
+ }
1775
+
1776
+ func configure(
1777
+ items: [NativeNavigationFloatingTabItem],
1778
+ selectedIndex: Int,
1779
+ labelVisibilityMode: String,
1780
+ icons: Bool,
1781
+ style: NativeNavigationTabbarStyleConfig
1782
+ ) {
1783
+ self.items = items
1784
+ self.labelVisibilityMode = labelVisibilityMode
1785
+ self.iconsVisible = icons
1786
+ self.tabbarStyle = style
1787
+ self.selectedIndex = items.indices.contains(selectedIndex) ? selectedIndex : 0
1788
+ backgroundShapeView.style = style
1789
+ rebuildButtons()
1790
+ setNeedsLayout()
1791
+ }
1792
+
1793
+ override func layoutSubviews() {
1794
+ super.layoutSubviews()
1795
+ backgroundShapeView.frame = bounds
1796
+ guard !buttons.isEmpty else {
1797
+ return
1798
+ }
1799
+
1800
+ if let centerIndex = centerButtonIndex(), buttons.indices.contains(centerIndex) {
1801
+ let buttonDiameter = tabbarStyle.centerButtonDiameter
1802
+ let centerGap = min(buttonDiameter + 22, bounds.width * 0.46)
1803
+ let barFrame = CGRect(x: 0, y: tabbarStyle.barTop, width: bounds.width, height: tabbarStyle.height)
1804
+ let leftWidth = max(0, bounds.midX - centerGap / 2)
1805
+ let rightX = min(bounds.width, bounds.midX + centerGap / 2)
1806
+ let centerFrame = CGRect(
1807
+ x: bounds.midX - buttonDiameter / 2,
1808
+ y: 0,
1809
+ width: buttonDiameter,
1810
+ height: buttonDiameter
1811
+ )
1812
+
1813
+ buttons[centerIndex].frame = centerFrame
1814
+ layoutButtons(Array(0..<centerIndex), in: CGRect(x: 0, y: barFrame.minY, width: leftWidth, height: barFrame.height))
1815
+ layoutButtons(
1816
+ Array((centerIndex + 1)..<buttons.count),
1817
+ in: CGRect(x: rightX, y: barFrame.minY, width: bounds.width - rightX, height: barFrame.height)
1818
+ )
1819
+ return
1820
+ }
1821
+
1822
+ layoutButtons(Array(buttons.indices), in: bounds)
1823
+ }
1824
+
1825
+ private func layoutButtons(_ indices: [Int], in rect: CGRect) {
1826
+ guard !indices.isEmpty else {
1827
+ return
1828
+ }
1829
+ let itemWidth = rect.width / CGFloat(indices.count)
1830
+ for (position, index) in indices.enumerated() where buttons.indices.contains(index) {
1831
+ buttons[index].frame = CGRect(
1832
+ x: rect.minX + CGFloat(position) * itemWidth,
1833
+ y: rect.minY,
1834
+ width: itemWidth,
1835
+ height: rect.height
1836
+ )
1837
+ }
1838
+ }
1839
+
1840
+ private func centerButtonIndex() -> Int? {
1841
+ guard tabbarStyle.shape == .curve, !items.isEmpty else {
1842
+ return nil
1843
+ }
1844
+ if let centerItemId = tabbarStyle.centerItemId,
1845
+ let index = items.firstIndex(where: { $0.id == centerItemId }) {
1846
+ return index
1847
+ }
1848
+ return items.count / 2
1849
+ }
1850
+
1851
+ private func rebuildButtons() {
1852
+ buttons.forEach { $0.removeFromSuperview() }
1853
+ buttons = items.enumerated().map { index, item in
1854
+ let button = NativeNavigationFloatingTabButton()
1855
+ button.tag = index
1856
+ button.configure(
1857
+ item: item,
1858
+ style: style(for: index)
1859
+ )
1860
+ button.addTarget(self, action: #selector(handleTap(_:)), for: .touchUpInside)
1861
+ addSubview(button)
1862
+ return button
1863
+ }
1864
+ }
1865
+
1866
+ private func updateButtons() {
1867
+ for (index, button) in buttons.enumerated() {
1868
+ guard items.indices.contains(index) else {
1869
+ continue
1870
+ }
1871
+ button.configure(
1872
+ item: items[index],
1873
+ style: style(for: index)
1874
+ )
1875
+ }
1876
+ }
1877
+
1878
+ private func style(for index: Int) -> NativeNavigationFloatingTabStyle {
1879
+ let isCenter = centerButtonIndex() == index
1880
+ return NativeNavigationFloatingTabStyle(
1881
+ selected: index == selectedIndex,
1882
+ labels: showsLabel(for: index),
1883
+ icons: iconsVisible,
1884
+ isCenter: isCenter,
1885
+ selectedTint: selectedTintColor,
1886
+ inactiveTint: inactiveTintColor,
1887
+ centerButtonColor: tabbarStyle.centerButtonColor ?? selectedTintColor,
1888
+ centerButtonIconColor: tabbarStyle.centerButtonIconColor,
1889
+ badgeBackgroundColor: badgeBackgroundColor,
1890
+ badgeTextColor: badgeTextColor
1891
+ )
1892
+ }
1893
+
1894
+ private func showsLabel(for index: Int) -> Bool {
1895
+ switch labelVisibilityMode {
1896
+ case "unlabeled":
1897
+ return false
1898
+ case "selected":
1899
+ return index == selectedIndex
1900
+ case "auto":
1901
+ let compact = traitCollection.horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone
1902
+ return !compact || index == selectedIndex
1903
+ default:
1904
+ return true
1905
+ }
1906
+ }
1907
+
1908
+ @objc private func handleTap(_ sender: NativeNavigationFloatingTabButton) {
1909
+ let index = sender.tag
1910
+ guard items.indices.contains(index), items[index].enabled else {
1911
+ return
1912
+ }
1913
+ selectedIndex = index
1914
+ updateButtons()
1915
+ onSelect?(index, items[index])
1916
+ }
1917
+ }
1918
+
1919
+ private final class NativeNavigationTabbarBackgroundView: UIView {
1920
+ var style = NativeNavigationTabbarStyleConfig() {
1921
+ didSet { setNeedsDisplay() }
1922
+ }
1923
+ var fillColor = UIColor.systemBackground.withAlphaComponent(0.46) {
1924
+ didSet { setNeedsDisplay() }
1925
+ }
1926
+
1927
+ override init(frame: CGRect) {
1928
+ super.init(frame: frame)
1929
+ isOpaque = false
1930
+ backgroundColor = .clear
1931
+ }
1932
+
1933
+ required init?(coder: NSCoder) {
1934
+ fatalError("init(coder:) has not been implemented")
1935
+ }
1936
+
1937
+ override func draw(_ rect: CGRect) {
1938
+ fillColor.setFill()
1939
+ NativeNavigationTabbarBackgroundPath.path(in: bounds, style: style).fill()
1940
+ }
1941
+ }
1942
+
1943
+ private final class NativeNavigationFloatingTabButton: UIControl {
1944
+ private let selectedView = UIView()
1945
+ private let imageView = UIImageView()
1946
+ private let titleLabel = UILabel()
1947
+ private let badgeLabel = UILabel()
1948
+ private var hasIcon = true
1949
+ private var hasLabel = true
1950
+ private var isCenterButton = false
1951
+ private var badgeText: String?
1952
+
1953
+ override init(frame: CGRect) {
1954
+ super.init(frame: frame)
1955
+ isAccessibilityElement = true
1956
+
1957
+ selectedView.isUserInteractionEnabled = false
1958
+ selectedView.alpha = 0
1959
+ addSubview(selectedView)
1960
+
1961
+ imageView.contentMode = .scaleAspectFit
1962
+ imageView.isUserInteractionEnabled = false
1963
+ addSubview(imageView)
1964
+
1965
+ titleLabel.textAlignment = .center
1966
+ titleLabel.font = .systemFont(ofSize: 11, weight: .semibold)
1967
+ titleLabel.adjustsFontSizeToFitWidth = true
1968
+ titleLabel.minimumScaleFactor = 0.78
1969
+ titleLabel.isUserInteractionEnabled = false
1970
+ addSubview(titleLabel)
1971
+
1972
+ badgeLabel.textAlignment = .center
1973
+ badgeLabel.font = .systemFont(ofSize: 11, weight: .bold)
1974
+ badgeLabel.textColor = .white
1975
+ badgeLabel.backgroundColor = .systemRed
1976
+ badgeLabel.layer.masksToBounds = true
1977
+ badgeLabel.isUserInteractionEnabled = false
1978
+ addSubview(badgeLabel)
1979
+ }
1980
+
1981
+ required init?(coder: NSCoder) {
1982
+ fatalError("init(coder:) has not been implemented")
1983
+ }
1984
+
1985
+ func configure(
1986
+ item: NativeNavigationFloatingTabItem,
1987
+ style: NativeNavigationFloatingTabStyle
1988
+ ) {
1989
+ isEnabled = item.enabled
1990
+ alpha = item.enabled ? 1 : 0.38
1991
+ isCenterButton = style.isCenter
1992
+ hasIcon = style.icons && (item.image != nil || item.selectedImage != nil)
1993
+ hasLabel = style.isCenter ? (!hasIcon && !item.title.isEmpty) : (style.labels && !item.title.isEmpty)
1994
+ badgeText = item.badge
1995
+
1996
+ let color = style.isCenter ? style.centerButtonIconColor : (style.selected ? style.selectedTint : style.inactiveTint)
1997
+ selectedView.backgroundColor = style.isCenter ? style.centerButtonColor : style.selectedTint.withAlphaComponent(style.selected ? 0.16 : 0)
1998
+ selectedView.alpha = style.isCenter || style.selected ? 1 : 0
1999
+
2000
+ layer.shadowColor = UIColor.black.cgColor
2001
+ layer.shadowOpacity = style.isCenter ? 0.2 : 0
2002
+ layer.shadowRadius = style.isCenter ? 14 : 0
2003
+ layer.shadowOffset = CGSize(width: 0, height: 8)
2004
+
2005
+ let image = style.selected ? (item.selectedImage ?? item.image) : item.image
2006
+ imageView.image = image
2007
+ imageView.tintColor = color
2008
+ imageView.isHidden = !hasIcon
2009
+
2010
+ titleLabel.text = item.title
2011
+ titleLabel.textColor = color
2012
+ titleLabel.font = .systemFont(ofSize: style.isCenter ? 12 : 11, weight: style.selected ? .bold : .semibold)
2013
+ titleLabel.isHidden = !hasLabel
2014
+
2015
+ badgeLabel.text = item.badge
2016
+ badgeLabel.textColor = style.badgeTextColor
2017
+ badgeLabel.backgroundColor = style.badgeBackgroundColor
2018
+ badgeLabel.isHidden = item.badge == nil || item.badge == "0"
2019
+ accessibilityLabel = item.accessibilityTitle
2020
+ accessibilityTraits = style.selected ? [.button, .selected] : .button
2021
+ setNeedsLayout()
2022
+ }
2023
+
2024
+ override func layoutSubviews() {
2025
+ super.layoutSubviews()
2026
+ if isCenterButton {
2027
+ selectedView.frame = bounds
2028
+ } else {
2029
+ let selectedSize: CGFloat = 58
2030
+ selectedView.frame = CGRect(x: (bounds.width - selectedSize) / 2, y: (bounds.height - selectedSize) / 2, width: selectedSize, height: selectedSize)
2031
+ }
2032
+ selectedView.layer.cornerRadius = selectedView.bounds.height / 2
2033
+
2034
+ let iconSize: CGFloat = isCenterButton ? 32 : 23
2035
+ if isCenterButton {
2036
+ if hasIcon {
2037
+ imageView.frame = CGRect(x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize, height: iconSize)
2038
+ titleLabel.frame = .zero
2039
+ } else {
2040
+ imageView.frame = .zero
2041
+ titleLabel.frame = CGRect(x: 8, y: (bounds.height - 18) / 2, width: bounds.width - 16, height: 18)
2042
+ }
2043
+ } else if hasIcon && hasLabel {
2044
+ imageView.frame = CGRect(x: (bounds.width - iconSize) / 2, y: 10, width: iconSize, height: iconSize)
2045
+ titleLabel.frame = CGRect(x: 5, y: bounds.height - 23, width: bounds.width - 10, height: 15)
2046
+ } else if hasIcon {
2047
+ imageView.frame = CGRect(x: (bounds.width - iconSize) / 2, y: (bounds.height - iconSize) / 2, width: iconSize, height: iconSize)
2048
+ titleLabel.frame = .zero
2049
+ } else {
2050
+ imageView.frame = .zero
2051
+ titleLabel.frame = CGRect(x: 5, y: (bounds.height - 18) / 2, width: bounds.width - 10, height: 18)
2052
+ }
2053
+
2054
+ let badgeHeight: CGFloat = 18
2055
+ let badgeWidth = max(badgeHeight, CGFloat((badgeText ?? "").count * 7 + 11))
2056
+ let anchor = hasIcon ? imageView.frame : CGRect(x: bounds.midX - 10, y: bounds.midY - 10, width: 20, height: 20)
2057
+ badgeLabel.frame = CGRect(
2058
+ x: min(bounds.width - badgeWidth - 8, anchor.midX + 7),
2059
+ y: max(6, anchor.minY - 6),
2060
+ width: badgeWidth,
2061
+ height: badgeHeight
2062
+ )
2063
+ badgeLabel.layer.cornerRadius = badgeHeight / 2
2064
+ }
2065
+ }
2066
+
1458
2067
  private final class NativeNavigationChromeContainer: UIView {
1459
2068
  var hitSlop = UIEdgeInsets.zero
1460
2069
 
@@ -1611,9 +2220,20 @@ final class NativeNavigationTabContentController: UIViewController {
1611
2220
  private func nativeNavigationFallbackBackground(for view: UIView) -> UIColor {
1612
2221
  if let color = view.backgroundColor,
1613
2222
  color.cgColor.alpha > 0 {
1614
- return color
2223
+ return color.withAlphaComponent(1)
2224
+ }
2225
+ return UIColor.systemBackground.withAlphaComponent(1.0)
2226
+ }
2227
+
2228
+ func nativeNavigationUsesStationaryTransitionCrossfade(direction: String) -> Bool {
2229
+ direction == "tab" || direction == "root" || direction == "none"
2230
+ }
2231
+
2232
+ private func nativeNavigationNeedsTransitionSurface(_ color: UIColor?) -> Bool {
2233
+ guard let color else {
2234
+ return true
1615
2235
  }
1616
- return .systemBackground
2236
+ return color.cgColor.alpha < 1
1617
2237
  }
1618
2238
 
1619
2239
  private func nativeNavigationSnapshotPlaceholder(for view: UIView) -> UIView {