指南

动画

nsh 中的所有动画都运行在 UI 线程上。你在 JS 中描述一次,平台就逐帧播放(iOS 上是 Core Animation 和 display link,Android 上是 UI 线程的动画器),不再回调 JS。繁忙的 JS 线程不会让动画卡顿,动画运行期间也不消耗任何 JS。

guide-animation.tsx 在线编辑 在新标签页打开
import { mount, signal, Text, View } from "nsh";
import { Animated, createNativeValue, Easing, LayoutAnimation } from "nsh/animated";

const TRACK = 250;
const muted = "#57606a";
const palette = ["#fd8c73", "#f2cc60", "#4ac26b", "#54aeff", "#8250df"];

function Chip(props: { label: string; onPress: () => void }) {
  return (
    <View onPress={props.onPress} activeOpacity={0.6} style={{ paddingHorizontal: 12, paddingVertical: 7, borderRadius: 14, backgroundColor: "#ddf4ff" }}>
      <Text style={{ color: "#0969da", fontWeight: "600" }}>{props.label}</Text>
    </View>
  );
}

function App() {
  // 1. A style transition: change the style, the platform animates it.
  const on = signal(false);

  // 2. Native values: timing and spring, driven on the UI thread.
  const timed = createNativeValue(0);
  const sprung = createNativeValue(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 = palette.map(() => createNativeValue(0));
  const spin = createNativeValue(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 wide = signal(false);

  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontWeight: "600" }}>Transition (style changes animate)</Text>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
        <View
          onPress={() => on.set(!on())}
          style={{
            width: 64,
            height: 64,
            borderRadius: on() ? 32 : 12,
            backgroundColor: on() ? "#8250df" : "#0969da",
            opacity: on() ? 0.6 : 1,
            transform: [{ rotate: on() ? "45deg" : "0deg" }],
            transitionProperty: "all",
            transitionDuration: 400,
            transitionTimingFunction: "ease-in-out",
          }}
        />
        <View class="size-10 rounded-full border-4 border-zinc-300 border-t-blue-600 animate-spin" />
        <View class="size-10 rounded-full bg-emerald-500 animate-pulse" />
        <Text style={{ flex: 1, color: muted, fontSize: 13 }}>Tap the square. animate-spin / animate-pulse run natively.</Text>
      </View>

      <Text style={{ fontWeight: "600" }}>timing and spring</Text>
      <View style={{ gap: 8 }}>
        <View style={{ width: 20, height: 20, borderRadius: 10, backgroundColor: "#0969da", transform: [{ translateX: timed }] }} />
        <View style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: "#bf3989", 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: ["#d0d7de", "#1a7f37"] }),
            transform: [{ scaleX: timed.interpolate({ inputRange: [0, TRACK], outputRange: [0.1, 1] }) }],
            transformOrigin: "left",
          }}
        />
        <Chip label="Move" onPress={run} />
      </View>

      <Text style={{ fontWeight: "600" }}>Animated.sequence / stagger</Text>
      <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>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
        <Chip label="Play" onPress={play} />
        <Chip label="Stop" onPress={() => show.stop()} />
        <Text style={{ color: muted }}>{status()}</Text>
      </View>

      <Text style={{ fontWeight: "600" }}>LayoutAnimation</Text>
      <View style={{ flexDirection: wide() ? "column" : "row", gap: 8 }}>
        <Each of={palette}>
          {(color) => <View style={{ height: wide() ? 18 : 40, flex: wide() ? undefined : 1, borderRadius: 6, backgroundColor: color() }} />}
        </Each>
      </View>
      <Chip
        label={wide() ? "Back to a row" : "Stack them"}
        onPress={() => {
          LayoutAnimation.configureNext(LayoutAnimation.Presets.spring);
          wide.set(!wide());
        }}
      />
    </View>
  );
}

mount(App);

共有四种工具,从最简单到最灵活:

工具适用场景
过渡样式变化时以动画代替跳变:颜色、不透明度、变换。
关键帧动画加载指示器、闪烁的占位块、跳动的提示。
原生值任何由随时间变化的值驱动的效果:弹簧、手势、滚动位置、动画序列。
LayoutAnimation重新渲染后尺寸与位置的变化:条目插入、删除、重新排序,面板展开。

#过渡

给视图设置 transitionProperty 和 transitionDuration,之后这些属性的变化就会从屏幕上的当前值以原生动画过渡到新值。

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);
样式类型说明
transitionPropertystring参与动画的属性,用逗号分隔:"all"、"colors"、"opacity"、"transform"、"box-shadow"、"border-radius" 等。
transitionDurationnumber | "150ms" | "0.2s"数字表示毫秒,或 CSS 时间。
transitionDelaynumber | "150ms" | "0.2s"开始前的延迟。
transitionTimingFunction"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | "cubic-bezier(…)"缓动曲线。

