Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Documentación

Animación

Todas las animaciones de NSH se ejecutan en el hilo de UI. La describes una sola vez, desde Rust o TSX, y la plataforma la reproduce fotograma a fotograma (Core Animation y un display link en iOS, el animador del hilo de UI en Android) sin volver a llamar a tu código. Un hilo del motor saturado no puede hacer que una animación se entrecorte, y una animación no consume nada de tu código mientras se ejecuta.

guide-animation.tsx
guide-animation.tsxLive
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,
            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, translate: [timed, 0] }} />
        <View style={{ width: 20, height: 20, borderRadius: 4, backgroundColor: ui().info, translate: [sprung, 0] }} />
        <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] }),
            scale: [timed.interpolate({ inputRange: [0, TRACK], outputRange: [0.1, 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(),
                translate: [0, 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);

Hay seis herramientas, de la más lista para usar a la más flexible:

Herramienta Úsala para
Motion Una vista que entra, sale, llama la atención o se repite en bucle: motions predefinidos (fadeInUp, shake, pulse, …) o keyframes propios, sobre su opacidad y su transformación.
Transiciones Un cambio de estilo que debe animarse en lugar de saltar: un color, una opacidad, una transformación.
Animaciones de keyframes Un spinner, un marcador de posición que pulsa, una pista que rebota.
Keyframes Cualquier valor animado mediante keyframes, tal como los reproduce CSS: offsets, una curva de easing por segmento, iteraciones y una dirección.
Valores animados Cualquier cosa dirigida por un valor a lo largo del tiempo: resortes, gestos, posición de scroll, secuencias.
Animaciones de layout Cambios de tamaño y posición tras una actualización: elementos insertados, eliminados, reordenados, un panel que se expande.

#Motion

Un motion mueve la opacidad y la transformación de una vista mediante keyframes, de forma nativa. Crea uno en el componente que posee la vista (motion() en Rust, createMotion() de nsh en TSX), vincúlalo a la vista y reproduce en él un motion predefinido o uno propio. enter coloca la vista donde empieza un motion y lo reproduce de inmediato, de modo que la vista entra desde su primer fotograma.

#[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);
            }),
    )
}

Un motion tiene un valor animado por canal: opacity (0..1), translateX y translateY (puntos), rotate, rotateX y rotateY (grados), scale, scaleX y scaleY. Empiezan en reposo (opacidad y escalas en 1, el resto en 0) y se aplican en este orden: traslaciones, luego rotaciones, luego escalas, respecto al origen de transformación de la vista. Un motion no tiene aspecto propio: mueve lo que tú has estilizado.

Un motion mueve solo la opacidad y la transformación. Al expandir m.style se establecen los opacity y transform de la vista; para conservar una opacidad propia, vincula solo los pasos (transform: m.transform). Los colores se mueven con un valor animado y interpolate.

TSX Rust Descripción
createMotion(options?) motion() Un motion con todos los canales en reposo.
createMotion({ initial: { opacity: 0 } }) m.value(MotionChannel::Opacity).set(0.0) Dónde empiezan los canales; los demás empiezan en reposo.
createMotion({ enter, delay, duration, ... }) motion().entering(spec) Empieza donde empieza enter y lo reproduce de inmediato; las demás opciones cambian su temporización. enter establece los canales que mueve, sobre initial.
style={m.style}, m.transform .bind_motion(&m) Vincula los canales: m.style es { opacity, transform }, y su transform una lista de siete pasos (scale se integra en scaleX y scaleY). Se aplican después de los translate, rotate y scale de la vista; una perspectiva antes de ellos da profundidad a un giro 3D: transform: [{ perspective: 800 }, ...m.transform]. En Rust, la transformación del motion se aplica después de la propia de la vista.
m.opacity, m.translateX, … m.value(MotionChannel::Opacity), … El valor animado de un canal, para interpolarlo o dirigirlo a mano.
m.play(motion, options?) m.play(spec) Reproduce un preset o un motion propio; uno nuevo interrumpe al que se está reproduciendo. Se resuelve con { finished }, true cuando termina (AnimationEnd en Rust).
m.animation(motion, options?) m.animation(spec) El mismo motion como animación compuesta, sin iniciar, para secuenciarlo, escalonarlo o repetirlo en bucle con otros.
play(motion, { ignoreReduceMotion: true }) m.play_ignoring_reduce_motion(spec) Lo reproduce incluso cuando el usuario pide menos movimiento.
m.stop() m.stop() Lo detiene donde está.
m.reset() m.reset() Lo detiene y devuelve cada canal al reposo.

