API

nsh/animated

动画 API 在 UI 线程上执行动画。你创建一个动画值(animated value),把它(或由它派生的值)绑定到样式属性上,然后启动动画;此后每一帧都在原生侧计算,不需要运行应用的代码。引擎线程或 JS 线程再忙也不会让动画卡顿,动画的每一帧也不消耗应用任何开销。Rust 代码使用 nsh_core::ui::prelude 中的 animated_value()、Animation、Spring、Timing 和 Easing,用元素的 .bind_* 方法绑定动画值;TSX 导入 nsh/animated。

mod-animated.tsx
mod-animated.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { Animated, createAnimatedValue, Easing } from "nsh/animated";
import { desktop, Panel, Split, ui, wide } from "./palette";

const TRACK = 240;
const curves: [string, Easing][] = [
  ["linear", Easing.linear],
  ["quad out", Easing.out(Easing.quad)],
  ["cubic inOut", Easing.inOut(Easing.cubic)],
  ["back out", Easing.out(Easing.back())],
  ["elastic out", Easing.out(Easing.elastic(1))],
  ["bounce out", Easing.out(Easing.bounce)],
];

function Button(props: { label: string; onPress: () => void }) {
  return (
    <Text
      onPress={props.onPress}
      activeStyle={{ opacity: 0.6 }}
      style={
        desktop
          ? { alignSelf: "flex-start", paddingHorizontal: 12, paddingVertical: 7, borderRadius: 8, overflow: "hidden", ...ui().raised, color: ui().text, fontSize: 13, fontWeight: "600" }
          : {
              paddingHorizontal: 14,
              paddingVertical: 8,
              borderRadius: 10,
              overflow: "hidden",
              ...ui().raised,
              color: ui().text,
              fontWeight: "600",
            }
      }
    >
      {props.label}
    </Text>
  );
}

