Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Documentation

Animation

Toute animation dans NSH s’exécute sur le thread UI. Vous la décrivez une seule fois, en Rust ou en TSX, et la plateforme la joue image par image (Core Animation et un display link sur iOS, l’animateur du thread UI sur Android) sans jamais rappeler votre code. Un thread moteur occupé ne peut pas faire saccader une animation, et une animation ne consomme aucun de vos cycles de code pendant qu’elle s’exécute.

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

Il existe six outils, du plus prêt à l’emploi au plus flexible :

OutilÀ utiliser pour
MotionUne vue qui entre, sort, attire l’œil ou boucle : des motions prêtes à l’emploi (fadeInUp, shake, pulse, …) ou vos propres keyframes, sur son opacité et sa transformation.
TransitionsUn changement de style qui doit s’animer au lieu de sauter : une couleur, une opacité, une transformation.
Animations par keyframesUn spinner, un placeholder qui pulse, un indice qui rebondit.
KeyframesToute valeur animée par keyframes, comme CSS les joue : offsets, un easing par segment, itérations et direction.
Valeurs animéesTout ce qui est piloté par une valeur dans le temps : springs, gestes, position de défilement, séquences.
Animations de layoutLes changements de taille et de position après une mise à jour : éléments insérés, supprimés, réordonnés, panneau qui se déploie.

#Motion

Un motion anime l’opacité et la transformation d’une vue par keyframes, en natif. Créez-en un dans le composant qui possède la vue (motion() en Rust, createMotion() depuis nsh en TSX), liez-le à la vue, puis jouez dessus un motion prêt à l’emploi ou le vôtre. enter place la vue là où un motion commence et le joue aussitôt, de sorte que la vue arrive dès sa première image.

#[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 possède une valeur animée par canal : opacity (0..1), translateX et translateY (points), rotate, rotateX et rotateY (degrés), scale, scaleX et scaleY. Ils démarrent au repos (opacité et échelles à 1, le reste à 0) et s’appliquent dans cet ordre : translations, puis rotations, puis échelles, autour de l’origine de transformation de la vue. Un motion n’a pas d’apparence propre : il anime ce que vous avez stylé.

Un motion n’anime que l’opacité et la transformation. Étaler m.style définit les propriétés opacity et transform de la vue ; pour conserver votre propre opacité, ne liez que les étapes (transform: m.transform). Les couleurs s’animent avec une valeur animée et interpolate.

TSXRustDescription
createMotion(options?)motion()Un motion dont tous les canaux sont au repos.
createMotion({ initial: { opacity: 0 } })m.value(MotionChannel::Opacity).set(0.0)Définit où les canaux démarrent ; les autres démarrent au repos.
createMotion({ enter, delay, duration, ... })motion().entering(spec)Démarre là où enter démarre et le joue aussitôt ; les autres options modifient son timing. enter définit les canaux qu’il anime, sur initial.
style={m.style}, m.transform.bind_motion(&m)Lie les canaux : m.style vaut { opacity, transform }, son transform est une liste de sept étapes (scale est intégré à scaleX et scaleY). Elles s’appliquent après les propriétés translate, rotate et scale de la vue ; une perspective placée avant elles donne sa profondeur à un flip 3D : transform: [{ perspective: 800 }, ...m.transform]. En Rust, la transformation du motion s’applique après celle de la vue.
m.opacity, m.translateX, …m.value(MotionChannel::Opacity), …La valeur animée d’un canal, à interpoler ou à piloter à la main.
m.play(motion, options?)m.play(spec)Joue un preset ou un motion à vous ; un nouveau motion interrompt celui en cours. Se résout avec { finished }, true à la fin (AnimationEnd en Rust).
m.animation(motion, options?)m.animation(spec)Le même motion sous forme d’animation composite, non démarrée, à séquencer, décaler ou mettre en boucle avec d’autres.
play(motion, { ignoreReduceMotion: true })m.play_ignoring_reduce_motion(spec)Le joue même lorsque l’utilisateur demande moins de mouvement.
m.stop()m.stop()L’arrête là où il en est.
m.reset()m.reset()L’arrête et remet chaque canal au repos.

#Presets