#Presets

Los motions predefinidos son datos: motions de nsh (motions.fadeInUp es lo que reproduce play("fadeInUp")) y MotionPreset en Rust (MotionPreset::FadeInUp.spec()). Las distancias están en puntos, dimensionadas para controles y tarjetas; para vistas más grandes, escala los valores de un motion o escribe tus propios keyframes. swing y las demás rotaciones giran respecto al origen de transformación de la vista: dale a un cartel que se balancea transformOrigin: "top" (.origin_top()).

Entrada: empiezan ocultos o desplazados y terminan en reposo.

TSX Rust Duración Descripción
fadeIn MotionPreset::FadeIn 300 ms Aparece con fundido.
fadeInUp MotionPreset::FadeInUp 450 ms Aparece con fundido, subiendo hasta su sitio.
fadeInDown MotionPreset::FadeInDown 450 ms Aparece con fundido, bajando hasta su sitio.
fadeInLeft MotionPreset::FadeInLeft 450 ms Aparece con fundido, viniendo desde la izquierda.
fadeInRight MotionPreset::FadeInRight 450 ms Aparece con fundido, viniendo desde la derecha.
zoomIn MotionPreset::ZoomIn 350 ms Aparece con fundido, creciendo desde el 90 %.
popIn MotionPreset::PopIn 450 ms Aparece de golpe: crece desde la mitad de su tamaño, supera el propio y se asienta.
bounceIn MotionPreset::BounceIn 650 ms Entra rebotando, con dos sobreimpulsos.
slideInUp MotionPreset::SlideInUp 400 ms Se desliza hacia arriba desde abajo, sin fundido (hojas modales, toasts).
slideInDown MotionPreset::SlideInDown 400 ms Se desliza hacia abajo desde arriba, sin fundido.
flipInX MotionPreset::FlipInX 600 ms Entra girando sobre su eje horizontal.
flipInY MotionPreset::FlipInY 600 ms Entra girando sobre su eje vertical.

Salida: empiezan en reposo y terminan ocultos o desplazados.

TSX Rust Duración Descripción
fadeOut MotionPreset::FadeOut 250 ms Desaparece con fundido.
fadeOutUp MotionPreset::FadeOutUp 300 ms Desaparece con fundido, alejándose hacia arriba.
fadeOutDown MotionPreset::FadeOutDown 300 ms Desaparece con fundido, alejándose hacia abajo.
fadeOutLeft MotionPreset::FadeOutLeft 300 ms Desaparece con fundido hacia la izquierda.
fadeOutRight MotionPreset::FadeOutRight 300 ms Desaparece con fundido hacia la derecha.
zoomOut MotionPreset::ZoomOut 250 ms Desaparece con fundido, encogiéndose hasta el 90 %.
popOut MotionPreset::PopOut 300 ms Sale de golpe: se hincha un poco y luego se encoge hasta desaparecer.
slideOutDown MotionPreset::SlideOutDown 300 ms Se desliza hacia abajo hasta salir de la vista, sin fundido.
slideOutUp MotionPreset::SlideOutUp 300 ms Se desliza hacia arriba hasta salir de la vista, sin fundido.

Atención: empiezan y terminan en reposo.

