Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions packages/core/ui/styling/css-animation-variables.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, afterEach } from 'vitest';

import { StyleScope } from './style-scope';
import { StackLayout } from '../layouts/stack-layout';
import { Label } from '../label';
import { KeyframeAnimation } from '../animation/keyframe-animation';

function play(css: string, className?: string): KeyframeAnimation[] {
const played: KeyframeAnimation[] = [];
vi.spyOn(KeyframeAnimation.prototype, 'play').mockImplementation(function (this: KeyframeAnimation) {
played.push(this);
return Promise.resolve();
});

const scope = new StyleScope();
scope.css = css;
const root: any = new StackLayout();
root._styleScope = scope;
const view: any = new Label();
root.addChild(view);
view._styleScope = scope;
if (className) {
view.className = className;
}
view._cssState.onLoaded();

return played;
}

describe('css animations using variables', () => {
afterEach(() => vi.restoreAllMocks());

it('resolve an animation shorthand held in a variable', () => {
const [animation] = play(`
@keyframes spin { from { opacity: 0; } to { opacity: 1; } }
label { --animate-spin: spin 2s linear infinite; animation: var(--animate-spin); }
`);

expect(animation).toBeDefined();
expect(animation.iterations).toBe(Number.POSITIVE_INFINITY);
expect(animation.animations.map((a) => a.opacity)).toEqual([0, 1]);
});

it('resolve animation longhands per view', () => {
const played = play(
`
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
label { animation-name: fade; animation-duration: var(--speed); }
.slow { --speed: 4s; }
`,
'slow',
);

expect(played.length).toBe(1);
expect(played[0].animations[1].duration).toBe(4000);
});

it('resolve variables inside keyframes against the view', () => {
const [animation] = play(`
@keyframes pulse { from { opacity: var(--from); } to { opacity: var(--to, 1); } }
label { --from: 0.25; animation: pulse 1s; }
`);

expect(animation.animations.map((a) => a.opacity)).toEqual([0.25, 1]);
});

it('keep styling the view when keyframes cannot be resolved', () => {
const played = play(`
@keyframes broken { from { opacity: var(--missing); } to { opacity: 1; } }
label { color: red; animation: broken 1s; }
`);

expect(played.length).toBe(0);
});

it('still play animations without variables', () => {
const [animation] = play(`
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
label { animation: fade 1s; }
`);

expect(animation.animations.map((a) => a.opacity)).toEqual([0, 1]);
});
});
86 changes: 80 additions & 6 deletions packages/core/ui/styling/style-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { File, knownFolders, path } from '../../file-system';
import { Application, CssChangedEventData, LoadAppCSSEventData } from '../../application';
import { profile } from './styling-profile';

import { Keyframes, KeyframeAnimationInfo, KeyframeAnimation } from '../animation/keyframe-animation';
import { Keyframes, KeyframeAnimationInfo, KeyframeAnimation, KeyframeInfo } from '../animation/keyframe-animation';

import { CssAnimationParser } from './css-animation-parser';
import { sanitizeModuleName } from '../../utils/common';
Expand Down Expand Up @@ -66,6 +66,34 @@ const tagToScopeTag: Map<string | number, string> = new Map();
let currentScopeTag: string = null;

const animationsSymbol = Symbol('animations');
const animationVariablesSymbol = Symbol('animationVariables');
const keyframeVariablesSymbol = Symbol('keyframeVariables');

function isAnimationDeclaration(declaration: { property: string }): boolean {
return declaration.property.startsWith('animation');
}

function keyframesUseVariables(keyframes: Keyframes): boolean {
return keyframes.keyframes.some((keyframe) => keyframe.declarations.some((declaration) => typeof declaration.value === 'string' && isCssVariableExpression(declaration.value)));
}

