文档

动画

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

guide-animation.tsx
guide-animation.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { Animated, createAnimatedValue, Easing, LayoutAnimation } from "nsh/animated";
import { desktop, 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}
      activeStyle={{ opacity: 0.6 }}
      style={
        desktop
          ? { ...ui().raised, alignSelf: "flex-start", height: size.control, justifyContent: "center", paddingHorizontal: 12, borderRadius: 8 }
          : { ...ui().raised, paddingHorizontal: 12, paddingVertical: 7, borderRadius: 10 }
      }
    >
      <Text style={{ color: ui().text, fontWeight: "600", fontSize: desktop ? 13 : undefined }}>{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: 400,
            transitionTimingFunction: "ease-in-out",
          }}
        />
        <View class="size-10 rounded-full border-4 animate-spin" style={{ borderColor: ui().border, borderTopColor: ui().accent }} />
        <View class="size-10 rounded-full animate-pulse" 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);

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

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

#过渡

指明要动画的属性和时长(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(),
    )
}
样式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()))

过渡从屏幕上的当前值开始,所以中途再次改变目标时,动画会平滑地转向。

#关键帧动画

内置四种以原生方式运行、默认无限循环的关键帧动画: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()
样式Rust类型说明
animationName.animate_spin()、.animate_ping()、.animate_pulse()、.animate_bounce()、.animate_none(),或 .animation(KeyframeAnimation::new(Keyframes::Spin))"none" | "spin" | "ping" | "pulse" | "bounce"动画名称。
animationDurationKeyframeAnimation::duration(ms)number | "1s" | …一次迭代的时长。
animationDelayKeyframeAnimation::delay(ms)number | "200ms" | …开始前的延迟。
animationTimingFunctionKeyframeAnimation::easing([x1, y1, x2, y2])同 transitionTimingFunction缓动曲线。
animationIterationCountKeyframeAnimation::iterations(n)number | "infinite"默认 "infinite"。

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

#动画值

动画值是一个存在于 UI 线程上的数字。创建它(Rust 中用 animated_value(..),TSX 中用 nsh/animated 的 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))))
}

可以绑定的属性: 不透明度(.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);

动画值还可以驱动画布绘制,接收滚动偏移(scrollValue)和手势(pan、pinch、rotation)。

动画值由绑定它的视图和创建它的组件保持存活,所以无论你的变量如何出入作用域,绑定都保持连接。在拥有它的组件中创建动画值(如果每个实例都需要自己的值,就不要放在模块顶层)。

#AnimatedValue 的方法

TSXRust说明
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;

#插值

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

#算术运算与 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))

#组合动画

动画由步骤搭建(Rust 中的 Animation,TSX 中 nsh/animated 的 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" });
});
TSXRust说明
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)Animation::stagger(ms, list)同时执行,但每个比前一个晚 ms 毫秒开始。
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/animated 的 Easing,Rust 中是 Easing 枚举。

TSXRust说明
Easing.linear、Easing.easeEasing::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.expCurve::Sin、Curve::Circle、Curve::Exp正弦、圆形、指数。
Easing.back(s?)Curve::Back(s)先回拉再前进(过冲量 s,默认 1.70158)。
Easing.elastic(bounciness?)Curve::Elastic(bounciness)弹性过冲。
Easing.bounceCurve::Bounce结尾处弹跳。
Easing.bezier(x1, y1, x2, y2)Easing::Bezier(x1, y1, x2, y2)三次贝塞尔曲线,与 CSS 相同。
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));

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::ui::{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);

#LayoutAnimation

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

use nsh_core::ui::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);
    });
};
TSXRust说明
configureNext(config, onEnd?)animate_layout(config)为下一次提交的变化添加动画;在 TSX 中,完成后调用 onEnd。
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, ms))三个阶段使用同一曲线的配置。
Presets.easeInEaseOut、Presets.linear、Presets.springLayoutAnimation::EASE_IN_EASE_OUT、LINEAR、SPRING现成的配置。
easeInEaseOut(onEnd?)、linear(onEnd?)、spring(onEnd?)用预设调用 animate_layout使用预设调用 configureNext。
Types、PropertiesLayoutCurve、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";property 是出现和消失的视图做什么动画("opacity"、"scaleXY"、"scale");springDamping(0..1,默认 0.7;LayoutCurve::Spring { damping })决定弹簧的过冲程度。

use nsh_core::ui::{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)
    }),
});

#各部分运行在哪里

运行于每帧运行你的代码
过渡、关键帧动画UI 线程无
timing / spring / decay、组合动画UI 线程无;结束时通知你的代码
interpolate、算术运算、diffClampUI 线程无
scrollValue、pan / pinch / rotationUI 线程无;开始和结束时通知你的代码
布局动画UI 线程无
requestAnimationFrame 循环(Rust 中的 request_animation_frame)、onScroll 处理函数、PanResponder你的代码,然后更新每一帧

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

#减少动态效果

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

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())

见无障碍。

#相关内容