TSX Rust Duración Descripción
shake MotionPreset::Shake 500 ms Se sacude de lado a lado: una contraseña incorrecta, un campo no válido.
headShake MotionPreset::HeadShake 800 ms Niega con la cabeza: un no más suave.
wiggle MotionPreset::Wiggle 600 ms Se menea alrededor de su centro: algo que conviene notar.
swing MotionPreset::Swing 900 ms Se balancea desde su borde superior, como un cartel.
pulse MotionPreset::Pulse 500 ms Crece un poco y vuelve: un énfasis suave.
heartbeat MotionPreset::Heartbeat 900 ms Late dos veces, como un corazón: un me gusta, un favorito.
rubberBand MotionPreset::RubberBand 800 ms Se comprime y se estira como goma.
jello MotionPreset::Jello 800 ms Tiembla como gelatina.
tada MotionPreset::Tada 900 ms Se encoge y luego crece con un meneo: una recompensa.
jump MotionPreset::Jump 700 ms Salta y aterriza aplastándose.
flash MotionPreset::Flash 800 ms Parpadea, apagándose y encendiéndose dos veces.

Bucle: se repiten hasta que se detienen.

TSX Rust Duración Descripción
spin MotionPreset::Spin 1000 ms Da una vuelta, para siempre.
float MotionPreset::Float 1500 ms Flota arriba y abajo, para siempre.
breathe MotionPreset::Breathe 1800 ms Respira: crece y se desvanece un poco, para siempre.
sway MotionPreset::Sway 1600 ms Se mece alrededor de su centro, para siempre.
bob MotionPreset::Bob 1000 ms Cabecea como una pelota que rebota, para siempre.
glow MotionPreset::Glow 2000 ms Brilla y se apaga, para siempre.

play aplica sobre un preset sus opciones de temporización (m.play("shake", { duration: 300 })), y un preset se expande dentro de un motion propio (m.play({ ...motions.fadeInUp, duration: 600 })). En Rust, cambia el spec de un preset: MotionPreset::FadeInUp.spec().timing(|t| t.delay(100.0)).

#Motions propios

Un motion propio consiste en keyframes por canal y una única temporización (las opciones de los keyframes). En TSX, escribe los keyframes de cada canal ({ opacity: [0, 1] }), o una lista de fotogramas como los escribe la Web Animations API ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }]), cada fotograma con su offset y su easing. En una lista de fotogramas, un fotograma sin offset se sitúa donde lo coloca su posición en la lista, y un canal sigue los fotogramas que lo nombran. Los ángulos pueden ser cadenas ("45deg", "0.5turn"). En Rust, construye un 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);

#Entradas y listas escalonadas

Dale a cada fila un motion de entrada, con un retraso que crece con su índice: las filas que aparecen a la vez entran una tras otra. Limita el retraso para que una fila añadida al final de la lista no tenga que esperar.

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

Los motions también se componen: m.animation(..) es un motion como animación compuesta, sin iniciar, así que Animated.stagger ejecuta de forma nativa un grupo de ellos, contando desde el primer elemento, el último, el centro o cualquier índice. Dales un initial oculto (o haz que empiecen donde empieza el motion) para que no se vea nada antes de su turno.

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

#Salidas

Reproduce la salida, espera a que termine y después elimina la vista. Una animación de layout permite que las vistas de alrededor cierren el hueco.

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));
    });
};

Los motions respetan por sí solos el ajuste de movimiento reducido del usuario: cuando el usuario pide menos movimiento, un motion llega de inmediato a donde termina, sin moverse.

#Transiciones

Indica qué animar y durante cuánto tiempo (.transition_all().duration(400.0) en Rust, transitionProperty y transitionDuration en TSX), y los cambios posteriores en esas propiedades se animan de forma nativa desde lo que hay en pantalla hasta el nuevo valor.

#[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(),
    )
}
Estilo Rust Tipo Descripción
transitionProperty .transition(), .transition_all(), .transition_colors(), .transition_opacity(), .transition_transform(), .transition_shadow() string Qué se anima, separado por comas: "all", "colors", "opacity", "transform", "box-shadow", "border-radius", …
transitionDuration .duration(ms) number | "150ms" | "0.2s" Milisegundos como número, o un tiempo CSS.
transitionDelay .delay(ms) number | "150ms" | "0.2s" Antes de empezar.
transitionTimingFunction .ease_linear(), .ease_in(), .ease_out(), .ease_in_out(), .ease_bezier(..) "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | "cubic-bezier(…)" La curva.

