@ajuarezso/capacitor-liquid-glass 0.3.8 → 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/README.es.md +163 -0
- package/README.md +232 -72
- package/dist/esm/definitions.d.ts +48 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +31 -2
- package/dist/esm/tab-bar-binder.d.ts +56 -0
- package/dist/esm/tab-bar-binder.js +186 -0
- package/dist/esm/web.d.ts +2 -1
- package/dist/esm/web.js +3 -0
- package/dist/plugin.cjs.js +217 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +217 -1
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/LiquidGlassPlugin/LiquidGlassPlugin.swift +35 -3
- package/ios/Sources/LiquidGlassPlugin/LiquidGlassTabBarOverlay.swift +148 -24
- package/package.json +1 -1
package/dist/plugin.js
CHANGED
|
@@ -1,9 +1,222 @@
|
|
|
1
1
|
var capacitorLiquidGlass = (function (exports, core) {
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
|
|
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
|
}
|
package/dist/plugin.js.map
CHANGED
|
@@ -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
|
-
|
|
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
|
-
///
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ajuarezso/capacitor-liquid-glass",
|
|
3
|
-
"version": "0.
|
|
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",
|