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}