Propiedades que admiten transición, por grupos: colores (colores de fondo, borde, contorno, sombra y texto, y paradas de degradado), opacidad, transformación, sombras (box-shadow) y forma (radio y ancho del borde). Funcionan tanto los nombres CSS como sus formas en camel case (background-color, backgroundColor, transform, scale, …). Los cambios de layout se animan con animaciones de layout.

Con Tailwind, lo mismo es transition (colores, opacidad, sombras y transformaciones), transition-all, transition-colors, transition-opacity, transition-transform, transition-shadow, con duration-*, delay-* y ease-*. Combinado con el estado pulsado (.active(..), active:) da estados pulsados animados:

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

Una transición parte del valor que hay actualmente en pantalla, de modo que si se cambia de nuevo el destino a mitad de camino, el giro es suave.

#Animaciones de keyframes

Cuatro animaciones de keyframes integradas se ejecutan de forma nativa, para siempre por defecto: spin (una vuelta en sentido horario), ping (se escala hasta 2× y se desvanece), pulse (se atenúa hasta la mitad de su opacidad y vuelve) y bounce (salta hacia arriba un cuarto de su altura y aterriza).

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()
Estilo Rust Tipo Descripción
animationName .animate_spin(), .animate_ping(), .animate_pulse(), .animate_bounce(), .animate_none() o .animation(KeyframeAnimation::new(Keyframes::Spin)) "none" | "spin" | "ping" | "pulse" | "bounce" La animación.
animationDuration KeyframeAnimation::duration(ms) number | "1s" | … Una iteración.
animationDelay KeyframeAnimation::delay(ms) number | "200ms" | … Antes de empezar.
animationTimingFunction KeyframeAnimation::easing([x1, y1, x2, y2]) como transitionTimingFunction La curva.
animationIterationCount KeyframeAnimation::iterations(n) number | "infinite" Por defecto "infinite".

Tailwind: animate-spin, animate-ping, animate-pulse, animate-bounce, animate-none.

#Valores animados

Un valor animado es un número que vive en el hilo de UI. Crea uno (animated_value(..) en Rust, createAnimatedValue de nsh en TSX), vincúlalo a estilos y anímalo; las vistas vinculadas lo siguen en cada fotograma sin necesidad de una actualización.

#[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))))
}

Qué se puede vincular: opacity (.bind_opacity(..)); backgroundColor, borderColor (todos los lados) y el color de un texto (el texto completo), mediante una interpolación a colores (.bind_background(..), .bind_border_color(..), .bind_color(..)); translate ([x, y], un valor por eje), rotate (grados) y scale ([x, y] por eje, o un valor para ambos); y los pasos de una lista transform: translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY y rotateZ (.bind_translate_x(..), .bind_translate_y(..), .bind_scale(..), .bind_rotate(..) en grados, .bind_transform(kind, ..) en Rust). Las sombras cambian con transiciones. Un anillo de foco o un precio que parpadea en verde es una interpolación de color vinculada al color del borde o del texto:

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)
// On a new price: flash.set(1.0); flash.timing(0.0, 600.0, Easing::EaseInOut);

Los valores animados también dirigen dibujos en canvas, reciben desplazamientos de scroll (scrollValue) y gestos (pan, pinch, rotation).

El valor se mantiene vivo gracias a las vistas vinculadas a él y al componente que lo creó, de modo que un vínculo permanece conectado aunque tus variables vayan y vengan. Crea los valores animados en el componente que los posee (no a nivel de módulo cuando cada instancia necesita el suyo).

#Métodos de AnimatedValue