Les motions prêts à l’emploi sont des données : motions depuis nsh (motions.fadeInUp est ce que play("fadeInUp") joue) et MotionPreset en Rust (MotionPreset::FadeInUp.spec()). Les distances sont en points, dimensionnées pour des contrôles et des cartes ; pour de plus grandes vues, mettez à l’échelle les valeurs d’un motion ou écrivez vos propres keyframes. swing et les autres rotations tournent autour de l’origine de transformation de la vue : donnez à une enseigne oscillante transformOrigin: "top" (.origin_top()).

Entrée : ils démarrent masqués ou éloignés et finissent au repos.

TSXRustDuréeDescription
fadeInMotionPreset::FadeIn300 msApparaît en fondu.
fadeInUpMotionPreset::FadeInUp450 msApparaît en fondu, en montant à sa place.
fadeInDownMotionPreset::FadeInDown450 msApparaît en fondu, en descendant à sa place.
fadeInLeftMotionPreset::FadeInLeft450 msApparaît en fondu, en venant de la gauche.
fadeInRightMotionPreset::FadeInRight450 msApparaît en fondu, en venant de la droite.
zoomInMotionPreset::ZoomIn350 msApparaît en fondu, en grandissant à partir de 90 %.
popInMotionPreset::PopIn450 msSurgit : part de la moitié de sa taille, dépasse la sienne puis se stabilise.
bounceInMotionPreset::BounceIn650 msEntre en rebondissant, en dépassant deux fois sa taille finale.
slideInUpMotionPreset::SlideInUp400 msGlisse depuis le bas, sans fondu (feuilles, toasts).
slideInDownMotionPreset::SlideInDown400 msGlisse depuis le haut, sans fondu.
flipInXMotionPreset::FlipInX600 msEntre en se retournant autour de son axe horizontal.
flipInYMotionPreset::FlipInY600 msEntre en se retournant autour de son axe vertical.

Sortie : ils démarrent au repos et finissent masqués ou éloignés.

TSXRustDuréeDescription
fadeOutMotionPreset::FadeOut250 msDisparaît en fondu.
fadeOutUpMotionPreset::FadeOutUp300 msDisparaît en fondu, en s’élevant.
fadeOutDownMotionPreset::FadeOutDown300 msDisparaît en fondu, en descendant.
fadeOutLeftMotionPreset::FadeOutLeft300 msDisparaît en fondu vers la gauche.
fadeOutRightMotionPreset::FadeOutRight300 msDisparaît en fondu vers la droite.
zoomOutMotionPreset::ZoomOut250 msDisparaît en fondu, en rétrécissant à 90 %.
popOutMotionPreset::PopOut300 msDisparaît en éclatant : gonfle un peu, puis rétrécit jusqu’à disparaître.
slideOutDownMotionPreset::SlideOutDown300 msGlisse vers le bas hors de la vue, sans fondu.
slideOutUpMotionPreset::SlideOutUp300 msGlisse vers le haut hors de la vue, sans fondu.

Attention : ils démarrent et finissent au repos.

TSXRustDuréeDescription
shakeMotionPreset::Shake500 msSecoue de côté : un mot de passe incorrect, un champ invalide.
headShakeMotionPreset::HeadShake800 msSecoue la tête : un « non » plus doux.
wiggleMotionPreset::Wiggle600 msSe trémousse autour de son centre : quelque chose à remarquer.
swingMotionPreset::Swing900 msSe balance depuis son bord supérieur, comme une enseigne.
pulseMotionPreset::Pulse500 msGrossit un peu puis revient : une emphase discrète.
heartbeatMotionPreset::Heartbeat900 msBat deux fois, comme un cœur : un like, un favori.
rubberBandMotionPreset::RubberBand800 msSe comprime et s’étire comme du caoutchouc.
jelloMotionPreset::Jello800 msTremble comme de la gelée.
tadaMotionPreset::Tada900 msRétrécit, puis grandit en se trémoussant : une récompense.
jumpMotionPreset::Jump700 msSaute et retombe en s’écrasant.
flashMotionPreset::Flash800 msClignote deux fois.

Boucle : ils se répètent jusqu’à leur arrêt.

TSXRustDuréeDescription
spinMotionPreset::Spin1000 msFait un tour, sans fin.
floatMotionPreset::Float1500 msFlotte de haut en bas, sans fin.
breatheMotionPreset::Breathe1800 msRespire : grossit et s’estompe un peu, sans fin.
swayMotionPreset::Sway1600 msSe balance autour de son centre, sans fin.
bobMotionPreset::Bob1000 msRebondit comme une balle, sans fin.
glowMotionPreset::Glow2000 msS’illumine et s’éteint, sans fin.

