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::new(task.text.clone()).size(30.0))
14//! // On the detail screen:
15//! Hero::new(task.id, CircleAvatar::new(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            &Theme::dark(),
169        );
170        ui.scene()
171            .primitives()
172            .iter()
173            .filter_map(|p| match p {
174                Primitive::Rect { rect, color, .. } if color.r > 0.5 && color.g < 0.5 => {
175                    Some(*rect)
176                }
177                _ => None,
178            })
179            .collect()
180    }
181
182    #[test]
183    fn a_matched_pair_flies_as_one_thing() {
184        // Mid-flight there is **one** red box, not two: both originals are out of the
185        // frame and a single copy is travelling.
186        let mid = flight(0.5, (7, 7));
187        assert_eq!(mid.len(), 1, "one thing in one place: {mid:?}");
188        let size = mid[0].width;
189        assert!(
190            size > 45.0 && size < 195.0,
191            "between the two sizes, not at either: {size}"
192        );
193    }
194
195    #[test]
196    fn the_flight_starts_where_it_was_and_ends_where_it_is_going() {
197        let start = flight(0.0, (7, 7));
198        assert_eq!(start.len(), 1);
199        assert!(
200            (start[0].width - 40.0).abs() < 1.0,
201            "starts at the source's size: {:?}",
202            start[0]
203        );
204        let end = flight(1.0, (7, 7));
205        assert_eq!(end.len(), 1);
206        assert!(
207            (end[0].width - 200.0).abs() < 1.0,
208            "ends at the destination's size: {:?}",
209            end[0]
210        );
211    }
212
213    #[test]
214    fn heroes_that_do_not_match_are_left_alone() {
215        // Different tags: no shared element, so both screens keep their own — the
216        // ordinary transition, which is the right answer and not a silent nothing.
217        let mid = flight(0.5, (7, 8));
218        assert_eq!(mid.len(), 2, "both drawn as usual: {mid:?}");
219    }
220
221    #[test]
222    fn a_tag_is_whatever_says_the_same() {
223        let by_number = Hero::<()>::new(42u64, Container::new());
224        let same = Hero::<()>::new(42u64, Container::new());
225        let other = Hero::<()>::new(43u64, Container::new());
226        assert_eq!(by_number.tag(), same.tag());
227        assert_ne!(by_number.tag(), other.tag());
228        // Strings work as well, and are their own kind of identity.
229        let by_name = Hero::<()>::new("avatar", Container::new());
230        assert_eq!(
231            by_name.tag(),
232            Hero::<()>::new("avatar", Container::new()).tag()
233        );
234    }
235}