@capgo/capacitor-native-navigation 8.1.1 → 8.2.1

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"
@@ -187,33 +186,56 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
187
186
  return
188
187
  }
189
188
 
190
- let tabBar = self.ensureTabBar()
189
+ self.tabbarStyle = self.makeTabbarStyle(from: call)
190
+ self.tabbarHeight = self.tabbarStyle.totalHeight
191
+
191
192
  let tabs = call.getArray("tabs") as? [[String: Any]] ?? []
192
193
  let selectedId = call.getString("selectedId")
193
194
  let labels = call.getBool("labels", true)
194
195
  let labelVisibilityMode = call.getString("labelVisibilityMode") ?? (labels ? "labeled" : "unlabeled")
195
196
  let icons = call.getBool("icons", true)
196
197
 
197
- let (items, selectedIndex) = self.makeTabBarItems(
198
- tabs,
199
- selectedId: selectedId,
200
- labelVisibilityMode: labelVisibilityMode,
201
- icons: icons
202
- )
203
-
204
- 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
205
208
  self.applySystemTabBarItems(items, selectedIndex: selectedIndex, animated: call.getBool("animated", false))
209
+ self.applyTabBarAppearance(tabBar: tabBar, options: call)
210
+ self.showTabBarChrome(tabBar)
206
211
  } else {
207
- tabBar.items = items
208
- if let selectedIndex = selectedIndex, selectedIndex < items.count {
209
- tabBar.selectedItem = items[selectedIndex]
210
- } else if tabBar.selectedItem == nil {
211
- 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
+ ])
212
236
  }
237
+ self.showFloatingTabBarChrome(tabBar)
213
238
  }
214
-
215
- self.applyTabBarAppearance(tabBar: tabBar, options: call)
216
- self.showTabBarChrome(tabBar)
217
239
  self.layoutChrome()
218
240
  self.updateInsetsAndNotify()
219
241
  call.resolve(self.insetsResult())
@@ -583,6 +605,40 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
583
605
  return bar
584
606
  }
585
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
+
586
642
  private func ensureSystemTabBar() -> UITabBar {
587
643
  if let tabBarController = tabBarController {
588
644
  hostWebViewInSelectedSystemTab()
@@ -710,20 +766,22 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
710
766
  }
711
767
 
712
768
  private func hideTabBarChrome() {
713
- if usesSystemLiquidGlass {
769
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
714
770
  setSystemTabBarHidden(true)
715
771
  tabBarController?.view.isHidden = false
716
772
  hostWebViewInSelectedSystemTab()
717
773
  } else {
718
774
  tabContainer?.isHidden = true
775
+ floatingTabBar?.isHidden = true
719
776
  tabBar?.isHidden = true
720
777
  }
721
778
  }
722
779
 
723
780
  private func showTabBarChrome(_ tabBar: UITabBar) {
724
- tabContainer?.isHidden = false
781
+ tabContainer?.isHidden = true
782
+ floatingTabBar?.isHidden = true
725
783
  tabBarController?.view.isHidden = false
726
- if usesSystemLiquidGlass {
784
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
727
785
  setSystemTabBarHidden(false)
728
786
  liftWebViewOverlaysAboveSystemTabs()
729
787
  hostWebViewInSelectedSystemTab()
@@ -732,6 +790,13 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
732
790
  }
733
791
  }
734
792
 
793
+ private func showFloatingTabBarChrome(_ tabBar: NativeNavigationFloatingTabBar) {
794
+ restoreWebViewFromSystemTabController()
795
+ tabBarController?.view.isHidden = true
796
+ tabContainer?.isHidden = false
797
+ tabBar.isHidden = false
798
+ }
799
+
735
800
  private func captureOriginalWebViewPlacementIfNeeded(_ webView: UIView) {
736
801
  guard originalWebViewSuperview == nil, let superview = webView.superview else {
737
802
  return
@@ -913,6 +978,33 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
913
978
  return (items, selectedIndex)
914
979
  }
915
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
+
916
1008
  private func tabTitle(
917
1009
  _ title: String?,
918
1010
  id: String,
@@ -1078,6 +1170,35 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1078
1170
  return imageView
1079
1171
  }
1080
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
+ }
1081
1202
  private func applyNavBarAppearance(navBar: UINavigationBar, options call: CAPPluginCall) {
1082
1203
  let appearance = UINavigationBarAppearance()
1083
1204
  let transparent = call.getBool("transparent", false)
@@ -1224,6 +1345,37 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1224
1345
  }
1225
1346
  }
