文档
动画
NSH 中的所有动画都运行在 UI 线程上。你用 Rust 或 TSX 描述一次,平台就逐帧播放(iOS 上是 Core Animation 和 display link,Android 上是 UI 线程的动画器),不再回调你的代码。繁忙的引擎线程不会让动画卡顿,动画运行期间也不消耗你的任何代码。
import { mount, signal, Text, View } from "nsh";
import { reduceMotion } from "nsh";
import { Animated, createAnimatedValue, Easing, LayoutAnimation } from "nsh";
import { button, buttonText, desktop, focusRing, Inspector, Panel, size, Stage, ui, wide } from "./palette";
const TRACK = 250;
// Read when they render, so they follow the theme.
const palette = () => [ui().down, ui().warn, ui().accent, ui().up, ui().info];
const SWATCHES = 5;
function Chip(props: { label: string; onPress: () => void }) {
return (
<View onPress={props.onPress} {...button()}>
<Text style={buttonText()}>{props.label}</Text>
</View>
);
}
function App() {
// 1. A style transition: change the style, the platform animates it.
const on = signal(false);
// 2. Animated values: timing and spring, driven on the UI thread.
const timed = createAnimatedValue(0);
const sprung = createAnimatedValue(0);
let there = false;
const run = () => {
there = !there;
timed.timing(there ? TRACK : 0, { duration: 700, easing: Easing.inOut(Easing.cubic) });
sprung.spring(there ? TRACK : 0, { stiffness: 180, damping: 9 });
};
// 3. A composite: a staggered hop, then all of them spin; runs natively end to end.
const ys = Array.from({ length: SWATCHES }, () => createAnimatedValue(0));
const spin = createAnimatedValue(0);
const status = signal("idle");
const show = Animated.sequence([
Animated.stagger(
70,
ys.map((y) =>
Animated.sequence([
Animated.timing(y, { toValue: -30, duration: 200, easing: Easing.out(Easing.quad) }),
Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
]),
),
),
Animated.timing(spin, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.cubic) }),
]);
const play = () => {
status.set("running…");
show.reset();
show.start(({ finished }) => status.set(finished ? "finished" : "stopped"));
};
// 4. Layout animation: the next commit's frame changes animate.
const stacked = signal(false);
const heading = (title: string) => <Text style={{ fontWeight: "600", color: ui().text }}>{title}</Text>;
const transition = () => (
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View
onPress={() => on.set(!on())}
style={{
width: 64,
height: 64,
borderRadius: on() ? 32 : 12,
backgroundColor: on() ? ui().info : ui().accent,
opacity: on() ? 0.6 : 1,
transform: [{ rotate: on() ? "45deg" : "0deg" }],
transitionProperty: "all",
transitionDuration: reduceMotion() ? 0 : 400,
transitionTimingFunction: "ease-in-out",
}}
hoverStyle={{ opacity: 0.88 }}
focusStyle={focusRing()}
activeStyle={{ opacity: 0.7 }}
/>
<View class="size-10 rounded-full border-4 animate-spin motion-reduce:animate-none" style={{ borderColor: ui().border, borderTopColor: ui().accent }} />
<View class="size-10 rounded-full animate-pulse motion-reduce:animate-none" style={{ backgroundColor: ui().up }} />
<Text style={{ flex: 1, color: ui().muted, fontSize: 13 }}>
{desktop ? "Click the square." : "Tap the square."} animate-spin / animate-pulse run natively.
</Text>
</View>
);
const tracks = () => (
<>
<View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: ui().accent, transform: [{ translateX: timed }] }} />
<View style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: ui().info, transform: [{ translateX: sprung }] }} />
<View
style={{
height: 8,
borderRadius: 4,
// One value, interpolated into a color and a width fraction.
backgroundColor: timed.interpolate({ inputRange: [0, TRACK], outputRange: [ui().border, ui().up] }),
transform: [{ scaleX: timed.interpolate({ inputRange: [0, TRACK], outputRange: [0.1, 1] }) }],
transformOrigin: "left",
width: wide() ? TRACK + 20 : undefined,
}}
/>
</>
);
const move = () => <Chip label="Move" onPress={run} />;
const swatches = () => (
<View style={{ flexDirection: "row", justifyContent: "center", alignItems: "flex-end", gap: 10, height: 64 }}>
<Each of={palette()}>
{(color, i) => (
<View
style={{
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: color(),
transform: [
{ translateY: ys[i()] },
{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "180deg"] }) },
],
}}
/>
)}
</Each>
</View>
);
const playback = () => (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
<Chip label="Play" onPress={play} />
<Chip label="Stop" onPress={() => show.stop()} />
<Text style={{ color: ui().muted, fontSize: desktop ? 13 : undefined }}>{status()}</Text>
</View>
);
const bars = () => (
<View style={{ flexDirection: stacked() ? "column" : "row", gap: 8 }}>
<Each of={palette()}>
{(color) => <View style={{ height: stacked() ? 18 : 40, flex: stacked() ? undefined : 1, borderRadius: 6, backgroundColor: color() }} />}
</Each>
</View>
);
const stack = () => (
<Chip
label={stacked() ? "Back to a row" : "Stack them"}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
stacked.set(!stacked());
}}
/>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 16, paddingTop: 24, gap: 14 }}>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={520} gap={14}>
{heading("Transition (style changes animate)")}
{transition()}
{heading("timing and spring")}
<View style={{ gap: 8 }}>{tracks()}</View>
{heading("Animated.sequence / stagger")}
{swatches()}
{heading("LayoutAnimation")}
{bars()}
</Stage>
<Inspector>
<Panel title="timing · spring">
<View style={{ paddingTop: 6 }}>{move()}</View>
</Panel>
<Panel title="Animated.sequence · stagger">
<View style={{ paddingTop: 6 }}>{playback()}</View>
</Panel>
<Panel title="LayoutAnimation.configureNext">
<View style={{ paddingTop: 6 }}>{stack()}</View>
</Panel>
</Inspector>
</View>
) : (
<>
{heading("Transition (style changes animate)")}
{transition()}
{heading("timing and spring")}
<View style={{ gap: 8 }}>
{tracks()}
{move()}
</View>
{heading("Animated.sequence / stagger")}
{swatches()}
{playback()}
{heading("LayoutAnimation")}
{bars()}
{stack()}
</>
)
}
</View>
);
}
mount(App);共有六种工具,从最现成到最灵活:
| 工具 | 适用场景 |
|---|---|
| 动效 | 视图的入场、退场、吸引注意或循环:现成的动效(fadeInUp、shake、pulse……)或你自己的关键帧,作用于它的不透明度和变换。 |
| 过渡 | 样式变化时以动画代替跳变:颜色、不透明度、变换。 |
| 关键帧动画 | 加载指示器、闪烁的占位块、跳动的提示。 |
| 关键帧 | 让任意动画值像 CSS 那样走过一组关键帧:offset、每段的缓动、迭代次数和方向。 |
| 动画值 | 任何由随时间变化的值驱动的效果:弹簧、手势、滚动位置、动画序列。 |
| 布局动画 | 更新后尺寸与位置的变化:条目插入、删除、重新排序,面板展开。 |
#动效
动效(motion)以原生方式让视图的不透明度和变换走过一组关键帧。在拥有该视图的组件中创建它(Rust 中的 motion(),TSX 中 nsh 的 createMotion()),绑定到视图上,再在它上面播放现成的动效或你自己的动效。enter 让视图从某个动效的起点开始,并立即播放它,于是视图从第一帧起就在入场。
#[component]
pub fn App() -> impl IntoElement {
let card = motion().entering(MotionPreset::FadeInUp);
view().flex_1().items_center().justify_center().child(
view()
.size(px(120.0))
.rounded(px(16.0))
.bg(rgb(0x0969da))
.bind_motion(&card)
.on_press(move || {
card.play(MotionPreset::Shake);
}),
)
}import { mount, View } from "nsh";
import { createMotion } from "nsh";
function App() {
const card = createMotion({ enter: "fadeInUp" });
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<View
onPress={() => card.play("shake")}
style={{ width: 120, height: 120, borderRadius: 16, backgroundColor: "#0969da", ...card.style }}
/>
</View>
);
}
mount(App);动效的每个通道都有一个动画值:opacity(0..1)、translateX 和 translateY(点)、rotate、rotateX 和 rotateY(度)、scale、scaleX 和 scaleY。它们从静止状态开始(不透明度和缩放为 1,其余为 0),并按这个顺序作用:先平移,再旋转,最后缩放,都绕视图的变换原点进行。动效本身没有外观:它只移动你设置好样式的东西。
动效只移动不透明度和变换。展开 m.style 会设置视图的 opacity;想保留你自己的不透明度,就只绑定变换(transform: m.transform)。颜色用动画值和 interpolate 来驱动。
| TSX | Rust | 说明 |
|---|---|---|
createMotion(options?) | motion() | 所有通道处于静止状态的动效。 |
createMotion({ initial: { opacity: 0 } }) | m.value(MotionChannel::Opacity).set(0.0) | 各通道的起始值;未指定的通道从静止状态开始。 |
createMotion({ enter, delay, duration, ... }) | motion().entering(spec) | 从 enter 的起点开始并立即播放它;其余选项修改它的时间参数。enter 设置它所移动的通道,优先于 initial。 |
style={m.style}、m.transform | .bind_motion(&m) | 绑定各通道:m.style 是 { opacity, transform };把 m.transform 展开到带有你自己一个步骤的变换中。一个动效占用视图 8 个变换步骤中的 7 个(scale 合并进 scaleX 和 scaleY),留下一个给你,例如 3D 翻转用的 [{ perspective: 600 }, ...m.transform]。Rust 中动效的变换作用在视图自身的变换之后。 |
m.opacity、m.translateX、… | m.value(MotionChannel::Opacity)、… | 单个通道的动画值,可用于插值或手动驱动。 |
m.play(motion, options?) | m.play(spec) | 播放预设或你自己的动效;新的动效会打断正在播放的那个。播放完成时以 true 兑现(Rust 中为 AnimationEnd)。 |
m.animation(motion, options?) | m.animation(spec) | 同一个动效,作为尚未启动的组合动画,可与其他动画串联、错开或循环。 |
play(motion, { ignoreReduceMotion: true }) | m.play_ignoring_reduce_motion(spec) | 即使用户要求减少动态效果也照常播放。 |
m.stop() | m.stop() | 停在当前位置。 |
m.reset() | m.reset() | 停止,并把所有通道放回静止状态。 |
#预设
现成的动效就是数据:nsh 的 motions(play("fadeInUp") 播放的就是 motions.fadeInUp),以及 Rust 中的 MotionPreset(MotionPreset::FadeInUp.spec())。距离以点为单位,按控件和卡片的尺寸设定;对更大的视图,可以按比例放大动效的数值,或者编写自己的关键帧。swing 等旋转都绕视图的变换原点进行:给摇摆的挂牌加上 transformOrigin: "top"(.origin_top())。
入场(enter):从隐藏或偏离的位置开始,在静止状态结束。
| TSX | Rust | 时长 | 说明 |
|---|---|---|---|
fadeIn | MotionPreset::FadeIn | 300 ms | 淡入。 |
fadeInUp | MotionPreset::FadeInUp | 450 ms | 淡入,同时向上升到位。 |
fadeInDown | MotionPreset::FadeInDown | 450 ms | 淡入,同时向下落到位。 |
fadeInLeft | MotionPreset::FadeInLeft | 450 ms | 淡入,从左侧进入。 |
fadeInRight | MotionPreset::FadeInRight | 450 ms | 淡入,从右侧进入。 |
zoomIn | MotionPreset::ZoomIn | 350 ms | 淡入,从 90 % 放大。 |
popIn | MotionPreset::PopIn | 450 ms | 弹出:从一半大小长大,略超过原尺寸后回落。 |
bounceIn | MotionPreset::BounceIn | 650 ms | 弹跳入场,两次越过目标。 |
slideInUp | MotionPreset::SlideInUp | 400 ms | 从下方滑上来,不淡入(面板、提示条)。 |
slideInDown | MotionPreset::SlideInDown | 400 ms | 从上方滑下来,不淡入。 |
flipInX | MotionPreset::FlipInX | 600 ms | 绕水平轴翻转入场。 |
flipInY | MotionPreset::FlipInY | 600 ms | 绕垂直轴翻转入场。 |
退场(exit):从静止状态开始,在隐藏或偏离的位置结束。
| TSX | Rust | 时长 | 说明 |
|---|---|---|---|
fadeOut | MotionPreset::FadeOut | 250 ms | 淡出。 |
fadeOutUp | MotionPreset::FadeOutUp | 300 ms | 淡出,同时向上离开。 |
fadeOutDown | MotionPreset::FadeOutDown | 300 ms | 淡出,同时向下离开。 |
fadeOutLeft | MotionPreset::FadeOutLeft | 300 ms | 向左淡出。 |
fadeOutRight | MotionPreset::FadeOutRight | 300 ms | 向右淡出。 |
zoomOut | MotionPreset::ZoomOut | 250 ms | 淡出,缩小到 90 %。 |
popOut | MotionPreset::PopOut | 300 ms | 弹走:先微微鼓起,再缩小消失。 |
slideOutDown | MotionPreset::SlideOutDown | 300 ms | 向下滑出视野,不淡出。 |
slideOutUp | MotionPreset::SlideOutUp | 300 ms | 向上滑出视野,不淡出。 |
吸引注意(attention):在静止状态开始,也在静止状态结束。
| TSX | Rust | 时长 | 说明 |
|---|---|---|---|
shake | MotionPreset::Shake | 500 ms | 左右摇晃:密码错误、字段无效。 |
headShake | MotionPreset::HeadShake | 800 ms | 摇头:更温和的“不”。 |
wiggle | MotionPreset::Wiggle | 600 ms | 绕中心扭动:提示有东西值得注意。 |
swing | MotionPreset::Swing | 900 ms | 像挂牌一样从上边缘摆动。 |
pulse | MotionPreset::Pulse | 500 ms | 稍稍变大再恢复:轻微的强调。 |
heartbeat | MotionPreset::Heartbeat | 900 ms | 像心跳一样跳两下:点赞、收藏。 |
rubberBand | MotionPreset::RubberBand | 800 ms | 像橡皮筋一样压扁又拉伸。 |
jello | MotionPreset::Jello | 800 ms | 像果冻一样晃动。 |
tada | MotionPreset::Tada | 900 ms | 先缩小,再边放大边扭动:一份奖励。 |
jump | MotionPreset::Jump | 700 ms | 跳起,落地时压扁一下。 |
flash | MotionPreset::Flash | 800 ms | 闪烁两次。 |
循环(loop):一直重复,直到被停止。
| TSX | Rust | 时长 | 说明 |
|---|---|---|---|
spin | MotionPreset::Spin | 1000 ms | 一圈接一圈地旋转。 |
float | MotionPreset::Float | 1500 ms | 上下漂浮。 |
breathe | MotionPreset::Breathe | 1800 ms | 呼吸:微微变大、变淡,循环往复。 |
sway | MotionPreset::Sway | 1600 ms | 绕中心来回摇摆。 |
bob | MotionPreset::Bob | 1000 ms | 像弹跳的球一样上下起伏。 |
glow | MotionPreset::Glow | 2000 ms | 明暗交替地闪烁。 |
play 的时间选项会覆盖预设的设置(m.play("shake", { duration: 300 })),预设也可以展开到你自己的动效中(m.play({ ...motions.fadeInUp, duration: 600 }))。在 Rust 中修改预设的 spec:MotionPreset::FadeInUp.spec().timing(|t| t.delay(100.0))。
#自定义动效
自定义动效由每个通道的关键帧和一套时间参数(即关键帧的选项)组成。在 TSX 中,可以按通道写关键帧({ opacity: [0, 1] }),也可以像 Web Animations API 那样写一组帧([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }]),每帧可带 offset 和 easing。在帧列表中,没有 offset 的帧按它在列表中的位置排布,每个通道只跟随写到它的那些帧。角度可以写成字符串("45deg"、"0.5turn")。在 Rust 中构建一个 MotionSpec。
let rise = MotionSpec::new(KeyframeTiming::new(400.0))
.values(MotionChannel::Opacity, [0.0, 1.0])
.track(
MotionChannel::TranslateY,
[Keyframe::at(0.0, 24.0).ease(Easing::EaseOut), Keyframe::at(1.0, 0.0)],
);
card.play(rise);
let flip = MotionSpec::new(KeyframeTiming::new(600.0).infinite())
.values(MotionChannel::RotateY, [0.0, 180.0, 360.0]);
card.play(flip);// 按通道写的关键帧。
card.play({
duration: 400,
keyframes: { opacity: [0, 1], translateY: [{ value: 24, easing: "easeOut" }, 0] },
});
// 一组帧。
card.play({
duration: 400,
keyframes: [
{ opacity: 0, translateY: 24, easing: "easeOut" },
{ opacity: 1, translateY: 0 },
],
});
card.play({ duration: 600, iterations: "infinite", keyframes: { rotateY: ["0deg", "180deg", "360deg"] } });#入场与列表的错开
给每一行一个入场动效,延迟随行号增长:同时出现的行会依次进入。给延迟设个上限,这样加在列表靠后位置的行不必久等。
each(items, |item: &Item| item.id, |item, index| {
let delay = index.get_untracked().min(10) as f32 * 60.0;
let row = motion().entering(MotionPreset::FadeInUp.spec().timing(|t| t.delay(delay)));
view()
.rounded_xl()
.bg(ZINC.shade(100))
.p_4()
.bind_motion(&row)
.child(text(move || item.get().title))
})function Row(props: { title: string; index: number }) {
const row = createMotion({ enter: "fadeInUp", delay: Math.min(props.index, 10) * 60 });
return (
<View class="rounded-xl bg-zinc-100 p-4" style={row.style}>
<Text>{props.title}</Text>
</View>
);
}
<Each of={items()} key={(item) => item.id}>
{(item, index) => <Row title={item().title} index={index()} />}
</Each>动效也可以组合:m.animation(..) 把动效变成尚未启动的组合动画,于是 Animated.stagger 能以原生方式运行一组动效,从第一项、最后一项、中间一项或任意下标开始计数。给它们一个隐藏的 initial(或让它们从动效的起点开始),这样轮到之前什么都不会显示。
let cards: Vec<Motion> = (0..5)
.map(|_| {
let m = motion();
m.value(MotionChannel::Opacity).set(0.0);
m
})
.collect();
Animation::stagger_from(60.0, StaggerFrom::Center, cards.iter().map(|m| m.animation(MotionPreset::FadeInUp))).start();const cards = [0, 1, 2, 3, 4].map(() => createMotion({ initial: { opacity: 0 } }));
Animated.stagger(60, cards.map((m) => m.animation("fadeInUp")), { from: "center" }).start();#退场
播放退场动效,等它结束,再移除视图。布局动画让周围的视图补上空位。
let remove = move || {
spawn(async move {
row.play(MotionPreset::FadeOutLeft).await;
animate_layout(LayoutAnimation::EASE_IN_EASE_OUT);
items.update(|items| items.retain(|i| i.id != id));
});
};const remove = async () => {
await row.play("fadeOutLeft");
LayoutAnimation.easeInEaseOut();
items.set(items().filter((i) => i.id !== id));
};动效会自动遵循用户的减少动态效果设置:当用户要求减少动态效果时,动效会立即落在它的终点,不产生移动。
#过渡
指明要动画的属性和时长(Rust 中的 .transition_all().duration(400.0),TSX 中的 transitionProperty 和 transitionDuration),之后这些属性的变化就会从屏幕上的当前值以原生动画过渡到新值。
#[component]
pub fn App() -> impl IntoElement {
let on = signal(false);
view().flex_1().items_center().justify_center().child(
view()
.on_press(move || on.update(|on| *on = !*on))
.size(px(100.0))
.rounded(move || px(if on.get() { 50.0 } else { 16.0 }))
.bg(move || if on.get() { rgb(0x8250df) } else { rgb(0x0969da) })
.rotate(move || deg(if on.get() { 45.0 } else { 0.0 }))
.scale(move || if on.get() { 1.2_f32 } else { 1.0 })
.transition_all()
.duration(400.0)
.ease_in_out(),
)
}import { mount, signal, View } from "nsh";
function App() {
const on = signal(false);
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<View
onPress={() => on.set(!on())}
style={{
width: 100,
height: 100,
borderRadius: on() ? 50 : 16,
backgroundColor: on() ? "#8250df" : "#0969da",
transform: [{ rotate: on() ? "45deg" : "0deg" }, { scale: on() ? 1.2 : 1 }],
transitionProperty: "all",
transitionDuration: 400,
transitionTimingFunction: "ease-in-out",
}}
/>
</View>
);
}
mount(App);| 样式 | Rust | 类型 | 说明 |
|---|---|---|---|
transitionProperty | .transition()、.transition_all()、.transition_colors()、.transition_opacity()、.transition_transform()、.transition_shadow() | string | 参与动画的属性,用逗号分隔:"all"、"colors"、"opacity"、"transform"、"box-shadow"、"border-radius" 等。 |
transitionDuration | .duration(ms) | number | "150ms" | "0.2s" | 数字表示毫秒,或 CSS 时间。 |
transitionDelay | .delay(ms) | number | "150ms" | "0.2s" | 开始前的延迟。 |
transitionTimingFunction | .ease_linear()、.ease_in()、.ease_out()、.ease_in_out()、.ease_bezier(..) | "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | "cubic-bezier(…)" | 缓动曲线。 |
可过渡的属性按组划分:颜色(背景、边框、轮廓、阴影和文字的颜色,渐变色标)、不透明度、变换、阴影(box-shadow)以及形状(圆角和边框宽度)。CSS 名称及其驼峰形式都可以用(background-color、backgroundColor、transform、scale 等)。布局的变化用布局动画来呈现。
用 Tailwind 时对应的是 transition(颜色、不透明度、阴影和变换)、transition-all、transition-colors、transition-opacity、transition-transform、transition-shadow,再配合 duration-*、delay-* 和 ease-*。与按下状态(.active(..)、active:)结合,就得到带动画的按下状态:
view()
.on_press(save)
.rounded_xl()
.bg(BLUE.shade(600))
.px_5()
.py_3()
.transition()
.duration(150.0)
.active(|s| s.scale(0.95).bg(BLUE.shade(800)))
.child(text("Save").font_weight(FontWeight::SEMIBOLD).text_color(white()))<View onPress={save} class="rounded-xl bg-blue-600 px-5 py-3 transition duration-150 active:scale-95 active:bg-blue-800">
<Text class="font-semibold text-white">Save</Text>
</View>过渡从屏幕上的当前值开始,所以中途再次改变目标时,动画会平滑地转向。
#关键帧动画
内置四种以原生方式运行、默认无限循环的关键帧动画:spin(顺时针转一圈)、ping(放大到 2 倍并淡出)、pulse(淡到一半不透明度再恢复)和 bounce(向上跳起自身高度的四分之一再落下)。
view()
.size_8()
.rounded_full()
.border(px(4.0))
.border_color(ZINC.shade(300))
.border_t_color(BLUE.shade(600))
.animate_spin()
view().h_4().w_40().rounded(px(4.0)).bg(ZINC.shade(200)).animate_pulse()<View class="size-8 rounded-full border-4 border-zinc-300 border-t-blue-600 animate-spin" />
<View class="h-4 w-40 rounded bg-zinc-200 animate-pulse" />| 样式 | Rust | 类型 | 说明 |
|---|---|---|---|
animationName | .animate_spin()、.animate_ping()、.animate_pulse()、.animate_bounce()、.animate_none(),或 .animation(KeyframeAnimation::new(Keyframes::Spin)) | "none" | "spin" | "ping" | "pulse" | "bounce" | 动画名称。 |
animationDuration | KeyframeAnimation::duration(ms) | number | "1s" | … | 一次迭代的时长。 |
animationDelay | KeyframeAnimation::delay(ms) | number | "200ms" | … | 开始前的延迟。 |
animationTimingFunction | KeyframeAnimation::easing([x1, y1, x2, y2]) | 同 transitionTimingFunction | 缓动曲线。 |
animationIterationCount | KeyframeAnimation::iterations(n) | number | "infinite" | 默认 "infinite"。 |
Tailwind:animate-spin、animate-ping、animate-pulse、animate-bounce、animate-none。
#动画值
动画值是一个存在于 UI 线程上的数字。创建它(Rust 中用 animated_value(..),TSX 中用 nsh 的 createAnimatedValue),把它绑定到样式,再让它动起来;绑定它的视图每一帧都跟随它,而无需更新。
#[component]
pub fn App() -> impl IntoElement {
let x = animated_value(0.0);
let opacity = animated_value(1.0);
let play = move || {
spawn(async move {
x.timing(240.0, 600.0, Easing::Curve(Curve::Cubic, EaseMode::Out)).await;
opacity.timing(0.3, 200.0, Easing::EaseInOut).await;
x.spring(0.0, Spring::default());
opacity.timing_with(1.0, Timing::default());
});
};
view()
.flex_1()
.justify_center()
.p_6()
.gap_6()
.child(
view()
.size(px(60.0))
.rounded(px(12.0))
.bg(rgb(0x0969da))
.bind_opacity(&opacity)
.bind_translate_x(&x),
)
.child(view().on_press(play).child(text("Play").text_color(rgb(0x0969da))))
}import { mount, Text, View } from "nsh";
import { createAnimatedValue, Easing } from "nsh";
function App() {
const x = createAnimatedValue(0);
const opacity = createAnimatedValue(1);
return (
<View style={{ flex: 1, justifyContent: "center", padding: 24, gap: 24 }}>
<View style={{ width: 60, height: 60, borderRadius: 12, backgroundColor: "#0969da", opacity, transform: [{ translateX: x }] }} />
<View
onPress={async () => {
await x.timing(240, { duration: 600, easing: Easing.out(Easing.cubic) });
await opacity.timing(0.3, { duration: 200 });
x.spring(0);
opacity.timing(1);
}}
>
<Text style={{ color: "#0969da" }}>Play</Text>
</View>
</View>
);
}
mount(App);可以绑定的属性: 不透明度(.bind_opacity(..));背景色、边框颜色(所有边)和文字颜色(整段文字),通过插值得到颜色(.bind_background(..)、.bind_border_color(..)、.bind_color(..));阴影的颜色、不透明度和半径(.bind_shadow_color(..) 等);以及变换步骤 translateX、translateY、scale、scaleX、scaleY、rotate、rotateX、rotateY 和 rotateZ(.bind_translate_x(..)、.bind_translate_y(..)、.bind_scale(..)、以度为单位的 .bind_rotate(..)、.bind_transform(kind, ..))。聚焦环或闪绿的价格就是绑定到边框或文字颜色的颜色插值:
let flash = animated_value(0.0);
let color = flash.interpolate_colors(&[0.0, 1.0], &[rgb(0x1f2328), rgb(0x1a7f37)]);
text(move || price.get().to_string()).bind_color(&color)
// 价格更新时:flash.set(1.0); flash.timing(0.0, 600.0, Easing::EaseInOut);const flash = createAnimatedValue(0);
const color = flash.interpolate({ inputRange: [0, 1], outputRange: ["#1f2328", "#1a7f37"] });
<Text style={{ color }}>{price()}</Text>
// 价格更新时:flash.set(1); flash.timing(0, { duration: 600 });动画值还可以驱动画布绘制,接收滚动偏移(scrollValue)和手势(pan、pinch、rotation)。
动画值由绑定它的视图和创建它的组件保持存活,所以无论你的变量如何出入作用域,绑定都保持连接。在拥有它的组件中创建动画值(如果每个实例都需要自己的值,就不要放在模块顶层)。
#AnimatedValue 的方法
| TSX | Rust | 说明 |
|---|---|---|
set(value) | set(value) | 跳到 value,停止正在进行的动画。 |
stop() | stop() | 让正在运行的动画停在原处;在 TSX 中会以停下时的值 resolve。 |
getValue() | value() | 当前值,在它运动所在的 UI 线程上读取(一帧之内给出结果):TSX 中是 promise,Rust 中是可 await 的结果。 |
setOffset(offset) | set_offset(offset) | 在显示值上加上 offset:绑定看到的是值 + 偏移,而 set、动画和拖动改变的是值本身。 |
flattenOffset() | flatten_offset() | 把偏移加到值上,并把偏移设为 0。 |
extractOffset() | extract_offset() | 把值移入偏移并把值设为 0:下一次拖动就从 0 开始,叠加在上一次停下的位置上。 |
timing(to, { duration?, easing?, delay? }) | timing(to, ms, easing)、timing_with(to, Timing { .. }) | 在 duration 毫秒内动画到 to(默认 300 毫秒,缓动 "easeInOut")。 |
spring(to, { stiffness?, damping?, mass?, velocity? }) | spring(to, Spring { .. }) | 以弹簧运动到 to(默认 stiffness 170、damping 26、mass 1)。 |
decay({ velocity?, deceleration?, clamp?, bounce? }) | decay(velocity, deceleration)、decay_within(velocity, deceleration, (min, max), bounce) | 以 velocity(每秒单位数)开始滑行并逐渐减速(默认每毫秒 deceleration 0.998)。设置范围 [min, max] 时,它停在边界上,或以 bounce(0..1)保留的速度从边界反弹。 |
每个动画都给出一个可以 await 的结果:完成时为 true,被打断时(stop()、set(),或同一个值上启动了新动画)为 false。在 TSX 中它是 Promise<boolean>,在 Rust 中是 AnimationEnd。在运动中的值上启动新动画会平滑地接替旧动画:没有指定速度的弹簧或衰减会沿用该值当前的速度。
// 弹簧参数:damping 越小越容易过冲,stiffness 越大越快。
x.spring(200.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() }); // 晃动
x.spring(200.0, Spring { stiffness: 400.0, damping: 30.0, ..Spring::default() }); // 干脆,不过冲
// 以拖动的松手速度滑行到停止。
view().pan_values(Some(&x), None).on_pan_end(move |e: &PanEvent| {
x.decay(Some(e.velocity_x), 0.998);
})
// 留在轨道上、在两端柔和反弹的滑行。
x.decay_within(Some(e.velocity_x), 0.998, (0.0, 300.0), 0.3);
// 值现在在哪里(它在 UI 线程上运动,所以结果是异步的)。
let at = x.value().await;// 弹簧参数:damping 越小越容易过冲,stiffness 越大越快。
x.spring(200, { stiffness: 180, damping: 8 }); // 晃动
x.spring(200, { stiffness: 400, damping: 30 }); // 干脆,不过冲
// 以拖动的松手速度滑行到停止。
<View pan={{ x }} onPanEnd={(e) => x.decay({ velocity: e.velocityX })} />
// 留在轨道上、在两端柔和反弹的滑行。
x.decay({ velocity: e.velocityX, clamp: [0, 300], bounce: 0.3 });
// 值现在在哪里(它在 UI 线程上运动,所以结果是异步的)。
const where = await x.getValue();#关键帧
keyframes 像 CSS 关键帧那样让一个值走过一组关键帧:TSX 中是 value.keyframes(frames, options),Rust 中是 value.keyframes(frames, KeyframeTiming)(作为组合动画的一步时用 Animated.keyframes(value, { keyframes, ... }) 和 Animation::keyframes(..))。关键帧可以是一个数字(或 "90deg" 这样的角度字符串),与其他关键帧均匀分布;也可以是 { offset, value, easing }(Rust 中为 Keyframe::new(value)、Keyframe::at(offset, value)、.ease(easing))。
let y = animated_value(0.0);
y.keyframes(
[Keyframe::new(0.0), Keyframe::at(0.3, -20.0).ease(Easing::EaseIn), Keyframe::new(0.0)],
KeyframeTiming::new(600.0).easing(Easing::EaseOut).iterations(Some(3)),
);
// 每秒跳一格的时钟指针。
let hand = animated_value(0.0);
hand.keyframes(
[Keyframe::new(0.0), Keyframe::new(360.0)],
KeyframeTiming::new(60_000.0).easing(Easing::Steps(60, StepPosition::JumpEnd)).infinite(),
);const y = createAnimatedValue(0);
y.keyframes([0, { offset: 0.3, value: -20, easing: "easeIn" }, 0], { duration: 600, easing: "easeOut", iterations: 3 });
// 每秒跳一格的时钟指针。
const hand = createAnimatedValue(0);
hand.keyframes([0, 360], { duration: 60_000, easing: Easing.steps(60), iterations: "infinite" });| 选项 | Rust(KeyframeTiming) | 默认值 | 说明 |
|---|---|---|---|
duration | KeyframeTiming::new(ms) | 300 | 一次迭代的时长,单位毫秒。 |
delay | .delay(ms) | 0 | 第一次迭代前的等待。 |
easing | .easing(easing) | CSS ease | 没有自带缓动的每一段所用的曲线。 |
iterations | .iterations(Some(n))、.infinite() | 1 | 迭代次数,或 "infinite"。 |
direction | .direction(PlaybackDirection::Alternate) | "normal" | "normal"、"reverse"、"alternate" 或 "alternate-reverse"。 |
它们的播放方式与 CSS 和 Web Animations API 一致:
- 缓动作用于每一段,而不是整体:关键帧的
easing决定从它开始的那一段,时间参数的easing决定其余各段。 - 没有 offset 的关键帧在相邻关键帧之间均匀分布;第一个默认为 0,最后一个默认为 1。
- 每次迭代都先跳到它的第一个关键帧。没有 offset 为 0 的关键帧时,值从它当前的位置出发。
- 在
delay期间,值已经停在第一个关键帧上(CSSanimation-fill-mode: backwards),因此延迟的入场显示的是它的第一帧,而不是静止状态的视图。动效同样如此。 reverse以及alternate中反向的迭代会倒着播放各段,每段的曲线也随之反转。- 值停在最后一次迭代结束的位置。播放完成时结果为
true,被打断时为false。
#插值
interpolate 通过分段线性映射把值转换为数字、颜色或角度,并返回一个可以绑定的新节点。一个值可以驱动多个属性。在 Rust 中,interpolate(&input, &output) 映射为数字,interpolate_colors(&input, &colors) 映射为颜色,interpolate_with(&input, output, Interpolation { .. }) 接受外推和缓动;在 TSX 中,value.interpolate({ inputRange, outputRange }) 接受全部选项。
let t = animated_value(0.0);
view()
.bind_background(&t.interpolate_colors(&[0.0, 1.0, 2.0], &[rgb(0x54aeff), rgb(0x8250df), rgb(0xcf222e)]))
.bind_opacity(&t.interpolate(&[0.0, 1.0], &[0.4, 1.0]))
.bind_rotate(&t.interpolate(&[0.0, 1.0], &[0.0, 180.0]))
.bind_scale(&t.interpolate_with(
&[0.0, 1.0],
AnimationOutput::Numbers(vec![0.6, 1.2]),
Interpolation {
easing: Easing::Curve(Curve::Back(1.70158), EaseMode::Out),
..Interpolation::default()
},
))const t = createAnimatedValue(0);
<View
style={{
backgroundColor: t.interpolate({ inputRange: [0, 1, 2], outputRange: ["#54aeff", "#8250df", "#cf222e"] }),
opacity: t.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }),
transform: [
{ rotate: t.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "180deg"] }) },
{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1.2], easing: Easing.out(Easing.back()) }) },
],
}}
/>| 选项 | Rust | 说明 |
|---|---|---|
inputRange | 输入切片 | 递增的输入节点。 |
outputRange | 输出切片,或 AnimationOutput | 每个节点对应一个输出:数字、颜色("#rrggbb"、"rgba(…)"、颜色名)或角度("45deg"、"1rad")。 |
extrapolate | Interpolation::extrapolate(Extrapolate::Extend) | 输入范围之外:"clamp"(默认,截断)、"extend"(延续两端的线段)或 "identity"(直接输出输入值)。 |
extrapolateLeft、extrapolateRight | extrapolate_left、extrapolate_right | 同上,只作用于一侧。 |
easing | easing | 塑造每一段的曲线。 |
#算术运算与 diffClamp
节点之间也可以做算术运算,同样在原生端执行:a.add(b)、a.sub(b)、a.mul(b)、a.div(b)、a.mod(b)(Rust 中为 a.modulo(b);当 b 为正时结果不为负,例如 0..360 的角度),其中 b 可以是节点或数字。在 TSX 中,Animated.add、subtract、multiply、divide 和 modulo 是对应的函数形式。
diffClamp(min, max)(diff_clamp)跟随节点的变化量,同时把结果限制在 min..max 之内。把它用在滚动偏移上,就得到内容上滑时隐藏、一往下滑就重新出现的标题栏:
let offset = animated_value(0.0);
let header = offset.diff_clamp(0.0, 56.0);
scroll().flex_1().scroll_value(&offset)
view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&header.mul(-1.0))const scroll = createAnimatedValue(0);
const header = scroll.diffClamp(0, 56);
<Scroll style={{ flex: 1 }} scrollValue={scroll}>…</Scroll>
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: 56, transform: [{ translateY: header.mul(-1) }] }}>…</View>加上 { snap: true }(Rust 中为 diff_clamp_with(min, max, DiffClamp::snapping()))后,滚动停止时只显示了一部分的标题栏会把移动做完:它在 UI 线程上用 200 ms 停到完全显示或完全隐藏,取较近的一端。只有当页面滚动的距离至少达到标题栏的高度时它才会隐藏,因此滚动距离不足的页面会显示整个标题栏。移动过程中再次滚动,标题栏会从当前位置继续跟随滚动。
let header = offset.diff_clamp_with(0.0, 56.0, DiffClamp::snapping());const header = scroll.diffClamp(0, 56, { snap: true });#组合动画
动画由步骤搭建(Rust 中的 Animation,TSX 中 nsh 的 Animated),整棵动画树在原生端运行:每个步骤在上一步结束的那一刻就在 UI 线程上开始,没有间隙,中间也不运行你的代码。
let ys: Vec<AnimatedValue> = (0..5).map(|_| animated_value(0.0)).collect();
let fade = animated_value(1.0);
let hop = Animation::stagger(
80.0,
ys.iter().map(|y| {
Animation::sequence([
Animation::timing(y, -30.0, Timing::new(200.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
])
}),
);
let breathe = Animation::sequence([
Animation::timing(&fade, 0.3, Timing::new(400.0, Easing::EaseInOut)),
Animation::timing(&fade, 1.0, Timing::new(400.0, Easing::EaseInOut)),
])
.looped(None);
let landed = hop.start();
spawn(async move {
status.set(if landed.await { "landed" } else { "stopped" });
});import { Animated, createAnimatedValue, Easing } from "nsh";
const ys = [0, 1, 2, 3, 4].map(() => createAnimatedValue(0));
const fade = createAnimatedValue(1);
const hop = Animated.stagger(
80,
ys.map((y) =>
Animated.sequence([
Animated.timing(y, { toValue: -30, duration: 200, easing: Easing.out(Easing.quad) }),
Animated.spring(y, { toValue: 0, stiffness: 300, damping: 12 }),
]),
),
);
const breathe = Animated.loop(
Animated.sequence([
Animated.timing(fade, { toValue: 0.3, duration: 400 }),
Animated.timing(fade, { toValue: 1, duration: 400 }),
]),
);
hop.start(({ finished }) => console.log(finished ? "landed" : "stopped"));| TSX | Rust | 说明 |
|---|---|---|
Animated.timing(value, { toValue, duration?, easing?, delay? }) | Animation::timing(&value, to, Timing { .. }) | 一个 timing 步骤。 |
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? }) | Animation::spring(&value, to, Spring { .. }) | 一个弹簧步骤。 |
Animated.decay(value, { velocity?, deceleration?, clamp?, bounce? }) | Animation::decay(&value, velocity, deceleration)、Animation::decay_within(..) | 一个衰减步骤。 |
Animated.sequence(list) | Animation::sequence(list) | 依次执行。 |
Animated.parallel(list, { stopTogether? }) | Animation::parallel(list)、.stop_together(..) | 同时执行。 |
Animated.stagger(ms, list, { from? }) | Animation::stagger(ms, list)、Animation::stagger_from(ms, StaggerFrom::Center, list) | 同时执行,但每个比前一个晚 ms 毫秒开始,从 from 开始计数:"first"(默认)、"last"、"center"(从中间向两侧扩散)或一个下标(StaggerFrom::First、Last、Center、Index(i))。 |
Animated.keyframes(value, { keyframes, ...timing }) | Animation::keyframes(&value, frames, KeyframeTiming) | 一步关键帧。 |
Animated.delay(ms) | Animation::delay(ms) | 等待。 |
Animated.loop(animation, { iterations?, resetBeforeIteration? }) | .looped(iterations)、.reset_before_iteration(..) | 重复(默认无限次;Rust 中为 None)。 |
组合动画提供 start()(在 TSX 中也可以带回调,并返回表示是否完成的 promise;在 Rust 中返回可 await 的 bool;再次调用会重新运行)、stop()(停在原处)和 reset()(停止,并把各个值恢复到开始时的状态)。回调对整棵动画树只调用一次。
正在运行的动画不会让它的值保持存活:拥有这些值的组件(它的视图和变量)消失时,无限循环的动画随之停止,结果为 false。
#缓动
缓动曲线是在原生端求值的数据:TSX 中是 nsh 的 Easing,Rust 中是 Easing 枚举。
| TSX | Rust | 说明 |
|---|---|---|
Easing.linear、Easing.ease | Easing::Linear、Easing::Bezier(0.25, 0.1, 0.25, 1.0) | 匀速;CSS 的 ease。 |
Easing.quad、Easing.cubic、Easing.poly(n) | Curve::Quad、Curve::Cubic、Curve::Poly(n) | t²、t³、tⁿ。 |
Easing.sin、Easing.circle、Easing.exp | Curve::Sin、Curve::Circle、Curve::Exp | 正弦、圆形、指数。 |
Easing.back(s?) | Curve::Back(s) | 先回拉再前进(过冲量 s,默认 1.70158)。 |
Easing.elastic(bounciness?) | Curve::Elastic(bounciness) | 弹性过冲。 |
Easing.bounce | Curve::Bounce | 结尾处弹跳。 |
Easing.bezier(x1, y1, x2, y2) | Easing::Bezier(x1, y1, x2, y2) | 三次贝塞尔曲线,与 CSS 相同。 |
Easing.steps(count, position?) | Easing::Steps(count, StepPosition::JumpEnd) | 在 count 个级别之间跳变而不连续移动,与 CSS steps() 相同:逐帧的精灵图、跳动的指针。position 为 "jump-end"(默认,也可写 "end")、"jump-start"("start")、"jump-none" 或 "jump-both"。 |
Easing.in(e)、Easing.out(e)、Easing.inOut(e) | Easing::Curve(curve, EaseMode::In)、EaseMode::Out、EaseMode::InOut | 曲线本身都是 “in” 形式;out 和 inOut 把它们反转。 |
在 TSX 中,凡是接受缓动的地方,也可以传入名称("linear"、"ease"、"easeIn"、"easeOut"、"easeInOut")或贝塞尔数组 [x1, y1, x2, y2];在 Rust 中,Easing::EaseIn、Easing::EaseOut 和 Easing::EaseInOut 就是 CSS 的对应曲线。
x.timing(1.0, 300.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
x.timing(1.0, 300.0, Easing::EaseOut);
x.timing(1.0, 300.0, Easing::Bezier(0.2, 0.8, 0.2, 1.0));x.timing(1, { easing: Easing.out(Easing.back()) });
x.timing(1, { easing: "easeOut" });
x.timing(1, { easing: [0.2, 0.8, 0.2, 1] });Easing.sample(easing, count)(Rust 中的 sample_easing)返回曲线在 0 到 1 之间 count 个等距点上的值,Animated.sampleSpring(config, fps?) / Animated.sampleDecay(config, fps?)(sample_spring、sample_decay)返回弹簧或衰减经过的值,每帧一个(默认每秒 60 帧),从 config.from(0)开始直到结束。它们由运行动画的同一套原生代码计算,所以画出的曲线与动画的实际运动完全一致:
use nsh_core::element::{sample_easing, sample_spring};
let curve = sample_easing(Easing::Curve(Curve::Back(1.70158), EaseMode::Out), 50); // 50 个进度值
let spring = sample_spring(0.0, 1.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() }, 60.0);const curve = Easing.sample(Easing.out(Easing.back()), 50); // 50 个进度值
const spring = Animated.sampleSpring({ toValue: 1, stiffness: 180, damping: 8 });#LayoutAnimation
布局动画以原生动画呈现下一次更新中的布局变化:移动或改变尺寸的视图平滑地到达新位置,新视图淡入(或缩放)出现,被删除的视图淡出。在改变状态之前调用它:Rust 中是 animate_layout(..),TSX 中是 LayoutAnimation.configureNext(config)。
use nsh_core::element::LayoutAnimation;
let items = signal(vec![1, 2, 3]);
let next = stored(4);
let add = move || {
animate_layout(LayoutAnimation::SPRING);
let n = next.with(|n| {
*n += 1;
*n - 1
});
items.update(|i| i.insert(0, n));
};
let remove = move || {
animate_layout(LayoutAnimation::EASE_IN_EASE_OUT);
items.update(|i| {
i.remove(0);
});
};import { signal } from "nsh";
import { LayoutAnimation } from "nsh";
const items = signal([1, 2, 3]);
let next = 4;
const add = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
items.set([next++, ...items()]);
};
const remove = () => {
LayoutAnimation.easeInEaseOut();
items.set(items().slice(1));
};| TSX | Rust | 说明 |
|---|---|---|
configureNext(config, onEnd?) | animate_layout(config) | 为下一次提交的变化添加动画;在 TSX 中,完成后调用 onEnd。 |
create(duration, type?, property?) | LayoutAnimation::all(LayoutTransition::new(curve, ms)) | 三个阶段使用同一曲线的配置。 |
Presets.easeInEaseOut、Presets.linear、Presets.spring | LayoutAnimation::EASE_IN_EASE_OUT、LINEAR、SPRING | 现成的配置。 |
easeInEaseOut(onEnd?)、linear(onEnd?)、spring(onEnd?) | 用预设调用 animate_layout | 使用预设调用 configureNext。 |
Types、Properties | LayoutCurve、LayoutProperty | 构造配置时用的名称。 |
配置为 { duration, create?, update?, delete? }(Rust 中为 LayoutAnimation { create, update, delete }):create 用于插入的视图,update 用于移动或改变尺寸的视图,delete 用于删除的视图。每个阶段为 { type?, property?, duration?, delay?, springDamping? }(LayoutTransition { curve, property, duration_ms, delay_ms }),其中 type 为 "easeInEaseOut"、"spring"、"linear"、"easeIn"、"easeOut" 或 "keyboard"(软键盘自己的曲线,从键盘开始移动时算起,让为它让位的内容与它一起移动;LayoutCurve::Keyboard);property 是出现和消失的视图做什么动画("opacity"、"scaleXY"、"scale");springDamping(0..1,默认 0.7;LayoutCurve::Spring { damping })决定弹簧的过冲程度。
use nsh_core::element::{LayoutAnimation, LayoutCurve, LayoutProperty, LayoutTransition};
animate_layout(LayoutAnimation {
create: Some(LayoutTransition::new(LayoutCurve::EaseOut, 300.0)),
update: Some(LayoutTransition::new(LayoutCurve::Spring { damping: 0.8 }, 300.0)),
delete: Some(LayoutTransition {
property: LayoutProperty::Scale,
..LayoutTransition::new(LayoutCurve::EaseIn, 300.0)
}),
});LayoutAnimation.configureNext({
duration: 300,
create: { type: "easeOut", property: "opacity" },
update: { type: "spring", springDamping: 0.8 },
delete: { type: "easeIn", property: "scaleXY" },
});#各部分运行在哪里
| 运行于 | 每帧运行你的代码 | |
|---|---|---|
| 过渡、关键帧动画 | UI 线程 | 无 |
动效、关键帧、timing / spring / decay、组合动画 | UI 线程 | 无;结束时通知你的代码 |
interpolate、算术运算、diffClamp | UI 线程 | 无 |
scrollValue、pan / pinch / rotation | UI 线程 | 无;开始和结束时通知你的代码 |
| 布局动画 | UI 线程 | 无 |
requestAnimationFrame 循环(Rust 中的 request_animation_frame)、onScroll 处理函数、PanResponder | 你的代码,然后更新 | 每一帧 |
requestAnimationFrame(TSX 中跟随屏幕刷新的全局函数)只用于无法事先描述的情况,例如在你的代码中运行的物理模拟。凡是能用动画值表达的,都应该用动画值。
#减少动态效果
当用户要求减少动态效果时(iOS 的”减弱动态效果”、Android 的”移除动画”),Rust 中的 reduce_motion() 和 TSX 中 nsh 的 reduceMotion() 为 true,Tailwind 的 motion-reduce: / motion-safe: 变体随之生效。
动效会自动遵循这一设置:play 和 enter 会立即落在动效的终点,不产生移动,结果与正常播放完一样为 true。退场依然以隐藏结束,入场依然以静止结束。对承载用户所需信息的动效(例如表示进度的旋转指示器),传入 ignoreReduceMotion: true(Rust 中用 play_ignoring_reduce_motion)。
过渡、关键帧动画、动画值、关键帧和组合动画按原样播放;用它们来选择更平和的替代方案:
let open = move || {
if reduce_motion() {
panel.set(0.0);
} else {
panel.spring(0.0, Spring::default());
}
};
view().style_when(|| !reduce_motion(), |s| s.animate_bounce())import { View } from "nsh";
import { reduceMotion } from "nsh";
const open = () => (reduceMotion() ? panel.set(0) : panel.spring(0));
<View class="motion-safe:animate-bounce" />见无障碍。
#相关内容
nsh的动画 API:TSX 参考。- 触摸与手势:用手指驱动动画值。
- 滚动:
scrollValue。 - 画布:在绘制中使用动画值。
- 样式参考:
transition*与animation*键。