TSX Rust Descripción
set(value) set(value) Salta a value y detiene cualquier animación.
stop() stop() Detiene la animación en curso donde está; en TSX, se resuelve con { value }, el valor en ese punto.
getValue() value() El valor actual, leído en el hilo de UI donde se mueve (se asienta en un frame): una promesa en TSX, una respuesta que se puede esperar con await en Rust.
setOffset(offset) set_offset(offset) Suma offset a lo que muestra el valor: los bindings ven valor + offset, mientras que set, las animaciones y los arrastres mueven el valor.
flattenOffset() flatten_offset() Suma el offset al valor y deja el offset en 0.
extractOffset() extract_offset() Mueve el valor al offset y deja el valor en 0: un arrastre empieza entonces desde 0, encima de donde terminó el anterior.
timing(to, { duration?, easing?, delay? }) timing(to, ms, easing), timing_with(to, Timing { .. }) Anima hasta to durante duration ms (300 por defecto, easing "easeInOut").
spring(to, { stiffness?, damping?, mass?, velocity? }) spring(to, Spring { .. }) Anima con un resorte hasta to (valores por defecto: stiffness 170, damping 26, mass 1).
decay({ velocity?, deceleration?, clamp?, bounce? }) decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce) Avanza por inercia desde velocity (unidades por segundo) y va frenando (deceleration 0.998 por ms por defecto). Con un clamp de [min, max] se detiene en un límite, o rebota desde él conservando bounce (0..1) de su velocidad.

Cada animación da una respuesta que puedes esperar con await, la de si terminó: true cuando se reprodujo hasta el final, false cuando algo la detuvo (stop(), set() o una nueva animación sobre el mismo valor). Es un Promise<{ finished: boolean }> en TSX y un AnimationEnd en Rust, que una tarea espera con bool. Iniciar una nueva animación sobre un valor en movimiento interrumpe la anterior con suavidad: un resorte o un decay sin velocidad continúa con la velocidad actual del valor.

// Spring settings: lower damping overshoots more, higher stiffness is faster.
x.spring(200.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() });   // wobbly
x.spring(200.0, Spring { stiffness: 400.0, damping: 30.0, ..Spring::default() });  // stiff, no overshoot

// A fling that coasts to a stop, from a pan's release velocity.
view().pan_values(Some(&x), None).on_pan_end(move |e: &PanEvent| {
    x.decay(Some(e.velocity_x), 0.998);
})

// A fling that stays on its track and bounces softly off its ends.
x.decay_within(Some(e.velocity_x), 0.998, (0.0, 300.0), 0.3);

// Where a value is now (it moves on the UI thread, so the answer is async).
let at = x.value().await;

#Keyframes

keyframes reproduce un valor a través de keyframes como lo hacen los keyframes de CSS: value.keyframes(frames, options) en TSX, value.keyframes(frames, KeyframeTiming) en Rust (Animated.keyframes(value, { keyframes, ... }) y Animation::keyframes(..) como un paso de una animación compuesta). Un keyframe es un número (o una cadena de ángulo como "90deg"), repartido de forma uniforme con los demás, o { offset, value, easing } (Keyframe::new(value), Keyframe::at(offset, value), .ease(easing) en Rust).

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)),
);

// A clock hand that ticks once a second.
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(),
);
Opción Rust (KeyframeTiming) Por defecto Descripción
duration KeyframeTiming::new(ms) 300 Una iteración, en milisegundos.
delay .delay(ms) 0 Antes de la primera iteración.
easing .easing(easing) CSS ease La curva de cada segmento que no tiene un easing propio.
iterations .iterations(Some(n)), .infinite() 1 Cuántas iteraciones, o "infinite".
direction .direction(PlaybackDirection::Alternate) "normal" "normal", "reverse", "alternate" o "alternate-reverse".

Se reproducen como en CSS y la Web Animations API:

  • Un easing da forma a cada segmento, no al conjunto: el easing de un keyframe da forma al segmento que empieza en él, y el easing del timing a todos los demás segmentos.
  • Los keyframes sin offset se reparten de forma uniforme entre sus vecinos; el primero toma 0 por defecto y el último 1.
  • Cada iteración empieza con un salto a su primer keyframe. Sin un keyframe en el offset 0, el valor parte de donde está.
  • Durante un delay, el valor ya tiene el primer keyframe (CSS animation-fill-mode: backwards), de modo que una entrada retrasada muestra su primer frame en lugar de la vista en reposo. Esto también vale para los motions.
  • reverse y las iteraciones hacia atrás de alternate recorren los segmentos al revés, cada uno con su curva invertida.
  • El valor se queda donde lo deja la última iteración. La respuesta indica si terminaron: true cuando lo hicieron, false cuando algo las detuvo.

