Docs
Animation
Write animation as atTime(time, duration), a function of time. animate() is the fallback. Keep fontSize a positive constant.
Write the animation of a custom element as a function of time. This is the recommended way. The timeline calls atTime(time, duration) for every frame it shows, and the method sets every animated property for that time.
import { clamp, easeOutCubic, map } from '@vidova/core';
/** Clip length when nothing gives one, such as a thumbnail. */
public readonly defaultDuration = 3;
public atTime(time: number, duration: number): void {
const enter = easeOutCubic(clamp(0, 1, time / 0.4));
const leave = easeOutCubic(clamp(0, 1, (time - (duration - 0.4)) / 0.4));
this.opacity(enter * (1 - leave));
this.y(map(40, 0, enter));
}timeis the seconds since the clip start, from 0 toduration.durationis the clip length in seconds.defaultDurationis the length used when there is no clip. Thumbnails pose the element at 65% of it.
Why atTime
The timeline can show any frame in any order: play, scrub, jump back. With atTime, each frame costs one call, and the same time always gives the same frame.
animate() is a thread. To show an earlier frame, the timeline makes a new instance and replays animate() from the clip start. Scrubbing back through a long clip gets slower the further in you are.
Rules for atTime
- Set every animated property on every call. A property you set only in some branches keeps the value from the last frame shown.
- Keep no state between calls. Do not count calls or keep the previous frame. Compute each value from
time,durationand the inputs. - Do not use unseeded random numbers or the wall clock. Use a seeded hash of an index for noise.
- Do not wait or measure.
atTimecannotyieldorawait. Do not readwidth(),height()orglyphs()in it, because the font can still be loading. Read sizes in reactive props instead.
A schedule of steps
Give each step a start time and a length. clamp(0, 1, (time - start) / seconds) goes from 0 to 1 over the step. Pass it through an easing function, then map it to the values.
public atTime(time: number): void {
const badge = this.badge();
const title = this.title();
// Badge pops in at 0.2 s for 0.3 s. The title slides in when the badge is done.
badge.scale(easeOutBack(clamp(0, 1, (time - 0.2) / 0.3)));
title.x(map(-200, 0, easeOutCubic(clamp(0, 1, (time - 0.5) / 0.6))));
}A step that runs after another starts at the end of the one before it. Steps that run together share a start time.
One clock signal
When many properties depend on text layout, keep one clock signal and read it in reactive props. Set the clock from atTime:
private readonly clock = createSignal(0);
public constructor(props?: TickerProps) {
super({ ...props });
this.add(<Txt ref={this.label} x={() => this.xAt(this.clock())} text={() => this.text()} />);
}
public atTime(time: number): void {
this.clock(time);
}Reactive props update when the font loads, so they can read sizes.
Repeating motion
A loop is a function of time too. Use the remainder of a division, or a sine:
const phase = (time % 0.8) / 0.8;
this.dot().y(Math.sin(phase * Math.PI * 2) * -40);fontSize stays positive
fontSize must be a positive number on every frame. Do not drive it from a value that starts at 0. Keep fontSize constant and animate scale.
this.scale(easeOutCubic(clamp(0, 1, time / 0.4)));A fontSize of 0 compiles and then fails at render. The clip shows nothing.
Refs
Hold a child when atTime needs to set it:
import { createRef } from '@vidova/core';
import { Rect, Txt } from '@vidova/2d';
private readonly title = createRef<Txt>();
public constructor(props?: HelloTitleProps) {
super({ ...props });
this.add(
<Rect>
<Txt ref={this.title} text={() => this.label()} fontFamily="Inter Variable" />
</Rect>,
);
}
public atTime(time: number): void {
this.title().opacity(easeOutCubic(clamp(0, 1, time / 0.4)));
}Class fields for refs are fine. Class fields for @signal props must not collide with Node members. See Layout.
Fallback: animate()
Use animate() only when the motion cannot be a function of time. It is a generator. yield pauses until the next frame. yield* runs another generator to completion, such as a tween.
public *animateIn(duration: number = 0.4): ThreadGenerator {
this.opacity(0);
yield* this.opacity(1, duration, easeOutCubic);
}
public *animate(duration?: number): ThreadGenerator {
yield* this.animateIn(duration ?? 0.4);
}animate()starts on the first frame of the clip. A tween with no timing function useseaseInOutCubic.- Keep
animateIn()separate. A thumbnail of an element withoutatTimeplaysanimateIn()before capture. - Do not yield a promise such as
fetch(...). The timeline stops the element and logs an error.yield nodeis allowed. It waits for the fonts and images of that node. - The same frame must come out the same on every replay. Keep state in the instance, never in module variables.
