feat: add useAnimation composable for frame-based animations

Drives animations with a frame counter, elapsed time, delta, and reset
function. Supports configurable interval and reactive isActive toggle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-26 16:13:43 +08:00
parent edf51bef3c
commit 2c186951ed
3 changed files with 285 additions and 0 deletions
@@ -0,0 +1,135 @@
import {
shallowRef,
watch,
toValue,
onScopeDispose,
type MaybeRefOrGetter,
type ShallowRef,
} from "vue";
const DEFAULT_INTERVAL = 100;
const MAX_TIMER_INTERVAL = 2_147_483_647;
export interface AnimationOptions {
/**
* Time between ticks in milliseconds.
* @default 100
*/
interval?: number;
/**
* Whether the animation is running. When set to `false`, the animation stops.
* When toggled back to `true`, all values reset to `0`.
* @default true
*/
isActive?: MaybeRefOrGetter<boolean>;
}
export interface AnimationResult {
/**
* Discrete counter that increments by 1 each interval.
* Useful for indexed sequences like spinner frames.
*/
readonly frame: Readonly<ShallowRef<number>>;
/**
* Total elapsed time in milliseconds since the animation started or was last reset.
* Useful for continuous math-based animations like sine waves.
*/
readonly time: Readonly<ShallowRef<number>>;
/**
* Time in milliseconds since the previous tick.
* Accounts for throttled renders. Useful for physics-based or velocity-driven motion.
*/
readonly delta: Readonly<ShallowRef<number>>;
/**
* Resets `frame`, `time`, and `delta` to `0` and restarts timing from the current moment.
* Useful for one-shot animations triggered by events.
*/
readonly reset: () => void;
}
function normalizeInterval(interval: number | undefined): number {
if (interval === undefined || !Number.isFinite(interval)) return DEFAULT_INTERVAL;
return Math.min(Math.max(1, Math.round(interval)), MAX_TIMER_INTERVAL);
}
/**
* A composable that drives animations. Returns a frame counter, elapsed time,
* frame delta, and a reset function.
*
* @example
* ```vue
* <script setup>
* import { useAnimation, Text } from '@vue-tui/runtime';
*
* const { frame } = useAnimation({ interval: 80 });
* const characters = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
* </script>
* <template>
* <Text>{{ characters[frame % characters.length] }}</Text>
* </template>
* ```
*/
export function useAnimation(options: AnimationOptions = {}): AnimationResult {
const frame = shallowRef(0);
const time = shallowRef(0);
const delta = shallowRef(0);
let timer: ReturnType<typeof setInterval> | undefined;
let startTime = 0;
let lastTickTime = 0;
const currentInterval = normalizeInterval(options.interval);
function tick() {
const now = performance.now();
frame.value++;
time.value = now - startTime;
delta.value = now - lastTickTime;
lastTickTime = now;
}
function start() {
stop();
startTime = performance.now();
lastTickTime = startTime;
frame.value = 0;
time.value = 0;
delta.value = 0;
timer = setInterval(tick, currentInterval);
}
function stop() {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
}
function reset() {
const wasActive = timer !== undefined;
stop();
frame.value = 0;
time.value = 0;
delta.value = 0;
if (wasActive) start();
}
// Watch isActive — when toggled to true, start (which resets values);
// when toggled to false, stop (values freeze).
const isActive = options.isActive ?? true;
watch(
() => toValue(isActive),
(active) => {
if (active) start();
else stop();
},
{ immediate: true, flush: "sync" },
);
onScopeDispose(stop);
return { frame, time, delta, reset };
}