Skip to main content

fret_ui_kit/declarative/
motion_value.rs

1use std::panic::Location;
2use std::time::Duration;
3
4use fret_ui::{ElementContext, Invalidation, UiHost};
5use fret_ui_headless::motion::inertia::{InertiaBounds, InertiaSimulation};
6use fret_ui_headless::motion::simulation::Simulation1D;
7use fret_ui_headless::motion::spring::{SpringDescription, SpringSimulation};
8use fret_ui_headless::motion::tolerance::Tolerance;
9
10use crate::declarative::motion::{
11    DrivenMotionF32, effective_frame_delta_for_cx, tween_value_at, tween_velocity_at,
12};
13use crate::declarative::scheduling::set_continuous_frames;
14
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct MotionKickF32 {
17    pub id: u64,
18    pub velocity: f32,
19}
20
21#[derive(Debug, Clone, Copy)]
22pub struct TweenSpecF32 {
23    pub duration: Duration,
24    pub ease: fn(f32) -> f32,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct SpringSpecF32 {
29    pub spring: SpringDescription,
30    pub tolerance: Tolerance,
31    pub snap_to_target: bool,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct InertiaSpecF32 {
36    pub drag: f64,
37    pub bounds: Option<(f32, f32)>,
38    pub bounce_spring: SpringDescription,
39    pub tolerance: Tolerance,
40}
41
42#[derive(Debug, Clone, Copy)]
43pub enum MotionToSpecF32 {
44    Tween(TweenSpecF32),
45    Spring(SpringSpecF32),
46}
47
48#[derive(Debug, Clone, Copy)]
49pub enum MotionValueF32Update {
50    Snap(f32),
51    To {
52        target: f32,
53        spec: MotionToSpecF32,
54        kick: Option<MotionKickF32>,
55    },
56    Inertia {
57        spec: InertiaSpecF32,
58        kick: MotionKickF32,
59    },
60}
61
62#[derive(Debug, Clone, Copy, PartialEq)]
63enum MotionValueF32Kind {
64    Snap,
65    Tween,
66    Spring,
67    Inertia,
68}
69
70#[derive(Debug, Clone, Copy)]
71struct MotionValueF32State {
72    initialized: bool,
73    last_frame_id: u64,
74    kind: MotionValueF32Kind,
75
76    value: f32,
77    velocity: f32,
78    animating: bool,
79    elapsed: Duration,
80
81    tween_start: f32,
82    tween_target: f32,
83    tween_duration: Duration,
84    tween_ease: fn(f32) -> f32,
85
86    spring_start: f32,
87    spring_target: f32,
88    spring: SpringDescription,
89    spring_tolerance: Tolerance,
90    spring_snap_to_target: bool,
91    spring_last_kick_id: u64,
92
93    inertia_start: f32,
94    inertia_start_velocity: f32,
95    inertia_drag: f64,
96    inertia_bounds: Option<(f32, f32)>,
97    inertia_bounce_spring: SpringDescription,
98    inertia_tolerance: Tolerance,
99    inertia_last_kick_id: u64,
100}
101
102impl Default for MotionValueF32State {
103    fn default() -> Self {
104        Self {
105            initialized: false,
106            last_frame_id: 0,
107            kind: MotionValueF32Kind::Snap,
108            value: 0.0,
109            velocity: 0.0,
110            animating: false,
111            elapsed: Duration::ZERO,
112
113            tween_start: 0.0,
114            tween_target: 0.0,
115            tween_duration: Duration::from_millis(200),
116            tween_ease: crate::headless::easing::smoothstep,
117
118            spring_start: 0.0,
119            spring_target: 0.0,
120            spring: SpringDescription::with_duration_and_bounce(Duration::from_millis(240), 0.0),
121            spring_tolerance: Tolerance::default(),
122            spring_snap_to_target: true,
123            spring_last_kick_id: 0,
124
125            inertia_start: 0.0,
126            inertia_start_velocity: 0.0,
127            inertia_drag: 0.135,
128            inertia_bounds: None,
129            inertia_bounce_spring: SpringDescription::with_duration_and_bounce(
130                Duration::from_millis(240),
131                0.25,
132            ),
133            inertia_tolerance: Tolerance::default(),
134            inertia_last_kick_id: 0,
135        }
136    }
137}
138
139#[track_caller]
140pub fn drive_motion_value_f32<H: UiHost>(
141    cx: &mut ElementContext<'_, H>,
142    initial: f32,
143    update: MotionValueF32Update,
144) -> DrivenMotionF32 {
145    let reduced_motion = super::prefers_reduced_motion(cx, Invalidation::Paint, false);
146    let loc = Location::caller();
147    cx.keyed(
148        (
149            loc.file(),
150            loc.line(),
151            loc.column(),
152            "drive_motion_value_f32",
153        ),
154        |cx| {
155            let frame_id = cx.frame_id.0;
156            let dt = effective_frame_delta_for_cx(cx);
157
158            let out = cx.slot_state(MotionValueF32State::default, |st| {
159                if !st.initialized {
160                    st.initialized = true;
161                    st.last_frame_id = frame_id;
162                    st.kind = MotionValueF32Kind::Snap;
163                    st.value = initial;
164                    st.velocity = 0.0;
165                    st.animating = false;
166                    st.elapsed = Duration::ZERO;
167                }
168
169                if reduced_motion {
170                    match update {
171                        MotionValueF32Update::Snap(v) => {
172                            st.kind = MotionValueF32Kind::Snap;
173                            st.value = v;
174                            st.velocity = 0.0;
175                            st.animating = false;
176                            st.elapsed = Duration::ZERO;
177                        }
178                        MotionValueF32Update::To { target, .. } => {
179                            st.kind = MotionValueF32Kind::Snap;
180                            st.value = target;
181                            st.velocity = 0.0;
182                            st.animating = false;
183                            st.elapsed = Duration::ZERO;
184                        }
185                        MotionValueF32Update::Inertia { .. } => {
186                            st.kind = MotionValueF32Kind::Snap;
187                            st.velocity = 0.0;
188                            st.animating = false;
189                            st.elapsed = Duration::ZERO;
190                        }
191                    }
192
193                    return DrivenMotionF32 {
194                        value: st.value,
195                        velocity: st.velocity,
196                        animating: st.animating,
197                    };
198                }
199
200                match update {
201                    MotionValueF32Update::Snap(v) => {
202                        st.kind = MotionValueF32Kind::Snap;
203                        st.value = v;
204                        st.velocity = 0.0;
205                        st.animating = false;
206                        st.elapsed = Duration::ZERO;
207                    }
208                    MotionValueF32Update::To { target, spec, kick } => match spec {
209                        MotionToSpecF32::Tween(spec) => {
210                            let needs_retarget = st.kind != MotionValueF32Kind::Tween
211                                || st.tween_target != target
212                                || st.tween_duration != spec.duration
213                                || st.tween_ease as usize != spec.ease as usize;
214
215                            if needs_retarget {
216                                st.kind = MotionValueF32Kind::Tween;
217                                st.tween_start = st.value;
218                                st.tween_target = target;
219                                st.tween_duration = spec.duration;
220                                st.tween_ease = spec.ease;
221                                st.elapsed = Duration::ZERO;
222                                st.animating = true;
223                            }
224
225                            if st.animating && st.last_frame_id != frame_id {
226                                st.last_frame_id = frame_id;
227                                st.elapsed = st.elapsed.saturating_add(dt);
228
229                                st.value = tween_value_at(
230                                    st.tween_start,
231                                    st.tween_target,
232                                    st.tween_duration,
233                                    st.tween_ease,
234                                    st.elapsed,
235                                );
236                                st.velocity = tween_velocity_at(
237                                    st.tween_start,
238                                    st.tween_target,
239                                    st.tween_duration,
240                                    st.tween_ease,
241                                    st.elapsed,
242                                );
243
244                                if st.elapsed >= st.tween_duration {
245                                    st.value = st.tween_target;
246                                    st.velocity = 0.0;
247                                    st.animating = false;
248                                }
249                            } else if st.last_frame_id == 0 {
250                                st.last_frame_id = frame_id;
251                            }
252                        }
253                        MotionToSpecF32::Spring(spec) => {
254                            let kick_retarget = kick.is_some()
255                                && kick.map(|k| k.id).unwrap_or(0) != st.spring_last_kick_id;
256                            let needs_retarget = st.kind != MotionValueF32Kind::Spring
257                                || st.spring_target != target
258                                || st.spring != spec.spring
259                                || st.spring_tolerance != spec.tolerance
260                                || st.spring_snap_to_target != spec.snap_to_target
261                                || kick_retarget;
262
263                            if needs_retarget {
264                                st.kind = MotionValueF32Kind::Spring;
265                                st.spring_start = st.value;
266                                st.spring_target = target;
267                                st.spring = spec.spring;
268                                st.spring_tolerance = spec.tolerance;
269                                st.spring_snap_to_target = spec.snap_to_target;
270                                st.elapsed = Duration::ZERO;
271                                st.animating = true;
272
273                                if let Some(kick) = kick
274                                    && kick.id != st.spring_last_kick_id
275                                {
276                                    st.velocity = kick.velocity;
277                                    st.spring_last_kick_id = kick.id;
278                                }
279                            }
280
281                            if st.animating && st.last_frame_id != frame_id {
282                                st.last_frame_id = frame_id;
283                                st.elapsed = st.elapsed.saturating_add(dt);
284
285                                let sim = SpringSimulation::new(
286                                    st.spring,
287                                    st.spring_start as f64,
288                                    st.spring_target as f64,
289                                    st.velocity as f64,
290                                    st.spring_snap_to_target,
291                                    st.spring_tolerance,
292                                );
293
294                                st.value = sim.x(st.elapsed) as f32;
295                                st.velocity = sim.dx(st.elapsed) as f32;
296
297                                if sim.is_done(st.elapsed) {
298                                    st.value = st.spring_target;
299                                    st.velocity = 0.0;
300                                    st.animating = false;
301                                }
302                            } else if st.last_frame_id == 0 {
303                                st.last_frame_id = frame_id;
304                            }
305                        }
306                    },
307                    MotionValueF32Update::Inertia { spec, kick } => {
308                        let kick_retarget = kick.id != st.inertia_last_kick_id;
309                        let needs_retarget = st.kind != MotionValueF32Kind::Inertia
310                            || kick_retarget
311                            || st.inertia_drag != spec.drag
312                            || st.inertia_bounds != spec.bounds
313                            || st.inertia_bounce_spring != spec.bounce_spring
314                            || st.inertia_tolerance != spec.tolerance;
315
316                        if needs_retarget {
317                            st.kind = MotionValueF32Kind::Inertia;
318                            st.inertia_drag = spec.drag;
319                            st.inertia_bounds = spec.bounds;
320                            st.inertia_bounce_spring = spec.bounce_spring;
321                            st.inertia_tolerance = spec.tolerance;
322
323                            if kick.id != st.inertia_last_kick_id {
324                                st.inertia_last_kick_id = kick.id;
325                                st.inertia_start = st.value;
326                                st.inertia_start_velocity = kick.velocity;
327                                st.velocity = kick.velocity;
328                                st.elapsed = Duration::ZERO;
329                                st.animating = true;
330                            } else if st.animating {
331                                st.inertia_start = st.value;
332                                st.inertia_start_velocity = st.velocity;
333                                st.elapsed = Duration::ZERO;
334                            }
335                        }
336
337                        if st.animating && st.last_frame_id != frame_id {
338                            st.last_frame_id = frame_id;
339                            st.elapsed = st.elapsed.saturating_add(dt);
340
341                            let inertia_bounds =
342                                st.inertia_bounds.map(|(min, max)| InertiaBounds {
343                                    min: min as f64,
344                                    max: max as f64,
345                                });
346                            let sim = InertiaSimulation::new(
347                                st.inertia_start as f64,
348                                st.inertia_start_velocity as f64,
349                                st.inertia_drag,
350                                inertia_bounds,
351                                st.inertia_bounce_spring,
352                                st.inertia_tolerance,
353                            );
354
355                            st.value = sim.x(st.elapsed) as f32;
356                            st.velocity = sim.dx(st.elapsed) as f32;
357
358                            if sim.is_done(st.elapsed) {
359                                st.value = sim.final_x() as f32;
360                                st.velocity = 0.0;
361                                st.animating = false;
362                            }
363                        } else if st.last_frame_id == 0 {
364                            st.last_frame_id = frame_id;
365                        }
366                    }
367                }
368
369                DrivenMotionF32 {
370                    value: st.value,
371                    velocity: st.velocity,
372                    animating: st.animating,
373                }
374            });
375
376            set_continuous_frames(cx, out.animating);
377            if out.animating {
378                cx.notify_for_animation_frame();
379            }
380            out
381        },
382    )
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    use fret_app::App;
390    use fret_core::{AppWindowId, Px, WindowFrameClockService};
391    use fret_runtime::{FrameId, TickId};
392    use fret_ui::elements::with_element_cx;
393
394    fn bounds() -> fret_core::Rect {
395        fret_core::Rect::new(
396            fret_core::Point::new(Px(0.0), Px(0.0)),
397            fret_core::Size::new(Px(800.0), Px(600.0)),
398        )
399    }
400
401    fn drive<H: UiHost>(
402        cx: &mut ElementContext<'_, H>,
403        update: MotionValueF32Update,
404    ) -> DrivenMotionF32 {
405        drive_motion_value_f32(cx, 0.0, update)
406    }
407
408    #[test]
409    fn motion_value_snap_then_spring_to_does_not_jump_on_first_frame() {
410        let window = AppWindowId::default();
411        let mut app = App::new();
412
413        app.with_global_mut(WindowFrameClockService::default, |svc, _app| {
414            svc.set_fixed_delta(window, Some(Duration::from_millis(16)));
415        });
416
417        for fid in [FrameId(1), FrameId(2)] {
418            app.set_frame_id(fid);
419            app.with_global_mut(WindowFrameClockService::default, |svc, app| {
420                svc.record_frame(window, app.frame_id());
421            });
422        }
423
424        app.set_tick_id(TickId(1));
425        let mut out = with_element_cx(&mut app, window, bounds(), "motion_value", |cx| {
426            drive(cx, MotionValueF32Update::Snap(10.0))
427        });
428        assert_eq!(out.value, 10.0);
429        assert!(!out.animating);
430
431        app.set_tick_id(TickId(2));
432        out = with_element_cx(&mut app, window, bounds(), "motion_value", |cx| {
433            drive(
434                cx,
435                MotionValueF32Update::To {
436                    target: 20.0,
437                    spec: MotionToSpecF32::Spring(SpringSpecF32 {
438                        spring: SpringDescription::with_duration_and_bounce(
439                            Duration::from_millis(240),
440                            0.0,
441                        ),
442                        tolerance: Tolerance::default(),
443                        snap_to_target: true,
444                    }),
445                    kick: Some(MotionKickF32 {
446                        id: 1,
447                        velocity: 0.0,
448                    }),
449                },
450            )
451        });
452
453        // First frame after retarget should start from the snapped value.
454        assert_eq!(out.value, 10.0);
455        assert!(out.animating);
456    }
457}