#Interpolación

interpolate asigna un valor, mediante segmentos lineales por tramos, a números, colores o ángulos, y devuelve un nuevo nodo que puedes enlazar. Un mismo valor puede controlar muchas propiedades. En Rust, interpolate(&input, &output) asigna a números, interpolate_colors(&input, &colors) a colores, y interpolate_with(&input, output, Interpolation { .. }) recibe la extrapolación y el easing; en TSX, value.interpolate({ inputRange, outputRange }) los recibe todos.

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()
        },
    ))
Opción Rust Descripción
inputRange el slice de entrada Puntos de entrada crecientes.
outputRange el slice de salida, o AnimationOutput Una salida por punto: números, colores ("#rrggbb", "rgba(…)", con nombre) o ángulos ("45deg", "1rad").
extrapolate Interpolation::extrapolate(Extrapolate::Extend) Fuera del rango de entrada: "clamp" (por defecto), "extend" (continúa los segmentos de los extremos) o "identity" (pasa la entrada tal cual).
extrapolateLeft, extrapolateRight extrapolate_left, extrapolate_right Lo mismo, para un solo lado.
easing easing Da forma a cada segmento.

#Aritmética y diffClamp

Los nodos se combinan con aritmética, también de forma nativa: a.add(b), a.sub(b), a.mul(b), a.div(b), a.mod(b) (a.modulo(b) en Rust; nunca negativo para un b positivo, p. ej. ángulos en 0..360), donde b es un nodo o un número. En TSX, Animated.add, subtract, multiply, divide y modulo son lo mismo en forma de funciones.

diffClamp(min, max) (diff_clamp) sigue los cambios del nodo mientras permanece dentro de min..max. Aplicado a un offset de scroll, produce un encabezado que se oculta cuando el contenido se desplaza hacia arriba y vuelve en cuanto se desplaza hacia abajo:

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

Con { snap: true } (diff_clamp_with(min, max, DiffClamp::snapping())), un encabezado que queda parcialmente visible cuando el scroll se detiene completa el movimiento: se asienta totalmente visible o totalmente oculto, lo que quede más cerca, en 200 ms en el hilo de UI. Solo se oculta una vez que la página se ha desplazado al menos su altura, así que una página desplazada menos que eso muestra el encabezado completo. Si se vuelve a hacer scroll durante el movimiento, este continúa desde donde está el encabezado.

let header = offset.diff_clamp_with(0.0, 56.0, DiffClamp::snapping());

#Animaciones compuestas

Las animaciones se construyen a partir de pasos (Animation en Rust, Animated de nsh en TSX) y todo el árbol se ejecuta de forma nativa: cada paso empieza en el hilo de UI en el instante en que termina el anterior, sin pausa y sin nada de tu código entre ambos.

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" });
});
TSX Rust Descripción
Animated.timing(value, { toValue, duration?, easing?, delay? }) Animation::timing(&value, to, Timing { .. }) Un paso de timing.
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? }) Animation::spring(&value, to, Spring { .. }) Un paso de resorte.
Animated.decay(value, { velocity?, deceleration?, clamp?, bounce? }) Animation::decay(&value, velocity, deceleration), Animation::decay_within(..) Un paso de decay.
Animated.sequence(list) Animation::sequence(list) Uno tras otro.
Animated.parallel(list, { stopTogether? }) Animation::parallel(list), .stop_together(..) Todos a la vez.
Animated.stagger(ms, list, { from? }) Animation::stagger(ms, list), Animation::stagger_from(ms, StaggerFrom::Center, list) Todos a la vez, cada uno empezando ms después del anterior, contando desde from: "first" (por defecto), "last", "center" (una onda que se expande desde el centro) o un índice (StaggerFrom::First, Last, Center, Index(i)).
Animated.keyframes(value, { keyframes, ...timing }) Animation::keyframes(&value, frames, KeyframeTiming) Un paso de keyframes.
Animated.delay(ms) Animation::delay(ms) Espera.
Animated.loop(animation, { iterations?, resetBeforeIteration? }) .looped(iterations), .reset_before_iteration(..) Repite (indefinidamente por defecto; None en Rust).

