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,4 +1,4 @@
import { Observable, EventData, Page, CoreTypes, GlassEffectConfig, View, GlassEffectType, TouchAnimationOptions, Label, Image } from '@nativescript/core';
import { Observable, EventData, Page, CoreTypes, GlassEffectConfig, View, Label, Animation, LiquidGlassContainer } from '@nativescript/core';
let page: Page;
@@ -42,10 +42,10 @@ export class GlassEffectModel extends Observable {
const glass = args.object as View;
switch (glass.id) {
case 'glass1':
glass.translateX = -40;
glass.translateX = 10;
break;
case 'glass2':
glass.translateX = 40;
glass.translateX = 70;
break;
}
@@ -58,22 +58,43 @@ export class GlassEffectModel extends Observable {
this.glassTargetLabels[label.id] = label;
}
toggleMergeGlass() {
async toggleMergeGlass(args) {
if (!this.glassTargets['glass1'] || !this.glassTargets['glass2']) {
return;
}
const container = args?.object as LiquidGlassContainer | undefined;
this.glassMerged = !this.glassMerged;
const glass1 = this.glassTargets['glass1'];
const glass2 = this.glassTargets['glass2'];
glass1.animate({ translate: { x: this.glassMerged ? -40 : 0, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeInOut }).catch(() => {});
glass2.animate({ translate: { x: this.glassMerged ? 40 : 0, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeInOut }).catch(() => {});
this.glassTargetLabels['share'].animate({ opacity: this.glassMerged ? 1 : 0, duration: 300, curve: CoreTypes.AnimationCurve.easeInOut }).catch(() => {});
// Use relative deltas for translate; the container will bake them into frames post-animation
const d1 = this.glassMerged ? 25 : -25; // left bubble moves inward/outward
const d2 = this.glassMerged ? -25 : 25; // right bubble moves inward/outward
this.glassTargetLabels['like'].text = this.glassMerged ? 'Done' : 'Like';
if (!this.glassMerged) {
this.glassTargetLabels['like'].text = 'Like';
}
const animateAll = new Animation([
{ target: glass1, translate: { x: d1, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeOut },
{ target: glass2, translate: { x: d2, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeOut },
{
target: this.glassTargetLabels['share'],
opacity: this.glassMerged ? 0 : 1,
duration: 300,
},
]);
animateAll.play().then(() => {
if (this.glassMerged) {
this.glassTargetLabels['like'].text = 'Done';
}
// Ask container to stabilize frames so UIGlassContainerEffect samples correct positions
setTimeout(() => container?.stabilizeLayout?.(), 0);
});
// for testing, on tap, can see glass effect changes animating differences
this.testGlassBindingChanges();
// this.testGlassBindingChanges();
}
testGlassBindingChanges() {

View File

@@ -24,7 +24,7 @@
</LiquidGlass>
<LiquidGlassContainer row="4" tap="{{toggleMergeGlass}}" horizontalAlignment="center" verticalAlignment="middle" class="m-t-20" width="300" height="100">
<LiquidGlassContainer row="4" tap="{{toggleMergeGlass}}" horizontalAlignment="left" verticalAlignment="middle" class="m-t-20" width="300" height="100" columns="*">
<LiquidGlass id="glass1" loaded="{{loadedGlass}}" borderRadius="50" width="100" height="100">
<Label id="share" text="Share" fontSize="22" class="font-weight-bold text-center" width="100" height="100" loaded="{{loadedGlassLabels}}" />
</LiquidGlass>
@@ -47,6 +47,9 @@
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Interactive" />
</GridLayout>
</GridLayout>
<!-- make scrollable to view glass on scroll -->
<ContentView height="500"/>
</StackLayout>
</ScrollView>

View File

@@ -895,54 +895,98 @@ export class View extends ViewCommon {
}
}
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
if (!this.nativeViewProtected || !supportsGlass()) {
return;
}
let effect: UIGlassEffect | UIVisualEffect;
protected _applyGlassEffect(
value: GlassEffectType,
options: {
effectType: 'glass' | 'container';
targetView?: UIVisualEffectView;
toGlassStyleFn?: (variant?: GlassEffectVariant) => number;
onCreate?: (effectView: UIVisualEffectView, effect: UIVisualEffect) => void;
onUpdate?: (effectView: UIVisualEffectView, effect: UIVisualEffect, duration: number) => void;
},
): UIVisualEffectView | undefined {
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;
let effect: UIGlassEffect | UIGlassContainerEffect | UIVisualEffect;
// Create the appropriate effect based on type and variant
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 (options.effectType === 'glass') {
const styleFn = options.toGlassStyleFn || this.toUIGlassStyle.bind(this);
effect = UIGlassEffect.effectWithStyle(styleFn(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;
}
}
} else if (options.effectType === 'container') {
effect = UIGlassContainerEffect.alloc().init();
(effect as UIGlassContainerEffect).spacing = config?.spacing ?? 8;
}
}
if (!this._glassEffectView) {
this._glassEffectView = UIVisualEffectView.alloc().initWithEffect(effect);
// this._glassEffectView.overrideUserInterfaceStyle = UIUserInterfaceStyle.Light;
// let touches pass to content
this._glassEffectView.userInteractionEnabled = false;
this._glassEffectView.clipsToBounds = true;
// size & autoresize
if (this._glassEffectMeasure) {
clearTimeout(this._glassEffectMeasure);
// Handle creating new effect view or updating existing one
if (options.targetView) {
// Update existing effect view
if (options.onUpdate) {
options.onUpdate(options.targetView, effect, duration);
} else {
// Default update behavior: animate effect changes
UIView.animateWithDurationAnimations(duration, () => {
options.targetView.effect = effect;
});
}
this._glassEffectMeasure = setTimeout(() => {
const size = this.nativeViewProtected.bounds.size;
this._glassEffectView.frame = CGRectMake(0, 0, size.width, size.height);
this._glassEffectView.autoresizingMask = 2;
this.nativeViewProtected.insertSubviewAtIndex(this._glassEffectView, 0);
return undefined;
} else if (options.onCreate) {
// Create new effect view and let caller handle setup
const effectView = UIVisualEffectView.alloc().initWithEffect(effect);
options.onCreate(effectView, effect);
return effectView;
}
return undefined;
}
[iosGlassEffectProperty.setNative](value: GlassEffectType) {
if (!this.nativeViewProtected || !supportsGlass()) {
return;
}
if (!this._glassEffectView) {
// Create new glass effect view
this._glassEffectView = this._applyGlassEffect(value, {
effectType: 'glass',
onCreate: (effectView, effect) => {
// let touches pass to content
effectView.userInteractionEnabled = false;
effectView.clipsToBounds = true;
// size & autoresize
if (this._glassEffectMeasure) {
clearTimeout(this._glassEffectMeasure);
}
this._glassEffectMeasure = setTimeout(() => {
const size = this.nativeViewProtected.bounds.size;
effectView.frame = CGRectMake(0, 0, size.width, size.height);
effectView.autoresizingMask = 2;
this.nativeViewProtected.insertSubviewAtIndex(effectView, 0);
});
},
});
} else {
// animate effect changes
UIView.animateWithDurationAnimations(duration, () => {
this._glassEffectView.effect = effect;
// Update existing glass effect view
this._applyGlassEffect(value, {
effectType: 'glass',
targetView: this._glassEffectView,
});
}
}
public toUIGlassStyle(value?: GlassEffectVariant) {
toUIGlassStyle(value?: GlassEffectVariant) {
if (supportsGlass()) {
switch (value) {
case 'regular':

View File

@@ -1239,6 +1239,32 @@ export abstract class ViewCommon extends ViewBase {
return false;
}
/**
* Shared helper method for applying glass effects to views.
* This method can be used by View and its subclasses (LiquidGlass, LiquidGlassContainer, etc.)
* iOS only at the moment but could be applied to others once supported in other platforms.
*
* @param value - The glass effect configuration
* @param options - Configuration options for different glass effect behaviors
* @param options.effectType - Type of effect to create: 'glass' | 'container'
* @param options.targetView - The UIVisualEffectView to apply the effect to (if updating existing view)
* @param options.toGlassStyleFn - Custom function to convert variant to UIGlassEffectStyle
* @param options.onCreate - Callback when a new effect view is created (for initial setup)
* @param options.onUpdate - Callback when an existing effect view is updated
*/
protected _applyGlassEffect(
value: GlassEffectType,
options: {
effectType: 'glass' | 'container';
targetView?: UIVisualEffectView;
toGlassStyleFn?: (variant?: GlassEffectVariant) => number;
onCreate?: (effectView: UIVisualEffectView, effect: UIVisualEffect) => void;
onUpdate?: (effectView: UIVisualEffectView, effect: UIVisualEffect, duration: number) => void;
},
): UIVisualEffectView | undefined {
return undefined;
}
public sendAccessibilityEvent(options: Partial<AccessibilityEventOptions>): void {
return;
}

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;
}