Skip to main content

frus_widgets/
animatedsize.rs

1//! **`AnimatedSize`**: a box that follows its child's size over time rather than at once.
2//!
3//! When what it holds grows or shrinks — a paragraph that expands, a list that gains a row —
4//! the box does not jump to the new size. It moves there over a duration, and the child is
5//! clipped to it on the way, as the reference's is.
6//!
7//! The box is a measured one (milestone 587's plan for #52): its child is laid out in a tree
8//! of its own, under the rule a [`ConstraintsTransformBox`](crate::ConstraintsTransformBox)
9//! gives it, and the box answers with the size it has got to rather than the child's. The
10//! runtime keeps, per box, where the size came from, where it is going and how far along it
11//! is; the layout reads it and tells it when the child's size has changed.
12
13use std::collections::{HashMap, HashSet};
14
15use frus_core::animation::Curve;
16use frus_core::{Alignment, Rect, Scene, Size};
17use frus_layout::Style;
18
19use crate::constraints::{AxisConstraint, ConstraintsTransform};
20use crate::interaction::{Status, WidgetId};
21use crate::theme::Theme;
22use crate::widget::Widget;
23
24/// How an [`AnimatedSize`] moves: over how long, along which curve, where the child sits in
25/// the box on the way, and whether what spills out of it is cut.
26#[derive(Clone, Debug)]
27pub struct SizeAnimation {
28    /// Seconds from one size to the next.
29    pub duration: f32,
30    /// The easing of that move.
31    pub curve: Curve,
32    /// Where the child sits in a box that is not its size yet.
33    pub alignment: Alignment,
34    /// Whether the child is cut to the box while it does not fit. On by default, as the
35    /// reference's `Clip.hardEdge`.
36    pub clip: bool,
37}
38
39/// One box's move: from, to, and how far along.
40#[derive(Clone, Debug)]
41struct Moving {
42    from: Size,
43    to: Size,
44    elapsed: f32,
45    duration: f32,
46    curve: Curve,
47    /// The child's size as this frame's layout last measured it, and how to move to it:
48    /// taken up when the runtime next advances (milestone 609).
49    seen: Option<(Size, SizeAnimation)>,
50    /// First seen in the frame not yet advanced: it takes its child's size at once.
51    fresh: bool,
52}
53
54impl Moving {
55    fn settled(size: Size) -> Self {
56        Self {
57            from: size,
58            to: size,
59            elapsed: 0.0,
60            duration: 0.0,
61            curve: Curve::Linear,
62            seen: None,
63            fresh: false,
64        }
65    }
66
67    fn done(&self) -> bool {
68        self.elapsed >= self.duration
69    }
70
71    fn current(&self) -> Size {
72        if self.done() {
73            return self.to;
74        }
75        let t = self
76            .curve
77            .transform((self.elapsed / self.duration).clamp(0.0, 1.0));
78        Size::new(
79            self.from.width + (self.to.width - self.from.width) * t,
80            self.from.height + (self.to.height - self.from.height) * t,
81        )
82    }
83}
84
85/// Every animated box's move, kept by the runtime between frames (milestone 594).
86#[derive(Default, Debug)]
87pub(crate) struct SizeAnims {
88    moving: HashMap<WidgetId, Moving>,
89    /// The boxes seen this frame, so that a box that has gone is forgotten.
90    seen: HashSet<WidgetId>,
91}
92
93/// Two sizes the same to within a hundredth of a pixel: a child measured again at the same
94/// size is not a change, whatever the last bit of the arithmetic says.
95fn same(a: Size, b: Size) -> bool {
96    (a.width - b.width).abs() < 0.01 && (a.height - b.height).abs() < 0.01
97}
98
99impl SizeAnims {
100    /// The layout measured the child of `id` at `child`. Returns the size the box has now.
101    ///
102    /// **Noted, not acted on.** A layout asks the same child several questions in one pass
103    /// — how wide with this much room, with that much, at its widest — and only the last is
104    /// the size it ends up at. Each answer used to be taken as a new target, so a box whose
105    /// child is given the width on offer saw its target move back and forth inside every
106    /// frame, started over every frame, and never settled: the demonstration's home screen
107    /// drew sixty frames a second doing nothing (milestone 609). The answer is kept, the
108    /// last one winning, and taken up once, when the runtime advances: a change starts the
109    /// move on the frame after it, as it did.
110    ///
111    /// A box seen for the first time takes its child's size at once, whichever answer was
112    /// the last. `record` is false for the layout's intrinsic questions — how big with no
113    /// room at all — which are not the size the child ends up at either.
114    pub(crate) fn observe(
115        &mut self,
116        id: WidgetId,
117        child: Size,
118        spec: &SizeAnimation,
119        record: bool,
120    ) -> Size {
121        self.seen.insert(id);
122        match self.moving.get_mut(&id) {
123            None => {
124                if record {
125                    let mut fresh = Moving::settled(child);
126                    fresh.fresh = true;
127                    self.moving.insert(id, fresh);
128                }
129                child
130            }
131            Some(moving) => {
132                if record {
133                    if moving.fresh {
134                        // Still the frame it appeared in: it is the size it is.
135                        moving.from = child;
136                        moving.to = child;
137                    } else {
138                        moving.seen = Some((child, spec.clone()));
139                    }
140                }
141                moving.current()
142            }
143        }
144    }
145
146    /// Takes up what the last layout measured: a box whose child has changed size starts
147    /// over from wherever it had got to.
148    fn take_up(moving: &mut Moving) {
149        moving.fresh = false;
150        let Some((child, spec)) = moving.seen.take() else {
151            return;
152        };
153        if !same(moving.to, child) {
154            *moving = Moving {
155                from: moving.current(),
156                to: child,
157                elapsed: 0.0,
158                duration: spec.duration.max(0.0),
159                curve: spec.curve,
160                seen: None,
161                fresh: false,
162            };
163        }
164    }
165
166    /// Where the box of `id` is and where it is going, for the layout's fingerprint: a
167    /// box on the move is a different geometry every frame, a box at rest is the same one.
168    /// Marks the box as still there.
169    pub(crate) fn fingerprint(&mut self, id: WidgetId) -> Option<(Size, Size)> {
170        self.seen.insert(id);
171        self.moving.get(&id).map(|m| (m.current(), m.to))
172    }
173
174    /// The size the child of `id` was last measured at: where the box is going.
175    #[cfg(test)]
176    fn target(&self, id: WidgetId) -> Option<Size> {
177        self.moving.get(&id).map(|m| m.to)
178    }
179
180    /// Advances every move by `dt` seconds, or ends them all at once when motion is to be
181    /// reduced. Forgets the boxes no frame has seen since the last call. Returns `true`
182    /// while any is still moving.
183    pub(crate) fn advance(&mut self, dt: f32, still: bool) -> bool {
184        let seen = std::mem::take(&mut self.seen);
185        self.moving.retain(|id, _| seen.contains(id));
186        let mut animating = false;
187        for moving in self.moving.values_mut() {
188            Self::take_up(moving);
189            if moving.done() {
190                continue;
191            }
192            moving.elapsed = if still {
193                moving.duration
194            } else {
195                moving.elapsed + dt
196            };
197            animating |= !moving.done();
198        }
199        animating
200    }
201}
202
203/// A box that **follows its child's size over time**: when the child grows or shrinks, the
204/// box moves to the new size over `duration` seconds instead of jumping there, and the child
205/// is clipped to it on the way.
206///
207/// By default the child is given the width on offer and asked for its height, which is what
208/// a section that expands and collapses wants. [`width`](Self::width) and
209/// [`height`](Self::height) change that, in the vocabulary of a
210/// [`ConstraintsTransformBox`](crate::ConstraintsTransformBox).
211///
212/// ```
213/// use frus_widgets::{text, AnimatedSize, Flex};
214/// # let expanded = true;
215/// let mut body = Flex::<()>::column().child(text("Summary"));
216/// if expanded {
217///     body = body.child(text("And everything else, which only shows when asked."));
218/// }
219/// let section = AnimatedSize::new(0.2, body);
220/// ```
221///
222/// A change is seen by the layout, so the box starts moving on the frame after the child
223/// changed; the reference's starts on the same frame.
224pub struct AnimatedSize<Msg = crate::callback::Callback> {
225    transform: ConstraintsTransform,
226    spec: SizeAnimation,
227    children: Vec<Box<dyn Widget<Msg>>>,
228}
229
230impl<Msg> AnimatedSize<Msg> {
231    /// A box holding `child`, following its size over `duration` seconds, linearly, with
232    /// the child centred while it does not fit.
233    pub fn new(duration: f32, child: impl Widget<Msg> + 'static) -> Self {
234        Self {
235            transform: ConstraintsTransform {
236                width: AxisConstraint::AsGiven,
237                height: AxisConstraint::Unbounded,
238                alignment: Alignment::CENTER,
239                report: false,
240            },
241            spec: SizeAnimation {
242                duration,
243                curve: Curve::Linear,
244                alignment: Alignment::CENTER,
245                clip: true,
246            },
247            children: vec![Box::new(child)],
248        }
249    }
250
251    /// The easing of the move.
252    pub fn curve(mut self, curve: Curve) -> Self {
253        self.spec.curve = curve;
254        self
255    }
256
257    /// Where the child sits in a box that is not its size yet.
258    pub fn alignment(mut self, alignment: Alignment) -> Self {
259        self.spec.alignment = alignment;
260        self.transform.alignment = alignment;
261        self
262    }
263
264    /// Whether the child is cut to the box while it does not fit. On by default.
265    pub fn clip(mut self, clip: bool) -> Self {
266        self.spec.clip = clip;
267        self
268    }
269
270    /// What the child is given across: the width on offer by default.
271    pub fn width(mut self, width: AxisConstraint) -> Self {
272        self.transform.width = width;
273        self
274    }
275
276    /// What the child is given down: nothing by default, so it is asked for its height.
277    pub fn height(mut self, height: AxisConstraint) -> Self {
278        self.transform.height = height;
279        self
280    }
281}
282
283impl<Msg: Clone> Widget<Msg> for AnimatedSize<Msg> {
284    fn style(&self) -> Style {
285        Style::default()
286    }
287
288    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
289        &self.children
290    }
291
292    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
293
294    fn on_click(&self) -> Option<Msg> {
295        None
296    }
297
298    fn constraints_transform(&self) -> Option<ConstraintsTransform> {
299        Some(self.transform)
300    }
301
302    fn animated_size(&self) -> Option<SizeAnimation> {
303        Some(self.spec.clone())
304    }
305
306    /// The room on offer, on an axis the child is given as it came: that axis is not the
307    /// child's to decide, so the box is as big as the room there, as the reference's is under
308    /// tight constraints.
309    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
310        crate::widget::FillAxes {
311            horizontal: self.transform.width == AxisConstraint::AsGiven,
312            vertical: self.transform.height == AxisConstraint::AsGiven,
313        }
314    }
315
316    fn debug_name(&self) -> &'static str {
317        "AnimatedSize"
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::{build_ui, Container, Flex, Runtime};
325
326    fn spec(duration: f32) -> SizeAnimation {
327        SizeAnimation {
328            duration,
329            curve: Curve::Linear,
330            alignment: Alignment::CENTER,
331            clip: true,
332        }
333    }
334
335    const ID: WidgetId = WidgetId::ROOT;
336
337    /// A box seen for the first time is its child's size at once: nothing to move from.
338    #[test]
339    fn a_new_box_starts_at_its_childs_size() {
340        let mut anims = SizeAnims::default();
341        let shown = anims.observe(ID, Size::new(100.0, 40.0), &spec(0.2), true);
342        assert_eq!(shown, Size::new(100.0, 40.0));
343        assert!(!anims.advance(0.016, false), "nothing is moving");
344    }
345
346    /// A child that grows is followed, not jumped to: the box starts where it was and gets
347    /// there after the duration, half-way at half the time.
348    #[test]
349    fn a_grown_child_is_followed_over_the_duration() {
350        let mut anims = SizeAnims::default();
351        anims.observe(ID, Size::new(100.0, 40.0), &spec(0.2), true);
352        // The next frame, as the shell advances between two.
353        anims.advance(0.0, false);
354        let now = anims.observe(ID, Size::new(100.0, 140.0), &spec(0.2), true);
355        assert_eq!(now.height, 40.0, "it starts where it was");
356        assert!(anims.advance(0.1, false));
357        let half = anims.observe(ID, Size::new(100.0, 140.0), &spec(0.2), true);
358        assert!((half.height - 90.0).abs() < 0.01, "{half:?}");
359        assert!(!anims.advance(0.1, false));
360        assert_eq!(
361            anims.observe(ID, Size::new(100.0, 140.0), &spec(0.2), true),
362            Size::new(100.0, 140.0)
363        );
364    }
365
366    /// A change in the middle of a move starts from wherever the box had got to.
367    #[test]
368    fn a_change_mid_move_starts_from_where_it_is() {
369        let mut anims = SizeAnims::default();
370        anims.observe(ID, Size::new(100.0, 0.0), &spec(0.2), true);
371        anims.advance(0.0, false);
372        anims.observe(ID, Size::new(100.0, 200.0), &spec(0.2), true);
373        anims.advance(0.1, false);
374        let back = anims.observe(ID, Size::new(100.0, 0.0), &spec(0.2), true);
375        assert!((back.height - 100.0).abs() < 0.01, "{back:?}");
376        anims.advance(0.1, false);
377        let later = anims.observe(ID, Size::new(100.0, 0.0), &spec(0.2), true);
378        assert!((later.height - 50.0).abs() < 0.01, "{later:?}");
379    }
380
381    /// **One frame's questions are not a change** (milestone 609): a layout measures the
382    /// same child at several widths in one pass, and only the last answer is the size it
383    /// ends up at. A box asked about 297, 481 and 312 px in one frame, and about the same in
384    /// the next, has not moved, and asks for no more frames.
385    #[test]
386    fn the_questions_of_one_frame_are_not_a_change() {
387        let mut anims = SizeAnims::default();
388        let asked = |anims: &mut SizeAnims| {
389            for width in [297.0, 481.0, 312.0] {
390                anims.observe(ID, Size::new(width, 22.0), &spec(0.2), true);
391            }
392        };
393        asked(&mut anims);
394        assert!(!anims.advance(0.016, false), "it appeared at its size");
395        for _ in 0..3 {
396            asked(&mut anims);
397            assert!(
398                !anims.advance(0.016, false),
399                "the same answers: nothing to follow"
400            );
401        }
402        // And a real change, the last answer, still starts a move.
403        for width in [297.0, 481.0, 350.0] {
404            anims.observe(ID, Size::new(width, 22.0), &spec(0.2), true);
405        }
406        assert!(anims.advance(0.016, false), "a new last answer is followed");
407    }
408
409    /// The layout's intrinsic questions are answered and not taken for a change.
410    #[test]
411    fn a_probe_is_not_a_change() {
412        let mut anims = SizeAnims::default();
413        anims.observe(ID, Size::new(100.0, 40.0), &spec(0.2), true);
414        let probe = anims.observe(ID, Size::new(0.0, 900.0), &spec(0.2), false);
415        assert_eq!(probe, Size::new(100.0, 40.0));
416        assert!(!anims.advance(0.016, false));
417    }
418
419    /// Reduced motion ends every move at once.
420    #[test]
421    fn reduced_motion_arrives_at_once() {
422        let mut anims = SizeAnims::default();
423        anims.observe(ID, Size::new(100.0, 40.0), &spec(0.2), true);
424        anims.observe(ID, Size::new(100.0, 140.0), &spec(0.2), true);
425        assert!(!anims.advance(0.001, true));
426        assert_eq!(
427            anims.observe(ID, Size::new(100.0, 140.0), &spec(0.2), true),
428            Size::new(100.0, 140.0)
429        );
430    }
431
432    /// A box no frame has seen is forgotten.
433    #[test]
434    fn a_box_that_has_gone_is_forgotten() {
435        let mut anims = SizeAnims::default();
436        anims.observe(ID, Size::new(100.0, 40.0), &spec(0.2), true);
437        anims.advance(0.016, false);
438        assert!(anims.target(ID).is_some());
439        anims.advance(0.016, false);
440        assert!(anims.target(ID).is_none());
441    }
442
443    fn page(rows: usize) -> Flex<()> {
444        let mut body = Flex::<()>::column();
445        for _ in 0..rows {
446            body = body.child(Container::<()>::new().height(40.0));
447        }
448        Flex::<()>::column()
449            .child(AnimatedSize::new(0.2, body))
450            .child(Container::<()>::new().height(10.0))
451    }
452
453    /// The box's height as the layout gives it, and where the thing below it starts.
454    fn heights(rt: &Runtime, rows: usize) -> (f32, f32) {
455        let tree = page(rows);
456        let ui = build_ui(&tree, Size::new(300.0, 600.0), rt, &Theme::default());
457        let _ = ui;
458        let (rects, _) = rt.layout_cache.borrow_mut().rects(
459            WidgetId::ROOT,
460            &tree,
461            rt,
462            &Theme::default(),
463            crate::relayout::Constraints::definite(Size::new(300.0, 600.0)),
464        );
465        (rects[1].height, rects[2].y)
466    }
467
468    /// **In a page**: one row, then three. The box is one row tall, still one row tall on
469    /// the frame the change is seen, part of the way on the frames after, and three rows tall
470    /// once the duration is over — and what is below it moves down with it.
471    #[test]
472    fn a_page_follows_a_growing_section() {
473        let mut rt = Runtime::default();
474        assert_eq!(heights(&rt, 1), (40.0, 40.0));
475        rt.advance(0.0);
476        assert_eq!(heights(&rt, 3).0, 40.0, "the frame the change is seen");
477        assert!(rt.advance(0.1));
478        let (half, below) = heights(&rt, 3);
479        assert!((half - 80.0).abs() < 0.5, "half-way: {half}");
480        assert!(
481            (below - half).abs() < 0.5,
482            "the next thing follows: {below}"
483        );
484        while rt.advance(0.05) {
485            heights(&rt, 3);
486        }
487        assert_eq!(heights(&rt, 3), (120.0, 120.0));
488    }
489
490    /// **An axis the child is given is the room's, at once**: a container widened from 200
491    /// to 300 px, and the bar inside, which fills the width it is given, is 300 px on the
492    /// next frame. The width is not the child's to decide, so there is nothing to follow; the
493    /// reference's box, under tight constraints, is not animated either.
494    #[test]
495    fn the_child_is_laid_out_at_its_new_size_at_once() {
496        let page = |width: f32| {
497            Container::<()>::new().width(width).child(
498                AnimatedSize::new(
499                    0.2,
500                    Container::<()>::new()
501                        .height(20.0)
502                        .color(frus_core::Color::rgb(0.2, 0.4, 0.8)),
503                )
504                .alignment(Alignment::TOP_LEFT),
505            )
506        };
507        let bar = |ui: &crate::Ui<()>| {
508            ui.scene()
509                .primitives()
510                .iter()
511                .find_map(|p| match p {
512                    frus_core::Primitive::Rect { rect, .. } => Some(*rect),
513                    _ => None,
514                })
515                .expect("the bar")
516        };
517        let mut rt = Runtime::default();
518        let size = Size::new(400.0, 300.0);
519        assert_eq!(
520            bar(&build_ui(&page(200.0), size, &rt, &Theme::default())).width,
521            200.0
522        );
523        build_ui(&page(300.0), size, &rt, &Theme::default());
524        rt.advance(0.1);
525        let ui = build_ui(&page(300.0), size, &rt, &Theme::default());
526        assert_eq!(bar(&ui).width, 300.0, "laid out at where it is going");
527    }
528
529    /// **The layout's intrinsic questions are not a change of size.** In a row, the layout
530    /// asks the box how narrow it could be before it gives it its width, and a paragraph
531    /// given no width at all is as tall as all its words stacked. The box must not start from
532    /// that: at rest from the first frame, as tall as the paragraph at the width it gets.
533    #[test]
534    fn a_row_asking_how_narrow_does_not_start_a_move() {
535        let words = "Some words that wrap once they are given a width to wrap in.";
536        let tree = crate::Row::<()>::new().child(AnimatedSize::new(0.2, crate::Text::new(words)));
537        let mut rt = Runtime::default();
538        let size = Size::new(300.0, 600.0);
539        let first = build_ui(&tree, size, &rt, &Theme::default());
540        let _ = first;
541        let (rects, _) = rt.layout_cache.borrow_mut().rects(
542            WidgetId::ROOT,
543            &tree,
544            &rt,
545            &Theme::default(),
546            crate::relayout::Constraints::definite(size),
547        );
548        let settled = rects[1].height;
549        assert!(
550            settled < 100.0,
551            "the paragraph at the width it gets: {settled}"
552        );
553        assert!(!rt.advance(0.05), "nothing to follow");
554    }
555
556    /// **On the way, the child is cut to the box**: half-way from one row to three, the rows
557    /// are painted at their own places and nothing shows below the box's 80 px.
558    #[test]
559    fn the_child_is_cut_to_the_box_on_the_way() {
560        let coloured = |rows: usize| {
561            let mut body = Flex::<()>::column();
562            for _ in 0..rows {
563                body = body.child(
564                    Container::<()>::new()
565                        .height(40.0)
566                        .color(frus_core::Color::rgb(0.2, 0.4, 0.8)),
567                );
568            }
569            Flex::<()>::column().child(AnimatedSize::new(0.2, body).alignment(Alignment::TOP_LEFT))
570        };
571        let mut rt = Runtime::default();
572        let size = Size::new(300.0, 600.0);
573        build_ui(&coloured(1), size, &rt, &Theme::default());
574        rt.advance(0.0);
575        build_ui(&coloured(3), size, &rt, &Theme::default());
576        rt.advance(0.1);
577        let ui = build_ui(&coloured(3), size, &rt, &Theme::default());
578        let painted: Vec<Rect> = ui
579            .scene()
580            .primitives()
581            .iter()
582            .filter_map(|p| match p {
583                frus_core::Primitive::Rect { rect, clip, .. } => Some(rect.intersect(*clip)),
584                _ => None,
585            })
586            .filter(|r| r.width > 0.0 && r.height > 0.0)
587            .collect();
588        assert_eq!(
589            painted.len(),
590            2,
591            "two rows show, the third is cut away: {painted:?}"
592        );
593        let bottom = painted.iter().map(|r| r.y + r.height).fold(0.0, f32::max);
594        assert!((bottom - 80.0).abs() < 0.5, "cut at the box: {painted:?}");
595    }
596}