play accepte par-dessus un preset ses options de timing (m.play("shake", { duration: 300 })), et un preset peut être étalé dans un motion à vous (m.play({ ...motions.fadeInUp, duration: 600 })). En Rust, modifiez la spec d’un preset : MotionPreset::FadeInUp.spec().timing(|t| t.delay(100.0)).

#Vos propres motions

Un motion à vous se compose de keyframes par canal et d’un seul timing (les options des keyframes). En TSX, écrivez les keyframes de chaque canal ({ opacity: [0, 1] }), ou une liste de frames comme l’écrit la Web Animations API ([{ opacity: 0, translateY: 24 }, { opacity: 1, translateY: 0 }]), chaque frame avec son offset et son easing. Dans une liste de frames, une frame sans offset se place là où sa position dans la liste la met, et un canal suit les frames qui le nomment. Les angles peuvent être des chaînes ("45deg", "0.5turn"). En Rust, construisez 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);

#Entrées et listes décalées

Donnez à chaque ligne un motion d’entrée, avec un délai qui croît avec son index : les lignes qui apparaissent ensemble arrivent les unes après les autres. Plafonnez le délai pour qu’une ligne ajoutée loin dans la liste n’attende pas.

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

Les motions se composent aussi : m.animation(..) est un motion sous forme d’animation composite, non démarrée, si bien que Animated.stagger en exécute un groupe en natif, en comptant depuis le premier élément, le dernier, le centre ou n’importe quel index. Donnez-leur un initial masqué (ou démarrez-les là où le motion commence) pour que rien ne s’affiche avant son tour.

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

#Sorties

Jouez la sortie, attendez-la, puis retirez la vue. Une animation de layout laisse les vues voisines combler le vide.

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

Les motions respectent d’eux-mêmes le réglage de réduction des animations de l’utilisateur : lorsque l’utilisateur demande moins de mouvement, un motion atteint immédiatement son état final, sans mouvement.

#Transitions

Nommez ce qui doit s’animer et pour combien de temps (.transition_all().duration(400.0) en Rust, transitionProperty et transitionDuration en TSX), et les changements ultérieurs de ces propriétés s’animent en natif, de ce qui est à l’écran vers la nouvelle valeur.

#[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(),
    )
}
StyleRustTypeDescription
transitionProperty.transition(), .transition_all(), .transition_colors(), .transition_opacity(), .transition_transform(), .transition_shadow()stringCe qui s’anime, séparé par des virgules : "all", "colors", "opacity", "transform", "box-shadow", "border-radius", …
transitionDuration.duration(ms)number | "150ms" | "0.2s"Des millisecondes sous forme de nombre, ou une durée CSS.
transitionDelay.delay(ms)number | "150ms" | "0.2s"Avant le démarrage.
transitionTimingFunction.ease_linear(), .ease_in(), .ease_out(), .ease_in_out(), .ease_bezier(..)"linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | "cubic-bezier(…)"La courbe.

Les propriétés qui font l’objet d’une transition, par groupe : couleurs (couleurs de fond, de bordure, de contour, d’ombre et de texte, stops de dégradé), opacité, transformation, ombres (box-shadow) et forme (rayon et largeur de bordure). Les noms CSS et leurs formes en camel-case fonctionnent tous deux (background-color, backgroundColor, transform, scale, …). Les changements de layout s’animent avec les animations de layout.

Avec Tailwind, cela donne transition (couleurs, opacité, ombres et transformations), transition-all, transition-colors, transition-opacity, transition-transform, transition-shadow, avec duration-*, delay-* et ease-*. Combiné à l’état pressé (.active(..), active:), il produit des états pressés animés :

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

Une transition part de la valeur actuellement à l’écran : changer à nouveau la cible en cours de route produit donc un virage fluide.

#Animations par keyframes

Quatre animations par keyframes intégrées s’exécutent en natif, sans fin par défaut : spin (un tour dans le sens horaire), ping (grossit jusqu’à 2× et disparaît en fondu), pulse (s’estompe à la moitié de l’opacité et revient) et bounce (saute d’un quart de sa hauteur et retombe).

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()
StyleRustTypeDescription
animationName.animate_spin(), .animate_ping(), .animate_pulse(), .animate_bounce(), .animate_none() ou .animation(KeyframeAnimation::new(Keyframes::Spin))"none" | "spin" | "ping" | "pulse" | "bounce"L’animation.
animationDurationKeyframeAnimation::duration(ms)number | "1s" | …Une itération.
animationDelayKeyframeAnimation::delay(ms)number | "200ms" | …Avant le démarrage.
animationTimingFunctionKeyframeAnimation::easing([x1, y1, x2, y2])comme transitionTimingFunctionLa courbe.
animationIterationCountKeyframeAnimation::iterations(n)number | "infinite"Par défaut "infinite".

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

