diff --git a/apps/automated/src/ui/layouts/flexbox-layout-tests.ts b/apps/automated/src/ui/layouts/flexbox-layout-tests.ts
index 3bbe457cdc..2f562cf4db 100644
--- a/apps/automated/src/ui/layouts/flexbox-layout-tests.ts
+++ b/apps/automated/src/ui/layouts/flexbox-layout-tests.ts
@@ -1746,3 +1746,41 @@ export const testFlexboxLayout_does_not_crash_with_proxy_view_container = test(a
// Omit testDivider_directionRow_verticalBeginning
// Omit divider test family, we don't draw dividers
+
+let activity_liquidglass_flexbox_layout = () =>
+ getViews(
+ `
+
+
+
+ `,
+ );
+
+export const testLiquidGlassFlexboxLayout = test(activity_liquidglass_flexbox_layout, noop, ({ flexbox, text1, text2, text3 }) => {
+ isTopAlignedWith(text1, flexbox);
+ isLeftAlignedWith(text1, flexbox);
+ isRightOf(text2, text1);
+ isTopAlignedWith(text2, flexbox);
+ isRightOf(text3, text2);
+ isTopAlignedWith(text3, flexbox);
+
+ equal(width(flexbox), width(text1) + width(text2) + width(text3));
+ // Layout helpers report device pixels, so fixed XML DIP sizes must be converted too.
+ closeEnough(height(flexbox), dipToDp(300));
+});
+
+export const testLiquidGlassViews_do_not_crash_when_updating_iosGlassEffect = test(activity_liquidglass_flexbox_layout, noop, ({ root, text1, text2 }) => {
+ const liquidGlass = text1 as unknown as View;
+ const liquidGlassContainer = text2 as unknown as View;
+
+ TKUnit.assertTrue(liquidGlass.nativeViewProtected instanceof UIVisualEffectView, 'LiquidGlass should create a UIVisualEffectView host.');
+ TKUnit.assertTrue(liquidGlassContainer.nativeViewProtected instanceof UIVisualEffectView, 'LiquidGlassContainer should create a UIVisualEffectView host.');
+
+ liquidGlass.iosGlassEffect = 'regular';
+ liquidGlassContainer.iosGlassEffect = { variant: 'clear', spacing: 12 };
+ liquidGlass.iosGlassEffect = 'none';
+ liquidGlassContainer.iosGlassEffect = 'none';
+
+ waitUntilTestElementLayoutIsValid(root);
+ TKUnit.assertTrue(root.isLoaded, 'Liquid glass view tree should remain loaded after glass effect updates.');
+});
diff --git a/apps/toolbox/src/pages/glass-effects.xml b/apps/toolbox/src/pages/glass-effects.xml
index 58601674af..c457ccf70b 100644
--- a/apps/toolbox/src/pages/glass-effects.xml
+++ b/apps/toolbox/src/pages/glass-effects.xml
@@ -15,13 +15,13 @@
-
+
-
+
@@ -32,6 +32,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/ui/core/view/index.ios.ts b/packages/core/ui/core/view/index.ios.ts
index a88ccaf8dc..85b4c8baaf 100644
--- a/packages/core/ui/core/view/index.ios.ts
+++ b/packages/core/ui/core/view/index.ios.ts
@@ -927,13 +927,11 @@ export class View extends ViewCommon {
const variant = config ? config.variant : (value as GlassEffectVariant);
const defaultDuration = 0.3;
const duration = config ? (config.animateChangeDuration ?? defaultDuration) : defaultDuration;
-
- let effect: UIGlassEffect | UIGlassContainerEffect | UIVisualEffect;
+ const glassSupported = supportsGlass();
+ let effect: UIVisualEffect = UIVisualEffect.new();
// Create the appropriate effect based on type and variant
- if (!value || ['identity', 'none'].includes(variant)) {
- effect = UIVisualEffect.new();
- } else {
+ if (value && !['identity', 'none'].includes(variant) && glassSupported) {
if (options.effectType === 'glass') {
const styleFn = options.toGlassStyleFn || this.toUIGlassStyle.bind(this);
effect = UIGlassEffect.effectWithStyle(styleFn(variant));
@@ -1086,9 +1084,9 @@ export class View extends ViewCommon {
if (supportsGlass()) {
switch (value) {
case 'regular':
- return UIGlassEffectStyle?.Regular ?? 0;
+ return UIGlassEffectStyle.Regular;
case 'clear':
- return UIGlassEffectStyle?.Clear ?? 1;
+ return UIGlassEffectStyle.Clear;
}
}
return 1;
diff --git a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts
index a9bc485687..323edf9fe0 100644
--- a/packages/core/ui/layouts/liquid-glass-container/index.ios.ts
+++ b/packages/core/ui/layouts/liquid-glass-container/index.ios.ts
@@ -1,4 +1,5 @@
import type { NativeScriptUIView } from '../../utils';
+import { supportsGlass } from '../../../utils/constants';
import { GlassEffectType, iosGlassEffectProperty, View } from '../../core/view';
import { LiquidGlassContainerCommon } from './liquid-glass-container-common';
import { toUIGlassStyle } from '../liquid-glass';
@@ -9,11 +10,16 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon {
private _normalizing = false;
createNativeView() {
+ const glassSupported = supportsGlass();
// Keep UIVisualEffectView as the root to preserve interactive container effect
- const effect = UIGlassContainerEffect.alloc().init();
- effect.spacing = 8;
+ const effect = glassSupported ? UIGlassContainerEffect.alloc().init() : UIVisualEffect.new();
+ if (glassSupported) {
+ (effect as UIGlassContainerEffect).spacing = 8;
+ }
const effectView = UIVisualEffectView.alloc().initWithEffect(effect);
- effectView.overrideUserInterfaceStyle = UIUserInterfaceStyle.Dark;
+ if (glassSupported) {
+ effectView.overrideUserInterfaceStyle = UIUserInterfaceStyle.Dark;
+ }
effectView.clipsToBounds = true;
effectView.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
@@ -53,12 +59,54 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon {
return false;
}
+ // When LiquidGlassContainer is a child of FlexboxLayout (or any layout that passes
+ // a measure spec already reduced by the child's padding), AbsoluteLayout.onMeasure
+ // would subtract our padding a second time. To prevent this double-deduction we
+ // temporarily zero the effective padding/border values before delegating to the
+ // AbsoluteLayout measurement, then restore them immediately after.
+ public onMeasure(widthMeasureSpec: number, heightMeasureSpec: number): void {
+ const pl = this.effectivePaddingLeft;
+ const pr = this.effectivePaddingRight;
+ const pt = this.effectivePaddingTop;
+ const pb = this.effectivePaddingBottom;
+ const bl = this.effectiveBorderLeftWidth;
+ const br = this.effectiveBorderRightWidth;
+ const bt = this.effectiveBorderTopWidth;
+ const bb = this.effectiveBorderBottomWidth;
+
+ this.effectivePaddingLeft = 0;
+ this.effectivePaddingRight = 0;
+ this.effectivePaddingTop = 0;
+ this.effectivePaddingBottom = 0;
+ this.effectiveBorderLeftWidth = 0;
+ this.effectiveBorderRightWidth = 0;
+ this.effectiveBorderTopWidth = 0;
+ this.effectiveBorderBottomWidth = 0;
+
+ try {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ } finally {
+ this.effectivePaddingLeft = pl;
+ this.effectivePaddingRight = pr;
+ this.effectivePaddingTop = pt;
+ this.effectivePaddingBottom = pb;
+ this.effectiveBorderLeftWidth = bl;
+ this.effectiveBorderRightWidth = br;
+ this.effectiveBorderTopWidth = bt;
+ this.effectiveBorderBottomWidth = bb;
+ }
+ }
+
// When children animate with translate (layer transform), UIVisualEffectView-based
// container effects may recompute based on the underlying frames (not transforms),
// which can cause jumps. Normalize any residual translation into the
// child's frame so the effect uses the final visual positions.
public onLayout(left: number, top: number, right: number, bottom: number): void {
- super.onLayout(left, top, right, bottom);
+ // AbsoluteLayout.onLayout positions children using our padding as an offset.
+ // Since the FlexboxLayout (or parent) already placed our UIVisualEffectView at
+ // (left, top), we normalise to local coordinates so that AbsoluteLayout places
+ // children in (0, 0, width, height) space — the coordinate space of _contentHost.
+ super.onLayout(0, 0, right - left, bottom - top);
// Try to fold any pending translates into frames on each layout pass
this._normalizeChildrenTransforms();
diff --git a/packages/core/ui/layouts/liquid-glass/index.ios.ts b/packages/core/ui/layouts/liquid-glass/index.ios.ts
index 2bf63e39d7..e545dd29bb 100644
--- a/packages/core/ui/layouts/liquid-glass/index.ios.ts
+++ b/packages/core/ui/layouts/liquid-glass/index.ios.ts
@@ -8,9 +8,12 @@ export class LiquidGlass extends LiquidGlassCommon {
private _contentHost: UIView;
createNativeView() {
+ const glassSupported = supportsGlass();
// Use UIVisualEffectView as the root so interactive effects can track touches
- const effect = UIGlassEffect.effectWithStyle(UIGlassEffectStyle.Clear);
- effect.interactive = true;
+ const effect = glassSupported ? UIGlassEffect.effectWithStyle(toUIGlassStyle('clear')) : UIVisualEffect.new();
+ if (glassSupported) {
+ (effect as UIGlassEffect).interactive = true;
+ }
const effectView = UIVisualEffectView.alloc().initWithEffect(effect);
effectView.frame = CGRectMake(0, 0, 0, 0);
effectView.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
@@ -49,6 +52,53 @@ export class LiquidGlass extends LiquidGlassCommon {
return false;
}
+ public onMeasure(widthMeasureSpec: number, heightMeasureSpec: number): void {
+ // When LiquidGlass is a child of FlexboxLayout (or any layout that passes a child
+ // measure spec already reduced by the child's padding), GridLayout.onMeasure would
+ // subtract our padding a second time. To prevent this double-deduction we temporarily
+ // zero the effective padding/border values before delegating to the GridLayout
+ // measurement, then restore them immediately after.
+ const pl = this.effectivePaddingLeft;
+ const pr = this.effectivePaddingRight;
+ const pt = this.effectivePaddingTop;
+ const pb = this.effectivePaddingBottom;
+ const bl = this.effectiveBorderLeftWidth;
+ const br = this.effectiveBorderRightWidth;
+ const bt = this.effectiveBorderTopWidth;
+ const bb = this.effectiveBorderBottomWidth;
+
+ this.effectivePaddingLeft = 0;
+ this.effectivePaddingRight = 0;
+ this.effectivePaddingTop = 0;
+ this.effectivePaddingBottom = 0;
+ this.effectiveBorderLeftWidth = 0;
+ this.effectiveBorderRightWidth = 0;
+ this.effectiveBorderTopWidth = 0;
+ this.effectiveBorderBottomWidth = 0;
+
+ try {
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ } finally {
+ this.effectivePaddingLeft = pl;
+ this.effectivePaddingRight = pr;
+ this.effectivePaddingTop = pt;
+ this.effectivePaddingBottom = pb;
+ this.effectiveBorderLeftWidth = bl;
+ this.effectiveBorderRightWidth = br;
+ this.effectiveBorderTopWidth = bt;
+ this.effectiveBorderBottomWidth = bb;
+ }
+ }
+
+ public onLayout(left: number, top: number, right: number, bottom: number): void {
+ // GridLayout.onLayout computes column/row offsets relative to (left, top), then
+ // adds its own padding on top. Since the FlexboxLayout (or parent) already placed
+ // our UIVisualEffectView at (left, top), we normalise to local coordinates so that
+ // GridLayout lays children out in (0, 0, width, height) space — which is exactly
+ // the coordinate space of our _contentHost UIView that hosts the children.
+ super.onLayout(0, 0, right - left, bottom - top);
+ }
+
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
this._applyGlassEffect(value, {
effectType: 'glass',
@@ -62,9 +112,9 @@ export function toUIGlassStyle(value?: GlassEffectVariant) {
if (supportsGlass()) {
switch (value) {
case 'regular':
- return UIGlassEffectStyle?.Regular ?? 0;
+ return UIGlassEffectStyle.Regular;
case 'clear':
- return UIGlassEffectStyle?.Clear ?? 1;
+ return UIGlassEffectStyle.Clear;
}
}
return 1;
diff --git a/packages/core/utils/constants.ios.ts b/packages/core/utils/constants.ios.ts
index 735e204362..2b9a98b6e0 100644
--- a/packages/core/utils/constants.ios.ts
+++ b/packages/core/utils/constants.ios.ts
@@ -1,4 +1,5 @@
export const SDK_VERSION = parseFloat(UIDevice.currentDevice.systemVersion);
+
export function supportsGlass(): boolean {
return __APPLE__ && SDK_VERSION >= 26;
}