1use 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#[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}