function App() {
  const dots = curves.map(() => createAnimatedValue(0));
  const spin = createAnimatedValue(0);
  const status = signal(desktop ? "Click a button" : "Tap a button");
  let there = false;

  const race = () => {
    there = !there;
    status.set("running…");
    Animated.parallel(
      dots.map((x, i) => Animated.timing(x, { toValue: there ? TRACK : 0, duration: 1000, easing: curves[i][1] })),
    )
      .start()
      .then((finished) => status.set(finished ? "finished" : "stopped"));
  };

  const turn = () => {
    spin.timing(0);
    spin.timing(1, { duration: 900, easing: Easing.inOut(Easing.cubic) });
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 16 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>Native animation</Text>
      <Split
        gap={16}
        width={360}
        inspector={
          wide() ? (
            <>
              <Panel title="Animated.parallel · six easings">
                <View style={{ gap: 10 }}>
                  <Text style={{ fontSize: 13, color: ui().text }}>{status()}</Text>
                  <Button label="Race" onPress={race} />
                </View>
              </Panel>
              <Panel title="timing · interpolate">
                <View style={{ paddingTop: 6 }}>
                  <Button label="Spin" onPress={turn} />
                </View>
              </Panel>
            </>
          ) : (
            <>
              <View style={{ flexDirection: "row", gap: 10 }}>
                <Button label="Race" onPress={race} />
                <Button label="Spin" onPress={turn} />
              </View>
              <Text style={{ color: ui().muted }}>{status()}</Text>
            </>
          )
        }
      >
      <View style={{ gap: 8 }}>
        <Each of={curves}>
          {(c, i) => (
            <View style={{ gap: 2 }}>
              <Text style={{ fontSize: 12, color: ui().muted }}>{c()[0]}</Text>
              <View style={{ width: TRACK + 16, height: 16, borderRadius: 8, backgroundColor: ui().inset.backgroundColor, boxShadow: ui().inset.boxShadow }}>
                <View
                  style={{
                    width: 16,
                    height: 16,
                    borderRadius: 8,
                    ...ui().accentFill,
                    borderWidth: 0,
                    transform: [{ translateX: dots[i()] }],
                  }}
                />
              </View>
            </View>
          )}
        </Each>
      </View>
      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <View
          style={{
            width: 64,
            height: 64,
            borderRadius: 14,
            backgroundColor: spin.interpolate({ inputRange: [0, 0.5, 1], outputRange: [ui().up, ui().warn, ui().up] }),
            transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
          }}
        />
        <Text style={{ flex: 1, color: ui().muted, fontSize: 13 }}>
          Rotation and color are interpolated from one animated value.
        </Text>
      </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_core::ui::{LayoutAnimation, prelude::*}; // animated_value、Animation、Spring、Timing、Easing、animate_layout

教程见动画。

#动画值可以驱动什么

位置RustTSX
opacity.bind_opacity(&value)style={{ opacity: value }}
backgroundColor.bind_background(&value.interpolate_colors(&[0.0, 1.0], &[white(), black()]))style={{ backgroundColor: value.interpolate({ inputRange: [0, 1], outputRange: ["#fff", "#000"] }) }}
borderColor、color.bind_border_color(&node)、.bind_color(&node)同上:一个颜色节点。borderColor 为所有边着色;Text 的 color 为整段文本着色
shadowColor、shadowOpacity、shadowRadius.bind_shadow_color(&node)、.bind_shadow_opacity(&value)、.bind_shadow_radius(&value)style={{ shadowOpacity: value }}(shadowColor 用颜色节点)
transform.bind_translate_x(&value)、.bind_translate_y、.bind_scale、.bind_rotate(角度)translateX、translateY、scale、scaleX、scaleY、rotate、rotateX、rotateY、rotateZ:transform: [{ translateX: value }]
滚动位置滚动视图或列表上的 .scroll_value(&value)<Scroll scrollValue={value}> 和 <List scrollValue={value}> 每帧把滚动偏移写入该值
手势.pan_values(Some(&x), Some(&y))、.pinch_value(&scale)、.rotation_value(&angle)任意元素上的 pan={{ x, y }}、pinch={{ scale }}、rotation={{ angle }}:手势直接写入这些值(见触摸与手势)
画布绘制调用中的动画参数画布绘制调用中的任意数字或颜色参数

其他样式属性只接受普通值;如果要为布局本身(尺寸、在布局流中的位置)添加动画,请使用 LayoutAnimation。

#createAnimatedValue

function createAnimatedValue(value?: number): AnimatedValue;

创建一个初始值为 value(默认 0)的动画值。Rust:animated_value(value),一个由创建它的组件持有的 Copy 句柄。

use nsh_core::ui::prelude::*;

#[component]
pub fn App() -> impl IntoElement {
    let x = animated_value(0.0);
    let right = stored(false);
    let toggle = move || {
        let to_right = !right.get();
        right.replace(to_right);
        let _ = x.spring(if to_right { 200.0 } else { 0.0 }, Spring::default());
    };
    view()
        .flex_1()
        .p_6()
        .pt_12()
        .gap_4()
        .child(view().size(px(60.0)).rounded(px(12.0)).bg(rgb(0x6366f1)).bind_translate_x(&x))
        .child(text("Move").on_press(toggle))
}

#AnimatedValue

class AnimatedValue extends AnimatedNode {
  constructor(value: number);
  set(value: number): void;
  stop(): Promise<number>;
  getValue(): Promise<number>;
  setOffset(offset: number): void;
  flattenOffset(): void;
  extractOffset(): void;
  timing(to: number, options?: { duration?: number; easing?: Easing; delay?: number }): Promise<boolean>;
  spring(to: number, options?: { stiffness?: number; damping?: number; mass?: number; velocity?: number }): Promise<boolean>;
  decay(options?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): Promise<boolean>;
}
方法Rust说明
set(value)set(value)直接跳到 value,并停止正在运行的动画。
stop()stop(),然后 value().await让正在运行的动画停在当前位置;resolve 为停下处的值。
getValue()value().awaitresolve 为当前值(含偏移),在 UI 线程上读取。
setOffset(offset)set_offset(offset)加到值上的偏移:绑定看到的是值 + 偏移;set、动画和拖动改变的是值本身。
flattenOffset()flatten_offset()把偏移加到值上,并把偏移归 0。
extractOffset()extract_offset()把值移入偏移,并把值归 0(这样下一次拖动从上一次结束的位置开始)。
timing(to, options)timing(to, ms, easing)、timing_with(to, Timing { … })沿缓动曲线动画到 to。duration 单位为毫秒(默认 300),easing(默认 "easeInOut"),delay 单位为毫秒(默认 0)。
spring(to, options)spring(to, Spring { … })以弹簧效果运动到 to。stiffness(默认 170)、damping(默认 26)、mass(默认 1)、velocity(初速度,单位/秒)。
decay(options)decay(velocity, deceleration)、decay_within(velocity, deceleration, (min, max), bounce)以 velocity(单位/秒)为初速度滑行并逐渐减速,每毫秒保留 deceleration(默认 0.998)的速度。适合在快速滑动后使用 onPanEnd 给出的速度。设置 clamp: [min, max] 时,它在边界处停下,或以 bounce(0..1,默认 0)比例的速度反弹。

每个动画方法都返回一个 Promise:动画完成时 resolve 为 true,被停止(stop、set 或同一个值上的另一个动画)时为 false。在 Rust 中返回 AnimationEnd,在任务中 await 它得到同样的 bool。

let opacity = animated_value(1.0);
spawn(async move {
    if opacity.timing(0.0, 200.0, Easing::EaseInOut).await {
        visible.set(false);
    }
});

#AnimatedNode

class AnimatedNode {
  interpolate(config: {
    inputRange: number[];
    outputRange: number[] | string[];
    extrapolate?: Extrapolation;
    extrapolateLeft?: Extrapolation;
    extrapolateRight?: Extrapolation;
    easing?: Easing;
  }): AnimatedNode;
  diffClamp(min: number, max: number): AnimatedNode;
  add(b: AnimatedNode | number): AnimatedNode;
  sub(b: AnimatedNode | number): AnimatedNode;
  mul(b: AnimatedNode | number): AnimatedNode;
  div(b: AnimatedNode | number): AnimatedNode;
  mod(b: AnimatedNode | number): AnimatedNode;
}

type Extrapolation = "extend" | "clamp" | "identity";

原生动画图中的一个节点:一个 AnimatedValue,或由其他节点派生的值。派生节点在其输入变化时在 UI 线程上求值。

方法Rust说明
interpolate(config)interpolate(&input, &output)、interpolate_colors(&input, &colors)、interpolate_with(&input, output, Interpolation { … })按分段区间映射输入。outputRange 可以是数字、颜色("#ff0000"、"rgba(…)")或角度("45deg"、"1rad")。超出 inputRange 时默认钳制(clamp);extrapolate(或 extrapolateLeft / extrapolateRight)可以是 "extend"(沿直线延伸)、"clamp" 或 "identity"(直接输出输入值)。easing 决定每一段的曲线。Rust 接受数字、主题颜色(跟随外观)以及供 bind_rotate 使用的角度(度)。
diffClamp(min, max)diff_clamp(min, max)跟随输入的变化量,但把结果限制在 min..max 内:内容向上滚动时隐藏、一旦向下滚动就重新出现的标题栏。
add、sub、mul、divadd、sub、mul、div与另一个节点或常量做算术运算。
mod(b)modulo(b)取模,b 为正时结果不会为负(比如把角度保持在 0..360)。
let scroll_y = animated_value(0.0);
let header = scroll_y.diff_clamp(0.0, 44.0);

view()
    .flex_1()
    .child(scroll().scroll_value(&scroll_y).flex_1() /* rows */)
    .child(view().h(px(44.0)).bind_translate_y(&header.mul(-1.0)))

#Animated

const Animated: {
  timing(value: AnimatedValue, config: { toValue: number; duration?: number; easing?: Easing; delay?: number }): CompositeAnimation;
  spring(value: AnimatedValue, config: { toValue: number; stiffness?: number; damping?: number; mass?: number; velocity?: number }): CompositeAnimation;
  decay(value: AnimatedValue, config?: { velocity?: number; deceleration?: number; clamp?: [number, number]; bounce?: number }): CompositeAnimation;
  sequence(list: CompositeAnimation[]): CompositeAnimation;
  parallel(list: CompositeAnimation[], config?: { stopTogether?: boolean }): CompositeAnimation;
  stagger(ms: number, list: CompositeAnimation[]): CompositeAnimation;
  delay(ms: number): CompositeAnimation;
  loop(animation: CompositeAnimation, config?: { iterations?: number; resetBeforeIteration?: boolean }): CompositeAnimation;
  add(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  subtract(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  multiply(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  divide(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  modulo(a: AnimatedNode, b: AnimatedNode | number): AnimatedNode;
  diffClamp(a: AnimatedNode, min: number, max: number): AnimatedNode;
  sampleSpring(config: SpringConfig & { from?: number }, fps?: number): number[];
  sampleDecay(config: DecayConfig & { from?: number }, fps?: number): number[];
};

interface CompositeAnimation {
  start(callback?: (result: { finished: boolean }) => void): Promise<boolean>;
  stop(): void;
  reset(): void;
}

Animated 用一个个步骤(timing、spring、decay、sequence、parallel 等)组合出动画。调用 start() 之前什么都不会运行;启动后整棵动画树都在原生侧运行,每一步在上一步结束的那一刻于 UI 线程上开始(步骤之间没有帧间隙,也不需要回到应用的代码)。Rust 用 Animation 组合同样的动画树。

函数Rust说明
timing、spring、decayAnimation::timing(&value, to, Timing)、Animation::spring(&value, to, Spring)、Animation::decay(&value, velocity, deceleration)单个值的单个动画;选项和默认值与 AnimatedValue 的同名方法相同,目标值写作 toValue。
sequence(list)Animation::sequence(list)依次运行这些动画。
parallel(list, { stopTogether })Animation::parallel(list).stop_together(…)同时运行。stopTogether(默认 true)时,停止其中一个会停止全部。
stagger(ms, list)Animation::stagger(ms, list)并行运行,每个比前一个晚 ms 毫秒开始。
delay(ms)Animation::delay(ms)等待;常用于 sequence 中。
loop(animation, { iterations, resetBeforeIteration })animation.looped(iterations).reset_before_iteration(…)重复 animation iterations 次(默认无限次),每次运行前把值恢复到起点,除非 resetBeforeIteration 为 false。
add、subtract、multiply、divide、modulo、diffClamp节点的方法与 AnimatedNode 的对应方法相同,只是写成函数形式。
sampleSpring、sampleDecaynsh_core::ui::sample_spring、sample_decay弹簧或衰减动画逐帧的值(见 Easing)。

CompositeAnimation:

方法Rust(Animation)说明
start(callback?)start(),返回可 await 的 AnimationEnd运行(或再次运行)。整棵动画树只会 resolve 一次(并调用 callback),结果表示它是正常完成还是被停止。
stop()stop()停在当前位置。
reset()reset()停止,并把各个值恢复到启动时的值。
let ys: Vec<AnimatedValue> = (0..4).map(|_| animated_value(0.0)).collect();
let hop = Animation::stagger(
    80.0,
    ys.iter().map(|y| {
        Animation::sequence([
            Animation::timing(y, -36.0, Timing::new(220.0, Easing::Curve(Curve::Quad, EaseMode::Out))),
            Animation::spring(y, 0.0, Spring { stiffness: 300.0, damping: 12.0, ..Spring::default() }),
        ])
    }),
);
let run = hop.start();
spawn(async move { log::info!("{}", if run.await { "done" } else { "stopped" }) });

#Easing

type Easing =
  | "linear" | "ease" | "easeIn" | "easeOut" | "easeInOut"
  | [number, number, number, number]   // 三次贝塞尔曲线 x1, y1, x2, y2
  | EasingCurve;

const Easing: {
  linear: Easing;
  ease: Easing;
  bezier(x1: number, y1: number, x2: number, y2: number): Easing;
  in(e: Easing): Easing;
  out(e: Easing): Easing;
  inOut(e: Easing): Easing;
  sample(easing: Easing, count: number): number[];
  quad: EasingCurve;
  cubic: EasingCurve;
  poly(n?: number): EasingCurve;
  sin: EasingCurve;
  circle: EasingCurve;
  exp: EasingCurve;
  elastic(bounciness?: number): EasingCurve;
  back(s?: number): EasingCurve;
  bounce: EasingCurve;
};

缓动是数据而不是应用的函数:运行时在原生侧对它求值。在 Rust 中它是枚举 Easing:Linear、EaseIn、EaseOut、EaseInOut、Bezier(x1, y1, x2, y2) 和 Curve(curve, mode),其中 Curve 为 Quad、Cubic、Poly(n)、Sin、Circle、Exp、Elastic(bounciness)、Back(s)、Bounce,EaseMode 为 In、Out、InOut。要绘制一条缓动曲线,Easing.sample(easing, count)(Rust:nsh_core::ui::sample_easing)给出它在 0 到 1 之间 count 个等距点上的值;Animated.sampleSpring 和 Animated.sampleDecay(sample_spring、sample_decay)从 from(0)开始逐帧给出弹簧或衰减动画的值(按 fps,默认 60),直到结束。三者都来自动画实际运行的原生代码。在 TSX 中凡是接受 Easing 的地方,都可以传入名称("easeOut")、由四个数字表示的三次贝塞尔曲线,或 Easing 对象中的曲线。

曲线Rust形状
quadCurve::Quadt²
cubicCurve::Cubict³
poly(n)Curve::Poly(n)tⁿ(n 默认为 2)
sinCurve::Sin正弦
circleCurve::Circle圆形
expCurve::Exp指数
elastic(bounciness)Curve::Elastic(bounciness)弹性过冲(bounciness 默认为 1)
back(s)Curve::Back(s)先回拉再前进;s 为过冲量(默认 1.70158)
bounceCurve::Bounce在结尾弹跳

这些曲线都是“in”形式(开始时慢)。Easing.out(curve) 把它反转,Easing.inOut(curve) 让它对称,Easing.in(curve) 保持原样;在 Rust 中这就是 Easing::Curve 的 EaseMode。

let _ = value.timing(1.0, 600.0, Easing::Curve(Curve::Back(1.70158), EaseMode::Out));
let _ = value.timing_with(1.0, Timing { easing: Easing::Bezier(0.2, 0.0, 0.0, 1.0), ..Timing::default() });
let _ = value.timing_with(1.0, Timing { easing: Easing::EaseOut, ..Timing::default() });

#LayoutAnimation

const LayoutAnimation: {
  configureNext(config: LayoutAnimationConfig, onEnd?: () => void): void;
  create(duration: number, type?: LayoutAnimationType, property?: LayoutAnimationProperty): LayoutAnimationConfig;
  Types: { easeInEaseOut; spring; linear; easeIn; easeOut };
  Properties: { opacity; scaleXY };
  Presets: { easeInEaseOut: LayoutAnimationConfig; linear: LayoutAnimationConfig; spring: LayoutAnimationConfig };
  easeInEaseOut(onEnd?: () => void): void;
  linear(onEnd?: () => void): void;
  spring(onEnd?: () => void): void;
};

interface LayoutAnimationConfig {
  duration: number;                // 毫秒
  create?: LayoutAnimationAnimation;    // 插入的视图
  update?: LayoutAnimationAnimation;    // 移动或改变尺寸的视图
  delete?: LayoutAnimationAnimation;    // 移除的视图
}

interface LayoutAnimationAnimation {
  type?: "easeInEaseOut" | "spring" | "linear" | "easeIn" | "easeOut";
  property?: "opacity" | "scaleXY" | "scale";
  duration?: number;
  delay?: number;
  springDamping?: number;          // 0..1,默认 0.7
}

LayoutAnimation.configureNext(config) 在原生侧为下一次提交中的布局变化添加动画:移动或改变尺寸的视图平滑过渡到新位置,新视图出现(按 property 淡入或缩放进入),被移除的视图消失。在会改变布局的状态变化之前调用它。Rust:animate_layout(LayoutAnimation),使用 nsh_core::ui::LayoutAnimation 的预设或它的 create、update、delete 过渡。

成员Rust说明
configureNext(config, onEnd?)animate_layout(config)为下一次提交的变化添加动画;结束时调用 onEnd。
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, duration))三个阶段使用同一条曲线的配置。
Presets.easeInEaseOutLayoutAnimation::EASE_IN_EASE_OUT300 毫秒,视图淡入淡出。
Presets.linearLayoutAnimation::LINEAR500 毫秒,匀速。
Presets.springLayoutAnimation::SPRING700 毫秒;位置以弹簧效果变化(阻尼 0.4),视图线性淡入淡出。
easeInEaseOut(onEnd?)、linear(onEnd?)、spring(onEnd?)animate_layout(LayoutAnimation::…)configureNext(Presets.…) 的简写。
Types、PropertiesLayoutCurve、LayoutProperty这些名称的常量形式。
let open = signal(false);
let toggle = move || {
    animate_layout(LayoutAnimation::SPRING);
    open.update(|o| *o = !*o);
};

view()
    .child(text("Details").on_press(toggle))
    .child(when(move || open.get(), || text("Shown with a spring.")))