#Valeurs animées

Une valeur animée est un nombre qui vit sur le thread UI. Créez-en une (animated_value(..) en Rust, createAnimatedValue depuis nsh en TSX), liez-la à des styles, puis animez-la ; les vues liées la suivent à chaque image sans mise à jour.

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

Ce qui peut être lié : opacity (.bind_opacity(..)) ; backgroundColor, borderColor (tous les côtés) et le color d’un texte (le texte entier), via une interpolation vers des couleurs (.bind_background(..), .bind_border_color(..), .bind_color(..)) ; translate ([x, y], une valeur par axe), rotate (degrés) et scale ([x, y] par axe, ou une seule valeur pour les deux) ; et les étapes d’une liste transform : translateX, translateY, scale, scaleX, scaleY, rotate, rotateX, rotateY et rotateZ (.bind_translate_x(..), .bind_translate_y(..), .bind_scale(..), .bind_rotate(..) en degrés, .bind_transform(kind, ..) en Rust). Les ombres changent avec les transitions. Un anneau de focus ou un prix qui clignote en vert est une interpolation de couleur liée à la couleur de bordure ou de texte :

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

Les valeurs animées pilotent aussi les dessins canvas, reçoivent les offsets de défilement (scrollValue) et les gestes (pan, pinch, rotation).

La valeur est maintenue en vie par les vues qui lui sont liées et par le composant qui l’a créée : une liaison reste donc connectée quelles que soient les variables qui apparaissent et disparaissent. Créez les valeurs animées dans le composant qui les possède (et non au niveau du module lorsque chaque instance a besoin des siennes).

#Méthodes d’AnimatedValue

TSXRustDescription
set(value)set(value)Saute à value en arrêtant toute animation.
stop()stop()Arrête l’animation en cours là où elle en est ; en TSX, se résout avec { value }, la valeur à cet instant.
getValue()value()La valeur actuelle, lue sur le thread UI là où elle évolue (elle se stabilise en une frame) : une promesse en TSX, une réponse attendable en Rust.
setOffset(offset)set_offset(offset)Ajoute offset à ce que la valeur affiche : les bindings voient valeur + offset, tandis que set, les animations et les glissements déplacent la valeur.
flattenOffset()flatten_offset()Ajoute l’offset à la valeur et remet l’offset à 0.
extractOffset()extract_offset()Déplace la valeur dans l’offset et remet la valeur à 0 : un glissement repart alors de 0, par-dessus l’endroit où le précédent s’est arrêté.
timing(to, { duration?, easing?, delay? })timing(to, ms, easing), timing_with(to, Timing { .. })Anime vers to en duration ms (300 par défaut, easing "easeInOut").
spring(to, { stiffness?, damping?, mass?, velocity? })spring(to, Spring { .. })Passe en mode ressort vers to (valeurs par défaut : stiffness 170, damping 26, mass 1).
decay({ velocity?, deceleration?, clamp?, bounce? })decay(velocity, deceleration), decay_within(velocity, deceleration, (min, max), bounce)Poursuit son élan depuis velocity (unités par seconde) et ralentit (décélération de 0,998 par ms par défaut). Avec un clamp de [min, max], elle s’arrête à une borne, ou rebondit dessus en conservant bounce (0..1) de sa vitesse.

Chaque animation fournit une réponse que vous pouvez attendre et qui indique si elle est allée au bout : true si elle a joué jusqu’à la fin, false si quelque chose l’a arrêtée (stop(), set(), ou une nouvelle animation sur la même valeur). C’est une Promise<{ finished: boolean }> en TSX et une AnimationEnd en Rust, qu’une tâche attend pour le bool. Lancer une nouvelle animation sur une valeur en mouvement interrompt l’ancienne en douceur : un ressort ou une décélération sans vélocité continue avec la vélocité actuelle de la valeur.

