Skip to content

Commit 8a88bbd

Browse files
committed
feat(linux): frame-driven animations
Tweens opacity, backgroundColor, translate, scale, rotate, width and height with CSS / Core Animation cubic-bezier curves, delays, iterations (including infinite), sequential playback and cancel. GTK 4.6's GL renderer shears views that are both rotated and translucent (fine with GSK_RENDERER=cairo and newer GTK).
1 parent 7b3e9f1 commit 8a88bbd

1 file changed

Lines changed: 175 additions & 34 deletions

File tree

‎packages/core/ui/animation/index.linux.ts‎

Lines changed: 175 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,191 @@ export * from './animation-common';
22
export * from './animation-shared';
33
export * from './animation-types';
44

5-
import { AnimationBase } from './animation-common';
5+
import { AnimationBase, Properties } from './animation-common';
6+
import type { PropertyAnimation } from './animation-shared';
7+
import { CubicBezierAnimationCurve } from '../../core-types/animation-types';
8+
import { CoreTypes } from '../../core-types';
9+
import { Color } from '../../color';
10+
import { requestAnimationFrame, cancelAnimationFrame } from '../../animation-frame';
611

7-
export function _resolveAnimationCurve(curve: any): any {
8-
return curve;
12+
type Easing = (t: number) => number;
13+
14+
/** Cubic bezier easing (same control points as CSS / Core Animation). */
15+
function bezier(x1: number, y1: number, x2: number, y2: number): Easing {
16+
const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;
17+
const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;
18+
const c = (p1: number) => 3 * p1;
19+
const at = (t: number, p1: number, p2: number) => ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;
20+
const slope = (t: number, p1: number, p2: number) => 3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);
21+
return (x: number) => {
22+
if (x <= 0 || x >= 1) return x;
23+
// Newton-Raphson for t(x)
24+
let t = x;
25+
for (let i = 0; i < 8; i++) {
26+
const dx = at(t, x1, x2) - x;
27+
const d = slope(t, x1, x2);
28+
if (Math.abs(dx) < 1e-5 || Math.abs(d) < 1e-6) break;
29+
t -= dx / d;
30+
}
31+
return at(Math.min(1, Math.max(0, t)), y1, y2);
32+
};
33+
}
34+
35+
const CURVES: Record<string, Easing> = {
36+
[CoreTypes.AnimationCurve.linear]: (t) => t,
37+
[CoreTypes.AnimationCurve.ease]: bezier(0.25, 0.1, 0.25, 1),
38+
[CoreTypes.AnimationCurve.easeIn]: bezier(0.42, 0, 1, 1),
39+
[CoreTypes.AnimationCurve.easeOut]: bezier(0, 0, 0.58, 1),
40+
[CoreTypes.AnimationCurve.easeInOut]: bezier(0.42, 0, 0.58, 1),
41+
[CoreTypes.AnimationCurve.spring]: bezier(0.5, 1.6, 0.4, 0.8),
42+
};
43+
44+
export function _resolveAnimationCurve(curve: any): Easing {
45+
if (curve instanceof CubicBezierAnimationCurve) {
46+
return bezier(curve.x1, curve.y1, curve.x2, curve.y2);
47+
}
48+
if (typeof curve === 'function') {
49+
return curve;
50+
}
51+
return CURVES[curve] ?? CURVES[CoreTypes.AnimationCurve.easeInOut];
952
}
1053

