Skip to main content

frus_widgets/
crossfade.rs

1//! **`AnimatedCrossFade`**: one of two children shown, the other faded in over it when the
2//! choice changes, and the box moving from the first one's size to the second one's.
3//!
4//! It is composed, as the reference's is, of pieces that already exist: an
5//! [`AnimatedSize`](crate::AnimatedSize) around a cell holding both children, each fading
6//! towards shown or hidden. The child being shown is laid out in the cell and gives it its
7//! size. The other is laid over it, at the cell's width and its own height, in a box that
8//! counts for nothing — so that the size the box moves to is the shown child's alone — and
9//! whatever of it runs past the box is cut.
10
11use std::cell::{OnceCell, RefCell};
12
13use frus_core::animation::Curve;
14use frus_core::{Alignment, Rect, Scene};
15use frus_layout::Style;
16
17use crate::interaction::Status;
18use crate::theme::Theme;
19use crate::widget::Widget;
20
21/// Which child an [`AnimatedCrossFade`] shows.
22const FIRST: u8 = 0;
23const SECOND: u8 = 1;
24
25/// Shows one of two children, and **cross-fades** to the other when told to, the box moving
26/// between their sizes as they fade.
27///
28/// ```
29/// use frus_widgets::{text, AnimatedCrossFade};
30/// # let expanded = false;
31/// let summary = AnimatedCrossFade::<()>::new(
32///     text("Three tasks"),
33///     text("Buy milk, write code, call Mum"),
34///     !expanded,
35///     0.25,
36/// );
37/// ```
38///
39/// The child that is not shown takes no taps and is not read out, and neither is the one on
40/// its way out. Both children fill the width the cross-fade is given.
41pub struct AnimatedCrossFade<Msg = crate::callback::Callback> {
42    first: RefCell<Option<Box<dyn Widget<Msg>>>>,
43    second: RefCell<Option<Box<dyn Widget<Msg>>>>,
44    show_first: bool,
45    duration: f32,
46    first_curve: Curve,
47    second_curve: Curve,
48    size_curve: Curve,
49    alignment: Alignment,
50    /// The composition, made on the first walk so that the order the builders were called
51    /// in cannot change it.
52    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
53}
54
55impl<Msg: Clone + 'static> AnimatedCrossFade<Msg> {
56    /// Shows `first` when `show_first`, `second` otherwise, cross-fading over `duration`
57    /// seconds when that changes.
58    pub fn new(
59        first: impl Widget<Msg> + 'static,
60        second: impl Widget<Msg> + 'static,
61        show_first: bool,
62        duration: f32,
63    ) -> Self {
64        Self {
65            first: RefCell::new(Some(Box::new(first))),
66            second: RefCell::new(Some(Box::new(second))),
67            show_first,
68            duration,
69            first_curve: Curve::Linear,
70            second_curve: Curve::Linear,
71            size_curve: Curve::Linear,
72            alignment: Alignment::TOP_CENTER,
73            built: OnceCell::new(),
74        }
75    }
76
77    /// The easing of the first child's fade.
78    pub fn first_curve(mut self, curve: Curve) -> Self {
79        self.first_curve = curve;
80        self
81    }
82
83    /// The easing of the second child's fade.
84    pub fn second_curve(mut self, curve: Curve) -> Self {
85        self.second_curve = curve;
86        self
87    }
88
89    /// The easing of the box's move between the two sizes.
90    pub fn size_curve(mut self, curve: Curve) -> Self {
91        self.size_curve = curve;
92        self
93    }
94
95    /// Where the children sit in a box that is not their size yet: the top, centred, by
96    /// default, as the reference's.
97    pub fn alignment(mut self, alignment: Alignment) -> Self {
98        self.alignment = alignment;
99        self
100    }
101
102    /// One child's place in the cell: faded towards shown or hidden, taking taps and being
103    /// read out only while shown, and — while hidden — laid over the cell rather than in it.
104    ///
105    /// The same four wrappers either way, under a key of its own, so that a child keeps its
106    /// fade when it goes from shown to hidden and back.
107    fn slot(&self, which: u8, child: Box<dyn Widget<Msg>>, curve: Curve) -> Box<dyn Widget<Msg>> {
108        let shown = (which == FIRST) == self.show_first;
109        let placed: Box<dyn Widget<Msg>> = if shown {
110            child
111        } else {
112            Box::new(
113                crate::OverflowBox::new(child)
114                    .natural_height()
115                    .alignment(self.alignment),
116            )
117        };
118        let faded = crate::AnimatedOpacity::new(
119            if shown { 1.0 } else { 0.0 },
120            self.duration,
121            curve,
122            FillWidth {
123                children: vec![placed],
124            },
125        );
126        Box::new(crate::Keyed::new(
127            ("cross-fade", which),
128            crate::IgnorePointer::new(crate::ExcludeSemantics::new(faded).excluding(!shown))
129                .ignoring(!shown),
130        ))
131    }
132
133    fn assemble(&self) -> Vec<Box<dyn Widget<Msg>>> {
134        let first = self.first.borrow_mut().take().expect("assembled once");
135        let second = self.second.borrow_mut().take().expect("assembled once");
136        let first = self.slot(FIRST, first, self.first_curve.clone());
137        let second = self.slot(SECOND, second, self.second_curve.clone());
138        // The shown child last, so that it is painted over the one leaving.
139        let layers = if self.show_first {
140            vec![second, first]
141        } else {
142            vec![first, second]
143        };
144        vec![Box::new(
145            crate::AnimatedSize::new(self.duration, Cell { layers })
146                .curve(self.size_curve.clone())
147                .alignment(self.alignment),
148        )]
149    }
150}
151
152impl<Msg: Clone + 'static> Widget<Msg> for AnimatedCrossFade<Msg> {
153    fn style(&self) -> Style {
154        Style::default()
155    }
156
157    fn build_themed(&self, _theme: &Theme) {
158        self.built.get_or_init(|| self.assemble());
159    }
160
161    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
162        self.built.get().map(|v| &v[..]).unwrap_or(&[])
163    }
164
165    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
166
167    fn on_click(&self) -> Option<Msg> {
168        None
169    }
170
171    fn debug_name(&self) -> &'static str {
172        "AnimatedCrossFade"
173    }
174}
175
176/// Both children in one cell, stretched to it: the shown one gives the cell its size, and
177/// the hidden one's box covers the cell without adding to it.
178struct Cell<Msg> {
179    layers: Vec<Box<dyn Widget<Msg>>>,
180}
181
182impl<Msg: Clone> Widget<Msg> for Cell<Msg> {
183    fn style(&self) -> Style {
184        Style {
185            overlap: true,
186            align: frus_layout::Align::Stretch,
187            ..Style::default()
188        }
189    }
190
191    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
192        &self.layers
193    }
194
195    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
196
197    fn on_click(&self) -> Option<Msg> {
198        None
199    }
200
201    fn debug_name(&self) -> &'static str {
202        "AnimatedCrossFade"
203    }
204}
205
206/// A child stretched across the width it is given, **asking for that width** so that the
207/// wrappers around it — the fade, the taps, the semantics, none of which stretch what they
208/// hold — are given it too (milestone 590's fill request, passed up through lone children).
209struct FillWidth<Msg> {
210    children: Vec<Box<dyn Widget<Msg>>>,
211}
212
213impl<Msg: Clone> Widget<Msg> for FillWidth<Msg> {
214    fn style(&self) -> Style {
215        Style {
216            flex_direction: frus_layout::FlexDirection::Column,
217            align: frus_layout::Align::Stretch,
218            ..Style::default()
219        }
220    }
221
222    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
223        crate::widget::FillAxes {
224            horizontal: true,
225            vertical: false,
226        }
227    }
228
229    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
230        &self.children
231    }
232
233    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
234
235    fn on_click(&self) -> Option<Msg> {
236        None
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::{build_ui, Container, Runtime, Size};
244    use frus_core::Color;
245
246    #[derive(Clone, Debug, PartialEq)]
247    enum Msg {
248        First,
249        Second,
250    }
251
252    const SIZE: Size = Size::new(300.0, 600.0);
253    const BLUE: Color = Color::rgb(0.2, 0.4, 0.8);
254    const RED: Color = Color::rgb(0.8, 0.2, 0.2);
255
256    /// A 40-px blue block that answers `First`, and a 100-px red one that answers `Second`,
257    /// above a marker whose top says where the cross-fade ends.
258    fn page(show_first: bool) -> crate::Flex<Msg> {
259        // A coloured box **as tall as what it holds**, not one told its height: a box told
260        // its height keeps it however it is laid out, and would hide a child squeezed to
261        // the cross-fade's box.
262        let block = |height: f32, color: Color, msg: Msg| {
263            crate::GestureDetector::new(
264                Container::<Msg>::new()
265                    .color(color)
266                    .child(Container::<Msg>::new().height(height)),
267            )
268            .on_tap(msg)
269        };
270        crate::Flex::<Msg>::column()
271            .child(AnimatedCrossFade::new(
272                block(40.0, BLUE, Msg::First),
273                block(100.0, RED, Msg::Second),
274                show_first,
275                0.2,
276            ))
277            .child(
278                Container::<Msg>::new()
279                    .height(10.0)
280                    .color(Color::rgb(0.1, 0.9, 0.1)),
281            )
282    }
283
284    /// Where the marker starts: the bottom of the cross-fade.
285    fn bottom(ui: &crate::Ui<Msg>) -> f32 {
286        ui.scene()
287            .primitives()
288            .iter()
289            .find_map(|p| match p {
290                frus_core::Primitive::Rect { rect, color, .. }
291                    if *color == Color::rgb(0.1, 0.9, 0.1) =>
292                {
293                    Some(rect.y)
294                }
295                _ => None,
296            })
297            .expect("the marker")
298    }
299
300    /// One frame, and the time after it: the runtime's own transitions and the tree's
301    /// animated values — the fades — advanced as the shell advances them.
302    fn frame(rt: &mut Runtime, show_first: bool, dt: f32) -> crate::Ui<Msg> {
303        let tree = page(show_first);
304        let theme = Theme::default();
305        crate::build_deferred(&tree, &theme, rt);
306        let ui = build_ui(&tree, SIZE, rt, &theme);
307        rt.advance(dt);
308        rt.advance_values(&tree, dt);
309        ui
310    }
311
312    /// What a tap in the middle of the first 40 px answers.
313    fn tap(ui: &crate::Ui<Msg>) -> Option<Msg> {
314        ui.hit(frus_core::Point::new(150.0, 20.0))
315            .and_then(|id| ui.msg_for(id))
316    }
317
318    /// The group opacity each colour is painted under, or `None` when it is not painted.
319    fn painted(ui: &crate::Ui<Msg>, color: Color) -> Option<f32> {
320        fn walk(primitives: &[frus_core::Primitive], color: Color, alpha: f32) -> Option<f32> {
321            for p in primitives {
322                match p {
323                    frus_core::Primitive::Rect { color: c, .. } if *c == color => {
324                        return Some(alpha)
325                    }
326                    frus_core::Primitive::Layer {
327                        primitives,
328                        opacity,
329                        ..
330                    } => {
331                        if let Some(found) = walk(primitives, color, alpha * opacity) {
332                            return Some(found);
333                        }
334                    }
335                    _ => {}
336                }
337            }
338            None
339        }
340        walk(ui.scene().primitives(), color, 1.0).filter(|a| *a > 0.0)
341    }
342
343    /// **At rest, the box is the shown child's size**, only the shown child is painted, and
344    /// only it takes a tap — the hidden one is taller and still adds nothing.
345    #[test]
346    fn at_rest_only_the_shown_child_counts() {
347        let rt = Runtime::default();
348        let ui = build_ui(&page(true), SIZE, &rt, &Theme::default());
349        assert_eq!(bottom(&ui), 40.0);
350        assert_eq!(painted(&ui, BLUE), Some(1.0));
351        assert_eq!(painted(&ui, RED), None);
352        assert_eq!(tap(&ui), Some(Msg::First));
353    }
354
355    /// **A switch fades one into the other and moves the box between their sizes**: half-way
356    /// both are painted, the box is between 40 and 100 px and the marker below it follows,
357    /// and at the end only the second is there, 100 px tall, taking the tap.
358    #[test]
359    fn a_switch_cross_fades_and_moves_the_box() {
360        let mut rt = Runtime::default();
361        frame(&mut rt, true, 0.016);
362        frame(&mut rt, false, 0.1);
363        let half = frame(&mut rt, false, 0.016);
364        let b = bottom(&half);
365        assert!(b > 45.0 && b < 95.0, "between the two sizes: {b}");
366        let (blue, red) = (painted(&half, BLUE), painted(&half, RED));
367        assert!(
368            blue.is_some_and(|a| a < 1.0) && red.is_some_and(|a| a < 1.0),
369            "both on the way: {blue:?} {red:?}"
370        );
371        assert_eq!(
372            tap(&half),
373            Some(Msg::Second),
374            "the one leaving takes nothing"
375        );
376        // The one arriving is painted over the one leaving.
377        let order: Vec<Color> = {
378            fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<Color>) {
379                for p in primitives {
380                    match p {
381                        frus_core::Primitive::Rect { color, .. } => out.push(*color),
382                        frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
383                        _ => {}
384                    }
385                }
386            }
387            let mut out = Vec::new();
388            walk(half.scene().primitives(), &mut out);
389            out
390        };
391        let blue_at = order.iter().position(|c| *c == BLUE);
392        let red_at = order.iter().position(|c| *c == RED);
393        assert!(red_at > blue_at, "the red one over the blue one: {order:?}");
394        for _ in 0..20 {
395            frame(&mut rt, false, 0.016);
396        }
397        let end = frame(&mut rt, false, 0.016);
398        assert_eq!(bottom(&end), 100.0);
399        assert_eq!(painted(&end, BLUE), None);
400        assert_eq!(painted(&end, RED), Some(1.0));
401    }
402
403    /// **The hidden child takes no tap and is not read out**, even where the shown one has
404    /// nothing of its own to answer with: a plain block is shown, a tappable, labelled one
405    /// is hidden under it.
406    #[test]
407    fn the_hidden_child_is_neither_touched_nor_read() {
408        let hidden = crate::Semantics::new(
409            frus_core::SemanticsProperties::default(),
410            // A width of its own: a `Semantics` stretches nothing it holds, and a block
411            // with no width would be there to tap nowhere.
412            crate::GestureDetector::new(
413                Container::<Msg>::new()
414                    .width(SIZE.width)
415                    .height(100.0)
416                    .color(RED),
417            )
418            .on_tap(Msg::Second),
419        )
420        .label("hidden");
421        let tree = AnimatedCrossFade::new(
422            Container::<Msg>::new().height(40.0).color(BLUE),
423            hidden,
424            true,
425            0.2,
426        );
427        let rt = Runtime::default();
428        let ui = build_ui(&tree, SIZE, &rt, &Theme::default());
429        assert_eq!(tap(&ui), None, "nothing to tap on the shown child");
430        assert!(
431            !ui.semantics()
432                .iter()
433                .any(|(_, _, props)| props.label.as_deref() == Some("hidden")),
434            "the hidden child is not read out"
435        );
436    }
437
438    /// **The child on its way out keeps its own height and is cut to the box**: switching
439    /// back from the 100-px red one, the red block is still 100 px tall as it fades, and
440    /// nothing of it shows below the box.
441    #[test]
442    fn the_leaving_child_is_cut_to_the_box() {
443        let mut rt = Runtime::default();
444        frame(&mut rt, false, 0.016);
445        frame(&mut rt, true, 0.1);
446        let half = frame(&mut rt, true, 0.016);
447        let edge = bottom(&half);
448        fn find(primitives: &[frus_core::Primitive]) -> Option<(Rect, Rect)> {
449            primitives.iter().find_map(|p| match p {
450                frus_core::Primitive::Rect {
451                    rect, clip, color, ..
452                } if *color == RED => Some((*rect, *clip)),
453                frus_core::Primitive::Layer { primitives, .. } => find(primitives),
454                _ => None,
455            })
456        }
457        let (rect, clip) = find(half.scene().primitives()).expect("the red block, fading");
458        assert_eq!(rect.height, 100.0, "its own height");
459        assert!(
460            clip.y + clip.height <= edge + 0.5,
461            "cut at {edge}: {clip:?}"
462        );
463        assert!(edge < 95.0, "the box is on its way down: {edge}");
464    }
465}