// 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 fait jouer une valeur à travers des keyframes, comme le font les keyframes CSS : value.keyframes(frames, options) en TSX, value.keyframes(frames, KeyframeTiming) en Rust (Animated.keyframes(value, { keyframes, ... }) et Animation::keyframes(..) comme étape d’une animation composite). Une keyframe est un nombre (ou une chaîne d’angle telle que "90deg"), réparti uniformément avec les autres, ou { 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(),
);
OptionRust (KeyframeTiming)Par défautDescription
durationKeyframeTiming::new(ms)300Une itération, en millisecondes.
delay.delay(ms)0Avant la première itération.
easing.easing(easing)ease de CSSLa courbe de chaque segment sans easing propre.
iterations.iterations(Some(n)), .infinite()1Le nombre d’itérations, ou "infinite".
direction.direction(PlaybackDirection::Alternate)"normal""normal", "reverse", "alternate" ou "alternate-reverse".

Elles se jouent comme en CSS et dans la Web Animations API :

  • Un easing façonne chaque segment, non l’ensemble : le easing d’une keyframe façonne le segment qui commence à elle, et le easing du timing tous les autres segments.
  • Les keyframes sans offset se répartissent uniformément entre leurs voisines ; la première vaut 0 par défaut et la dernière 1.
  • Chaque itération commence par un saut vers sa première keyframe. Sans keyframe à l’offset 0, la valeur part de là où elle se trouve.
  • Pendant un delay, la valeur détient déjà la première keyframe (animation-fill-mode: backwards en CSS), de sorte qu’une entrée différée montre sa première image plutôt que la vue au repos. Cela vaut aussi pour les motions.
  • reverse et les itérations en sens inverse de alternate jouent les segments à rebours, chacun avec sa courbe inversée.
  • La valeur reste là où la dernière itération la laisse. La réponse indique si elles sont allées au bout : true si c’est le cas, false si quelque chose les a arrêtées.

#Interpolation

interpolate fait correspondre une valeur à des nombres, des couleurs ou des angles par segments linéaires, et renvoie un nouveau nœud que vous pouvez lier. Une seule valeur peut piloter de nombreuses propriétés. En Rust, interpolate(&input, &output) produit des nombres, interpolate_colors(&input, &colors) des couleurs, et interpolate_with(&input, output, Interpolation { .. }) accepte l’extrapolation et l’easing ; en TSX, value.interpolate({ inputRange, outputRange }) les accepte tous.

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()
        },
    ))
OptionRustDescription
inputRangela slice d’entréePoints d’entrée croissants.
outputRangela slice de sortie, ou AnimationOutputUne sortie par point : nombres, couleurs ("#rrggbb", "rgba(…)", nommées) ou angles ("45deg", "1rad").
extrapolateInterpolation::extrapolate(Extrapolate::Extend)En dehors de la plage d’entrée : "clamp" (par défaut), "extend" (prolonge les segments des extrémités) ou "identity" (renvoie l’entrée telle quelle).
extrapolateLeft, extrapolateRightextrapolate_left, extrapolate_rightIdem, pour un seul côté.
easingeasingFaçonne chaque segment.

#Arithmétique et diffClamp

Les nœuds se combinent par arithmétique, également en natif : a.add(b), a.sub(b), a.mul(b), a.div(b), a.mod(b) (a.modulo(b) en Rust ; jamais négatif pour un b positif, par exemple des angles dans 0..360), où b est un nœud ou un nombre. En TSX, Animated.add, subtract, multiply, divide et modulo sont les mêmes opérations sous forme de fonctions.

diffClamp(min, max) (diff_clamp) suit les variations du nœud tout en restant dans min..max. Appliqué à un offset de défilement, il produit un en-tête qui se masque quand le contenu défile vers le haut et revient dès qu’il défile vers le bas :

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

Avec { snap: true } (diff_clamp_with(min, max, DiffClamp::snapping())), un en-tête laissé partiellement visible à l’arrêt du défilement termine le mouvement : il se stabilise entièrement visible ou entièrement masqué, selon le plus proche, en 200 ms sur le thread UI. Il ne se masque que lorsque la page a défilé d’au moins sa hauteur, de sorte qu’une page défilée de moins que cela montre l’en-tête en entier. Si l’on défile à nouveau pendant le mouvement, celui-ci reprend depuis la position de l’en-tête.

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

#Animations composites