1226
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
+
1227
1379
  private func applyTabBarBadgeOptions(_ appearance: UITabBarAppearance, options call: CAPPluginCall) {
1228
1380
  if let color = colorValue(call.getString("badgeBackgroundColor")) {
1229
1381
  applyTabItemAppearances(appearance) { itemAppearance in
@@ -1264,17 +1416,18 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1264
1416
  }
1265
1417
 
1266
1418
  if let container = tabContainer {
1267
- let availableWidth = max(0, width - (floatingTabbarHorizontalMargin * 2))
1268
- 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)
1269
1422
  let originX = (width - tabbarWidth) / 2
1270
- let originY = height - safeInsets.bottom - floatingTabbarBottomGap - tabbarHeight
1271
- container.frame = CGRect(x: originX, y: originY, width: tabbarWidth, height: tabbarHeight)
1272
- container.layer.cornerRadius = tabbarHeight / 2
1273
- 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
1274
1427
  tabEffectView?.frame = container.bounds
1275
- tabEffectView?.layer.cornerRadius = tabbarHeight / 2
1276
- tabBar?.frame = container.bounds
1277
- tabBar?.layer.cornerRadius = tabbarHeight / 2
1428
+ tabEffectView?.layer.cornerRadius = tabbarStyle.cornerRadius
1429
+ floatingTabBar?.frame = container.bounds
1430
+ floatingTabBar?.layer.cornerRadius = 0
1278
1431
  }
1279
1432
 
1280
1433
  if let tabBarController = tabBarController {
@@ -1287,7 +1440,7 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1287
1440
  }
1288
1441
 
1289
1442
  private func bringChromeToFront() {
1290
- if usesSystemLiquidGlass {
1443
+ if usesSystemLiquidGlass && tabbarStyle.shape != .curve {
1291
1444
  if let navContainer = navContainer {
1292
1445
  bridge?.viewController?.view.bringSubviewToFront(navContainer)
1293
1446
  }
@@ -1386,10 +1539,10 @@ public class NativeNavigationPlugin: CAPPlugin, CAPBridgedPlugin, UITabBarContro
1386
1539
  private func currentInsets() -> [String: Any] {
1387
1540
  let safeInsets = bridge?.viewController?.view.safeAreaInsets ?? .zero
1388
1541
  let navHeight = isEnabled && navbarVisible ? navbarHeight + safeInsets.top : 0
1542
+ let usesSystemTabbar = usesSystemLiquidGlass && tabbarStyle.shape != .curve
1389
1543
  let nativeTabHeight = max(tabBar?.frame.height ?? 0, 49 + safeInsets.bottom)
1390
- let tabHeight = isEnabled && tabbarVisible
1391
- ? (usesSystemLiquidGlass ? nativeTabHeight : tabbarHeight + safeInsets.bottom + floatingTabbarBottomGap)
1392
- : 0
1544
+ let customTabHeight = tabbarHeight + safeInsets.bottom + tabbarStyle.bottomGap
1545
+ let tabHeight = isEnabled && tabbarVisible ? (usesSystemTabbar ? nativeTabHeight : customTabHeight) : 0
1393
1546
  return [
1394
1547
  "top": navHeight,
1395
1548
  "right": safeInsets.right,
@@ -1489,6 +1642,428 @@ private struct SVGRenderStyle {
1489
1642
  }
1490
1643
  }
1491
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
+
1492
2067
  private final class NativeNavigationChromeContainer: UIView {
1493
2068
  var hitSlop = UIEdgeInsets.zero
1494
2069