Una animación compuesta tiene start() (en TSX también con un callback y una promesa, ambos reciben { finished }; en Rust un bool que se puede esperar; volver a llamarla la ejecuta de nuevo), stop() (se detiene donde está) y reset() (se detiene y devuelve sus valores a donde estaban cuando empezó). El callback se ejecuta una vez para todo el árbol.

Una animación en curso no mantiene vivos sus valores: cuando desaparece el componente que los posee (sus vistas y variables), un bucle infinito se detiene con él, y su respuesta es que no terminó (false).

#Easing

Las curvas de easing son datos, evaluados de forma nativa: Easing de nsh en TSX, el enum Easing en Rust.

TSX Rust Descripción
Easing.linear, Easing.ease Easing::Linear, Easing::Bezier(0.25, 0.1, 0.25, 1.0) Velocidad constante; el ease de CSS.
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 Sinusoidal, circular, exponencial.
Easing.back(s?) Curve::Back(s) Retrocede antes de avanzar (overshoot s, 1.70158 por defecto).
Easing.elastic(bounciness?) Curve::Elastic(bounciness) Rebase elástico.
Easing.bounce Curve::Bounce Rebota al final.
Easing.bezier(x1, y1, x2, y2) Easing::Bezier(x1, y1, x2, y2) Una curva Bézier cúbica, como en CSS.
Easing.steps(count, position?) Easing::Steps(count, StepPosition::JumpEnd) Salta entre count niveles en lugar de moverse, como steps() de CSS: un sprite fotograma a fotograma, una manecilla que hace tic. position es "jump-end" (por defecto, también "end"), "jump-start" ("start"), "jump-none" o "jump-both".
Easing.in(e), Easing.out(e), Easing.inOut(e) Easing::Curve(curve, EaseMode::In), EaseMode::Out, EaseMode::InOut Las curvas son curvas “in”; out y inOut les dan la vuelta.

En TSX, en cualquier lugar donde se acepte un easing también puedes pasar un nombre ("linear", "ease", "easeIn", "easeOut", "easeInOut") o un array Bézier [x1, y1, x2, y2]; en Rust, Easing::EaseIn, Easing::EaseOut y Easing::EaseInOut son las curvas de 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) (sample_easing en Rust) devuelve la curva en count puntos equidistantes de 0 a 1, y Animated.sampleSpring(config, fps?) / Animated.sampleDecay(config, fps?) (sample_spring, sample_decay) los valores por los que pasa un resorte o un decay, uno por frame (60 por segundo por defecto) desde config.from (0) hasta que termina. Los calcula el mismo código nativo con el que se ejecutan las animaciones, así que un gráfico de una curva muestra exactamente lo que hace la animación:

use nsh_core::element::{sample_easing, sample_spring};

let curve = sample_easing(Easing::Curve(Curve::Back(1.70158), EaseMode::Out), 50); // 50 progress values
let spring = sample_spring(0.0, 1.0, Spring { stiffness: 180.0, damping: 8.0, ..Spring::default() }, 60.0);

#LayoutAnimation

Una layout animation anima de forma nativa los cambios de frame de la siguiente actualización: las vistas que se mueven o cambian de tamaño se deslizan a sus nuevos frames, las vistas nuevas aparecen con un fundido (o con escala) y las vistas eliminadas se desvanecen. Llámala justo antes del cambio de estado: animate_layout(..) en Rust, LayoutAnimation.configureNext(config) en TSX.

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);
    });
};
TSX Rust Descripción
configureNext(config, onEnd?) animate_layout(config) Anima los cambios del siguiente commit; en TSX, onEnd se ejecuta al terminar.
create(duration, type?, property?) LayoutAnimation::all(LayoutTransition::new(curve, ms)) Una configuración con una sola curva para las tres fases.
Presets.easeInEaseOut, Presets.linear, Presets.spring LayoutAnimation::EASE_IN_EASE_OUT, LINEAR, SPRING Configuraciones predefinidas.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?) animate_layout con un preset configureNext con un preset.
Types, Properties LayoutCurve, LayoutProperty Los nombres, para construir configuraciones.

