Skip to content

Commit 31f7427

Browse files
authored
feat(ios): iosGlassEffect and LiquidGlass containers (#10901)
1 parent cd6fccb commit 31f7427

File tree

15 files changed

+519
-55
lines changed

15 files changed

+519
-55
lines changed

apps/toolbox/src/pages/glass-effects.ts

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Observable, EventData, Page, CoreTypes, GlassEffectConfig } from '@nativescript/core';
1+
import { Observable, EventData, Page, CoreTypes, GlassEffectConfig, View, Label, Animation, LiquidGlassContainer } from '@nativescript/core';
22

33
let page: Page;
44

@@ -10,7 +10,111 @@ export function navigatingTo(args: EventData) {
1010
export class GlassEffectModel extends Observable {
1111
iosGlassEffectInteractive: GlassEffectConfig = {
1212
interactive: true,
13-
tint: '#faabab',
13+
// tint: '#faabab',
1414
variant: 'clear',
1515
};
16+
currentEffect: GlassEffectConfig = {
17+
variant: 'none',
18+
interactive: false,
19+
// tint: '#ccc',
20+
};
21+
22+
toggleGlassEffect(args) {
23+
const btn = args.object as View;
24+
this.currentEffect =
25+
this.currentEffect.variant === 'none'
26+
? {
27+
variant: 'clear',
28+
interactive: true,
29+
// tint: '#faabab',
30+
}
31+
: {
32+
variant: 'none',
33+
interactive: false,
34+
// tint: '#ccc',
35+
};
36+
btn.iosGlassEffect = this.currentEffect;
37+
}
38+
39+
glassMerged = false;
40+
glassTargets = {};
41+
loadedGlass(args) {
42+
const glass = args.object as View;
43+
switch (glass.id) {
44+
case 'glass1':
45+
glass.translateX = 10;
46+
break;
47+
case 'glass2':
48+
glass.translateX = 70;
49+
50+
break;
51+
}
52+
this.glassTargets[glass.id] = glass;
53+
}
54+
55+
glassTargetLabels: { [key: string]: Label } = {};
56+
loadedGlassLabels(args) {
57+
const label = args.object as Label;
58+
this.glassTargetLabels[label.id] = label;
59+
}
60+
61+
async toggleMergeGlass(args) {
62+
if (!this.glassTargets['glass1'] || !this.glassTargets['glass2']) {
63+
return;
64+
}
65+
const container = args?.object as LiquidGlassContainer | undefined;
66+
this.glassMerged = !this.glassMerged;
67+
const glass1 = this.glassTargets['glass1'];
68+
const glass2 = this.glassTargets['glass2'];
69+
70+
// Use relative deltas for translate; the container will bake them into frames post-animation
71+
const d1 = this.glassMerged ? 25 : -25; // left bubble moves inward/outward
72+
const d2 = this.glassMerged ? -25 : 25; // right bubble moves inward/outward
73+
74+
if (!this.glassMerged) {
75+
this.glassTargetLabels['like'].text = 'Like';
76+
}
77+
78+
const animateAll = new Animation([
79+
{ target: glass1, translate: { x: d1, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeOut },
80+
{ target: glass2, translate: { x: d2, y: 0 }, duration: 300, curve: CoreTypes.AnimationCurve.easeOut },
81+
{
82+
target: this.glassTargetLabels['share'],
83+
opacity: this.glassMerged ? 0 : 1,
84+
duration: 300,
85+
},
86+
]);
87+
animateAll.play().then(() => {
88+
if (this.glassMerged) {
89+
this.glassTargetLabels['like'].text = 'Done';
90+
}
91+
92+
// Ask container to stabilize frames so UIGlassContainerEffect samples correct positions
93+
setTimeout(() => container?.stabilizeLayout?.(), 0);
94+
});
95+
96+
// for testing, on tap, can see glass effect changes animating differences
97+
// this.testGlassBindingChanges();
98+
}
99+
100+
testGlassBindingChanges() {
101+
setTimeout(() => {
102+
this.iosGlassEffectInteractive = {
103+
interactive: false,
104+
variant: 'regular',
105+
// can even animate tint changes (requires starting of transparent tint)
106+
// tint: '#faabab',
107+
};
108+
this.notifyPropertyChange('iosGlassEffectInteractive', this.iosGlassEffectInteractive);
109+
setTimeout(() => {
110+
this.iosGlassEffectInteractive = {
111+
interactive: true,
112+
variant: 'clear',
113+
// by setting tint to transparent, it will animate on next change
114+
// tint: '#00000000',
115+
};
116+
this.notifyPropertyChange('iosGlassEffectInteractive', this.iosGlassEffectInteractive);
117+
}, 1500);
118+
}, 1500);
119+
}
16120
}
Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,57 @@
11
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
22
<Page.actionBar>
3-
<ActionBar title="Glass Effects" class="action-bar">
3+
<ActionBar title="Glass Effects" color="white">
44
</ActionBar>
55
</Page.actionBar>
66

7-
<GridLayout>
7+
<GridLayout backgroundColor="#000">
8+
<!-- test color changes over light/dark backgrounds for text -->
9+
<!-- <ContentView backgroundColor="#000" height="300" verticalAlignment="bottom"/> -->
10+
<Image src="res://bg1.jpg" stretch="aspectFill" iosOverflowSafeArea="true" />
811

9-
<Image src="https://cdn.wallpapersafari.com/89/64/c6MnRY.jpg" stretch="aspectFill" iosOverflowSafeArea="true" />
12+
<ScrollView backgroundColor="transparent">
13+
<StackLayout>
14+
<GridLayout rows="*,auto,auto,auto,auto,auto,*">
1015

11-
<GridLayout rows="*,auto,auto,auto,*">
16+
<Button row="2" text="Toggle Glass" tap="{{toggleGlassEffect}}" horizontalAlignment="center" verticalAlignment="middle" class="c-white font-weight-bold m-y-20 p-4" fontSize="22" borderRadius="32" width="300" height="100" touchAnimation="{{touchAnimation}}" iosGlassEffect="{{currentEffect}}"/>
1217

13-
<GridLayout row="1" width="300" height="150" iosGlassEffect="regular" horizontalAlignment="center" verticalAlignment="middle">
14-
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Regular" />
15-
</GridLayout>
18+
<LiquidGlass row="3" width="300" height="100" borderRadius="32" iosGlassEffect="{{iosGlassEffectInteractive}}" >
1619

17-
<GridLayout row="2" width="300" height="150" iosGlassEffect="clear" horizontalAlignment="center" verticalAlignment="middle" class="m-t-10">
18-
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Clear" />
19-
</GridLayout>
20+
<Label text="Glass Interactive" fontSize="22" class="font-weight-bold text-center c-white" />
21+
22+
</LiquidGlass>
23+
24+
25+
<LiquidGlassContainer row="4" tap="{{toggleMergeGlass}}" horizontalAlignment="left" verticalAlignment="middle" class="m-t-20" width="300" height="100" columns="*">
26+
<LiquidGlass id="glass1" loaded="{{loadedGlass}}" borderRadius="50" width="100" height="100">
27+
<Label id="share" text="Share" fontSize="22" class="font-weight-bold text-center" width="100" height="100" loaded="{{loadedGlassLabels}}" />
28+
</LiquidGlass>
29+
<LiquidGlass id="glass2" loaded="{{loadedGlass}}" borderRadius="50" width="100" height="100">
30+
<Label id="like" text="Like" fontSize="22" class="font-weight-bold text-center" loaded="{{loadedGlassLabels}}" />
31+
</LiquidGlass>
32+
</LiquidGlassContainer>
33+
</GridLayout>
34+
35+
<GridLayout rows="*,auto,auto,auto,*" class="m-t-10">
36+
<GridLayout row="1" width="300" height="100" iosGlassEffect="regular" horizontalAlignment="center" verticalAlignment="middle" borderRadius="32">
37+
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Regular" />
38+
</GridLayout>
39+
40+
<GridLayout row="2" width="300" height="100" iosGlassEffect="clear" horizontalAlignment="center" verticalAlignment="middle" class="m-t-10" borderRadius="32">
41+
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Clear" />
42+
</GridLayout>
43+
44+
<GridLayout row="3" width="300" height="100" iosGlassEffect="{{iosGlassEffectInteractive}}" horizontalAlignment="center" verticalAlignment="middle" class="m-t-10" borderRadius="32">
45+
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Interactive" />
46+
</GridLayout>
47+
</GridLayout>
48+
49+
<!-- make scrollable to view glass on scroll -->
50+
<ContentView height="500"/>
51+
</StackLayout>
52+
53+
</ScrollView>
2054

21-
<GridLayout row="3" width="300" height="150" iosGlassEffect="{{iosGlassEffectInteractive}}" horizontalAlignment="center" verticalAlignment="middle" class="m-t-10">
22-
<Label class="text-center c-white" fontWeight="bold" fontSize="18" text="Glass Effects Interactive" />
23-
</GridLayout>
24-
</GridLayout>
2555

2656
</GridLayout>
2757
</Page>

packages/core/ui/button/index.ios.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { borderTopWidthProperty, borderRightWidthProperty, borderBottomWidthProp
55
import { textAlignmentProperty, whiteSpaceProperty, textOverflowProperty } from '../text-base';
66
import { layout } from '../../utils';
77
import { CoreTypes } from '../../core-types';
8-
import { Color } from '../../color';
98

109
export * from './button-common';
1110

packages/core/ui/core/view/index.ios.ts

Lines changed: 84 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Point, Position } from './view-interfaces';
2-
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty, iosGlassEffectProperty, GlassEffectType, GlassEffectVariant, statusBarStyleProperty } from './view-common';
2+
import { ViewCommon, isEnabledProperty, originXProperty, originYProperty, isUserInteractionEnabledProperty, testIDProperty, iosGlassEffectProperty, GlassEffectType, GlassEffectVariant, GlassEffectConfig, statusBarStyleProperty } from './view-common';
33
import { isAccessibilityServiceEnabled } from '../../../application';
44
import { updateA11yPropertiesCallback } from '../../../application/helpers-common';
55
import { ShowModalOptions, hiddenProperty } from '../view-base';
@@ -904,6 +904,62 @@ export class View extends ViewCommon {
904904
}
905905
}
906906

