Skip to main content

uzor_interactive/
animated_list.rs

1//! Animated list with stagger entry/exit animations
2//!
3//! Items fade in with slide-up animation on entry, and fade out
4//! with slide-down + scale on exit. Stagger delay creates wave effect.
5
6/// Animation state for a single list item
7#[derive(Debug, Clone, Copy)]
8pub struct ItemState {
9    /// Opacity (0.0 = invisible, 1.0 = fully visible)
10    pub opacity: f32,
11
12    /// Vertical offset in pixels (positive = down)
13    pub y_offset: f32,
14
15    /// Scale factor (1.0 = normal size)
16    pub scale: f32,
17}
18
19impl Default for ItemState {
20    fn default() -> Self {
21        Self {
22            opacity: 0.0,
23            y_offset: 0.0,
24            scale: 0.7,
25        }
26    }
27}
28
29impl ItemState {
30    /// Create entry animation state (invisible, below, scaled down)
31    pub fn entry_start() -> Self {
32        Self {
33            opacity: 0.0,
34            y_offset: 20.0,
35            scale: 0.7,
36        }
37    }
38
39    /// Create visible state (fully opaque, no offset, normal scale)
40    pub fn visible() -> Self {
41        Self {
42            opacity: 1.0,
43            y_offset: 0.0,
44            scale: 1.0,
45        }
46    }
47
48    /// Create exit animation state (invisible, below, scaled down)
49    pub fn exit_end() -> Self {
50        Self {
51            opacity: 0.0,
52            y_offset: 20.0,
53            scale: 0.7,
54        }
55    }
56
57    /// Interpolate between two states
58    pub fn lerp(from: Self, to: Self, t: f32) -> Self {
59        let t = t.clamp(0.0, 1.0);
60        Self {
61            opacity: from.opacity + (to.opacity - from.opacity) * t,
62            y_offset: from.y_offset + (to.y_offset - from.y_offset) * t,
63            scale: from.scale + (to.scale - from.scale) * t,
64        }
65    }
66}
67
68/// Animated list manager
69///
70/// Tracks animation state for multiple items with staggered entry/exit.
71#[derive(Debug, Clone)]
72pub struct AnimatedList {
73    /// Number of items in the list
74    item_count: usize,
75
76    /// Animation states for each item
77    states: Vec<ItemAnimationState>,
78
79    /// Stagger delay between items (seconds)
80    pub stagger_delay: f32,
81
82    /// Animation duration per item (seconds)
83    pub animation_duration: f32,
84}
85
86#[derive(Debug, Clone)]
87struct ItemAnimationState {
88    /// Current visual state
89    current: ItemState,
90
91    /// Animation progress (0.0 = start, 1.0 = complete)
92    progress: f32,
93
94    /// Animation type
95    animation: AnimationType,
96
97    /// Start time of animation
98    start_time: f64,
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum AnimationType {
103    None,
104    Entry,
105    Exit,
106}
107
108impl Default for AnimatedList {
109    fn default() -> Self {
110        Self::new(0)
111    }
112}
113
114impl AnimatedList {
115    /// Create a new animated list
116    pub fn new(item_count: usize) -> Self {
117        Self {
118            item_count,
119            states: vec![
120                ItemAnimationState {
121                    current: ItemState::entry_start(),
122                    progress: 0.0,
123                    animation: AnimationType::Entry,
124                    start_time: 0.0,
125                };
126                item_count
127            ],
128            stagger_delay: 0.05,
129            animation_duration: 0.2,
130        }
131    }
132
133    /// Set stagger delay between items
134    pub fn with_stagger_delay(mut self, delay: f32) -> Self {
135        self.stagger_delay = delay;
136        self
137    }
138
139    /// Set animation duration per item
140    pub fn with_duration(mut self, duration: f32) -> Self {
141        self.animation_duration = duration;
142        self
143    }
144
145    /// Update item count (triggers entry/exit animations as needed)
146    pub fn set_item_count(&mut self, new_count: usize, current_time: f64) {
147        if new_count > self.item_count {
148            // Add new items with entry animation
149            for i in self.item_count..new_count {
150                self.states.push(ItemAnimationState {
151                    current: ItemState::entry_start(),
152                    progress: 0.0,
153                    animation: AnimationType::Entry,
154                    start_time: current_time + (i - self.item_count) as f64 * self.stagger_delay as f64,
155                });
156            }
157        } else if new_count < self.item_count {
158            // Mark removed items for exit animation
159            for state in self.states.iter_mut().skip(new_count) {
160                if state.animation != AnimationType::Exit {
161                    state.animation = AnimationType::Exit;
162                    state.progress = 0.0;
163                    state.start_time = current_time;
164                }
165            }
166        }
167
168        self.item_count = new_count;
169    }
170
171    /// Update all animations
172    pub fn update(&mut self, current_time: f64) {
173        for (index, state) in self.states.iter_mut().enumerate() {
174            if state.animation == AnimationType::None {
175                continue;
176            }
177
178            let elapsed = (current_time - state.start_time) as f32;
179            let stagger_offset = index as f32 * self.stagger_delay;
180
181            // Calculate progress with stagger
182            let effective_elapsed = (elapsed - stagger_offset).max(0.0);
183            state.progress = (effective_elapsed / self.animation_duration).clamp(0.0, 1.0);
184
185            // Apply easing (ease-out cubic)
186            let eased_progress = 1.0 - (1.0 - state.progress).powi(3);
187
188            // Update current state based on animation type
189            match state.animation {
190                AnimationType::Entry => {
191                    state.current = ItemState::lerp(
192                        ItemState::entry_start(),
193                        ItemState::visible(),
194                        eased_progress,
195                    );
196
197                    if state.progress >= 1.0 {
198                        state.animation = AnimationType::None;
199                        state.current = ItemState::visible();
200                    }
201                }
202                AnimationType::Exit => {
203                    state.current = ItemState::lerp(
204                        ItemState::visible(),
205                        ItemState::exit_end(),
206                        eased_progress,
207                    );
208                }
209                AnimationType::None => {}
210            }
211        }
212
213        // Remove fully exited items
214        self.states.retain(|state| {
215            state.animation != AnimationType::Exit || state.progress < 1.0
216        });
217    }
218
219    /// Get animation state for item at index
220    pub fn get_item_state(&self, index: usize) -> Option<ItemState> {
221        self.states.get(index).map(|s| s.current)
222    }
223
224    /// Get all item states
225    pub fn item_states(&self) -> impl Iterator<Item = (usize, ItemState)> + '_ {
226        self.states
227            .iter()
228            .enumerate()
229            .filter(|(i, _)| *i < self.item_count)
230            .map(|(i, s)| (i, s.current))
231    }
232
233    /// Check if any animations are in progress
234    pub fn is_animating(&self) -> bool {
235        self.states.iter().any(|s| s.animation != AnimationType::None)
236    }
237
238    /// Trigger entry animation for all items
239    pub fn animate_in(&mut self, current_time: f64) {
240        for (index, state) in self.states.iter_mut().enumerate() {
241            state.animation = AnimationType::Entry;
242            state.progress = 0.0;
243            state.start_time = current_time + index as f64 * self.stagger_delay as f64;
244            state.current = ItemState::entry_start();
245        }
246    }
247
248    /// Trigger exit animation for all items
249    pub fn animate_out(&mut self, current_time: f64) {
250        for (index, state) in self.states.iter_mut().enumerate() {
251            state.animation = AnimationType::Exit;
252            state.progress = 0.0;
253            state.start_time = current_time + index as f64 * self.stagger_delay as f64;
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_item_state_lerp() {
264        let start = ItemState::entry_start();
265        let end = ItemState::visible();
266
267        let mid = ItemState::lerp(start, end, 0.5);
268        assert!((mid.opacity - 0.5).abs() < 0.01);
269        assert!(mid.y_offset > 0.0 && mid.y_offset < 20.0);
270        assert!(mid.scale > 0.7 && mid.scale < 1.0);
271    }
272
273    #[test]
274    fn test_animated_list_creation() {
275        let list = AnimatedList::new(5);
276        assert_eq!(list.item_count, 5);
277        assert_eq!(list.states.len(), 5);
278    }
279
280    #[test]
281    fn test_entry_animation() {
282        let mut list = AnimatedList::new(3);
283
284        // At t=0, all items should be in entry state
285        list.update(0.0);
286        for i in 0..3 {
287            let state = list.get_item_state(i).unwrap();
288            assert!(state.opacity < 0.1); // Nearly invisible
289        }
290
291        // At t=0.1, first item should be partially visible
292        list.update(0.1);
293        let state0 = list.get_item_state(0).unwrap();
294        assert!(state0.opacity > 0.0);
295
296        // At t=1.0, all items should be fully visible
297        list.update(1.0);
298        for i in 0..3 {
299            let state = list.get_item_state(i).unwrap();
300            assert!((state.opacity - 1.0).abs() < 0.1);
301            assert!(state.y_offset.abs() < 1.0);
302        }
303    }
304
305    #[test]
306    fn test_stagger_delay() {
307        let mut list = AnimatedList::new(3).with_stagger_delay(0.1);
308
309        list.update(0.0);
310
311        // At t=0.05 (before stagger), second item should still be invisible
312        list.update(0.05);
313        let state1 = list.get_item_state(1).unwrap();
314        assert!(state1.opacity < 0.1);
315
316        // At t=0.15 (after stagger), second item should be animating
317        list.update(0.15);
318        let state1 = list.get_item_state(1).unwrap();
319        assert!(state1.opacity > 0.1);
320    }
321
322    #[test]
323    fn test_add_items() {
324        let mut list = AnimatedList::new(2);
325        list.update(1.0); // Complete initial animation
326
327        // Add one more item
328        list.set_item_count(3, 1.0);
329        assert_eq!(list.states.len(), 3);
330
331        // New item should start with entry animation
332        list.update(1.0);
333        let state2 = list.get_item_state(2).unwrap();
334        assert!(state2.opacity < 1.0);
335    }
336
337    #[test]
338    fn test_remove_items() {
339        let mut list = AnimatedList::new(3);
340        list.update(1.0); // Complete entry animations
341
342        // Remove one item
343        list.set_item_count(2, 1.0);
344
345        // Third item should be animating out
346        list.update(1.0);
347        assert!(list.is_animating());
348
349        // After exit animation completes, item should be removed
350        list.update(2.0);
351        assert_eq!(list.states.len(), 2);
352    }
353
354    #[test]
355    fn test_animate_in_out() {
356        let mut list = AnimatedList::new(2);
357
358        // Animate in
359        list.animate_in(0.0);
360        assert!(list.is_animating());
361
362        list.update(1.0);
363        assert!(!list.is_animating());
364
365        // Animate out
366        list.animate_out(1.0);
367        assert!(list.is_animating());
368
369        // Check mid-exit animation (before items are removed)
370        list.update(1.15);
371        if let Some(state0) = list.get_item_state(0) {
372            assert!(state0.opacity < 1.0); // Should be fading out
373        }
374
375        // After full exit animation, items should be removed
376        list.update(2.0);
377        assert_eq!(list.states.len(), 0);
378    }
379}