11-
// TODO(linux): frame-clock driven animations; for now targets are applied at once.
54+
const mix = (from: number, to: number, t: number) => from + (to - from) * t;
55+
56+
/** Reads the current value and returns an applier for progress 0..1. */
57+
function animator(animation: PropertyAnimation): ((t: number) => void) | null {
58+
const target: any = animation.target;
59+
const to: any = animation.value;
60+
switch (animation.property) {
61+
case Properties.opacity: {
62+
const from = target.opacity ?? 1;
63+
return (t) => (target.opacity = mix(from, to, t));
64+
}
65+
case Properties.backgroundColor: {
66+
const from: Color = target.backgroundColor instanceof Color ? target.backgroundColor : new Color(0, 0, 0, 0);
67+
const end: Color = to instanceof Color ? to : new Color(to);
68+
return (t) => (target.backgroundColor = new Color(Math.round(mix(from.a, end.a, t)), Math.round(mix(from.r, end.r, t)), Math.round(mix(from.g, end.g, t)), Math.round(mix(from.b, end.b, t))));
69+
}
70+
case Properties.translate: {
71+
const from = [target.translateX || 0, target.translateY || 0];
72+
return (t) => {
73+
target.translateX = mix(from[0], to.x, t);
74+
target.translateY = mix(from[1], to.y, t);
75+
};
76+
}
77+
case Properties.scale: {
78+
const from = [target.scaleX ?? 1, target.scaleY ?? 1];
79+
return (t) => {
80+
target.scaleX = mix(from[0], to.x, t);
81+
target.scaleY = mix(from[1], to.y, t);
82+
};
83+
}
84+
case Properties.rotate: {
85+
const from = target.rotate || 0;
86+
const end = typeof to === 'number' ? to : to.z;
87+
return (t) => (target.rotate = mix(from, end, t));
88+
}
89+
case Properties.width:
90+
case Properties.height: {
91+
const key = animation.property;
92+
const measured = key === Properties.width ? target.getActualSize?.().width : target.getActualSize?.().height;
93+
const from = typeof target[key] === 'number' ? target[key] : measured;
94+
// percentages / auto can't be tweened: jump at the end
95+
if (typeof to !== 'number' || typeof from !== 'number') {
96+
return (t) => t >= 1 && (target[key] = to);
97+
}
98+
return (t) => (target[key] = mix(from, to, t));
99+
}
100+
default:
101+
return null;
102+
}
103+
}
104+
105+
/** Frame-driven tweens of the view properties (the views render through GTK). */
12106
export class Animation extends AnimationBase {
107+
private _frame = 0;
108+
private _cancelled = false;
109+
13110
public play(): any {
111+
if (this.isPlaying) {
112+
return this._rejectAlreadyPlaying();
113+
}
14114
const promise = super.play();
15-
for (const property of this._propertyAnimations) {
16-
const target: any = property.target;
17-
const value: any = property.value;
18-
switch (property.property) {
19-
case 'opacity':
20-
target.opacity = value;
21-
break;
22-
case 'backgroundColor':
23-
target.backgroundColor = value;
24-
break;
25-
case 'translate':
26-
target.translateX = value.x;
27-
target.translateY = value.y;
28-
break;
29-
case 'scale':
30-
target.scaleX = value.x;
31-
target.scaleY = value.y;
32-
break;
33-
case 'rotate':
34-
target.rotate = typeof value === 'number' ? value : value.z;
35-
break;
36-
case 'width':
37-
target.width = value;
38-
break;
39-
case 'height':
40-
target.height = value;
41-
break;
42-
}
115+
this._cancelled = false;
116+
const animations = this._propertyAnimations ?? [];
117+
if (this._playSequentially) {
118+
animations
119+
.reduce((chain, animation) => chain.then(() => this._run([animation])), Promise.resolve())
120+
.then(
121+
() => this._done(),
122+
() => {},
123+
);
124+
} else {
125+
this._run(animations).then(
126+
() => this._done(),
127+
() => {},
128+
);
43129
}
44-
setTimeout(() => this._resolveAnimationFinishedPromise(), 0);
45130
return promise;
46131
}
47132

133+
private _done() {
134+
if (!this._cancelled) {
135+
this._resolveAnimationFinishedPromise();
136+
}
137+
}
138+
139+
/** Runs animations in parallel on one frame loop. */
140+
private _run(animations: PropertyAnimation[]): Promise<void> {
141+
return new Promise((resolve, reject) => {
142+
const running = animations.map((animation) => ({ animation, apply: null as ((t: number) => void) | null, start: 0, easing: _resolveAnimationCurve(animation.curve) })).filter((r) => r.animation.target);
143+
const startedAt = Date.now();
144+
const tick = () => {
145+
if (this._cancelled) {
146+
reject();
147+
return;
148+
}
149+
const now = Date.now();
150+
let pending = false;
151+
for (const r of running) {
152+
const { animation } = r;
153+
const delay = animation.delay ?? 0;
154+
const duration = Math.max(1, animation.duration ?? 300);
155+
const iterations = animation.iterations ?? 1;
156+
const elapsed = now - startedAt - delay;
157+
if (elapsed < 0) {
158+
pending = true;
159+
continue;
160+
}
161+
// capture start values when the property starts moving
162+
r.apply ??= animator(animation);
163+
if (!r.apply) continue;
164+
const cycle = elapsed / duration;
165+
const finished = cycle >= iterations;
166+
const t = finished ? 1 : cycle % 1;
167+
r.apply(r.easing(t));
168+
pending ||= !finished;
169+
}
170+
if (pending) {
171+
this._frame = requestAnimationFrame(tick);
172+
} else {
173+
resolve();
174+
}
175+
};
176+
this._frame = requestAnimationFrame(tick);
177+
});
178+
}
179+
180+
public cancel(): void {
181+
if (!this.isPlaying) {
182+
return;
183+
}
184+
this._cancelled = true;
185+
cancelAnimationFrame(this._frame);
186+
this._rejectAnimationFinishedPromise();
187+
}
188+
48189
public _resolveAnimationCurve(curve: any): any {
49-
return curve;
190+
return _resolveAnimationCurve(curve);
50191
}
51192
}

0 commit comments

Comments
 (0)