Skip to main content

bevy_react/animations/
mod.rs

1//! `ReactUiAnimationsPlugin` — a Reanimated-style animation engine for
2//! `bevy-react`.
3//!
4//! The model mirrors React Native's Reanimated: a React app declares **shared
5//! values** (one animatable `f32` with a stable id) and assigns **drivers**
6//! (`withTiming`, `withSpring`, `withRepeat`, `withSequence`) to them; an
7//! `Animated.node` binds style properties to those values. All per-frame work —
8//! advancing drivers, interpolation, writing components — happens **here, on the
9//! Bevy side**, never crossing back to JS. The one exception is completion:
10//! a driver started with a correlation token reports its settlement (one
11//! [`AnimationSettled`] message, forwarded by the integrator) so a JS callback
12//! can fire — once per animation, not per frame.
13//!
14//! This crate is deliberately decoupled from the main `bevy-react` crate (which
15//! depends on it): it owns the animation wire types ([`mod@protocol`]) and the
16//! orchestration systems, and receives commands through an [`AnimationInbox`]
17//! channel the integrator hands it.
18
19use std::collections::HashMap;
20
21use bevy::prelude::*;
22use bevy::ui::UiTransform;
23use crossbeam_channel::Receiver;
24
25mod apply;
26mod eval;
27pub(crate) mod props;
28pub mod protocol;
29mod runner;
30
31use apply::apply_animated_nodes;
32pub(crate) use apply::push_transform_dirt;
33pub use eval::{Lerp, build_ui_transform};
34use eval::{eval_color, eval_scalar};
35
36pub use protocol::{
37    AnimatableProperty, AnimatedBindings, AnimationCommand, Binding, Driver, Easing, SharedId,
38    ValueKind,
39};
40pub use runner::{Runner, build_runner};
41
42/// Adds the animation orchestration: the [`SharedValues`] table, the per-frame
43/// driver/apply systems, and the [`AnimationInbox`] that feeds commands in.
44///
45/// Added automatically by `bevy_react::ReactUiPlugin` unless
46/// `.with_animations(false)`. The integrator is responsible for ordering
47/// [`AnimationSet::Apply`] after the reconciler's op-apply so per-frame animation
48/// writes win over this frame's static style.
49pub struct ReactUiAnimationsPlugin {
50    inbox: Receiver<AnimationCommand>,
51}
52
53impl ReactUiAnimationsPlugin {
54    /// Build the plugin around the receiving end of the `op_animate` channel.
55    pub fn new(inbox: Receiver<AnimationCommand>) -> Self {
56        Self { inbox }
57    }
58}
59
60impl Plugin for ReactUiAnimationsPlugin {
61    fn build(&self, app: &mut App) {
62        app.init_resource::<SharedValues>()
63            // The apply system reports content writes to the layer cache; the
64            // integrator inits this too, but standalone use shouldn't panic.
65            .init_resource::<crate::layer::LayerContentDirt>()
66            .add_message::<AnimationSettled>()
67            .insert_resource(AnimationInbox(self.inbox.clone()))
68            .configure_sets(
69                Update,
70                (AnimationSet::Drain, AnimationSet::Tick, AnimationSet::Apply).chain(),
71            )
72            .add_systems(
73                Update,
74                (
75                    drain_animation_commands.in_set(AnimationSet::Drain),
76                    tick_animations.in_set(AnimationSet::Tick),
77                    apply_animated_nodes.in_set(AnimationSet::Apply),
78                ),
79            );
80    }
81}
82
83/// Ordering handles for the three animation systems. The integrator orders
84/// [`AnimationSet::Apply`] relative to its own reconciler systems.
85#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
86pub enum AnimationSet {
87    /// Drain inbound commands into the [`SharedValues`] table.
88    Drain,
89    /// Advance every active driver by the frame delta.
90    Tick,
91    /// Write resolved values onto `UiTransform` / colors.
92    Apply,
93}
94
95/// Component placed (by the main reconciler) on any `Animated.node`. Carries the
96/// property→[`Binding`] map. Requires `UiTransform` so the apply system can always
97/// drive it.
98#[derive(Component, Debug, Clone)]
99#[require(UiTransform)]
100pub struct AnimatedNode(pub AnimatedBindings);
101
102/// A token-tagged driver settled: `finished` is `true` when it ran to its natural
103/// end, `false` when a `set`/`cancel`/new `animate` interrupted it. Written by
104/// the drain/tick systems for every [`AnimationCommand::Animate`] that carried a
105/// `token`; the integrator (`bevy-react`) forwards these to the JS completion
106/// callbacks. The one thing this crate sends back toward JS.
107#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
108pub struct AnimationSettled {
109    /// The shared value the driver was animating.
110    pub id: SharedId,
111    /// The JS-side correlation token from the `animate` command.
112    pub token: u64,
113    /// Natural completion (`true`) vs interruption (`false`).
114    pub finished: bool,
115}
116
117/// The receiving end of the `op_animate` channel, drained each frame.
118#[derive(Resource)]
119pub struct AnimationInbox(pub(crate) Receiver<AnimationCommand>);
120
121/// The live table of shared values, keyed by [`SharedId`]. Each entry holds the
122/// current reading plus an optional active driver. Settlements of token-tagged
123/// drivers accumulate in `settled` until the owning system flushes them to the
124/// [`AnimationSettled`] message stream.
125#[derive(Resource, Default)]
126pub struct SharedValues {
127    values: HashMap<SharedId, SharedValueState>,
128    settled: Vec<AnimationSettled>,
129}
130
131struct SharedValueState {
132    current: f32,
133    active: Option<Runner>,
134    /// Correlation token of the active driver's JS completion callback, if any.
135    token: Option<u64>,
136}
137
138impl SharedValueState {
139    /// The settlement for interrupting a still-active token-tagged driver
140    /// (`set`/`cancel`/a superseding `animate`), consuming the token.
141    fn interrupted(&mut self, id: SharedId) -> Option<AnimationSettled> {
142        self.active.as_ref()?;
143        let token = self.token.take()?;
144        Some(AnimationSettled {
145            id,
146            token,
147            finished: false,
148        })
149    }
150}
151
152impl SharedValues {
153    /// The current reading of a shared value, if it exists.
154    pub fn get(&self, id: SharedId) -> Option<f32> {
155        self.values.get(&id).map(|s| s.current)
156    }
157
158    /// Number of live shared values (handy in tests).
159    pub fn len(&self) -> usize {
160        self.values.len()
161    }
162
163    /// Whether the table is empty.
164    pub fn is_empty(&self) -> bool {
165        self.values.is_empty()
166    }
167
168    fn declare(&mut self, id: SharedId, initial: f32) {
169        // Idempotent: only the first declaration sets the initial reading, so a
170        // value survives React re-renders (matching `useSharedValue`).
171        self.values.entry(id).or_insert(SharedValueState {
172            current: initial,
173            active: None,
174            token: None,
175        });
176    }
177
178    fn set(&mut self, id: SharedId, value: f32) {
179        let s = self.values.entry(id).or_insert(SharedValueState {
180            current: value,
181            active: None,
182            token: None,
183        });
184        self.settled.extend(s.interrupted(id));
185        s.current = value;
186        s.active = None;
187    }
188
189    fn animate(&mut self, id: SharedId, driver: &Driver, token: Option<u64>) {
190        let s = self.values.entry(id).or_insert(SharedValueState {
191            current: 0.0,
192            active: None,
193            token: None,
194        });
195        self.settled.extend(s.interrupted(id));
196        let from = s.current;
197        s.active = Some(build_runner(driver, from));
198        s.token = token;
199    }
200
201    fn cancel(&mut self, id: SharedId) {
202        if let Some(s) = self.values.get_mut(&id) {
203            self.settled.extend(s.interrupted(id));
204            s.active = None;
205        }
206    }
207
208    fn clear(&mut self) {
209        self.values.clear();
210        // Reset also wipes the JS callback registry, so pending settlements would
211        // land on nobody — drop them.
212        self.settled.clear();
213    }
214
215    fn tick(&mut self, dt: f32) {
216        for (&id, s) in self.values.iter_mut() {
217            if let Some(runner) = s.active.as_mut() {
218                let (value, finished) = runner.step(dt);
219                s.current = value;
220                if finished {
221                    s.active = None;
222                    if let Some(token) = s.token.take() {
223                        self.settled.push(AnimationSettled {
224                            id,
225                            token,
226                            finished: true,
227                        });
228                    }
229                }
230            }
231        }
232    }
233
234    /// Flush the settlements accumulated since the last flush.
235    fn take_settled(&mut self) -> Vec<AnimationSettled> {
236        std::mem::take(&mut self.settled)
237    }
238}
239
240// --- Systems -------------------------------------------------------------------
241
242fn drain_animation_commands(
243    inbox: Res<AnimationInbox>,
244    mut values: ResMut<SharedValues>,
245    mut settled: MessageWriter<AnimationSettled>,
246) {
247    while let Ok(cmd) = inbox.0.try_recv() {
248        match cmd {
249            AnimationCommand::Declare { id, initial } => values.declare(id, initial),
250            AnimationCommand::Set { id, value } => values.set(id, value),
251            AnimationCommand::Animate { id, driver, token } => values.animate(id, &driver, token),
252            AnimationCommand::Cancel { id } => values.cancel(id),
253            AnimationCommand::Clear => values.clear(),
254        }
255    }
256    settled.write_batch(values.take_settled());
257}
258
259fn tick_animations(
260    time: Res<Time>,
261    mut values: ResMut<SharedValues>,
262    mut settled: MessageWriter<AnimationSettled>,
263) {
264    values.tick(time.delta_secs());
265    settled.write_batch(values.take_settled());
266}
267
268// (Driver runtime — `Runner`, `build_runner`, easing — lives in `runner.rs`.)
269
270#[cfg(test)]
271mod tests;