Una configuración es { duration, create?, update?, delete? } (LayoutAnimation { create, update, delete } en Rust): create para las vistas insertadas, update para las movidas o redimensionadas, delete para las eliminadas. Cada fase es { type?, property?, duration?, delay?, springDamping? } (LayoutTransition { curve, property, duration_ms, delay_ms }), donde type es "easeInEaseOut", "spring", "linear", "easeIn", "easeOut" o "keyboard" (la curva propia del teclado de software, medida desde que el teclado empezó a moverse, de modo que lo que le hace sitio se mueve con él; LayoutCurve::Keyboard), property es lo que animan las vistas al aparecer y desaparecer ("opacity", "scaleXY", "scale"), y springDamping (0..1, 0.7 por defecto; LayoutCurve::Spring { damping }) fija el overshoot del resorte.

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)
    }),
});

Una layout animation cubre todos los frames que cambia la actualización, en cualquier parte de la pantalla: una lista que se refresca o datos en vivo que llegan en la misma actualización se mueven con ella. En un componente de Rust, anima solo aquello con lo que se mueve el cambio de un elemento mediante ElementRef::animate_layout(transition): el propio frame del elemento y el de su contenido, y los de las vistas que su tamaño empuja (sus hermanos y los de estos, hasta la pantalla). Los demás frames de la actualización cambian de golpe. Así es como nsh/base revela su contenido con Collapsible y AccordionPanel.

use nsh_core::element::{LayoutCurve, LayoutTransition};

let region = element_ref();
let open = signal(false);
let toggle = move || {
    region.animate_layout(LayoutTransition::new(LayoutCurve::Spring { damping: 1.0 }, 265.0));
    open.update(|o| *o = !*o);
};
view()
    .element_ref(&region)
    .overflow_hidden()
    .style_with(move |s| if open.get() { s } else { s.h(px(0.0)) })

#Qué se ejecuta dónde

Se ejecuta en Tu código por frame
Transiciones, animaciones de keyframes Hilo de UI ninguno
Motions, keyframes, timing / spring / decay, composiciones Hilo de UI ninguno; tu código se entera cuando terminan
interpolate, aritmética, diffClamp Hilo de UI ninguno
scrollValue, pan / pinch / rotation Hilo de UI ninguno; tu código se entera del inicio y del final
Layout animations Hilo de UI ninguno
Bucles de requestAnimationFrame (request_animation_frame en Rust), manejadores de onScroll, PanResponder tu código, y luego una actualización en cada frame

Recurre a requestAnimationFrame (un global en TSX, que sigue la pantalla) solo para lo que no se puede describir de antemano, como una simulación física en tu código. Todo lo que pueda ser un valor animado debería serlo.

#Movimiento reducido

Cuando el usuario pide menos movimiento (iOS “Reduce Motion”, Android “Remove animations”), reduce_motion() en Rust y reduceMotion() de nsh en TSX son true, y se aplican las variantes de Tailwind motion-reduce: / motion-safe:.

Los motions siguen el ajuste por sí solos: play y enter llegan de inmediato a donde termina el motion, sin moverse, y la respuesta es que terminó (true), igual que para un motion que se reprodujo. Una salida sigue terminando oculta, una entrada sigue terminando en reposo. Pasa ignoreReduceMotion: true (play_ignoring_reduce_motion en Rust) para un motion que transmite un significado que el usuario necesita, como un indicador de progreso giratorio.

Las transiciones, las animaciones de keyframes, los valores animados, los keyframes y las composiciones se reproducen tal como están escritos; úsalos para elegir una alternativa más calmada:

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

Consulta Accesibilidad.

#Relacionado