Skip to main content

tessera_components/
scaffold.rs

1//! Scaffold layout for persistent bars and app content.
2//!
3//! ## Usage
4//!
5//! Layer top/bottom bars, floating buttons, and snackbars above app content.
6use tessera_ui::{
7    Color, ComputedData, Constraint, Dp, LayoutResult, MeasurementError, Modifier, Px, PxPosition,
8    RenderSlot, WindowInsets,
9    layout::{MeasureScope, layout},
10    tessera, use_context,
11};
12
13use crate::{
14    alignment::Alignment,
15    modifier::{ModifierExt as _, Padding},
16    theme::MaterialTheme,
17};
18
19fn center_axis(container: Px, child: Px) -> Px {
20    (container - child) / 2
21}
22
23fn compute_overlay_offset(
24    alignment: Alignment,
25    container_w: Px,
26    container_h: Px,
27    child_w: Px,
28    child_h: Px,
29) -> (Px, Px) {
30    match alignment {
31        Alignment::TopStart => (Px::ZERO, Px::ZERO),
32        Alignment::TopCenter => (center_axis(container_w, child_w), Px::ZERO),
33        Alignment::TopEnd => (container_w - child_w, Px::ZERO),
34        Alignment::CenterStart => (Px::ZERO, center_axis(container_h, child_h)),
35        Alignment::Center => (
36            center_axis(container_w, child_w),
37            center_axis(container_h, child_h),
38        ),
39        Alignment::CenterEnd => (container_w - child_w, center_axis(container_h, child_h)),
40        Alignment::BottomStart => (Px::ZERO, container_h - child_h),
41        Alignment::BottomCenter => (center_axis(container_w, child_w), container_h - child_h),
42        Alignment::BottomEnd => (container_w - child_w, container_h - child_h),
43    }
44}
45
46#[derive(Clone, PartialEq)]
47struct ScaffoldLayout {
48    content_padding: Padding,
49    fab_alignment: Alignment,
50    fab_offset: [Dp; 2],
51    snackbar_alignment: Alignment,
52    snackbar_offset: [Dp; 2],
53    has_content: bool,
54    has_top_bar: bool,
55    has_bottom_bar: bool,
56    has_fab: bool,
57    has_snackbar: bool,
58}
59
60impl ScaffoldLayout {
61    fn content_constraint(
62        &self,
63        parent_constraint: Constraint,
64        top_bar_height: Px,
65        bottom_bar_height: Px,
66    ) -> Constraint {
67        let left: Px = self.content_padding.left.into();
68        let top: Px = self.content_padding.top.into();
69        let right: Px = self.content_padding.right.into();
70        let bottom: Px = self.content_padding.bottom.into();
71
72        Constraint::new(
73            parent_constraint.width - (left + right),
74            parent_constraint.height - (top + bottom + top_bar_height + bottom_bar_height),
75        )
76    }
77}
78
79impl tessera_ui::LayoutModifierNode for ScaffoldLayout {
80    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
81        let mut result = LayoutResult::default();
82        let children = input.children();
83        let mut iter = children.into_iter();
84
85        let content = self.has_content.then(|| {
86            iter.next()
87                .expect("scaffold content slot must exist when has_content is true")
88        });
89        let bottom_bar = self.has_bottom_bar.then(|| {
90            iter.next()
91                .expect("scaffold bottom bar slot must exist when has_bottom_bar is true")
92        });
93        let top_bar = self.has_top_bar.then(|| {
94            iter.next()
95                .expect("scaffold top bar slot must exist when has_top_bar is true")
96        });
97        let snackbar = self.has_snackbar.then(|| {
98            iter.next()
99                .expect("scaffold snackbar slot must exist when has_snackbar is true")
100        });
101        let fab = self.has_fab.then(|| {
102            iter.next()
103                .expect("scaffold fab slot must exist when has_fab is true")
104        });
105
106        let parent_constraint = *input.parent_constraint().as_ref();
107        let unconstrained_children = input.parent_constraint().without_min();
108
109        let top_bar_size = if let Some(child) = top_bar {
110            child.measure(input, &unconstrained_children)?.size()
111        } else {
112            ComputedData {
113                width: Px::ZERO,
114                height: Px::ZERO,
115            }
116        };
117
118        let bottom_bar_size = if let Some(child) = bottom_bar {
119            child.measure(input, &unconstrained_children)?.size()
120        } else {
121            ComputedData {
122                width: Px::ZERO,
123                height: Px::ZERO,
124            }
125        };
126
127        let content_size = if let Some(child) = content {
128            let constraint = self.content_constraint(
129                parent_constraint,
130                top_bar_size.height,
131                bottom_bar_size.height,
132            );
133            let size = child.measure(input, &constraint)?.size();
134            let content_x: Px = self.content_padding.left.into();
135            let content_y: Px = self.content_padding.top.into();
136            result.place_child(
137                child,
138                PxPosition::new(content_x, content_y + top_bar_size.height),
139            );
140            Some(size)
141        } else {
142            None
143        };
144
145        let mut intrinsic_width = top_bar_size.width.max(bottom_bar_size.width);
146        if let Some(size) = content_size {
147            let left: Px = self.content_padding.left.into();
148            let right: Px = self.content_padding.right.into();
149            intrinsic_width = intrinsic_width.max(size.width + left + right);
150        }
151
152        let intrinsic_height = top_bar_size.height
153            + bottom_bar_size.height
154            + content_size
155                .map(|size| {
156                    let top: Px = self.content_padding.top.into();
157                    let bottom: Px = self.content_padding.bottom.into();
158                    size.height + top + bottom
159                })
160                .unwrap_or(Px::ZERO);
161
162        let final_width = parent_constraint.width.clamp(intrinsic_width);
163        let final_height = parent_constraint.height.clamp(intrinsic_height);
164
165        if let Some(child) = top_bar {
166            result.place_child(
167                child,
168                PxPosition::new(center_axis(final_width, top_bar_size.width), Px::ZERO),
169            );
170        }
171
172        if let Some(child) = bottom_bar {
173            result.place_child(
174                child,
175                PxPosition::new(
176                    center_axis(final_width, bottom_bar_size.width),
177                    final_height - bottom_bar_size.height,
178                ),
179            );
180        }
181
182        let bottom_reserved = bottom_bar_size.height;
183
184        if let Some(child) = snackbar {
185            let size = child.measure(input, &unconstrained_children)?.size();
186            let (x, mut y) = compute_overlay_offset(
187                self.snackbar_alignment,
188                final_width,
189                final_height,
190                size.width,
191                size.height,
192            );
193            if matches!(
194                self.snackbar_alignment,
195                Alignment::BottomStart | Alignment::BottomCenter | Alignment::BottomEnd
196            ) {
197                y -= bottom_reserved;
198            }
199            let offset_x: Px = self.snackbar_offset[0].into();
200            let offset_y: Px = self.snackbar_offset[1].into();
201            result.place_child(child, PxPosition::new(x + offset_x, y + offset_y));
202        }
203
204        if let Some(child) = fab {
205            let size = child.measure(input, &unconstrained_children)?.size();
206            let (x, mut y) = compute_overlay_offset(
207                self.fab_alignment,
208                final_width,
209                final_height,
210                size.width,
211                size.height,
212            );
213            if matches!(
214                self.fab_alignment,
215                Alignment::BottomStart | Alignment::BottomCenter | Alignment::BottomEnd
216            ) {
217                y -= bottom_reserved;
218            }
219            let offset_x: Px = self.fab_offset[0].into();
220            let offset_y: Px = self.fab_offset[1].into();
221            result.place_child(child, PxPosition::new(x + offset_x, y + offset_y));
222        }
223
224        Ok(result.with_size(ComputedData {
225            width: final_width,
226            height: final_height,
227        }))
228    }
229}
230
231/// # scaffold
232///
233/// Layout top/bottom bars with floating content for app screens with persistent
234/// actions.
235///
236/// ## Usage
237///
238/// Use for screens with app bars, floating actions, and transient messages.
239///
240/// ## Parameters
241///
242/// - `modifier` — optional modifier chain applied to the scaffold container.
243/// - `window_insets` — optional window insets applied to all slots after
244///   subtracting insets consumed by ancestors. Use `WindowInsets::SafeDrawing`
245///   for a window-sized scaffold.
246/// - `container_color` — optional background color that extends under the
247///   insets. With window insets and a theme, defaults to the theme's surface.
248/// - `content_padding` — optional padding applied around the content area.
249/// - `content` — optional main content slot.
250/// - `top_bar` — optional top bar slot.
251/// - `bottom_bar` — optional bottom bar slot.
252/// - `floating_action_button` — optional floating action button slot.
253/// - `floating_action_button_alignment` — optional floating action button
254///   alignment.
255/// - `floating_action_button_offset` — additional x/y offset applied to the
256///   floating action button, defaulting to `0dp`.
257/// - `snackbar_host` — optional snackbar host slot.
258/// - `snackbar_alignment` — optional snackbar host alignment.
259/// - `snackbar_offset` — additional x/y offset applied to the snackbar host,
260///   defaulting to `0dp`.
261///
262/// ## Examples
263///
264/// ```
265/// use tessera_components::app_bar::top_app_bar;
266/// use tessera_components::scaffold::scaffold;
267/// use tessera_components::text::text;
268/// use tessera_components::theme::{MaterialTheme, material_theme};
269/// use tessera_ui::tessera;
270///
271/// #[tessera]
272/// fn demo() {
273///     material_theme()
274///         .theme(|| MaterialTheme::default())
275///         .child(|| {
276///             scaffold()
277///                 .top_bar(|| {
278///                     top_app_bar().title("Inbox");
279///                 })
280///                 .content(|| {
281///                     text("Hello scaffold");
282///                 });
283///         });
284/// }
285/// ```
286#[tessera]
287pub fn scaffold(
288    modifier: Option<Modifier>,
289    window_insets: Option<WindowInsets>,
290    container_color: Option<Color>,
291    content_padding: Option<Padding>,
292    content: Option<RenderSlot>,
293    top_bar: Option<RenderSlot>,
294    bottom_bar: Option<RenderSlot>,
295    floating_action_button: Option<RenderSlot>,
296    floating_action_button_alignment: Option<Alignment>,
297    floating_action_button_offset: Option<[Dp; 2]>,
298    snackbar_host: Option<RenderSlot>,
299    snackbar_alignment: Option<Alignment>,
300    snackbar_offset: Option<[Dp; 2]>,
301) {
302    let mut modifier = modifier.unwrap_or_else(|| Modifier::new().fill_max_size());
303    let container_color = if container_color.is_none() && window_insets.is_some() {
304        use_context::<MaterialTheme>().map(|theme| theme.get().color_scheme.surface)
305    } else {
306        container_color
307    };
308    if let Some(color) = container_color {
309        modifier = modifier.background(color);
310    }
311    if let Some(insets) = window_insets {
312        modifier = modifier.window_insets_padding(insets);
313    }
314    let content_padding = content_padding.unwrap_or(Padding::all(Dp(0.0)));
315    let fab_alignment = floating_action_button_alignment.unwrap_or(Alignment::BottomEnd);
316    let floating_action_button_offset = floating_action_button_offset.unwrap_or([Dp(0.0), Dp(0.0)]);
317    let snackbar_alignment = snackbar_alignment.unwrap_or(Alignment::BottomCenter);
318    let snackbar_offset = snackbar_offset.unwrap_or([Dp(0.0), Dp(0.0)]);
319
320    layout()
321        .modifier(modifier)
322        .policy(ScaffoldLayout {
323            content_padding,
324            fab_alignment,
325            fab_offset: floating_action_button_offset,
326            snackbar_alignment,
327            snackbar_offset,
328            has_content: content.is_some(),
329            has_top_bar: top_bar.is_some(),
330            has_bottom_bar: bottom_bar.is_some(),
331            has_fab: floating_action_button.is_some(),
332            has_snackbar: snackbar_host.is_some(),
333        })
334        .child(move || {
335            if let Some(content) = content {
336                content.render();
337            }
338            if let Some(bottom_bar) = bottom_bar {
339                bottom_bar.render();
340            }
341            if let Some(top_bar) = top_bar {
342                top_bar.render();
343            }
344            if let Some(snackbar_host) = snackbar_host {
345                snackbar_host.render();
346            }
347            if let Some(floating_action_button) = floating_action_button {
348                floating_action_button.render();
349            }
350        });
351}
352
353#[cfg(test)]
354mod tests {
355    use tessera_ui::{
356        ComputedData, Dp, Insets, LayoutResult, MeasurementError, Modifier, NoopDrawModifier, Px,
357        WindowInsets,
358        layout::{MeasureScope, layout},
359        tessera,
360    };
361
362    use crate::modifier::{ModifierExt as _, SemanticsArgs};
363
364    use super::scaffold;
365
366    #[derive(Clone, PartialEq)]
367    struct FixedTestLayout {
368        width: i32,
369        height: i32,
370    }
371
372    impl tessera_ui::LayoutModifierNode for FixedTestLayout {
373        fn measure(&self, _input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
374            Ok(LayoutResult::new(ComputedData {
375                width: Px::new(self.width),
376                height: Px::new(self.height),
377            }))
378        }
379    }
380
381    #[tessera]
382    fn fixed_test_box(tag: Option<String>, width: Option<i32>, height: Option<i32>) {
383        let tag = tag.unwrap_or_default();
384        let width = width.unwrap_or_default();
385        let height = height.unwrap_or_default();
386
387        layout()
388            .policy(FixedTestLayout { width, height })
389            .draw(NoopDrawModifier)
390            .modifier(Modifier::new().semantics(SemanticsArgs {
391                test_tag: Some(tag),
392                ..Default::default()
393            }));
394    }
395
396    #[tessera]
397    fn scaffold_layout_case(insets: Option<WindowInsets>) {
398        scaffold()
399            .modifier(Modifier::new().constrain(
400                Some(tessera_ui::AxisConstraint::exact(Px::new(100))),
401                Some(tessera_ui::AxisConstraint::exact(Px::new(80))),
402            ))
403            .window_insets_optional(insets)
404            .top_bar(|| {
405                fixed_test_box()
406                    .tag("scaffold_top".to_string())
407                    .width(100)
408                    .height(10);
409            })
410            .bottom_bar(|| {
411                fixed_test_box()
412                    .tag("scaffold_bottom".to_string())
413                    .width(100)
414                    .height(12);
415            })
416            .content(|| {
417                fixed_test_box()
418                    .tag("scaffold_content".to_string())
419                    .width(20)
420                    .height(8);
421            });
422    }
423
424    #[test]
425    fn scaffold_positions_top_bottom_and_content_slots() {
426        tessera_ui::assert_layout! {
427            viewport: (120, 100),
428            content: {
429                scaffold_layout_case();
430            },
431            expect: {
432                node("scaffold_top").position(0, 0).size(100, 10);
433                node("scaffold_content").position(0, 10).size(20, 8);
434                node("scaffold_bottom").position(0, 68).size(100, 12);
435            }
436        }
437    }
438
439    #[test]
440    fn scaffold_positions_bars_and_content_inside_window_insets() {
441        tessera_ui::assert_layout! {
442            viewport: (120, 100),
443            content: {
444                scaffold_layout_case()
445                    .insets(WindowInsets::Fixed(Insets::new(Dp::ZERO, Dp(10.0), Dp::ZERO, Dp(7.0))));
446            },
447            expect: {
448                node("scaffold_top").position(0, 10).size(100, 10);
449                node("scaffold_content").position(0, 20).size(20, 8);
450                node("scaffold_bottom").position(0, 61).size(100, 12);
451            }
452        }
453    }
454}