@ajuarezso/capacitor-liquid-glass 0.3.1 → 0.3.3

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.
@@ -23,94 +23,142 @@ struct LiquidGlassTabItem {
23
23
 
24
24
  /// Visual style of the tab bar background.
25
25
  enum LiquidGlassTabBarStyle: String {
26
- /// Translucent blurred — auto-applies Liquid Glass on iOS 26+ SDK.
26
+ /// Translucent blurred — adopta Liquid Glass automáticamente en iOS 26+
27
+ /// cuando el binario está linkeado contra el SDK iOS 26 (Xcode 26+).
27
28
  case `default`
28
- /// Minimal blur (`systemUltraThinMaterial`) — more see-through than default.
29
+ /// Minimal blur (`systemUltraThinMaterial`) — más ver-through que default.
30
+ /// NOTA: este override CANCELA el adopt automático de Liquid Glass.
29
31
  case ultraThin
30
- /// No background, no blur — content behind shows through 100%.
32
+ /// No background, no blur — content behind se ve completo.
33
+ /// NOTA: este override CANCELA el adopt automático de Liquid Glass.
31
34
  case transparent
32
- /// REAL iOS 26 `UIGlassEffect` (Music + App Store material).
33
- /// Fallback en iOS < 26 `UIBlurEffect.systemThinMaterial`.
35
+ /// Liquid Glass nativo iOS 26 alias funcional de `.default`. Se mantiene
36
+ /// como case separado para semántica explícita en el consumer ("este
37
+ /// layout pide específicamente Liquid Glass, no fallback genérico").
34
38
  case liquidGlass
35
39
  }
36
40
 
37
- /// A floating UITabBar overlay that adopts iOS 26 Liquid Glass automatically
38
- /// when the app is built against the iOS 26 SDK. On earlier iOS the bar falls
39
- /// back to the translucent blurred UITabBar appearance.
40
- final class LiquidGlassTabBarOverlay: NSObject {
41
+ /// Tab bar flotante que adopta iOS 26 Liquid Glass automáticamente cuando la
42
+ /// app se compila contra el SDK iOS 26.
43
+ ///
44
+ /// **Decisión arquitectónica crítica**: este view controller se adjunta como
45
+ /// CHILD del rootViewController del window, y el `UITabBar` vive como subview
46
+ /// de `self.view`. Esto crea el view controller hierarchy completo que iOS 26
47
+ /// necesita para aplicar el material Liquid Glass automáticamente al UITabBar.
48
+ ///
49
+ /// Agregar el `UITabBar` directamente al `UIWindow` (sin view controller
50
+ /// container) NO funciona — iOS 26 omite el adopt automático y la pill se
51
+ /// renderiza con un material translúcido genérico que se ve opaco en
52
+ /// comparación con el material real de Music / App Store.
53
+ ///
54
+ /// Patrón inspirado en `stay-liquid` (alistairheath/stay-liquid GitHub).
55
+ final class LiquidGlassTabBarOverlay: UIViewController {
41
56
 
42
57
  // Public callbacks
43
58
  var onTabSelected: ((Int, String) -> Void)?
44
59
  var onLayoutChanged: ((Double, Double) -> Void)?
45
60
 
46
61
  // Internal state
47
- private weak var window: UIWindow?
48
- private var tabBar: UITabBar?
62
+ private let tabBar = UITabBar()
49
63
  private var items: [LiquidGlassTabItem] = []
50
- /// Overlay `UIVisualEffectView` que provee el material cuando el style es
51
- /// `.liquidGlass`. Solo se monta cuando el estilo lo requiere, y se
52
- /// destruye al cambiar a otro estilo. Vive como subview del window justo
53
- /// DEBAJO del `tabBar` (que en este modo queda 100% transparente).
54
- private var glassOverlayView: UIVisualEffectView?
64
+ private weak var hostVC: UIViewController?
65
+
66
+ /// Reemplaza `self.view` por una `PassThroughView` custom. El override
67
+ /// de `hitTest` vive en la `UIView` (no en el `UIViewController`), así
68
+ /// que necesitamos una subclase dedicada en lugar de override el VC.
69
+ override func loadView() {
70
+ let v = PassThroughView()
71
+ v.allowedHitView = tabBar
72
+ self.view = v
73
+ }
55
74
 
56
- func attach(to window: UIWindow) {
57
- if self.window === window, tabBar != nil { return }
58
- self.window = window
75
+ override func viewDidLoad() {
76
+ super.viewDidLoad()
77
+ // Background transparente — solo el `UITabBar` (la pill flotante)
78
+ // ocupa espacio visible. El resto del área es transparente y la
79
+ // `PassThroughView` deja pasar los toques al webview por debajo.
80
+ view.backgroundColor = .clear
59
81
 
60
- let tabBar = UITabBar()
61
82
  tabBar.translatesAutoresizingMaskIntoConstraints = false
62
83
  tabBar.delegate = self
63
84
 
64
85
  // Default appearance: triggers Liquid Glass automatically on iOS 26+.
65
- // (Sobreescrito en `configure(...)` si el caller pidió otro estilo).
86
+ // Sobreescrito en `configure(...)` si el caller pidió otro estilo.
66
87
  applyAppearance(tabBar, style: .default)
67
88
 
68
- window.addSubview(tabBar)
89
+ view.addSubview(tabBar)
69
90
 
70
91
  NSLayoutConstraint.activate([
71
- tabBar.leadingAnchor.constraint(equalTo: window.leadingAnchor),
72
- tabBar.trailingAnchor.constraint(equalTo: window.trailingAnchor),
73
- tabBar.bottomAnchor.constraint(equalTo: window.bottomAnchor),
92
+ tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
93
+ tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
94
+ tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor),
74
95
  ])
96
+ }
97
+
98
+ /// View que cubre todo el rootVC pero solo absorbe taps que aterrizan
99
+ /// sobre `allowedHitView` (el `UITabBar` floating). El resto pasa al
100
+ /// webview de Capacitor por debajo. Sin esto, este overlay bloquearía
101
+ /// TODO el contenido de la app.
102
+ private final class PassThroughView: UIView {
103
+ weak var allowedHitView: UIView?
104
+
105
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
106
+ let hit = super.hitTest(point, with: event)
107
+ guard let hit, let allowed = allowedHitView else { return nil }
108
+ if hit === allowed || hit.isDescendant(of: allowed) { return hit }
109
+ return nil
110
+ }
111
+ }
75
112
 
76
- self.tabBar = tabBar
113
+ /// Adjunta este view controller como child del rootViewController del
114
+ /// window. Idempotente: si ya está adjuntado al mismo root, no-op.
115
+ func attach(to window: UIWindow) {
116
+ guard let rootVC = window.rootViewController else { return }
117
+ if self.parent === rootVC { return }
118
+ // Si está adjuntado a otro VC (cambio de root entre sesiones),
119
+ // detachar primero antes de re-adjuntar.
120
+ if self.parent != nil {
121
+ self.willMove(toParent: nil)
122
+ self.view.removeFromSuperview()
123
+ self.removeFromParent()
124
+ }
125
+
126
+ rootVC.addChild(self)
127
+ self.view.translatesAutoresizingMaskIntoConstraints = false
128
+ rootVC.view.addSubview(self.view)
129
+ NSLayoutConstraint.activate([
130
+ self.view.leadingAnchor.constraint(equalTo: rootVC.view.leadingAnchor),
131
+ self.view.trailingAnchor.constraint(equalTo: rootVC.view.trailingAnchor),
132
+ self.view.topAnchor.constraint(equalTo: rootVC.view.topAnchor),
133
+ self.view.bottomAnchor.constraint(equalTo: rootVC.view.bottomAnchor),
134
+ ])
135
+ self.didMove(toParent: rootVC)
136
+ self.hostVC = rootVC
77
137
  emitLayout()
78
138
  }
79
139
 
80
140
  /// Aplica el appearance correspondiente al estilo solicitado.
81
- /// - `default`: Liquid Glass (iOS 26+) o blur translúcido como fallback.
141
+ /// - `default` / `liquidGlass`: Liquid Glass nativo iOS 26 (SDK 26 + runtime
142
+ /// 26). En iOS < 26 cae al blur translúcido legacy.
82
143
  /// - `ultraThin`: blur mínimo `systemUltraThinMaterial` — más ver-through.
83
144
  /// - `transparent`: sin background ni blur — content behind se ve completo.
84
- /// - `liquidGlass`: tab bar 100% transparente + overlay `UIVisualEffectView`
85
- /// con `UIGlassEffect` (iOS 26+) o `systemThinMaterial` fallback.
86
145
  private func applyAppearance(_ tabBar: UITabBar, style: LiquidGlassTabBarStyle) {
87
146
  let appearance = UITabBarAppearance()
88
147
  switch style {
89
- case .default:
148
+ case .default, .liquidGlass:
149
+ // En iOS 26+ con SDK 26, esto adopta Liquid Glass automáticamente.
150
+ // NO hacer override de `backgroundEffect` — eso anula el adopt y
151
+ // produce un material opaco genérico.
90
152
  appearance.configureWithDefaultBackground()
91
- removeGlassOverlay()
92
153
  case .ultraThin:
93
154
  appearance.configureWithDefaultBackground()
94
155
  appearance.backgroundEffect = UIBlurEffect(style: .systemUltraThinMaterial)
95
156
  appearance.backgroundColor = UIColor.clear
96
- removeGlassOverlay()
97
157
  case .transparent:
98
158
  appearance.configureWithTransparentBackground()
99
159
  appearance.backgroundEffect = nil
100
160
  appearance.backgroundColor = UIColor.clear
101
161
  appearance.shadowColor = UIColor.clear
102
- removeGlassOverlay()
103
- case .liquidGlass:
104
- // Tab bar 100% transparente — el material lo aporta el overlay
105
- // `UIVisualEffectView` con `UIGlassEffect` montado debajo del bar.
106
- // Es el patrón canon de iOS 26 para aplicar Liquid Glass real a
107
- // controles que no exponen API directa para asignarlo (UITabBar
108
- // solo acepta `UIBlurEffect` en `backgroundEffect`).
109
- appearance.configureWithTransparentBackground()
110
- appearance.backgroundEffect = nil
111
- appearance.backgroundColor = UIColor.clear
112
- appearance.shadowColor = UIColor.clear
113
- installGlassOverlay(behind: tabBar)
114
162
  }
115
163
  tabBar.standardAppearance = appearance
116
164
  if #available(iOS 15.0, *) {
@@ -118,53 +166,7 @@ final class LiquidGlassTabBarOverlay: NSObject {
118
166
  }
119
167
  }
120
168
 
121
- /// Monta un `UIVisualEffectView` con `UIGlassEffect` (iOS 26+) o
122
- /// `systemThinMaterial` como fallback, justo DEBAJO del `tabBar` en el
123
- /// z-order del window. `tabBar` está en modo transparente, así que todo
124
- /// el material visible viene del overlay.
125
- ///
126
- /// El overlay queda con `isUserInteractionEnabled = false` para no
127
- /// interferir con los taps que el `tabBar` consume por encima.
128
- private func installGlassOverlay(behind tabBar: UITabBar) {
129
- guard let parent = tabBar.superview else { return }
130
- // Idempotente: si ya existe overlay no recrearlo (evita flicker en
131
- // re-configs por badge updates u otros cambios sin style change).
132
- if glassOverlayView != nil { return }
133
-
134
- let effect: UIVisualEffect
135
- #if compiler(>=6.1)
136
- if #available(iOS 26.0, *) {
137
- effect = UIGlassEffect()
138
- } else {
139
- effect = UIBlurEffect(style: .systemThinMaterial)
140
- }
141
- #else
142
- effect = UIBlurEffect(style: .systemThinMaterial)
143
- #endif
144
-
145
- let overlay = UIVisualEffectView(effect: effect)
146
- overlay.translatesAutoresizingMaskIntoConstraints = false
147
- overlay.isUserInteractionEnabled = false
148
- parent.insertSubview(overlay, belowSubview: tabBar)
149
-
150
- NSLayoutConstraint.activate([
151
- overlay.leadingAnchor.constraint(equalTo: tabBar.leadingAnchor),
152
- overlay.trailingAnchor.constraint(equalTo: tabBar.trailingAnchor),
153
- overlay.topAnchor.constraint(equalTo: tabBar.topAnchor),
154
- overlay.bottomAnchor.constraint(equalTo: tabBar.bottomAnchor),
155
- ])
156
-
157
- self.glassOverlayView = overlay
158
- }
159
-
160
- private func removeGlassOverlay() {
161
- glassOverlayView?.removeFromSuperview()
162
- glassOverlayView = nil
163
- }
164
-
165
169
  func configure(items: [LiquidGlassTabItem], selectedIndex: Int, tintHex: String?, style: LiquidGlassTabBarStyle) {
166
- guard let tabBar else { return }
167
-
168
170
  // Preserve selection across re-configs (badge changes, etc.)
169
171
  let previousSelectedId: String? = tabBar.selectedItem.flatMap { current in
170
172
  self.items.indices.contains(current.tag) ? self.items[current.tag].id : nil
@@ -189,8 +191,6 @@ final class LiquidGlassTabBarOverlay: NSObject {
189
191
 
190
192
  tabBar.setItems(uiItems, animated: false)
191
193
 
192
- // Si el caller pidió explícitamente "no selection" (selectedIndex < 0),
193
- // respetar eso y NO restaurar la selección previa — deja selectedItem = nil.
194
194
  if selectedIndex < 0 {
195
195
  tabBar.selectedItem = nil
196
196
  } else if let previousSelectedId, let restoreIdx = items.firstIndex(where: { $0.id == previousSelectedId }) {
@@ -206,7 +206,7 @@ final class LiquidGlassTabBarOverlay: NSObject {
206
206
  }
207
207
 
208
208
  func updateBadge(id: String, badge: String?) {
209
- guard let tabBar, let tabBarItems = tabBar.items,
209
+ guard let tabBarItems = tabBar.items,
210
210
  let idx = items.firstIndex(where: { $0.id == id }),
211
211
  idx < tabBarItems.count else { return }
212
212
  let value = (badge?.isEmpty ?? true) ? nil : badge
@@ -214,20 +214,10 @@ final class LiquidGlassTabBarOverlay: NSObject {
214
214
  }
215
215
 
216
216
  func show() {
217
- guard let tabBar = tabBar else { return }
218
- // Si ya está visible, no hacer nada (evita flicker en re-show consecutivos)
219
217
  if !tabBar.isHidden && tabBar.alpha == 1.0 { emitLayout(); return }
220
- // Reset estado pre-animación
221
218
  tabBar.isHidden = false
222
219
  tabBar.alpha = 0
223
220
  tabBar.transform = CGAffineTransform(translationX: 0, y: 20)
224
- // Sincronizar el overlay glass (si está montado) con la misma curva,
225
- // sino el material queda visible mientras el bar fade-out/in.
226
- let overlay = glassOverlayView
227
- overlay?.isHidden = false
228
- overlay?.alpha = 0
229
- overlay?.transform = CGAffineTransform(translationX: 0, y: 20)
230
- // Fade-in + slide-up (curva native iOS para apariciones)
231
221
  UIView.animate(
232
222
  withDuration: 0.28,
233
223
  delay: 0,
@@ -235,10 +225,8 @@ final class LiquidGlassTabBarOverlay: NSObject {
235
225
  initialSpringVelocity: 0,
236
226
  options: [.curveEaseOut, .allowUserInteraction],
237
227
  animations: {
238
- tabBar.alpha = 1
239
- tabBar.transform = .identity
240
- overlay?.alpha = 1
241
- overlay?.transform = .identity
228
+ self.tabBar.alpha = 1
229
+ self.tabBar.transform = .identity
242
230
  },
243
231
  completion: nil
244
232
  )
@@ -246,32 +234,24 @@ final class LiquidGlassTabBarOverlay: NSObject {
246
234
  }
247
235
 
248
236
  func hide() {
249
- guard let tabBar = tabBar else { return }
250
237
  if tabBar.isHidden { return }
251
- // Fade-out + slide-down (más rápido que el show — el exit es snappy)
252
- let overlay = glassOverlayView
253
238
  UIView.animate(
254
239
  withDuration: 0.18,
255
240
  delay: 0,
256
241
  options: [.curveEaseIn, .allowUserInteraction],
257
242
  animations: {
258
- tabBar.alpha = 0
259
- tabBar.transform = CGAffineTransform(translationX: 0, y: 20)
260
- overlay?.alpha = 0
261
- overlay?.transform = CGAffineTransform(translationX: 0, y: 20)
243
+ self.tabBar.alpha = 0
244
+ self.tabBar.transform = CGAffineTransform(translationX: 0, y: 20)
262
245
  },
263
246
  completion: { _ in
264
- tabBar.isHidden = true
265
- tabBar.transform = .identity
266
- overlay?.isHidden = true
267
- overlay?.transform = .identity
247
+ self.tabBar.isHidden = true
248
+ self.tabBar.transform = .identity
268
249
  }
269
250
  )
270
251
  }
271
252
 
272
253
  func setSelectedIndex(_ index: Int) {
273
- guard let tabBar, let items = tabBar.items else { return }
274
- // index < 0 → deseleccionar todos los tabs (selectedItem = nil)
254
+ guard let items = tabBar.items else { return }
275
255
  if index < 0 {
276
256
  tabBar.selectedItem = nil
277
257
  return
@@ -286,7 +266,6 @@ final class LiquidGlassTabBarOverlay: NSObject {
286
266
  }
287
267
 
288
268
  func currentLayout() -> (height: Double, bottomSafeArea: Double) {
289
- guard let tabBar else { return (0, 0) }
290
269
  tabBar.layoutIfNeeded()
291
270
  let bottomInset = tabBar.safeAreaInsets.bottom
292
271
  return (Double(tabBar.frame.height), Double(bottomInset))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajuarezso/capacitor-liquid-glass",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "iOS 26 Liquid Glass native chrome (TabBar, NavigationBar, Alerts, Sheets) for Capacitor apps. Falls back gracefully on iOS < 26 and Android.",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",