feat(ios): iosGlassEffect and LiquidGlass containers

This commit is contained in:
Nathan Walker
2025-10-21 17:48:34 -07:00
parent 9b963b4019
commit 90a50e3d13
9 changed files with 250 additions and 102 deletions

View File

@@ -1,10 +1,12 @@
import type { NativeScriptUIView } from '../../utils';
import { GlassEffectConfig, GlassEffectType, GlassEffectVariant, iosGlassEffectProperty, View } from '../../core/view';
import { GlassEffectType, iosGlassEffectProperty, View } from '../../core/view';
import { LiquidGlassContainerCommon } from './liquid-glass-container-common';
import { toUIGlassStyle } from '../liquid-glass';
export class LiquidGlassContainer extends LiquidGlassContainerCommon {
public nativeViewProtected: UIVisualEffectView;
private _contentHost: UIView;
private _normalizing = false;
createNativeView() {
// Keep UIVisualEffectView as the root to preserve interactive container effect
@@ -15,7 +17,7 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon {
effectView.clipsToBounds = true;
effectView.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
// Add a host view for children so GridLayout can lay them out normally
// Add a host view for children so parent can lay them out normally
const host = UIView.new();
host.frame = effectView.bounds;
host.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
@@ -42,32 +44,91 @@ export class LiquidGlassContainer extends LiquidGlassContainerCommon {
this.nativeViewProtected.layer.insertSublayerBelow(childNativeView.outerShadowContainerLayer, childNativeView.layer);
}
// Normalize in case the child comes in with a residual translate from a previous state
this._scheduleNormalize();
return true;
}
return false;
}
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
console.log('iosGlassEffectProperty:', value);
let effect: UIGlassContainerEffect | UIVisualEffect;
const config: GlassEffectConfig | null = typeof value !== 'string' ? value : null;
const variant = config ? config.variant : (value as GlassEffectVariant);
const defaultDuration = 0.3;
const duration = config ? (config.animateChangeDuration ?? defaultDuration) : defaultDuration;
if (!value || ['identity', 'none'].includes(variant)) {
// empty effect
effect = UIVisualEffect.new();
} else {
effect = UIGlassContainerEffect.alloc().init();
(effect as UIGlassContainerEffect).spacing = config?.spacing ?? 8;
// 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);
// Try to fold any pending translates into frames on each layout pass
this._normalizeChildrenTransforms();
}
// Allow callers to stabilize layout after custom animations
public stabilizeLayout() {
this._normalizeChildrenTransforms(true);
}
private _scheduleNormalize() {
if (this._normalizing) return;
this._normalizing = true;
// Next tick to allow any pending frame/transform updates to settle
setTimeout(() => {
try {
this._normalizeChildrenTransforms();
} finally {
this._normalizing = false;
}
});
}
private _normalizeChildrenTransforms(force = false) {
let changed = false;
const count = this.getChildrenCount?.() ?? 0;
for (let i = 0; i < count; i++) {
const child = this.getChildAt(i) as View | undefined;
if (!child) continue;
const tx = child.translateX || 0;
const ty = child.translateY || 0;
if (!tx && !ty) continue;
const native = child.nativeViewProtected as UIView;
if (!native) continue;
// Skip if the child is still animating (unless forced)
if (!force) {
const keys = native.layer.animationKeys ? native.layer.animationKeys() : null;
const hasAnimations = !!(keys && keys.count > 0);
if (hasAnimations) continue;
}
const frame = native.frame;
native.transform = CGAffineTransformIdentity;
native.frame = CGRectMake(frame.origin.x + tx, frame.origin.y + ty, frame.size.width, frame.size.height);
child.translateX = 0;
child.translateY = 0;
changed = true;
}
if (effect) {
// animate effect changes
UIView.animateWithDurationAnimations(duration, () => {
this.nativeViewProtected.effect = effect;
});
if (changed) {
// Ask the effect view to re-evaluate its internal state using updated frames
const nv = this.nativeViewProtected;
if (nv) {
nv.setNeedsLayout();
nv.layoutIfNeeded();
// Also request layout on contentView in case the effect inspects it directly
nv.contentView?.setNeedsLayout?.();
nv.contentView?.layoutIfNeeded?.();
}
}
}
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
this._applyGlassEffect(value, {
effectType: 'container',
targetView: this.nativeViewProtected,
toGlassStyleFn: toUIGlassStyle,
});
}
}