可过渡的属性按组划分:颜色(背景、边框、轮廓、阴影和文字的颜色,渐变色标)、不透明度、变换、阴影(box-shadow)以及形状(圆角和边框宽度)。CSS 名称及其驼峰形式都可以用(background-color、backgroundColor、transform、scale 等)。布局不会过渡:尺寸与位置的变化请用 LayoutAnimation。

用 Tailwind 时对应的是 transition(颜色、不透明度、阴影和变换)、transition-all、transition-colors、transition-opacity、transition-transform、transition-shadow,再配合 duration-*、delay-* 和 ease-*。与 active: 变体结合,就得到带动画的按下状态:

<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 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" />
样式类型说明
animationName"none" | "spin" | "ping" | "pulse" | "bounce"动画名称。
animationDurationnumber | "1s" | …一次迭代的时长。
animationDelaynumber | "200ms" | …开始前的延迟。
animationTimingFunction同 transitionTimingFunction缓动曲线。
animationIterationCountnumber | "infinite"默认 "infinite"。

Tailwind:animate-spin、animate-ping、animate-pulse、animate-bounce、animate-none。

#原生值

原生值是一个存在于 UI 线程上的数字。用 nsh/animated 中的 createNativeValue 创建,把它绑定到样式,再让它动起来;绑定它的视图每一帧都跟随它,而无需重新渲染。

import { mount, Text, View } from "nsh";
import { createNativeValue, Easing } from "nsh/animated";

function App() {
  const x = createNativeValue(0);
  const opacity = createNativeValue(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);

可以绑定的属性: opacity、backgroundColor(通过插值得到颜色),以及 transform 中的 translateX、translateY、scale、scaleX、scaleY、rotate、rotateX、rotateY 和 rotateZ。原生值还可以驱动画布绘制,接收滚动偏移(scrollValue)和手势(pan、pinch、rotation)。

原生值由绑定它的视图保持存活,所以不会因为某个 JS 变量超出作用域而解除绑定。在拥有它的组件中创建原生值(如果每个实例都需要自己的值,就不要放在模块顶层)。

#NativeValue 的方法

方法说明
set(value)跳到 value,停止正在进行的动画。
stop()让正在运行的动画停在原处。
timing(to, { duration?, easing?, delay? })在 duration 毫秒内动画到 to(默认 300 毫秒,缓动 "easeInOut")。
spring(to, { stiffness?, damping?, mass?, velocity? })以弹簧运动到 to(默认 stiffness 170、damping 26、mass 1)。
decay({ velocity?, deceleration? })以 velocity(每秒单位数)开始滑行并逐渐减速(默认每毫秒 deceleration 0.998)。

每个动画都返回 Promise<boolean>:完成时为 true,被打断时(stop()、set(),或同一个值上启动了新动画)为 false。在运动中的值上启动新动画会平滑地接替旧动画:没有指定 velocity 的 spring 或 decay 会沿用该值当前的速度。

// 弹簧参数: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 })} />

#插值

value.interpolate({ inputRange, outputRange }) 通过分段线性映射把值转换为数字、颜色或角度,并返回一个可以绑定的新节点。一个值可以驱动多个属性。

const t = createNativeValue(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()) }) },
    ],
  }}
/>
选项说明
inputRange递增的输入节点。
outputRange每个节点对应一个输出:数字、颜色("#rrggbb"、"rgba(…)"、颜色名)或角度("45deg"、"1rad")。
extrapolate输入范围之外:"clamp"(默认,截断)、"extend"(延续两端的线段)或 "identity"(直接输出输入值)。
extrapolateLeft、extrapolateRight同上,只作用于一侧。
easing塑造每一段的曲线。

#算术运算与 diffClamp

节点之间也可以做算术运算,同样在原生端执行:a.add(b)、a.sub(b)、a.mul(b)、a.div(b)、a.mod(b)(当 b 为正时结果不为负,例如 0..360 的角度),其中 b 可以是节点或数字。Animated.add、subtract、multiply、divide 和 modulo 是对应的函数形式。

node.diffClamp(min, max) 跟随节点的变化量,同时把结果限制在 min..max 之内。把它用在滚动偏移上,就得到内容上滑时隐藏、一往下滑就重新出现的标题栏:

const scroll = createNativeValue(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>

#组合动画

Animated 用步骤搭建动画,并在原生端运行整棵动画树:每个步骤在上一步结束的那一刻就在 UI 线程上开始,没有间隙,中间也没有 JS。

import { Animated, createNativeValue, Easing } from "nsh/animated";

const ys = [0, 1, 2, 3, 4].map(() => createNativeValue(0));
const fade = createNativeValue(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"));
函数说明
Animated.timing(value, { toValue, duration?, easing?, delay? })一个 timing 步骤。
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? })一个弹簧步骤。
Animated.decay(value, { velocity?, deceleration? })一个衰减步骤。
Animated.sequence(list)依次执行。
Animated.parallel(list, { stopTogether? })同时执行。
Animated.stagger(ms, list)同时执行,但每个比前一个晚 ms 毫秒开始。
Animated.delay(ms)等待。
Animated.loop(animation, { iterations?, resetBeforeIteration? })重复(默认无限次)。

CompositeAnimation 提供 start(callback?)(同时返回表示是否完成的 promise;再次调用会重新运行)、stop()(停在原处)和 reset()(停止,并把各个值恢复到开始时的状态)。回调对整棵动画树只调用一次。

#Easing

nsh/animated 中的 Easing 提供常用的缓动曲线。它们是在原生端求值的数据,而不是 JS 函数。

Easing说明
Easing.linear、Easing.ease匀速;CSS 的 ease。
Easing.quad、Easing.cubic、Easing.poly(n)t²、t³、tⁿ。
Easing.sin、Easing.circle、Easing.exp正弦、圆形、指数。
Easing.back(s?)先回拉再前进(过冲量 s,默认 1.70158)。
Easing.elastic(bounciness?)弹性过冲。
Easing.bounce结尾处弹跳。
Easing.bezier(x1, y1, x2, y2)三次贝塞尔曲线,与 CSS 相同。
Easing.in(e)、Easing.out(e)、Easing.inOut(e)曲线本身都是 “in” 形式;out 和 inOut 把它们反转。

凡是接受缓动的地方,也可以传入名称("linear"、"ease"、"easeIn"、"easeOut"、"easeInOut")或贝塞尔数组 [x1, y1, x2, y2]。

x.timing(1, { easing: Easing.out(Easing.back()) });
x.timing(1, { easing: "easeOut" });
x.timing(1, { easing: [0.2, 0.8, 0.2, 1] });

#LayoutAnimation

LayoutAnimation.configureNext(config) 以原生动画呈现下一次更新中的布局变化:移动或改变尺寸的视图平滑地到达新位置,新视图淡入(或缩放)出现,被删除的视图淡出。在改变状态之前调用它。

import { signal } from "nsh";
import { LayoutAnimation } from "nsh/animated";

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));
};
成员说明
configureNext(config, onEnd?)为下一次提交的变化添加动画;完成后调用 onEnd。
create(duration, type?, property?)三个阶段使用同一曲线的配置。
Presets.easeInEaseOut、Presets.linear、Presets.spring现成的配置。
easeInEaseOut(onEnd?)、linear(onEnd?)、spring(onEnd?)使用预设调用 configureNext。
Types、Properties构造配置时用的名称。

配置为 { duration, create?, update?, delete? }:create 用于插入的视图,update 用于移动或改变尺寸的视图,delete 用于删除的视图。每个阶段为 { type?, property?, duration?, delay?, springDamping? },其中 type 为 "easeInEaseOut"、"spring"、"linear"、"easeIn" 或 "easeOut";property 是出现和消失的视图做什么动画("opacity"、"scaleXY"、"scale");springDamping(0..1,默认 0.7)决定弹簧的过冲程度。

LayoutAnimation.configureNext({
  duration: 300,
  create: { type: "easeOut", property: "opacity" },
  update: { type: "spring", springDamping: 0.8 },
  delete: { type: "easeIn", property: "scaleXY" },
});

#各部分运行在哪里

运行于每帧的 JS
过渡、关键帧动画UI 线程无
timing / spring / decay、组合动画UI 线程无;结束时通知 JS
interpolate、算术运算、diffClampUI 线程无
scrollValue、pan / pinch / rotationUI 线程无;开始和结束时通知 JS
LayoutAnimationUI 线程无
requestAnimationFrame 循环、onScroll 处理函数、PanResponderJS,然后重新渲染每一帧

requestAnimationFrame(跟随屏幕刷新的全局函数)只用于无法事先描述的情况,例如在 JS 中运行的物理模拟。凡是能用原生值表达的,都应该用原生值。

#减少动态效果

当用户要求减少动态效果时(iOS 的”减弱动态效果”、Android 的”移除动画”),nsh/accessibility 中的 reduceMotion() 为 true,Tailwind 的 motion-reduce: / motion-safe: 变体随之生效。nsh 不会自行关闭你的动画;用它们来选择更平和的替代方案:

import { View } from "nsh";
import { reduceMotion } from "nsh/accessibility";

const open = () => (reduceMotion() ? panel.set(0) : panel.spring(0));

<View class="motion-safe:animate-bounce" />

见无障碍。

#相关内容