Skip to main content

frust_widgets/nav/
hero.rs

1//! Shared-element ("hero") transition wrapper: a transparent single-child
2//! container that lets a [`navigator`](super::navigator) morph a tagged
3//! element between two pages during a page transition.
4//!
5//! # How it works
6//!
7//! [`hero(tag, child)`](hero) wraps `child` and, on every paint, reports its
8//! own absolute bounds under `tag` to whatever tagged-rect reporter the
9//! navigator installed over the page
10//! ([`PaintCtx::report_hero`](frust_core::PaintCtx::report_hero) — a no-op
11//! returning [`HeroDirective::Normal`] the rest of the time, so a hero outside
12//! a transition is a zero-cost transparent wrapper). When a transition is in
13//! flight and the **same** tag is present on both the outgoing and incoming
14//! page, the navigator hands one endpoint a [`HeroDirective::Morph`] (repaint
15//! the child under a rect→rect transform onto the interpolated morph rect) and
16//! the other a [`HeroDirective::Suppress`] (skip painting, so the element does
17//! not also show at its rest position). The morph is painted by the hero on the
18//! page drawn last (on top), keeping the overlay above both page layers whether
19//! the transition is a push or a pop — see [`super::navigator`]'s
20//! `paint_transition`.
21//!
22//! # Contract
23//!
24//! A hero is a *transparent* layout wrapper: it takes its child's size and
25//! places it at the origin, forwards events and semantics to the child
26//! unchanged, and never mutates app state. Heroes activate on any push/pop
27//! transition where both pages carry the tag — no `Route`/`Router` change is
28//! required.
29
30use frust_core::{
31    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, HeroDirective,
32    InputEvent, LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, SliverConstraints, SliverGeometry,
33    SliverWidget, View, Widget, any,
34};
35use kurbo::{Point, Rect, Size};
36
37use super::transition::rect_to_rect;
38
39/// A declarative shared-element wrapper. See the [module docs](self).
40pub struct HeroView<State: 'static> {
41    tag: String,
42    child: AnyView<State>,
43}
44
45/// Tag `child` as a shared element under `tag`, so a [`navigator`](super::navigator)
46/// morphs it between two pages that both carry the same tag during a page
47/// transition. Outside a transition this is a transparent wrapper.
48pub fn hero<State: 'static, V: View<State>>(tag: impl Into<String>, child: V) -> HeroView<State> {
49    HeroView {
50        tag: tag.into(),
51        child: any(child),
52    }
53}
54
55/// The retained widget for a [`HeroView`].
56pub struct HeroWidget {
57    tag: String,
58    child: ChildPod,
59}
60
61impl<State: 'static> View<State> for HeroView<State> {
62    type Element = HeroWidget;
63
64    fn build(&self, ctx: &mut BuildCtx<'_>) -> HeroWidget {
65        HeroWidget {
66            tag: self.tag.clone(),
67            child: crate::authoring::build_child(&self.child, ctx),
68        }
69    }
70
71    fn rebuild(
72        &self,
73        prev: &Self,
74        element: &mut HeroWidget,
75        ctx: &mut BuildCtx<'_>,
76    ) -> ChangeFlags {
77        let mut flags = ChangeFlags::NONE;
78        if prev.tag != self.tag {
79            element.tag = self.tag.clone();
80            // A tag change only affects which heroes match during a transition —
81            // no relayout, just a repaint.
82            flags |= ChangeFlags::PAINT;
83        }
84        flags |= crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx);
85        flags
86    }
87
88    fn teardown(&self, element: &mut HeroWidget, ctx: &mut BuildCtx<'_>) {
89        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
90    }
91}
92
93impl Widget for HeroWidget {
94    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
95        // Transparent wrapper: the hero is exactly its child, placed at the
96        // origin — so the bounds it reports on paint are the child's own.
97        let size = self.child.layout_child(ctx, bc);
98        self.child.set_origin(Point::ZERO);
99        size
100    }
101
102    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
103        let bounds = Rect::from_origin_size(ctx.origin(), ctx.size());
104        match ctx.report_hero(&self.tag, bounds) {
105            // Common case (no flight, or a tag on one side only): paint normally.
106            HeroDirective::Normal => self.child.paint_child(ctx, scene),
107            // This endpoint is being morphed by its counterpart on the other
108            // page — do not also paint it at its rest position.
109            HeroDirective::Suppress => {}
110            // Paint the morph overlay: repaint the retained child subtree under
111            // a transform mapping our own paint bounds onto the interpolated
112            // destination rect (position + scale).
113            HeroDirective::Morph { dest } => {
114                scene.push_transform(rect_to_rect(bounds, dest));
115                self.child.paint_child(ctx, scene);
116                scene.pop_transform();
117            }
118        }
119    }
120
121    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
122        crate::authoring::route_event_single(&mut self.child, ctx, event)
123    }
124
125    fn semantics(&self, ctx: &mut SemanticsCtx) {
126        // Transparent wrapper: forward to the single child.
127        self.child.semantics_child(ctx);
128    }
129
130    /// A hero is a sliver exactly when its child is. The sliver face is the hero
131    /// itself, so the layout runs through the child pod and leaves it sized for
132    /// this wrapper's own paint (the [`ComponentWidget`](frust_core::ComponentWidget)
133    /// pattern).
134    fn as_sliver(&mut self) -> Option<&mut dyn SliverWidget> {
135        if self.child.is_sliver() {
136            Some(self)
137        } else {
138            None
139        }
140    }
141
142    crate::authoring::visit_children!(child);
143}
144
145impl SliverWidget for HeroWidget {
146    /// Pass-through, like [`Widget::layout`]: the child sits at the origin and
147    /// receives `sc` unchanged, under the child pod's layout skip.
148    fn layout_sliver(&mut self, ctx: &mut LayoutCtx, sc: &SliverConstraints) -> SliverGeometry {
149        self.child.set_origin(Point::ZERO);
150        // A viewport reaches this only through `ChildPod::layout_sliver_child`,
151        // which asks `as_sliver` first, so `None` here means a caller invoked
152        // `layout_sliver` on a wrapper whose `as_sliver` answered `None`: a
153        // contract breach that fails in debug builds; release builds fall back
154        // to an empty sliver.
155        self.child.layout_sliver_child(ctx, sc).unwrap_or_else(|| {
156            debug_assert!(
157                false,
158                "HeroWidget::layout_sliver called on a wrapper whose as_sliver answered None"
159            );
160            SliverGeometry::ZERO
161        })
162    }
163}