function keyframesArrayFromCSS(keyframes: Keyframes, view?: ViewBase): KeyframeInfo[] | null {
let unparsed = keyframes.keyframes;
if (view) {
unparsed = unparsed.map((keyframe) => ({
values: keyframe.values,
declarations: keyframe.declarations.map(({ property, value }) => ({ property, value: _evaluateCssVariableExpression(view, property, value) })),
}));
}

try {
return CssAnimationParser.keyframesArrayFromCSS(unparsed);
} catch (e) {
Trace.write(`Failed to parse keyframes [${keyframes.name}]${view ? ` for ${view}` : ''}. ${e}`, Trace.categories.Animation, Trace.messageType.warn);

return null;
}
}
const kebabCasePattern = /-([a-z])/g;
const kebabCaseReplacementFunc = (g: string) => g[1].toUpperCase();
const pattern = /('|")(.*?)\1/;
Expand Down Expand Up @@ -373,6 +401,9 @@ class CSSSource {
if (rulesets && rulesets.length) {
rulesets.forEach((rule) => {
rule[animationsSymbol] = CssAnimationParser.keyframeAnimationsFromCSSDeclarations(rule.declarations);
if (rule[animationsSymbol] && rule.declarations.some((declaration) => isAnimationDeclaration(declaration) && isCssVariableExpression(declaration.value))) {
rule[animationVariablesSymbol] = true;
}
});
}

Expand Down Expand Up @@ -798,9 +829,16 @@ export class CssState {
const animations: KeyframeAnimation[] = [];

matchingSelectors.forEach((selector) => {
const ruleAnimations: KeyframeAnimationInfo[] = selector.ruleset?.[animationsSymbol];
const ruleset = selector.ruleset;
let ruleAnimations: KeyframeAnimationInfo[] = ruleset?.[animationsSymbol];
if (ruleAnimations && ruleset[animationVariablesSymbol]) {
ruleAnimations = this.resolveAnimationVariables(ruleset);
}
if (ruleAnimations) {
for (const animationInfo of ruleAnimations) {
for (let animationInfo of ruleAnimations) {
if (animationInfo[keyframeVariablesSymbol]) {
animationInfo = this.resolveKeyframeVariables(animationInfo);
}
const animation = KeyframeAnimation.keyframeAnimationFromInfo(animationInfo);
if (animation) {
animations.push(animation);
Expand All @@ -823,6 +861,32 @@ export class CssState {
}
}

private resolveAnimationVariables(ruleset: RuleSet): KeyframeAnimationInfo[] {
const view = this.viewRef.get();
const scope = view?._styleScope;
if (!scope) {
return null;
}

const declarations = ruleset.declarations.filter(isAnimationDeclaration).map(({ property, value }) => ({ property, value: _evaluateCssVariableExpression(view, property, value) }));
const animations = CssAnimationParser.keyframeAnimationsFromCSSDeclarations(declarations);
if (animations) {
for (const animation of animations) {
scope._applyKeyframes(animation);
}
}

return animations;
}

private resolveKeyframeVariables(animation: KeyframeAnimationInfo): KeyframeAnimationInfo {
const view = this.viewRef.get();
const resolved = Object.assign(new KeyframeAnimationInfo(), animation);
resolved.keyframes = view ? keyframesArrayFromCSS(animation[keyframeVariablesSymbol], view) : null;

return resolved;
}

private stopKeyframeAnimations(): void {
if (!this._playsKeyframeAnimations) {
return;
Expand Down Expand Up @@ -1168,7 +1232,7 @@ export class StyleScope {
const keyframeRule = this.findKeyframeRule(animationName);

const animation = new KeyframeAnimationInfo();
animation.keyframes = keyframeRule ? CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
animation.keyframes = keyframeRule ? keyframesArrayFromCSS(keyframeRule) : null;

return animation;
}
Expand Down Expand Up @@ -1292,13 +1356,23 @@ export class StyleScope {

if (animations != null && animations.length) {
for (const animation of animations) {
const keyframeRule = this.findKeyframeRule(animation.name);
animation.keyframes = keyframeRule ? CssAnimationParser.keyframesArrayFromCSS(keyframeRule.keyframes) : null;
this._applyKeyframes(animation);
}
}
}
}

public _applyKeyframes(animation: KeyframeAnimationInfo): void {
const keyframeRule = this.findKeyframeRule(animation.name);
if (keyframeRule && keyframesUseVariables(keyframeRule)) {
animation[keyframeVariablesSymbol] = keyframeRule;
animation.keyframes = null;
} else {
animation[keyframeVariablesSymbol] = undefined;
animation.keyframes = keyframeRule ? keyframesArrayFromCSS(keyframeRule) : null;
}
}

public getAnimations(ruleset: RuleSet): KeyframeAnimationInfo[] {
return ruleset[animationsSymbol];
}
Expand Down
Loading