Les animations se construisent à partir d’étapes (Animation en Rust, Animated depuis nsh en TSX) et l’arbre entier s’exécute en natif : chaque étape démarre sur le thread UI à l’instant où la précédente se termine, sans délai et sans aucun code de votre part entre les deux.

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" });
});
TSXRustDescription
Animated.timing(value, { toValue, duration?, easing?, delay? })Animation::timing(&value, to, Timing { .. })Une étape de timing.
Animated.spring(value, { toValue, stiffness?, damping?, mass?, velocity? })Animation::spring(&value, to, Spring { .. })Une étape de ressort.
Animated.decay(value, { velocity?, deceleration?, clamp?, bounce? })Animation::decay(&value, velocity, deceleration), Animation::decay_within(..)Une étape de décélération.
Animated.sequence(list)Animation::sequence(list)L’une après l’autre.
Animated.parallel(list, { stopTogether? })Animation::parallel(list), .stop_together(..)Toutes en même temps.
Animated.stagger(ms, list, { from? })Animation::stagger(ms, list), Animation::stagger_from(ms, StaggerFrom::Center, list)Toutes en même temps, chacune démarrant ms après la précédente, à compter de from : "first" (par défaut), "last", "center" (une onde qui part du milieu) ou un index (StaggerFrom::First, Last, Center, Index(i)).
Animated.keyframes(value, { keyframes, ...timing })Animation::keyframes(&value, frames, KeyframeTiming)Une étape de keyframes.
Animated.delay(ms)Animation::delay(ms)Attend.
Animated.loop(animation, { iterations?, resetBeforeIteration? }).looped(iterations), .reset_before_iteration(..)Répète (indéfiniment par défaut ; None en Rust).

Une animation composite dispose de start() (en TSX aussi avec un callback et une promesse, qui reçoivent tous deux { finished } ; en Rust une bool attendable ; l’appeler à nouveau la relance), de stop() (s’arrête là où elle en est) et de reset() (s’arrête et remet ses valeurs là où elles étaient au démarrage). Le callback s’exécute une seule fois pour l’arbre entier.

Une animation en cours ne maintient pas ses valeurs en vie : lorsque le composant qui les possède disparaît (ses vues et ses variables), une boucle infinie s’arrête avec lui, et sa réponse indique qu’elle n’est pas allée au bout (false).

#Easing

Les courbes d’easing sont des données, évaluées en natif : Easing depuis nsh en TSX, l’enum Easing en Rust.

TSXRustDescription
Easing.linear, Easing.easeEasing::Linear, Easing::Bezier(0.25, 0.1, 0.25, 1.0)Vitesse constante ; le 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.expCurve::Sin, Curve::Circle, Curve::ExpSinusoïdale, circulaire, exponentielle.
Easing.back(s?)Curve::Back(s)Recule avant de partir (dépassement s, 1,70158 par défaut).
Easing.elastic(bounciness?)Curve::Elastic(bounciness)Dépassement élastique.
Easing.bounceCurve::BounceRebondit à la fin.
Easing.bezier(x1, y1, x2, y2)Easing::Bezier(x1, y1, x2, y2)Une courbe de Bézier cubique, comme en CSS.
Easing.steps(count, position?)Easing::Steps(count, StepPosition::JumpEnd)Saute entre count niveaux au lieu de se déplacer, comme le steps() de CSS : un sprite image par image, une aiguille qui tictaque. position vaut "jump-end" (par défaut, aussi "end"), "jump-start" ("start"), "jump-none" ou "jump-both".
Easing.in(e), Easing.out(e), Easing.inOut(e)Easing::Curve(curve, EaseMode::In), EaseMode::Out, EaseMode::InOutCes courbes sont des courbes « in » ; out et inOut les inversent.

En TSX, partout où un easing est accepté, vous pouvez aussi passer un nom ("linear", "ease", "easeIn", "easeOut", "easeInOut") ou un tableau de Bézier [x1, y1, x2, y2] ; en Rust, Easing::EaseIn, Easing::EaseOut et Easing::EaseInOut sont les courbes 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) renvoie la courbe en count points régulièrement espacés de 0 à 1, et Animated.sampleSpring(config, fps?) / Animated.sampleDecay(config, fps?) (sample_spring, sample_decay) les valeurs que traverse un ressort ou une décélération, une par frame (60 par seconde par défaut) depuis config.from (0) jusqu’à sa fin. Elles sont calculées par le même code natif que celui qui exécute les animations, si bien que le tracé d’une courbe montre exactement ce que fait l’animation :

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