907+
protected _applyGlassEffect(
908+
value: GlassEffectType,
909+
options: {
910+
effectType: 'glass' | 'container';
911+
targetView?: UIVisualEffectView;
912+
toGlassStyleFn?: (variant?: GlassEffectVariant) => number;
913+
onCreate?: (effectView: UIVisualEffectView, effect: UIVisualEffect) => void;
914+
onUpdate?: (effectView: UIVisualEffectView, effect: UIVisualEffect, duration: number) => void;
915+
},
916+
): UIVisualEffectView | undefined {
917+
const config: GlassEffectConfig | null = typeof value !== 'string' ? value : null;
918+
const variant = config ? config.variant : (value as GlassEffectVariant);
919+
const defaultDuration = 0.3;
920+
const duration = config ? (config.animateChangeDuration ?? defaultDuration) : defaultDuration;
921+
922+
let effect: UIGlassEffect | UIGlassContainerEffect | UIVisualEffect;
923+
924+
// Create the appropriate effect based on type and variant
925+
if (!value || ['identity', 'none'].includes(variant)) {
926+
effect = UIVisualEffect.new();
927+
} else {
928+
if (options.effectType === 'glass') {
929+
const styleFn = options.toGlassStyleFn || this.toUIGlassStyle.bind(this);
930+
effect = UIGlassEffect.effectWithStyle(styleFn(variant));
931+
if (config) {
932+
(effect as UIGlassEffect).interactive = !!config.interactive;
933+
if (config.tint) {
934+
(effect as UIGlassEffect).tintColor = typeof config.tint === 'string' ? new Color(config.tint).ios : config.tint;
935+
}
936+
}
937+
} else if (options.effectType === 'container') {
938+
effect = UIGlassContainerEffect.alloc().init();
939+
(effect as UIGlassContainerEffect).spacing = config?.spacing ?? 8;
940+
}
941+
}
942+
943+
// Handle creating new effect view or updating existing one
944+
if (options.targetView) {
945+
// Update existing effect view
946+
if (options.onUpdate) {
947+
options.onUpdate(options.targetView, effect, duration);
948+
} else {
949+
// Default update behavior: animate effect changes
950+
UIView.animateWithDurationAnimations(duration, () => {
951+
options.targetView.effect = effect;
952+
});
953+
}
954+
return undefined;
955+
} else if (options.onCreate) {
956+
// Create new effect view and let caller handle setup
957+
const effectView = UIVisualEffectView.alloc().initWithEffect(effect);
958+
options.onCreate(effectView, effect);
959+
return effectView;
960+
}
961+
return undefined;
962+
}
907963
[statusBarStyleProperty.getDefault]() {
908964
return this.style.statusBarStyle;
909965
}
@@ -929,45 +985,37 @@ export class View extends ViewCommon {
929985
if (!this.nativeViewProtected || !supportsGlass()) {
930986
return;
931987
}
932-
if (this._glassEffectView) {
933-
this._glassEffectView.removeFromSuperview();
934-
this._glassEffectView = null;
935-
}
936-
if (!value) {
937-
return;
938-
}
939-
let effect: UIGlassEffect;
940-
if (typeof value === 'string') {
941-
effect = UIGlassEffect.effectWithStyle(this.toUIGlassStyle(value));
988+
989+
if (!this._glassEffectView) {
990+
// Create new glass effect view
991+
this._glassEffectView = this._applyGlassEffect(value, {
992+
effectType: 'glass',
993+
onCreate: (effectView, effect) => {
994+
// let touches pass to content
995+
effectView.userInteractionEnabled = false;
996+
effectView.clipsToBounds = true;
997+
// size & autoresize
998+
if (this._glassEffectMeasure) {
999+
clearTimeout(this._glassEffectMeasure);
1000+
}
1001+
this._glassEffectMeasure = setTimeout(() => {
1002+
const size = this.nativeViewProtected.bounds.size;
1003+
effectView.frame = CGRectMake(0, 0, size.width, size.height);
1004+
effectView.autoresizingMask = 2;
1005+
this.nativeViewProtected.insertSubviewAtIndex(effectView, 0);
1006+
});
1007+
},
1008+
});
9421009
} else {
943-
if (value.variant === 'identity') {
944-
return;
945-
}
946-
effect = UIGlassEffect.effectWithStyle(this.toUIGlassStyle(value.variant));
947-
if (value.interactive) {
948-
effect.interactive = true;
949-
}
950-
if (value.tint) {
951-
effect.tintColor = typeof value.tint === 'string' ? new Color(value.tint).ios : value.tint;
952-
}
1010+
// Update existing glass effect view
1011+
this._applyGlassEffect(value, {
1012+
effectType: 'glass',
1013+
targetView: this._glassEffectView,
1014+
});
9531015
}
954-
this._glassEffectView = UIVisualEffectView.alloc().initWithEffect(effect);
955-
// let touches pass to content
956-
this._glassEffectView.userInteractionEnabled = false;
957-
this._glassEffectView.clipsToBounds = true;
958-
// size & autoresize
959-
if (this._glassEffectMeasure) {
960-
clearTimeout(this._glassEffectMeasure);
961-
}
962-
this._glassEffectMeasure = setTimeout(() => {
963-
const size = this.nativeViewProtected.bounds.size;
964-
this._glassEffectView.frame = CGRectMake(0, 0, size.width, size.height);
965-
this._glassEffectView.autoresizingMask = 2;
966-
this.nativeViewProtected.insertSubviewAtIndex(this._glassEffectView, 0);
967-
});
9681016
}
9691017

970-
public toUIGlassStyle(value?: GlassEffectVariant) {
1018+
toUIGlassStyle(value?: GlassEffectVariant) {
9711019
if (supportsGlass()) {
9721020
switch (value) {
9731021
case 'regular':

0 commit comments

Comments
 (0)