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;