Une layout animation anime en natif les changements de frame de la prochaine mise à jour : les vues qui se déplacent ou se redimensionnent glissent vers leurs nouvelles frames, les nouvelles vues apparaissent en fondu (ou en échelle), les vues supprimées disparaissent en fondu. Appelez-la juste avant le changement d’état : 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);
    });
};
TSXRustDescription
configureNext(config, onEnd?)animate_layout(config)Anime les changements du prochain commit ; en TSX, onEnd s’exécute à la fin.
create(duration, type?, property?)LayoutAnimation::all(LayoutTransition::new(curve, ms))Une config avec une seule courbe pour les trois phases.
Presets.easeInEaseOut, Presets.linear, Presets.springLayoutAnimation::EASE_IN_EASE_OUT, LINEAR, SPRINGConfigs prêtes à l’emploi.
easeInEaseOut(onEnd?), linear(onEnd?), spring(onEnd?)animate_layout avec un presetconfigureNext avec un preset.
Types, PropertiesLayoutCurve, LayoutPropertyLes noms, pour construire des configs.

Une config est { duration, create?, update?, delete? } (LayoutAnimation { create, update, delete } en Rust) : create pour les vues insérées, update pour celles déplacées ou redimensionnées, delete pour celles supprimées. Chaque phase est { type?, property?, duration?, delay?, springDamping? } (LayoutTransition { curve, property, duration_ms, delay_ms }), où type vaut "easeInEaseOut", "spring", "linear", "easeIn", "easeOut" ou "keyboard" (la courbe propre au clavier logiciel, chronométrée depuis le moment où le clavier a commencé à bouger, afin que ce qui lui fait de la place bouge avec lui ; LayoutCurve::Keyboard), property est ce qui s’anime quand des vues apparaissent et disparaissent ("opacity", "scaleXY", "scale"), et springDamping (0..1, 0,7 par défaut ; LayoutCurve::Spring { damping }) règle le dépassement du ressort.

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

Une layout animation couvre toutes les frames que la mise à jour modifie, où qu’elles se trouvent à l’écran : une liste qui se rafraîchit ou des données en direct qui arrivent dans la même mise à jour bougent avec elle. Dans un composant Rust, n’animez que ce que le changement d’un seul élément déplace avec ElementRef::animate_layout(transition) : la frame de l’élément lui-même et celles de son contenu, ainsi que celles des vues que sa taille pousse (ses frères et leurs contenus, jusqu’à l’écran). Les autres frames de la mise à jour changent d’un coup. C’est ainsi que nsh/base révèle son contenu avec Collapsible et 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)) })

#Ce qui s’exécute où

S’exécute surVotre code à chaque frame
Transitions, animations par keyframesThread UIaucun
Motions, keyframes, timing / spring / decay, compositesThread UIaucun ; votre code est averti de leur fin
interpolate, arithmétique, diffClampThread UIaucun
scrollValue, pan / pinch / rotationThread UIaucun ; votre code est averti du début et de la fin
Layout animationsThread UIaucun
Boucles requestAnimationFrame (request_animation_frame en Rust), handlers onScroll, PanRespondervotre code, puis une mise à jourà chaque frame

N’utilisez requestAnimationFrame (un global en TSX, qui suit l’affichage) que pour ce qui ne peut pas être décrit à l’avance, comme une simulation physique dans votre code. Tout ce qui peut être une valeur animée devrait l’être.

#Mouvement réduit

Lorsque l’utilisateur demande moins de mouvement (« Réduire les animations » sur iOS, « Supprimer les animations » sur Android), reduce_motion() en Rust et reduceMotion() depuis nsh en TSX valent true, et les variantes Tailwind motion-reduce: / motion-safe: s’appliquent.

Les motions suivent ce réglage d’elles-mêmes : play et enter atterrissent aussitôt là où la motion se termine, sans bouger, et la réponse indique qu’elle est allée au bout (true), comme pour une motion qui a joué. Une sortie finit toujours masquée, une entrée finit toujours au repos. Passez ignoreReduceMotion: true (play_ignoring_reduce_motion en Rust) pour une motion qui porte un sens dont l’utilisateur a besoin, comme un indicateur de progression qui tourne.

Les transitions, animations par keyframes, valeurs animées, keyframes et composites se jouent telles qu’elles sont écrites ; utilisez-les pour choisir une alternative plus calme :

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

Voir Accessibilité.

#Voir aussi