@ajuarezso/capacitor-liquid-glass 0.3.7 → 0.4.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.
package/dist/plugin.js CHANGED
@@ -1,9 +1,222 @@
1
1
  var capacitorLiquidGlass = (function (exports, core) {
2
2
  'use strict';
3
3
 
4
- const LiquidGlass = core.registerPlugin('LiquidGlass', {
4
+ /**
5
+ * Keeps the native tab bar glued to an HTML element when `containerElement` is
6
+ * passed to `showTabBar`. Mirrors the measure-and-observe approach of
7
+ * `@capacitor/google-maps`, but adapted for a **fixed overlay** rather than an
8
+ * inline view:
9
+ *
10
+ * - Google Maps (iOS) reparents its native view into a `WKChildScrollView` so
11
+ * it scrolls *with* page content. A tab bar must stay glued to the element's
12
+ * on-screen rect, so we instead push the rect to native, which positions an
13
+ * on-top overlay via Auto Layout constraints (never `setFrame` — see the
14
+ * iOS 26 Liquid Glass notes in `LiquidGlassTabBarOverlay.swift`).
15
+ * - We re-sync on `ResizeObserver` + `scroll` (capture phase, catches nested
16
+ * scroll containers) + `resize`/`orientationchange` + `visualViewport`
17
+ * (keyboard / browser-chrome / pinch-zoom), all **coalesced through a single
18
+ * `requestAnimationFrame`** so a 120 Hz scroll fires at most one bridge call
19
+ * per frame (the Maps plugin's lack of this is its main jitter source).
20
+ *
21
+ * When `containerElement` is omitted, this is a transparent pass-through to the
22
+ * native bottom-pinned behaviour — zero regression.
23
+ */
24
+ class TabBarBinder {
25
+ constructor(native) {
26
+ this.native = native;
27
+ this.element = null;
28
+ this.resizeObserver = null;
29
+ this.rafId = null;
30
+ /** Last rect pushed to native — skip redundant bridge calls when unchanged. */
31
+ this.lastSent = null;
32
+ /**
33
+ * Bumped on every teardown. Async work (the `measure` retry loop, the awaits
34
+ * in `showTabBar`) snapshots it and bails if a newer call superseded it —
35
+ * prevents a slow first measurement from clobbering a second `showTabBar`.
36
+ */
37
+ this.generation = 0;
38
+ /** Stable identity so `removeEventListener` actually detaches the listeners. */
39
+ this.onReflow = () => this.scheduleSync();
40
+ }
41
+ async showTabBar(options) {
42
+ // A fresh call always supersedes any previous binding (bumps generation).
43
+ this.teardown();
44
+ const gen = this.generation;
45
+ const target = options.containerElement;
46
+ const wantsBinding = core.Capacitor.getPlatform() === 'ios' && target != null;
47
+ if (!wantsBinding) {
48
+ return this.native.showTabBar(this.stripElement(options));
49
+ }
50
+ const element = this.resolve(target);
51
+ if (!element) {
52
+ // Selector didn't match — fall back to bottom-pinned instead of throwing.
53
+ return this.native.showTabBar(this.stripElement(options));
54
+ }
55
+ this.element = element;
56
+ const bounds = await this.measure(element, gen);
57
+ if (gen !== this.generation)
58
+ return; // superseded while measuring
59
+ await this.native.showTabBar({ ...this.stripElement(options), bounds });
60
+ if (gen !== this.generation)
61
+ return; // superseded while the bridge call ran
62
+ this.lastSent = bounds;
63
+ this.observe(element);
64
+ }
65
+ async hideTabBar() {
66
+ this.teardown();
67
+ return this.native.hideTabBar();
68
+ }
69
+ // --- internals -----------------------------------------------------------
70
+ /** Removes the (possibly non-serializable) element ref before crossing the bridge. */
71
+ stripElement(options) {
72
+ if (options.containerElement == null)
73
+ return options;
74
+ const { containerElement: _drop, ...rest } = options;
75
+ return rest;
76
+ }
77
+ resolve(target) {
78
+ if (typeof target !== 'string')
79
+ return target;
80
+ return document.getElementById(target) ?? document.querySelector(target);
81
+ }
82
+ rect(element) {
83
+ const r = element.getBoundingClientRect();
84
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
85
+ }
86
+ /**
87
+ * Retry until the element has a non-zero width AND height (guards against
88
+ * pre-layout reads). Bails early if a newer `showTabBar`/`hideTabBar` bumped
89
+ * the generation, so a stale interval can't outlive the call that started it.
90
+ * If it still measures 0 after ~3s, resolves with the zero rect (native falls
91
+ * back to bottom-pinned) and warns so the misconfig is visible.
92
+ */
93
+ measure(element, gen) {
94
+ const valid = (b) => b.width !== 0 && b.height !== 0;
95
+ return new Promise((resolve) => {
96
+ let bounds = this.rect(element);
97
+ if (valid(bounds)) {
98
+ resolve(bounds);
99
+ return;
100
+ }
101
+ let retries = 0;
102
+ const id = setInterval(() => {
103
+ if (gen !== this.generation) {
104
+ clearInterval(id);
105
+ resolve(bounds);
106
+ return;
107
+ }
108
+ bounds = this.rect(element);
109
+ retries++;
110
+ if (valid(bounds) || retries >= 30) {
111
+ clearInterval(id);
112
+ if (!valid(bounds)) {
113
+ console.warn('[LiquidGlass] containerElement still measures 0 after 3s — the native bar will fall back to bottom-pinned.');
114
+ }
115
+ resolve(bounds);
116
+ }
117
+ }, 100);
118
+ });
119
+ }
120
+ observe(element) {
121
+ if (typeof ResizeObserver !== 'undefined') {
122
+ this.resizeObserver = new ResizeObserver(this.onReflow);
123
+ this.resizeObserver.observe(element);
124
+ }
125
+ // Position changes a ResizeObserver won't catch. Capture phase so scrolls in
126
+ // nested `overflow:auto` containers (which don't bubble to window) re-sync too.
127
+ window.addEventListener('scroll', this.onReflow, { passive: true, capture: true });
128
+ window.addEventListener('resize', this.onReflow, { passive: true });
129
+ window.addEventListener('orientationchange', this.onReflow, { passive: true });
130
+ const vv = window.visualViewport;
131
+ if (vv) {
132
+ vv.addEventListener('resize', this.onReflow);
133
+ vv.addEventListener('scroll', this.onReflow);
134
+ }
135
+ }
136
+ scheduleSync() {
137
+ if (this.rafId != null)
138
+ return;
139
+ this.rafId = requestAnimationFrame(() => {
140
+ this.rafId = null;
141
+ if (!this.element)
142
+ return;
143
+ const bounds = this.rect(this.element);
144
+ // Hidden / collapsed (display:none, detached, off-screen with 0 width):
145
+ // keep the last position, don't push a degenerate rect. A zero in EITHER
146
+ // dimension is useless — matches the native guard (rect.width/height > 0).
147
+ if (bounds.width === 0 || bounds.height === 0)
148
+ return;
149
+ // Skip redundant bridge calls when the rect didn't actually move (e.g. a
150
+ // scroll in an unrelated container, or a position:fixed element). Each
151
+ // skipped call also saves a native `layoutIfNeeded`.
152
+ if (this.sameRect(bounds, this.lastSent))
153
+ return;
154
+ this.lastSent = bounds;
155
+ void this.native.setTabBarBounds({ bounds });
156
+ });
157
+ }
158
+ sameRect(a, b) {
159
+ if (!b)
160
+ return false;
161
+ return (Math.abs(a.x - b.x) < 0.5 &&
162
+ Math.abs(a.y - b.y) < 0.5 &&
163
+ Math.abs(a.width - b.width) < 0.5 &&
164
+ Math.abs(a.height - b.height) < 0.5);
165
+ }
166
+ teardown() {
167
+ // Invalidate any in-flight measure/await chain from a previous call.
168
+ this.generation++;
169
+ this.lastSent = null;
170
+ if (this.rafId != null) {
171
+ cancelAnimationFrame(this.rafId);
172
+ this.rafId = null;
173
+ }
174
+ this.resizeObserver?.disconnect();
175
+ this.resizeObserver = null;
176
+ // `capture` must match the add-time flag for removal to take effect.
177
+ window.removeEventListener('scroll', this.onReflow, { capture: true });
178
+ window.removeEventListener('resize', this.onReflow);
179
+ window.removeEventListener('orientationchange', this.onReflow);
180
+ const vv = window.visualViewport;
181
+ if (vv) {
182
+ vv.removeEventListener('resize', this.onReflow);
183
+ vv.removeEventListener('scroll', this.onReflow);
184
+ }
185
+ this.element = null;
186
+ }
187
+ }
188
+
189
+ const native = core.registerPlugin('LiquidGlass', {
5
190
  web: () => Promise.resolve().then(function () { return web; }).then((m) => new m.LiquidGlassWeb()),
6
191
  });
192
+ /**
193
+ * Drives the HTML-element binding lifecycle (measure + observe) on top of the
194
+ * native bridge. When `showTabBar` is called without `containerElement` it is a
195
+ * transparent pass-through, so existing callers behave exactly as before.
196
+ */
197
+ const binder = new TabBarBinder(native);
198
+ /**
199
+ * Public plugin façade. Everything delegates straight to the native bridge
200
+ * except `showTabBar`/`hideTabBar`, which route through {@link TabBarBinder} so
201
+ * the optional `containerElement` binding works without any consumer wiring.
202
+ */
203
+ const LiquidGlass = {
204
+ showTabBar: (options) => binder.showTabBar(options),
205
+ hideTabBar: () => binder.hideTabBar(),
206
+ // iOS-only: the native method only exists on iOS. On Android the bridge would
207
+ // throw "not implemented"; resolve quietly instead (the binding layer already
208
+ // gates on getPlatform() === 'ios', this guards direct low-level callers).
209
+ setTabBarBounds: (options) => core.Capacitor.getPlatform() === 'ios' ? native.setTabBarBounds(options) : Promise.resolve(),
210
+ setSelectedTab: (options) => native.setSelectedTab(options),
211
+ updateTabBadge: (options) => native.updateTabBadge(options),
212
+ getTabBarLayout: () => native.getTabBarLayout(),
213
+ showSearchBar: (options) => native.showSearchBar(options),
214
+ hideSearchBar: () => native.hideSearchBar(),
215
+ clearSearchText: () => native.clearSearchText(),
216
+ // Preserve the overloaded signature for consumers (the bind keeps `this`).
217
+ addListener: native.addListener.bind(native),
218
+ removeAllListeners: () => native.removeAllListeners(),
219
+ };
7
220
 
8
221
  /**
9
222
  * Web fallback — real Liquid Glass requires native iOS 26. On the web we
@@ -27,6 +240,9 @@ var capacitorLiquidGlass = (function (exports, core) {
27
240
  async getTabBarLayout() {
28
241
  return { height: 0, bottomSafeArea: 0 };
29
242
  }
243
+ async setTabBarBounds(_options) {
244
+ // no-op on web — native-only positioning.
245
+ }
30
246
  async showSearchBar(_options) {
31
247
  // no-op on web — caller should render its own DOM search input.
32
248
  }
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.js","sources":["esm/index.js","esm/web.js"],"sourcesContent":["import { registerPlugin } from '@capacitor/core';\nconst LiquidGlass = registerPlugin('LiquidGlass', {\n web: () => import('./web').then((m) => new m.LiquidGlassWeb()),\n});\nexport * from './definitions';\nexport { LiquidGlass };\n","import { WebPlugin } from '@capacitor/core';\n/**\n * Web fallback — real Liquid Glass requires native iOS 26. On the web we\n * resolve no-ops so the app can run in the browser during development; the\n * Angular shell is expected to render its own CSS glassmorphism tab bar when\n * `isNativePlatform()` is false.\n */\nexport class LiquidGlassWeb extends WebPlugin {\n async showTabBar(_options) {\n // no-op on web\n }\n async hideTabBar() {\n // no-op on web\n }\n async setSelectedTab(_options) {\n // no-op on web\n }\n async updateTabBadge(_options) {\n // no-op on web\n }\n async getTabBarLayout() {\n return { height: 0, bottomSafeArea: 0 };\n }\n async showSearchBar(_options) {\n // no-op on web — caller should render its own DOM search input.\n }\n async hideSearchBar() {\n // no-op on web\n }\n async clearSearchText() {\n // no-op on web\n }\n}\n"],"names":["registerPlugin","WebPlugin"],"mappings":";;;AACK,UAAC,WAAW,GAAGA,mBAAc,CAAC,aAAa,EAAE;IAClD,IAAI,GAAG,EAAE,MAAM,mDAAe,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,cAAc,EAAE,CAAC;IAClE,CAAC;;ICFD;IACA;IACA;IACA;IACA;IACA;IACO,MAAM,cAAc,SAASC,cAAS,CAAC;IAC9C,IAAI,MAAM,UAAU,CAAC,QAAQ,EAAE;IAC/B;IACA,IAAI;IACJ,IAAI,MAAM,UAAU,GAAG;IACvB;IACA,IAAI;IACJ,IAAI,MAAM,cAAc,CAAC,QAAQ,EAAE;IACnC;IACA,IAAI;IACJ,IAAI,MAAM,cAAc,CAAC,QAAQ,EAAE;IACnC;IACA,IAAI;IACJ,IAAI,MAAM,eAAe,GAAG;IAC5B,QAAQ,OAAO,EAAE,MAAM,EAAE,CAAC,EAAE,cAAc,EAAE,CAAC,EAAE;IAC/C,IAAI;IACJ,IAAI,MAAM,aAAa,CAAC,QAAQ,EAAE;IAClC;IACA,IAAI;IACJ,IAAI,MAAM,aAAa,GAAG;IAC1B;IACA,IAAI;IACJ,IAAI,MAAM,eAAe,GAAG;IAC5B;IACA,IAAI;IACJ;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"plugin.js","sources":["esm/tab-bar-binder.js","esm/index.js","esm/web.js"],"sourcesContent":["import { Capacitor } from '@capacitor/core';\n/**\n * Keeps the native tab bar glued to an HTML element when `containerElement` is\n * passed to `showTabBar`. Mirrors the measure-and-observe approach of\n * `@capacitor/google-maps`, but adapted for a **fixed overlay** rather than an\n * inline view:\n *\n * - Google Maps (iOS) reparents its native view into a `WKChildScrollView` so\n * it scrolls *with* page content. A tab bar must stay glued to the element's\n * on-screen rect, so we instead push the rect to native, which positions an\n * on-top overlay via Auto Layout constraints (never `setFrame` — see the\n * iOS 26 Liquid Glass notes in `LiquidGlassTabBarOverlay.swift`).\n * - We re-sync on `ResizeObserver` + `scroll` (capture phase, catches nested\n * scroll containers) + `resize`/`orientationchange` + `visualViewport`\n * (keyboard / browser-chrome / pinch-zoom), all **coalesced through a single\n * `requestAnimationFrame`** so a 120 Hz scroll fires at most one bridge call\n * per frame (the Maps plugin's lack of this is its main jitter source).\n *\n * When `containerElement` is omitted, this is a transparent pass-through to the\n * native bottom-pinned behaviour — zero regression.\n */\nexport class TabBarBinder {\n constructor(native) {\n this.native = native;\n this.element = null;\n this.resizeObserver = null;\n this.rafId = null;\n /** Last rect pushed to native — skip redundant bridge calls when unchanged. */\n this.lastSent = null;\n /**\n * Bumped on every teardown. Async work (the `measure` retry loop, the awaits\n * in `showTabBar`) snapshots it and bails if a newer call superseded it —\n * prevents a slow first measurement from clobbering a second `showTabBar`.\n */\n this.generation = 0;\n /** Stable identity so `removeEventListener` actually detaches the listeners. */\n this.onReflow = () => this.scheduleSync();\n }\n async showTabBar(options) {\n // A fresh call always supersedes any previous binding (bumps generation).\n this.teardown();\n const gen = this.generation;\n const target = options.containerElement;\n const wantsBinding = Capacitor.getPlatform() === 'ios' && target != null;\n if (!wantsBinding) {\n return this.native.showTabBar(this.stripElement(options));\n }\n const element = this.resolve(target);\n if (!element) {\n // Selector didn't match — fall back to bottom-pinned instead of throwing.\n return this.native.showTabBar(this.stripElement(options));\n }\n this.element = element;\n const bounds = await this.measure(element, gen);\n if (gen !== this.generation)\n return; // superseded while measuring\n await this.native.showTabBar({ ...this.stripElement(options), bounds });\n if (gen !== this.generation)\n return; // superseded while the bridge call ran\n this.lastSent = bounds;\n this.observe(element);\n }\n async hideTabBar() {\n this.teardown();\n return this.native.hideTabBar();\n }\n // --- internals -----------------------------------------------------------\n /** Removes the (possibly non-serializable) element ref before crossing the bridge. */\n stripElement(options) {\n if (options.containerElement == null)\n return options;\n const { containerElement: _drop, ...rest } = options;\n void _drop;\n return rest;\n }\n resolve(target) {\n if (typeof target !== 'string')\n return target;\n return document.getElementById(target) ?? document.querySelector(target);\n }\n rect(element) {\n const r = element.getBoundingClientRect();\n return { x: r.x, y: r.y, width: r.width, height: r.height };\n }\n /**\n * Retry until the element has a non-zero width AND height (guards against\n * pre-layout reads). Bails early if a newer `showTabBar`/`hideTabBar` bumped\n * the generation, so a stale interval can't outlive the call that started it.\n * If it still measures 0 after ~3s, resolves with the zero rect (native falls\n * back to bottom-pinned) and warns so the misconfig is visible.\n */\n measure(element, gen) {\n const valid = (b) => b.width !== 0 && b.height !== 0;\n return new Promise((resolve) => {\n let bounds = this.rect(element);\n if (valid(bounds)) {\n resolve(bounds);\n return;\n }\n let retries = 0;\n const id = setInterval(() => {\n if (gen !== this.generation) {\n clearInterval(id);\n resolve(bounds);\n return;\n }\n bounds = this.rect(element);\n retries++;\n if (valid(bounds) || retries >= 30) {\n clearInterval(id);\n if (!valid(bounds)) {\n console.warn('[LiquidGlass] containerElement still measures 0 after 3s — the native bar will fall back to bottom-pinned.');\n }\n resolve(bounds);\n }\n }, 100);\n });\n }\n observe(element) {\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(this.onReflow);\n this.resizeObserver.observe(element);\n }\n // Position changes a ResizeObserver won't catch. Capture phase so scrolls in\n // nested `overflow:auto` containers (which don't bubble to window) re-sync too.\n window.addEventListener('scroll', this.onReflow, { passive: true, capture: true });\n window.addEventListener('resize', this.onReflow, { passive: true });\n window.addEventListener('orientationchange', this.onReflow, { passive: true });\n const vv = window.visualViewport;\n if (vv) {\n vv.addEventListener('resize', this.onReflow);\n vv.addEventListener('scroll', this.onReflow);\n }\n }\n scheduleSync() {\n if (this.rafId != null)\n return;\n this.rafId = requestAnimationFrame(() => {\n this.rafId = null;\n if (!this.element)\n return;\n const bounds = this.rect(this.element);\n // Hidden / collapsed (display:none, detached, off-screen with 0 width):\n // keep the last position, don't push a degenerate rect. A zero in EITHER\n // dimension is useless — matches the native guard (rect.width/height > 0).\n if (bounds.width === 0 || bounds.height === 0)\n return;\n // Skip redundant bridge calls when the rect didn't actually move (e.g. a\n // scroll in an unrelated container, or a position:fixed element). Each\n // skipped call also saves a native `layoutIfNeeded`.\n if (this.sameRect(bounds, this.lastSent))\n return;\n this.lastSent = bounds;\n void this.native.setTabBarBounds({ bounds });\n });\n }\n sameRect(a, b) {\n if (!b)\n return false;\n return (Math.abs(a.x - b.x) < 0.5 &&\n Math.abs(a.y - b.y) < 0.5 &&\n Math.abs(a.width - b.width) < 0.5 &&\n Math.abs(a.height - b.height) < 0.5);\n }\n teardown() {\n // Invalidate any in-flight measure/await chain from a previous call.\n this.generation++;\n this.lastSent = null;\n if (this.rafId != null) {\n cancelAnimationFrame(this.rafId);\n this.rafId = null;\n }\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n // `capture` must match the add-time flag for removal to take effect.\n window.removeEventListener('scroll', this.onReflow, { capture: true });\n window.removeEventListener('resize', this.onReflow);\n window.removeEventListener('orientationchange', this.onReflow);\n const vv = window.visualViewport;\n if (vv) {\n vv.removeEventListener('resize', this.onReflow);\n vv.removeEventListener('scroll', this.onReflow);\n }\n this.element = null;\n }\n}\n","import { Capacitor, registerPlugin } from '@capacitor/core';\nimport { TabBarBinder } from './tab-bar-binder';\nconst native = registerPlugin('LiquidGlass', {\n web: () => import('./web').then((m) => new m.LiquidGlassWeb()),\n});\n/**\n * Drives the HTML-element binding lifecycle (measure + observe) on top of the\n * native bridge. When `showTabBar` is called without `containerElement` it is a\n * transparent pass-through, so existing callers behave exactly as before.\n */\nconst binder = new TabBarBinder(native);\n/**\n * Public plugin façade. Everything delegates straight to the native bridge\n * except `showTabBar`/`hideTabBar`, which route through {@link TabBarBinder} so\n * the optional `containerElement` binding works without any consumer wiring.\n */\nconst LiquidGlass = {\n showTabBar: (options) => binder.showTabBar(options),\n hideTabBar: () => binder.hideTabBar(),\n // iOS-only: the native method only exists on iOS. On Android the bridge would\n // throw \"not implemented\"; resolve quietly instead (the binding layer already\n // gates on getPlatform() === 'ios', this guards direct low-level callers).\n setTabBarBounds: (options) => Capacitor.getPlatform() === 'ios' ? native.setTabBarBounds(options) : Promise.resolve(),\n setSelectedTab: (options) => native.setSelectedTab(options),\n updateTabBadge: (options) => native.updateTabBadge(options),\n getTabBarLayout: () => native.getTabBarLayout(),\n showSearchBar: (options) => native.showSearchBar(options),\n hideSearchBar: () => native.hideSearchBar(),\n clearSearchText: () => native.clearSearchText(),\n // Preserve the overloaded signature for consumers (the bind keeps `this`).\n addListener: native.addListener.bind(native),\n removeAllListeners: () => native.removeAllListeners(),\n};\nexport * from './definitions';\nexport { LiquidGlass };\n","import { WebPlugin } from '@capacitor/core';\n/**\n * Web fallback — real Liquid Glass requires native iOS 26. On the web we\n * resolve no-ops so the app can run in the browser during development; the\n * Angular shell is expected to render its own CSS glassmorphism tab bar when\n * `isNativePlatform()` is false.\n */\nexport class LiquidGlassWeb extends WebPlugin {\n async showTabBar(_options) {\n // no-op on web\n }\n async hideTabBar() {\n // no-op on web\n }\n async setSelectedTab(_options) {\n // no-op on web\n }\n async updateTabBadge(_options) {\n // no-op on web\n }\n async getTabBarLayout() {\n return { height: 0, bottomSafeArea: 0 };\n }\n async setTabBarBounds(_options) {\n // no-op on web — native-only positioning.\n }\n async showSearchBar(_options) {\n // no-op on web — caller should render its own DOM search input.\n }\n async hideSearchBar() {\n // no-op on web\n }\n async clearSearchText() {\n // no-op on web\n }\n}\n"],"names":["Capacitor","registerPlugin","WebPlugin"],"mappings":"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"}
@@ -12,6 +12,7 @@ public class LiquidGlassPlugin: CAPPlugin, CAPBridgedPlugin {
12
12
  CAPPluginMethod(name: "setSelectedTab", returnType: CAPPluginReturnPromise),
13
13
  CAPPluginMethod(name: "updateTabBadge", returnType: CAPPluginReturnPromise),
14
14
  CAPPluginMethod(name: "getTabBarLayout", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "setTabBarBounds", returnType: CAPPluginReturnPromise),
15
16
  CAPPluginMethod(name: "showSearchBar", returnType: CAPPluginReturnPromise),
16
17
  CAPPluginMethod(name: "hideSearchBar", returnType: CAPPluginReturnPromise),
17
18
  CAPPluginMethod(name: "clearSearchText", returnType: CAPPluginReturnPromise),
@@ -28,6 +29,10 @@ public class LiquidGlassPlugin: CAPPlugin, CAPBridgedPlugin {
28
29
  let selectedIndex = call.getInt("selectedIndex") ?? 0
29
30
  let tintHex = call.getString("tintColor")
30
31
  let styleRaw = call.getString("tabBarStyle") ?? "default"
32
+ // Optional binding rect. When present the bar is positioned to match an
33
+ // HTML element instead of being pinned to the bottom (the JS layer
34
+ // measures the element and injects this). Invalid/absent → bottom-pinned.
35
+ let bounds = call.getObject("bounds").flatMap { Self.rect(from: $0) }
31
36
 
32
37
  let items = rawItems.compactMap { LiquidGlassTabItem(dictionary: $0) }
33
38
  guard !items.isEmpty else {
@@ -37,11 +42,36 @@ public class LiquidGlassPlugin: CAPPlugin, CAPBridgedPlugin {
37
42
 
38
43
  DispatchQueue.main.async { [weak self] in
39
44
  guard let self else { return }
40
- self.presentTabBar(items: items, selectedIndex: selectedIndex, tintHex: tintHex, styleRaw: styleRaw)
45
+ self.presentTabBar(items: items, selectedIndex: selectedIndex, tintHex: tintHex, styleRaw: styleRaw, bounds: bounds)
41
46
  call.resolve()
42
47
  }
43
48
  }
44
49
 
50
+ @objc func setTabBarBounds(_ call: CAPPluginCall) {
51
+ guard let boundsDict = call.getObject("bounds"), let rect = Self.rect(from: boundsDict) else {
52
+ call.reject("bounds {x, y, width, height} is required")
53
+ return
54
+ }
55
+ DispatchQueue.main.async { [weak self] in
56
+ self?.tabBarOverlay?.setBounds(rect)
57
+ call.resolve()
58
+ }
59
+ }
60
+
61
+ /// Parses a `{x, y, width, height}` JS object into a `CGRect`. Tolerates
62
+ /// numbers arriving as `Double`, `Int` or `NSNumber` across the bridge.
63
+ private static func rect(from dict: JSObject) -> CGRect? {
64
+ func num(_ value: Any?) -> Double? {
65
+ if let d = value as? Double { return d }
66
+ if let n = value as? NSNumber { return n.doubleValue }
67
+ if let i = value as? Int { return Double(i) }
68
+ return nil
69
+ }
70
+ guard let x = num(dict["x"]), let y = num(dict["y"]),
71
+ let w = num(dict["width"]), let h = num(dict["height"]) else { return nil }
72
+ return CGRect(x: x, y: y, width: w, height: h)
73
+ }
74
+
45
75
  @objc func hideTabBar(_ call: CAPPluginCall) {
46
76
  DispatchQueue.main.async { [weak self] in
47
77
  self?.tabBarOverlay?.hide()
@@ -151,7 +181,7 @@ public class LiquidGlassPlugin: CAPPlugin, CAPBridgedPlugin {
151
181
  searchOverlay?.show(on: window)
152
182
  }
153
183
 
154
- private func presentTabBar(items: [LiquidGlassTabItem], selectedIndex: Int, tintHex: String?, styleRaw: String) {
184
+ private func presentTabBar(items: [LiquidGlassTabItem], selectedIndex: Int, tintHex: String?, styleRaw: String, bounds: CGRect?) {
155
185
  // CRÍTICO: usar `bridge?.viewController` (el VC que contiene el
156
186
  // WKWebView de Capacitor) en lugar del `rootViewController` del
157
187
  // window. iOS 26 aplica Liquid Glass automáticamente al UITabBar
@@ -174,7 +204,9 @@ public class LiquidGlassPlugin: CAPPlugin, CAPBridgedPlugin {
174
204
  }
175
205
 
176
206
  let style = LiquidGlassTabBarStyle(rawValue: styleRaw) ?? .default
177
- tabBarOverlay?.attach(to: hostVC)
207
+ // `bridge?.webView` is needed to convert the JS rect (viewport CSS px)
208
+ // into the host VC's coordinate space when binding to an HTML element.
209
+ tabBarOverlay?.attach(to: hostVC, bounds: bounds, webView: bridge?.webView)
178
210
  tabBarOverlay?.configure(items: items, selectedIndex: selectedIndex, tintHex: tintHex, style: style)
179
211
  tabBarOverlay?.show()
180
212
  }
@@ -66,6 +66,23 @@ final class LiquidGlassTabBarOverlay: UIViewController {
66
66
  private let tabBar = UITabBar()
67
67
  private var items: [LiquidGlassTabItem] = []
68
68
  private weak var hostVC: UIViewController?
69
+ /// WKWebView of the Capacitor bridge — used to convert the JS rect (viewport
70
+ /// CSS px) into the host VC's coordinate space when bound to an HTML element.
71
+ private weak var webView: UIView?
72
+
73
+ /// Currently-active container constraints (bottom-pinned OR bound), so a
74
+ /// re-`attach`/mode-switch can deactivate the previous set cleanly.
75
+ private var activeConstraints: [NSLayoutConstraint] = []
76
+ /// Mutable bound-mode geometry. Kept as properties so `setBounds` mutates
77
+ /// `.constant` (no detach/reattach → VC hierarchy stays intact and the iOS
78
+ /// 26 Liquid Glass auto-adopt is never re-triggered/lost).
79
+ private var boundTop: NSLayoutConstraint?
80
+ private var boundLeading: NSLayoutConstraint?
81
+ private var boundWidth: NSLayoutConstraint?
82
+ private var boundHeight: NSLayoutConstraint?
83
+ /// `true` when the container tracks an HTML element rect; `false` when
84
+ /// pinned to the bottom of the host (default).
85
+ private var isBoundMode = false
69
86
 
70
87
  override func viewDidLoad() {
71
88
  super.viewDidLoad()
@@ -101,37 +118,144 @@ final class LiquidGlassTabBarOverlay: UIViewController {
101
118
 
102
119
  /// Adjunta este view controller como child del view controller que
103
120
  /// contiene el WKWebView de Capacitor (`bridge?.viewController`).
104
- /// Idempotente: si ya está adjuntado al mismo host, no-op.
105
- func attach(to hostVC: UIViewController?) {
121
+ ///
122
+ /// - `bounds == nil` → **bottom-pinned** (comportamiento default histórico):
123
+ /// leading/trailing/bottom al host, sin top — la altura sale del intrinsic
124
+ /// content size del UITabBar (~50pt + safe-area-bottom).
125
+ /// - `bounds != nil` (y válido) → **bound mode**: el container se posiciona
126
+ /// para coincidir con el rect de un elemento HTML, vía constraints
127
+ /// mutables top/leading/width/height (NUNCA `setFrame` — ver `setBounds`).
128
+ ///
129
+ /// Idempotente respecto al parenting: si ya es child del mismo host no
130
+ /// re-adjunta, pero SÍ re-evalúa el layout (permite cambiar de modo o de
131
+ /// rect entre llamadas a `showTabBar`).
132
+ func attach(to hostVC: UIViewController?, bounds: CGRect?, webView: UIView?) {
106
133
  guard let hostVC else { return }
107
- if self.parent === hostVC { return }
108
- // Si está adjuntado a otro VC (cambio de host entre sesiones),
109
- // detachar primero antes de re-adjuntar.
110
- if self.parent != nil {
111
- self.willMove(toParent: nil)
112
- self.view.removeFromSuperview()
113
- self.removeFromParent()
134
+ self.webView = webView
135
+
136
+ let wantsBound = bounds.map { $0.width > 0 && $0.height > 0 } ?? false
137
+
138
+ // No-op idempotente del PATH DEFAULT (bottom-pinned): re-mostrar el bar
139
+ // en el MISMO host sin binding (re-config de badge / cambio de route /
140
+ // liberación de modal — el consumer lo dispara agresivamente) debe NO
141
+ // tocar el layout del container, exactamente como antes de que existiera
142
+ // bound mode. Sin este early-return cada re-llamada haría
143
+ // deactivate+activate de las 3 constraints + un tabBarLayoutChanged de
144
+ // más (regresión detectada en review). Solo aplica cuando ya estamos
145
+ // bottom-pinned con constraints activas y NO se pide binding.
146
+ if self.parent === hostVC, !wantsBound, !isBoundMode, !activeConstraints.isEmpty {
147
+ return
114
148
  }
115
149
 
116
- hostVC.addChild(self)
117
- hostVC.view.addSubview(self.view)
118
- self.view.translatesAutoresizingMaskIntoConstraints = false
119
- // CRÍTICO: `didMove(toParent:)` ANTES de activar constraints. iOS 26
120
- // ejecuta el primer layout pass al didMove; tener el parenting
121
- // completo antes de que el sistema mida el UITabBar es lo que el
122
- // heurístico de Liquid Glass auto-adopt espera (patrón stay-liquid).
123
- self.didMove(toParent: hostVC)
124
- // SIN top constraint la altura del view se deriva del intrinsic
125
- // content size del UITabBar dentro (~50pt + safe-area-bottom).
126
- NSLayoutConstraint.activate([
127
- self.view.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor),
128
- self.view.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor),
129
- self.view.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor),
130
- ])
150
+ if self.parent !== hostVC {
151
+ // Si está adjuntado a otro VC (cambio de host entre sesiones),
152
+ // detachar primero antes de re-adjuntar. Reseteamos TODO el estado
153
+ // de modo/constraints para no quedar apuntando a constraints muertas
154
+ // del host viejo (defensa en profundidad review).
155
+ if self.parent != nil {
156
+ NSLayoutConstraint.deactivate(activeConstraints)
157
+ activeConstraints = []
158
+ boundTop = nil; boundLeading = nil; boundWidth = nil; boundHeight = nil
159
+ isBoundMode = false
160
+ self.willMove(toParent: nil)
161
+ self.view.removeFromSuperview()
162
+ self.removeFromParent()
163
+ }
164
+ hostVC.addChild(self)
165
+ hostVC.view.addSubview(self.view)
166
+ self.view.translatesAutoresizingMaskIntoConstraints = false
167
+ // CRÍTICO: `didMove(toParent:)` ANTES de activar constraints. iOS 26
168
+ // ejecuta el primer layout pass al didMove; tener el parenting
169
+ // completo antes de que el sistema mida el UITabBar es lo que el
170
+ // heurístico de Liquid Glass auto-adopt espera (patrón stay-liquid).
171
+ self.didMove(toParent: hostVC)
172
+ }
131
173
  self.hostVC = hostVC
174
+
175
+ // `width > 0 && height > 0` es obligatorio: un container medido en 0×0 en
176
+ // el primer layout pass es la única forma realista de PERDER el adopt de
177
+ // Liquid Glass. Si el rect llega vacío caemos a bottom-pinned y el
178
+ // siguiente `setBounds` con rect válido cambia a bound mode.
179
+ if let bounds, wantsBound {
180
+ applyBoundConstraints(bounds, hostVC: hostVC)
181
+ } else {
182
+ applyBottomPinned(hostVC: hostVC)
183
+ }
132
184
  emitLayout()
133
185
  }
134
186
 
187
+ /// Default: container pegado al bottom del host, ancho completo, altura
188
+ /// intrínseca del UITabBar. Idéntico al comportamiento previo a bound mode.
189
+ private func applyBottomPinned(hostVC: UIViewController) {
190
+ NSLayoutConstraint.deactivate(activeConstraints)
191
+ boundTop = nil; boundLeading = nil; boundWidth = nil; boundHeight = nil
192
+ isBoundMode = false
193
+ let constraints = [
194
+ view.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor),
195
+ view.trailingAnchor.constraint(equalTo: hostVC.view.trailingAnchor),
196
+ view.bottomAnchor.constraint(equalTo: hostVC.view.bottomAnchor),
197
+ ]
198
+ NSLayoutConstraint.activate(constraints)
199
+ activeConstraints = constraints
200
+ }
201
+
202
+ /// Bound mode: posiciona el container al rect del elemento HTML mediante 4
203
+ /// constraints mutables (top/leading/width/height). Se usan CONSTRAINTS y no
204
+ /// `setFrame` a propósito: un `setFrame` pelea contra el Auto Layout pass del
205
+ /// host y puede resolver a `.zero` justo en el primer layout pass que iOS 26
206
+ /// inspecciona para el adopt → bar medido 0×0 → sin Liquid Glass.
207
+ private func applyBoundConstraints(_ rect: CGRect, hostVC: UIViewController) {
208
+ NSLayoutConstraint.deactivate(activeConstraints)
209
+ isBoundMode = true
210
+ let r = convertRectToHost(rect, hostVC: hostVC)
211
+ let top = view.topAnchor.constraint(equalTo: hostVC.view.topAnchor, constant: r.origin.y)
212
+ let leading = view.leadingAnchor.constraint(equalTo: hostVC.view.leadingAnchor, constant: r.origin.x)
213
+ let width = view.widthAnchor.constraint(equalToConstant: r.size.width)
214
+ let height = view.heightAnchor.constraint(equalToConstant: r.size.height)
215
+ NSLayoutConstraint.activate([top, leading, width, height])
216
+ boundTop = top; boundLeading = leading; boundWidth = width; boundHeight = height
217
+ activeConstraints = [top, leading, width, height]
218
+ }
219
+
220
+ /// Reposiciona el container a un nuevo rect (driven por el ResizeObserver/
221
+ /// scroll del lado JS). Solo muta `.constant` de las constraints existentes
222
+ /// — sin re-parenting, sin tocar el UITabBar — envuelto en `CATransaction`
223
+ /// con acciones implícitas desactivadas para que el movimiento sea snap (no
224
+ /// un lerp animado que se vería como jitter al scrollear).
225
+ func setBounds(_ rect: CGRect) {
226
+ // Bar oculto (post hideTabBar): no mutar constraints ni emitir layout de
227
+ // un bar que no se ve — evita estado inconsistente si un rAF en vuelo o
228
+ // una llamada low-level llega tras el hide (review).
229
+ guard !view.isHidden else { return }
230
+ guard rect.width > 0, rect.height > 0, let hostVC else { return }
231
+ // Primer rect válido tras un fallback a bottom-pinned → entrar a bound mode.
232
+ guard isBoundMode, let boundTop, let boundLeading, let boundWidth, let boundHeight else {
233
+ applyBoundConstraints(rect, hostVC: hostVC)
234
+ emitLayout()
235
+ return
236
+ }
237
+ let r = convertRectToHost(rect, hostVC: hostVC)
238
+ CATransaction.begin()
239
+ CATransaction.setDisableActions(true)
240
+ boundTop.constant = r.origin.y
241
+ boundLeading.constant = r.origin.x
242
+ boundWidth.constant = r.size.width
243
+ boundHeight.constant = r.size.height
244
+ hostVC.view.layoutIfNeeded()
245
+ CATransaction.commit()
246
+ emitLayout()
247
+ }
248
+
249
+ /// Convierte el rect COMPLETO (viewport CSS px == puntos del bounds del
250
+ /// webView) al espacio de coordenadas del host VC. Convertir el rect entero
251
+ /// — no solo el origen — cubre el caso de un webView insetado o escalado
252
+ /// respecto al host (sin esto, origen y tamaño quedarían en espacios
253
+ /// distintos; review). Con webView nil, fallback al rect crudo.
254
+ private func convertRectToHost(_ rect: CGRect, hostVC: UIViewController) -> CGRect {
255
+ guard let webView else { return rect }
256
+ return webView.convert(rect, to: hostVC.view)
257
+ }
258
+
135
259
  /// Aplica el appearance correspondiente al estilo solicitado.
136
260
  /// - `default` / `liquidGlass`: **NO-OP** — el UITabBar mantiene su
137
261
  /// appearance default sin tocar para que iOS 26 aplique Liquid Glass
@@ -206,13 +330,11 @@ final class LiquidGlassTabBarOverlay: UIViewController {
206
330
  tabBar.selectedItem = uiItems[selectedIndex]
207
331
  }
208
332
 
209
- // tintColor SOLO se aplica para estilos override (`.ultraThin`,
210
- // `.transparent`). En `.default` / `.liquidGlass` iOS 26 aplica su
211
- // propio tint nativo automático como parte del material Liquid Glass.
212
- // Sobreescribirlo con un color custom (incluso brand) rompe el balance
213
- // del material y produce un look híbrido en vez del adopt puro.
214
- if style == .ultraThin || style == .transparent,
215
- let tintHex, let tint = UIColor(hex: tintHex) {
333
+ // tintColor aplicado siempre el A/B test contra stay-liquid confirmó
334
+ // que el adopt del material Liquid Glass NO se logra en este proyecto
335
+ // independientemente del tintColor, así que mantenerlo solo aporta
336
+ // identidad de marca (naranja brand) sin perder nada.
337
+ if let tintHex, let tint = UIColor(hex: tintHex) {
216
338
  tabBar.tintColor = tint
217
339
  }
218
340
  emitLayout()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ajuarezso/capacitor-liquid-glass",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
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",