View File

@@ -1,3 +1,5 @@
import { GridLayout } from '../grid-layout';
import { AbsoluteLayout } from '../absolute-layout';
export class LiquidGlassContainerCommon extends GridLayout {}
export class LiquidGlassContainerCommon extends AbsoluteLayout {
stabilizeLayout() {}
}

View File

@@ -1,3 +1,9 @@
import { LiquidGlassCommon } from './liquid-glass-common';
import type { GlassEffectVariant } from '../../core/view';
export class LiquidGlass extends LiquidGlassCommon {}
export function toUIGlassStyle(value?: GlassEffectVariant) {
// Can support when Android equivalent is available
return 0;
}

View File

@@ -1,3 +1,10 @@
import { LiquidGlassCommon } from './liquid-glass-common';
export class LiquidGlass extends LiquidGlassCommon {}
/**
* Convert GlassEffectVariant to corresponding platform glass style.
* @param value GlassEffectVariant | undefined
* @returns 0 | 1 | UIGlassEffectStyle
*/
export function toUIGlassStyle(value?: GlassEffectVariant): 0 | 1 | UIGlassEffectStyle;

View File

@@ -1,7 +1,6 @@
import type { NativeScriptUIView } from '../../utils';
import { supportsGlass } from '../../../utils/constants';
import { GlassEffectConfig, GlassEffectType, GlassEffectVariant, iosGlassEffectProperty, View } from '../../core/view';
import { Color } from '../../../color';
import { type GlassEffectType, type GlassEffectVariant, iosGlassEffectProperty, View } from '../../core/view';
import { LiquidGlassCommon } from './liquid-glass-common';
export class LiquidGlass extends LiquidGlassCommon {
@@ -9,7 +8,6 @@ export class LiquidGlass extends LiquidGlassCommon {
private _contentHost: UIView;
createNativeView() {
console.log('createNativeView');
// Use UIVisualEffectView as the root so interactive effects can track touches
const effect = UIGlassEffect.effectWithStyle(UIGlassEffectStyle.Clear);
effect.interactive = true;
@@ -18,7 +16,7 @@ export class LiquidGlass extends LiquidGlassCommon {
effectView.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
effectView.clipsToBounds = true;
// Host for all children so GridLayout (derived) layout works as usual
// Host for all children so parent layout works as usual
const host = UIView.new();
host.frame = effectView.bounds;
host.autoresizingMask = UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
@@ -52,42 +50,22 @@ export class LiquidGlass extends LiquidGlassCommon {
}
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
console.log('iosGlassEffectProperty:', value);
let effect: UIGlassEffect | UIVisualEffect;
const config: GlassEffectConfig | null = typeof value !== 'string' ? value : null;
const variant = config ? config.variant : (value as GlassEffectVariant);
const defaultDuration = 0.3;
const duration = config ? (config.animateChangeDuration ?? defaultDuration) : defaultDuration;
if (!value || ['identity', 'none'].includes(variant)) {
// empty effect
effect = UIVisualEffect.new();
} else {
effect = UIGlassEffect.effectWithStyle(this.toUIGlassStyle(variant));
if (config) {
(effect as UIGlassEffect).interactive = !!config.interactive;
if (config.tint) {
(effect as UIGlassEffect).tintColor = typeof config.tint === 'string' ? new Color(config.tint).ios : config.tint;
}
}
}
if (effect) {
// animate effect changes
UIView.animateWithDurationAnimations(duration, () => {
this.nativeViewProtected.effect = effect;
});
}
}
public toUIGlassStyle(value?: GlassEffectVariant) {
if (supportsGlass()) {
switch (value) {
case 'regular':
return UIGlassEffectStyle?.Regular ?? 0;
case 'clear':
return UIGlassEffectStyle?.Clear ?? 1;
}
}
return 1;
this._applyGlassEffect(value, {
effectType: 'glass',
targetView: this.nativeViewProtected,
toGlassStyleFn: toUIGlassStyle,
});
}
}
export function toUIGlassStyle(value?: GlassEffectVariant) {
if (supportsGlass()) {
switch (value) {
case 'regular':
return UIGlassEffectStyle?.Regular ?? 0;
case 'clear':
return UIGlassEffectStyle?.Clear ?? 1;
}
}
return 1;
}