Skip to main content

frus_widgets/
hero.rs

1//! [`Hero`]: a **shared element** between two screens — the thing that appears to fly
2//! from where it was to where it is going, instead of one copy fading out while
3//! another fades in.
4//!
5//! The whole difficulty is identity. The two screens are separate trees, built
6//! independently, and nothing in a widget tree says "this picture is the same picture
7//! as that one". A `Hero` says exactly that, and nothing else: it carries a **tag**,
8//! and two heroes with the same tag on the two sides of a route transition are
9//! understood to be one thing in two places.
10//!
11//! ```ignore
12//! // In the list:
13//! Hero::new(task.id, CircleAvatar::initials(task.text.clone()).size(30.0))
14//! // On the detail screen:
15//! Hero::new(task.id, CircleAvatar::initials(task.text.clone()).size(96.0))
16//! ```
17//!
18//! What flies is the **destination**'s own painting, lifted out of the frame and
19//! mapped onto the box it is travelling through — never a third widget built for the
20//! occasion, which would be a second definition of the same thing. Both originals are
21//! taken out of the frame for as long as the flight lasts, since a thing cannot be in
22//! three places at once.
23//!
24//! A hero with no counterpart on the other side simply renders: there is nothing to
25//! fly to, and pretending otherwise would be worse than a plain transition.
26
27use std::hash::{Hash, Hasher};
28
29use frus_core::{Rect, Scene};
30use frus_layout::Style;
31
32use crate::interaction::{Status, WidgetId};
33use crate::theme::Theme;
34use crate::widget::{sizing_of, Widget};
35
36/// A hero seen by the render driver: which tag, which screen of the transition it
37/// belongs to, and where it sits.
38#[derive(Copy, Clone, Debug, PartialEq)]
39pub struct HeroSpot {
40    /// The shared tag.
41    pub tag: u64,
42    /// `0` = the screen being left, `1` = the one being entered. Outside a transition
43    /// there is no flight and this is meaningless.
44    pub screen: u8,
45    /// The widget's identity.
46    pub id: WidgetId,
47    /// Where it sits, in absolute coordinates.
48    pub rect: Rect,
49}
50
51/// Interpolates a rectangle towards another, corner by corner.
52///
53/// A straight line between the two boxes. Platforms that curve the path do it to make
54/// a diagonal flight read as one motion rather than two; that is a refinement on top
55/// of this, not a different mechanism.
56pub fn lerp_rect(from: Rect, to: Rect, t: f32) -> Rect {
57    let mix = |a: f32, b: f32| a + (b - a) * t;
58    Rect::new(
59        mix(from.x, to.x),
60        mix(from.y, to.y),
61        mix(from.width, to.width),
62        mix(from.height, to.height),
63    )
64}
65
66/// Marks its child as **the same element** as the hero carrying the same tag on the
67/// other side of a route transition.
68pub struct Hero<Msg = crate::callback::Callback> {
69    tag: u64,
70    children: Vec<Box<dyn Widget<Msg>>>,
71}
72
73impl<Msg> Hero<Msg> {
74    /// Tags `child` as a shared element. The tag may be any hashable value — a domain
75    /// id is the usual one, since it is already the thing that says "the same".
76    pub fn new(tag: impl Hash, child: impl Widget<Msg> + 'static) -> Self {
77        let mut hasher = std::collections::hash_map::DefaultHasher::new();
78        tag.hash(&mut hasher);
79        Self {
80            tag: hasher.finish(),
81            children: vec![Box::new(child)],
82        }
83    }
84
85    /// The resolved tag.
86    pub fn tag(&self) -> u64 {
87        self.tag
88    }
89}
90
91impl<Msg: Clone> Widget<Msg> for Hero<Msg> {
92    fn style(&self) -> Style {
93        sizing_of(
94            self.children
95                .first()
96                .map(|child| child.style())
97                .unwrap_or_default(),
98        )
99    }
100
101    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
102        &self.children
103    }
104
105    // Forwarded, not answered: see `sizing_of` and milestone 285. A wrapper that said
106    // "not a stack" over a layout leaf would collapse it to nothing.
107    fn stack(&self) -> bool {
108        self.children.first().is_some_and(|child| child.stack())
109    }
110
111    fn continuous(&self) -> bool {
112        self.children
113            .first()
114            .is_some_and(|child| child.continuous())
115    }
116
117    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
118        // Nothing of its own: the child is the element.
119    }
120
121    fn on_click(&self) -> Option<Msg> {
122        None
123    }
124
125    fn hero_tag(&self) -> Option<u64> {
126        Some(self.tag)
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::{build_ui, Container, Navigator, Runtime};
134    use frus_core::{Color, Primitive, Size};
135
136    const RED: Color = Color {
137        r: 1.0,
138        g: 0.0,
139        b: 0.0,
140        a: 1.0,
141    };
142
143    /// A transition at `progress`, with a small hero on the outgoing screen and a big
144    /// one on the incoming screen.
145    fn flight(progress: f32, tags: (u64, u64)) -> Vec<Rect> {
146        let leaving = Container::<()>::new()
147            .width(400.0)
148            .height(300.0)
149            .child(Hero::new(
150                tags.0,
151                Container::new().width(40.0).height(40.0).color(RED),
152            ));
153        let entering = Container::<()>::new()
154            .width(400.0)
155            .height(300.0)
156            .child(Hero::new(
157                tags.1,
158                Container::new().width(200.0).height(200.0).color(RED),
159            ));
160        let navigator = Navigator::new("entering", entering)
161            .size(400.0, 300.0)
162            .from("leaving", leaving, progress, true);
163        let runtime = Runtime::default();
164        let ui = build_ui(
165            &navigator,
166            Size::new(400.0, 300.0),
167            &runtime,
168            // Under a theme that follows Linux, whose transition zooms the pages inside
169            // layers (milestone 620): a hero still flies above both, whatever the machine.
170            &Theme::dark().with_platform(frus_core::TargetPlatform::Linux),
171        );
172        // Inside the pages' layers too: a page in a transition may be one.
173        fn red(primitives: &[Primitive], out: &mut Vec<Rect>) {
174            for p in primitives {
175                match p {
176                    Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
177                        out.push(*rect)
178                    }
179                    Primitive::Layer { primitives, .. } => red(primitives, out),
180                    _ => {}
181                }
182            }
183        }
184        let mut found = Vec::new();
185        red(ui.scene().primitives(), &mut found);
186        found
187    }
188
189    #[test]
190    fn a_matched_pair_flies_as_one_thing() {
191        // Mid-flight there is **one** red box, not two: both originals are out of the
192        // frame and a single copy is travelling.
193        let mid = flight(0.5, (7, 7));
194        assert_eq!(mid.len(), 1, "one thing in one place: {mid:?}");
195        let size = mid[0].width;
196        assert!(
197            size > 45.0 && size < 195.0,
198            "between the two sizes, not at either: {size}"
199        );
200    }
201
202    #[test]
203    fn the_flight_starts_where_it_was_and_ends_where_it_is_going() {
204        let start = flight(0.0, (7, 7));
205        assert_eq!(start.len(), 1);
206        assert!(
207            (start[0].width - 40.0).abs() < 1.0,
208            "starts at the source's size: {:?}",
209            start[0]
210        );
211        let end = flight(1.0, (7, 7));
212        assert_eq!(end.len(), 1);
213        assert!(
214            (end[0].width - 200.0).abs() < 1.0,
215            "ends at the destination's size: {:?}",
216            end[0]
217        );
218    }
219
220    #[test]
221    fn heroes_that_do_not_match_are_left_alone() {
222        // Different tags: no shared element, so both screens keep their own — the
223        // ordinary transition, which is the right answer and not a silent nothing.
224        let mid = flight(0.5, (7, 8));
225        assert_eq!(mid.len(), 2, "both drawn as usual: {mid:?}");
226    }
227
228    #[test]
229    fn a_tag_is_whatever_says_the_same() {
230        let by_number = Hero::<()>::new(42u64, Container::new());
231        let same = Hero::<()>::new(42u64, Container::new());
232        let other = Hero::<()>::new(43u64, Container::new());
233        assert_eq!(by_number.tag(), same.tag());
234        assert_ne!(by_number.tag(), other.tag());
235        // Strings work as well, and are their own kind of identity.
236        let by_name = Hero::<()>::new("avatar", Container::new());
237        assert_eq!(
238            by_name.tag(),
239            Hero::<()>::new("avatar", Container::new()).tag()
240        );
241    }
242}