Skip to main content

tessera_components/
app_bar.rs

1//! Top app bars for app-level navigation and actions.
2//!
3//! ## Usage
4//!
5//! Show screen titles, navigation, and primary actions above page content.
6use tessera_foundation::gesture::DragAxis;
7use tessera_ui::{
8    Asset, AxisConstraint, Callback, CallbackWith, Color, ComputedData, Constraint, Dp,
9    LayoutResult, MeasurementError, Modifier, Px, PxPosition, RenderSlot, State,
10    layout::{MeasureScope, layout},
11    provide_context, remember,
12    renderer::AppTitleBar,
13    tessera, use_context,
14};
15
16use crate::{
17    alignment::{Alignment, CrossAxisAlignment, MainAxisAlignment},
18    column::column,
19    icon_button::icon_button,
20    material_icons::filled,
21    modifier::{DraggableArgs, ModifierExt as _, Padding, SemanticsArgs},
22    progress::cubic_bezier_easing,
23    res::I18N,
24    row::row,
25    spacer::spacer,
26    surface::surface,
27    text::text,
28    theme::{ContentColor, MaterialColorScheme, MaterialTheme},
29};
30
31pub use scroll::{AppBarScrollBehavior, AppBarScrollState, app_bar_scroll_provider};
32
33mod scroll;
34
35/// Layout and typography of a top app bar.
36#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum TopAppBarVariant {
38    /// A single 64dp row with a title and optional subtitle.
39    #[default]
40    Small,
41    /// A 112dp bar, or 136dp with a subtitle, with headline typography.
42    MediumFlexible,
43    /// A 120dp bar, or 152dp with a subtitle, with display typography.
44    LargeFlexible,
45}
46
47/// Horizontal title placement within a top app bar.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum AppBarTitleAlignment {
50    /// Align the title to the leading content inset.
51    #[default]
52    Start,
53    /// Center relative to the complete bar, avoiding navigation and actions.
54    Center,
55}
56
57/// Default tokens for app bars.
58pub struct AppBarDefaults;
59
60impl AppBarDefaults {
61    /// Default height for a top app bar.
62    pub const TOP_APP_BAR_HEIGHT: Dp = Dp(64.0);
63    /// Default elevation for a top app bar.
64    pub const TOP_APP_BAR_ELEVATION: Dp = Dp(0.0);
65    /// Default horizontal padding applied to app bar content.
66    pub const HORIZONTAL_PADDING: Dp = Dp(4.0);
67    /// Default total title inset when no navigation icon is present.
68    pub const TITLE_INSET: Dp = Dp(16.0);
69    /// Default spacing between trailing action items.
70    pub const ACTIONS_SPACING: Dp = Dp(0.0);
71    /// Default padding applied to app bar content.
72    pub const CONTENT_PADDING: Padding = Padding::symmetric(Self::HORIZONTAL_PADDING, Dp(0.0));
73    /// Default padding applied to standard top app bar content.
74    pub const TOP_APP_BAR_CONTENT_PADDING: Padding = Self::CONTENT_PADDING;
75
76    /// Expanded medium height without a subtitle.
77    pub const MEDIUM_FLEXIBLE_HEIGHT: Dp = Dp(112.0);
78    /// Expanded medium height with a subtitle.
79    pub const MEDIUM_FLEXIBLE_SUBTITLE_HEIGHT: Dp = Dp(136.0);
80    /// Expanded large height without a subtitle.
81    pub const LARGE_FLEXIBLE_HEIGHT: Dp = Dp(120.0);
82    /// Expanded large height with a subtitle.
83    pub const LARGE_FLEXIBLE_SUBTITLE_HEIGHT: Dp = Dp(152.0);
84
85    /// Container color for app bars.
86    pub fn container_color(scheme: &MaterialColorScheme) -> Color {
87        scheme.surface
88    }
89
90    /// Title content color for app bars.
91    pub fn title_color(scheme: &MaterialColorScheme) -> Color {
92        scheme.on_surface
93    }
94
95    /// Navigation icon color for app bars.
96    pub fn navigation_icon_color(scheme: &MaterialColorScheme) -> Color {
97        scheme.on_surface
98    }
99
100    /// Action icon color for app bars.
101    pub fn action_icon_color(scheme: &MaterialColorScheme) -> Color {
102        scheme.on_surface_variant
103    }
104}
105
106/// # app_bar
107///
108/// Render a container row for app-level navigation and actions.
109///
110/// ## Usage
111///
112/// Use for custom top bars with mixed content like search fields or tabs.
113///
114/// ## Parameters
115///
116/// - `modifier` — modifier chain applied to the app bar container.
117/// - `container_color` — optional app bar surface color.
118/// - `content_color` — optional default content color propagated to
119///   descendants.
120/// - `elevation` — optional surface elevation.
121/// - `content_padding` — optional padding applied to the row content.
122/// - `main_axis_alignment` — optional main-axis alignment for the row.
123/// - `cross_axis_alignment` — optional cross-axis alignment for the row.
124/// - `content` — optional row content slot.
125///
126/// ## Examples
127/// ```rust
128/// # use tessera_ui::tessera;
129/// # #[tessera]
130/// # fn component() {
131/// use tessera_components::{app_bar::app_bar, text::text};
132/// # use tessera_components::theme::{MaterialTheme, material_theme};
133///
134/// # material_theme()
135/// #     .theme(|| MaterialTheme::default())
136/// #     .child(|| {
137/// app_bar().content(|| {
138///     text("Inbox");
139/// });
140/// #     });
141/// # }
142/// # tessera_ui::testing::layout_test(component).run();
143/// ```
144#[tessera]
145pub fn app_bar(
146    modifier: Option<Modifier>,
147    container_color: Option<Color>,
148    content_color: Option<Color>,
149    elevation: Option<Dp>,
150    content_padding: Option<Padding>,
151    main_axis_alignment: Option<MainAxisAlignment>,
152    cross_axis_alignment: Option<CrossAxisAlignment>,
153    content: Option<RenderSlot>,
154) {
155    let modifier = modifier.unwrap_or_default();
156    let scheme = use_context::<MaterialTheme>()
157        .expect("MaterialTheme must be provided")
158        .get()
159        .color_scheme;
160    let container_color =
161        container_color.unwrap_or_else(|| AppBarDefaults::container_color(&scheme));
162    let content_color = content_color.unwrap_or_else(|| AppBarDefaults::title_color(&scheme));
163    let elevation = elevation.unwrap_or(AppBarDefaults::TOP_APP_BAR_ELEVATION);
164    let content_padding = content_padding.unwrap_or(AppBarDefaults::CONTENT_PADDING);
165    let main_axis_alignment = main_axis_alignment.unwrap_or(MainAxisAlignment::Start);
166    let cross_axis_alignment = cross_axis_alignment.unwrap_or(CrossAxisAlignment::Center);
167    let content = content.unwrap_or_else(RenderSlot::empty);
168
169    surface()
170        .style(container_color.into())
171        .content_color(content_color)
172        .content_alignment(Alignment::CenterStart)
173        .elevation(elevation)
174        .tonal_elevation(elevation)
175        .modifier(
176            modifier
177                .fill_max_width()
178                .height(AppBarDefaults::TOP_APP_BAR_HEIGHT),
179        )
180        .child(move || {
181            let content = content;
182            row()
183                .modifier(Modifier::new().fill_max_size().padding(content_padding))
184                .main_axis_alignment(main_axis_alignment)
185                .cross_axis_alignment(cross_axis_alignment)
186                .children_shared(content);
187        });
188}
189
190impl TopAppBarBuilder {
191    /// Append a trailing action item.
192    pub fn action<F, Output>(&self, action: F) -> Self
193    where
194        F: Fn() -> Output + Send + Sync + 'static,
195    {
196        self.props.append("actions", move |props| {
197            props
198                .actions
199                .get_or_insert_with(Vec::new)
200                .push(RenderSlot::new(action))
201        });
202        Self { props: self.props }
203    }
204
205    /// Append a trailing action item using a shared callback.
206    pub fn action_shared(&self, action: impl Into<RenderSlot> + Send + Sync + 'static) -> Self {
207        self.props.append("actions", move |props| {
208            props
209                .actions
210                .get_or_insert_with(Vec::new)
211                .push(action.into())
212        });
213        Self { props: self.props }
214    }
215
216    /// Replace the trailing actions with shared callbacks.
217    pub fn actions_shared(&self, actions: Vec<RenderSlot>) -> Self {
218        let value = Some(actions);
219        self.props
220            .configure("actions", move |props| props.actions = value);
221        Self { props: self.props }
222    }
223
224    /// Appends a desktop minimize window control button.
225    pub fn window_control_minimize(&self) -> Self {
226        self.action(|| window_control(WindowControl::Minimize))
227    }
228
229    /// Appends a desktop maximize or restore window control button.
230    pub fn window_control_toggle_maximize(&self) -> Self {
231        self.action(|| window_control(WindowControl::Maximize))
232    }
233
234    /// Appends a desktop close window control button.
235    pub fn window_control_close(&self) -> Self {
236        self.action(|| window_control(WindowControl::Close))
237    }
238}
239
240#[derive(Clone, Copy, PartialEq)]
241enum WindowControl {
242    Minimize,
243    Maximize,
244    Close,
245}
246
247#[tessera]
248fn window_control(control: WindowControl) {
249    let (label, icon, action): (&str, Asset, fn()) = match control {
250        WindowControl::Minimize => (
251            "md3-minimize-window",
252            filled::MINIMIZE_SVG,
253            tessera_platform::window::minimize,
254        ),
255        WindowControl::Maximize => (
256            "md3-toggle-maximize-window",
257            filled::FULLSCREEN_SVG,
258            tessera_platform::window::toggle_maximize,
259        ),
260        WindowControl::Close => (
261            "md3-close-window",
262            filled::CLOSE_SVG,
263            tessera_platform::window::close,
264        ),
265    };
266    icon_button()
267        .standard()
268        .accessibility_label(I18N.text(label))
269        .icon(icon)
270        .on_click(action);
271}
272
273/// # top_app_bar
274///
275/// Render a small or flexible top app bar for screen titles, navigation, and
276/// actions.
277///
278/// ## Usage
279///
280/// Use as the primary top bar to show the current screen title and actions.
281/// Flexible variants grow to accommodate multiline titles and subtitles.
282///
283/// ## Parameters
284///
285/// - `modifier` — modifier chain applied to the surface.
286/// - `container_color` — optional app bar surface color.
287/// - `content_color` — optional default title color.
288/// - `elevation` — optional surface elevation.
289/// - `content_padding` — optional padding applied to app bar content.
290/// - `title` — title text displayed in the bar.
291/// - `title_content` — optional image, logo or custom title content replacing
292///   the title text while retaining the subtitle and alignment.
293/// - `title_area_modifier` — modifier chain applied to the title and subtitle
294///   area.
295/// - `window_chrome` — use this bar as the title bar when the window is
296///   configured with `WindowConfig::with_app_titlebar`.
297/// - `navigation_icon` — optional leading icon slot.
298/// - `actions` — trailing action slots.
299/// - `navigation_icon_color` — optional color for the navigation icon slot.
300/// - `action_icon_color` — optional color for trailing action slots.
301/// - `title_inset` — optional total title inset when no navigation icon is
302///   present.
303/// - `actions_spacing` — optional spacing inserted between action items.
304/// - `variant` — small, medium flexible, or large flexible layout.
305/// - `subtitle` — optional secondary text below the title.
306/// - `title_alignment` — leading or globally centered title placement.
307/// - `scroll_state` — shared height and overlap state also passed to the scroll
308///   provider.
309/// - `scrolled_container_color` — optional container color when content scrolls
310///   under the bar.
311///
312/// ## Examples
313///
314/// ```rust
315/// # use tessera_ui::tessera;
316/// # #[tessera]
317/// # fn component() {
318/// use tessera_components::{
319///     app_bar::{TopAppBarVariant, top_app_bar},
320///     button::button,
321///     text::text,
322/// };
323/// use tessera_ui::{Callback, remember};
324/// # use tessera_components::theme::{MaterialTheme, material_theme};
325///
326/// # material_theme()
327/// #     .theme(|| MaterialTheme::default())
328/// #     .child(|| {
329/// let unread = remember(|| 3usize);
330/// let mark_read = Callback::new(move || unread.set(0));
331/// top_app_bar()
332///     .variant(TopAppBarVariant::MediumFlexible)
333///     .title("Inbox")
334///     .subtitle(format!("{} unread messages", unread.get()))
335///     .action(move || {
336///         button().text().on_click_shared(mark_read).child(|| {
337///             text("Mark read");
338///         });
339///     });
340/// mark_read.call();
341/// assert_eq!(unread.get(), 0);
342/// #     });
343/// # }
344/// # tessera_ui::testing::layout_test(component).run();
345/// ```
346#[tessera]
347pub fn top_app_bar(
348    modifier: Option<Modifier>,
349    container_color: Option<Color>,
350    content_color: Option<Color>,
351    elevation: Option<Dp>,
352    content_padding: Option<Padding>,
353    #[prop(into)] title: Option<String>,
354    title_content: Option<RenderSlot>,
355    title_area_modifier: Option<Modifier>,
356    window_chrome: Option<bool>,
357    navigation_icon: Option<RenderSlot>,
358    #[prop(skip_setter)] actions: Option<Vec<RenderSlot>>,
359    navigation_icon_color: Option<Color>,
360    action_icon_color: Option<Color>,
361    title_inset: Option<Dp>,
362    actions_spacing: Option<Dp>,
363    variant: Option<TopAppBarVariant>,
364    #[prop(into)] subtitle: Option<String>,
365    title_alignment: Option<AppBarTitleAlignment>,
366    scroll_state: Option<State<AppBarScrollState>>,
367    scrolled_container_color: Option<Color>,
368) {
369    let theme = use_context::<MaterialTheme>()
370        .expect("MaterialTheme must be provided")
371        .get();
372    let scheme = theme.color_scheme;
373    let variant = variant.unwrap_or_default();
374    let alignment = title_alignment.unwrap_or_default();
375    let has_navigation = navigation_icon.is_some();
376    let title_bar = if window_chrome.unwrap_or(false) {
377        AppTitleBar::current()
378    } else {
379        AppTitleBar::None
380    };
381    let mut actions = actions.unwrap_or_default();
382    if title_bar == AppTitleBar::Custom {
383        for control in [
384            WindowControl::Minimize,
385            WindowControl::Maximize,
386            WindowControl::Close,
387        ] {
388            actions.push(RenderSlot::new(move || window_control(control)));
389        }
390    }
391    let has_actions = !actions.is_empty();
392    let title = title.unwrap_or_default();
393    let subtitle = subtitle.filter(|value| !value.is_empty());
394    let has_subtitle = subtitle.is_some();
395    let title_color = content_color.unwrap_or_else(|| AppBarDefaults::title_color(&scheme));
396    let nav_color =
397        navigation_icon_color.unwrap_or_else(|| AppBarDefaults::navigation_icon_color(&scheme));
398    let action_color =
399        action_icon_color.unwrap_or_else(|| AppBarDefaults::action_icon_color(&scheme));
400    let (title_style, subtitle_style) = match variant {
401        TopAppBarVariant::Small => (theme.typography.title_large, theme.typography.label_medium),
402        TopAppBarVariant::MediumFlexible => (
403            theme.typography.headline_medium,
404            theme.typography.label_large,
405        ),
406        TopAppBarVariant::LargeFlexible => (
407            theme.typography.display_small,
408            theme.typography.title_medium,
409        ),
410    };
411    let policy = TopAppBarLayout {
412        scroll_state,
413        variant,
414        alignment,
415        has_navigation,
416        has_actions,
417        has_subtitle,
418        padding: content_padding.unwrap_or(AppBarDefaults::TOP_APP_BAR_CONTENT_PADDING),
419        title_inset: title_inset.unwrap_or(AppBarDefaults::TITLE_INSET),
420    };
421    let title_area_modifier = title_area_modifier.unwrap_or_default();
422    let title_area_modifier = if title_bar == AppTitleBar::None {
423        title_area_modifier
424    } else {
425        title_area_modifier.window_drag_region()
426    };
427    let actions_spacing = actions_spacing.unwrap_or(AppBarDefaults::ACTIONS_SPACING);
428    let elevation = elevation.unwrap_or(AppBarDefaults::TOP_APP_BAR_ELEVATION);
429    let fraction = scroll_state
430        .map(|state| state.with(AppBarScrollState::collapsed_fraction))
431        .unwrap_or(0.0);
432    let color_fraction = scroll_state
433        .map(|state| state.with(AppBarScrollState::color_fraction))
434        .unwrap_or(0.0);
435    let container = container_color
436        .unwrap_or_else(|| AppBarDefaults::container_color(&scheme))
437        .lerp(
438            &scrolled_container_color.unwrap_or(scheme.surface_container),
439            color_fraction,
440        );
441    let modifier = modifier.unwrap_or_default();
442    let modifier = if let Some(state) = scroll_state {
443        let active = remember(|| state);
444        if active.get() != state {
445            active.set(state);
446        }
447        modifier.draggable_with(DraggableArgs {
448            enabled: state.with(AppBarScrollState::drag_enabled),
449            axis: Some(DragAxis::Vertical),
450            on_drag_delta: CallbackWith::new(move |delta: crate::modifier::DragDelta| {
451                active
452                    .get()
453                    .with_mut(|value| value.drag_by(delta.y.to_f32()))
454            }),
455            on_drag_stopped: Some(Callback::new(move || {
456                active.get().with_mut(AppBarScrollState::end_drag)
457            })),
458            ..Default::default()
459        })
460    } else {
461        modifier
462    };
463    surface()
464        .style(container.into())
465        .content_color(title_color)
466        .elevation(elevation)
467        .tonal_elevation(elevation)
468        .modifier(
469            modifier
470                .fill_max_width()
471                .clip_to_bounds()
472                .semantics(SemanticsArgs {
473                    role: Some(tessera_ui::accesskit::Role::Toolbar),
474                    merge_descendants: true,
475                    ..Default::default()
476                }),
477        )
478        .child(move || {
479            let title = title.clone();
480            let subtitle = subtitle.clone();
481            let title_area_modifier = title_area_modifier.clone();
482            let actions = actions.clone();
483            layout().policy(policy.clone()).child(move || {
484                row()
485                    .cross_axis_alignment(CrossAxisAlignment::Center)
486                    .children(move || {
487                        if let Some(navigation_icon) = navigation_icon {
488                            provide_context(
489                                || ContentColor { current: nav_color },
490                                move || {
491                                    let context =
492                                        use_context::<ContentColor>().expect("navigation color");
493                                    if context.get().current != nav_color {
494                                        context.set(ContentColor { current: nav_color });
495                                    }
496                                    navigation_icon.render();
497                                },
498                            );
499                        }
500                    });
501                let title = title.clone();
502                let subtitle = subtitle.clone();
503                let title_area_modifier = if alignment == AppBarTitleAlignment::Start {
504                    title_area_modifier.clone().fill_max_width()
505                } else {
506                    title_area_modifier.clone()
507                };
508                let compact_title = title.clone();
509                let compact_subtitle = subtitle.clone();
510                column()
511                    .modifier(title_layer_modifier(
512                        title_area_modifier.clone(),
513                        if variant == TopAppBarVariant::Small {
514                            1.0
515                        } else {
516                            cubic_bezier_easing(fraction, 0.8, 0.0, 0.8, 0.15)
517                        },
518                        variant == TopAppBarVariant::Small || fraction >= 0.5,
519                    ))
520                    .main_axis_alignment(MainAxisAlignment::Center)
521                    .cross_axis_alignment(if alignment == AppBarTitleAlignment::Center {
522                        CrossAxisAlignment::Center
523                    } else {
524                        CrossAxisAlignment::Start
525                    })
526                    .children(move || {
527                        if let Some(content) = title_content {
528                            content.render();
529                        } else if !compact_title.is_empty() {
530                            text(compact_title.clone())
531                                .single_line(true)
532                                .style(theme.typography.title_large)
533                                .color(title_color);
534                        }
535                        if let Some(subtitle) = &compact_subtitle {
536                            text(subtitle.clone())
537                                .single_line(true)
538                                .style(theme.typography.label_medium)
539                                .color(scheme.on_surface_variant);
540                        }
541                    });
542                let actions = actions.clone();
543                row()
544                    .cross_axis_alignment(CrossAxisAlignment::Center)
545                    .children(move || {
546                        let actions = actions.clone();
547                        provide_context(
548                            || ContentColor {
549                                current: action_color,
550                            },
551                            move || {
552                                let context = use_context::<ContentColor>().expect("action color");
553                                if context.get().current != action_color {
554                                    context.set(ContentColor {
555                                        current: action_color,
556                                    });
557                                }
558                                for (index, action) in actions.iter().enumerate() {
559                                    if index > 0 && actions_spacing.0 > 0.0 {
560                                        spacer().modifier(Modifier::new().width(actions_spacing));
561                                    }
562                                    action.render();
563                                }
564                            },
565                        );
566                    });
567                if variant != TopAppBarVariant::Small {
568                    let title = title.clone();
569                    let subtitle = subtitle.clone();
570                    column()
571                        .modifier(title_layer_modifier(
572                            title_area_modifier.clone(),
573                            1.0 - fraction,
574                            fraction < 0.5,
575                        ))
576                        .cross_axis_alignment(if alignment == AppBarTitleAlignment::Center {
577                            CrossAxisAlignment::Center
578                        } else {
579                            CrossAxisAlignment::Start
580                        })
581                        .children(move || {
582                            if let Some(content) = title_content {
583                                content.render();
584                            } else if !title.is_empty() {
585                                text(title.clone()).style(title_style).color(title_color);
586                            }
587                            if let Some(subtitle) = &subtitle {
588                                text(subtitle.clone())
589                                    .style(subtitle_style)
590                                    .color(scheme.on_surface_variant);
591                            }
592                        });
593                }
594            });
595        });
596}
597
598fn title_layer_modifier(modifier: Modifier, alpha: f32, semantics: bool) -> Modifier {
599    let modifier = modifier.clip_to_bounds().alpha(alpha);
600    if semantics {
601        modifier
602    } else {
603        modifier.semantics(SemanticsArgs {
604            hidden: true,
605            merge_descendants: false,
606            ..Default::default()
607        })
608    }
609}
610
611#[derive(Clone, PartialEq)]
612struct TopAppBarLayout {
613    scroll_state: Option<State<AppBarScrollState>>,
614    variant: TopAppBarVariant,
615    alignment: AppBarTitleAlignment,
616    has_navigation: bool,
617    has_actions: bool,
618    has_subtitle: bool,
619    padding: Padding,
620    title_inset: Dp,
621}
622
623impl tessera_ui::LayoutModifierNode for TopAppBarLayout {
624    fn measure(&self, scope: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
625        let parent = *scope.parent_constraint().as_ref();
626        let width = parent.width.max.unwrap_or(parent.width.min);
627        let top = self.padding.top.to_px();
628        let bottom = self.padding.bottom.to_px();
629        let left = self.padding.left.to_px().min(width);
630        let right = self.padding.right.to_px().min((width - left).max(Px::ZERO));
631        let available = (width - left - right).max(Px::ZERO);
632        let toolbar_height = (parent.height - top - bottom)
633            .clamp(AppBarDefaults::TOP_APP_BAR_HEIGHT.to_px())
634            .min(AppBarDefaults::TOP_APP_BAR_HEIGHT.to_px());
635        let children = scope.children();
636        let actions = children[2].measure(
637            scope,
638            &Constraint::new(
639                AxisConstraint::at_most(available),
640                AxisConstraint::at_most(toolbar_height),
641            ),
642        )?;
643        let navigation = children[0].measure(
644            scope,
645            &Constraint::new(
646                AxisConstraint::at_most((available - actions.width).max(Px::ZERO)),
647                AxisConstraint::at_most(toolbar_height),
648            ),
649        )?;
650        let expanded = self.variant != TopAppBarVariant::Small;
651        let inset = self.title_inset.to_px().max(left).min(width);
652        let title_start = if !self.has_navigation {
653            inset
654        } else {
655            (left + navigation.width + Dp(4.0).to_px()).min(width)
656        };
657        let title_end = if !self.has_actions {
658            (width - self.title_inset.to_px().max(right)).max(title_start)
659        } else {
660            (width - right - actions.width - Dp(4.0).to_px()).max(title_start)
661        }
662        .min(width);
663        let title_width = (title_end - title_start).max(Px::ZERO);
664        let title_height = if self.alignment == AppBarTitleAlignment::Start {
665            AxisConstraint::exact(toolbar_height)
666        } else {
667            AxisConstraint::at_most(toolbar_height)
668        };
669        let title = children[1].measure(
670            scope,
671            &Constraint::new(AxisConstraint::at_most(title_width), title_height),
672        )?;
673        let preferred = match (self.variant, self.has_subtitle) {
674            (TopAppBarVariant::Small, _) => AppBarDefaults::TOP_APP_BAR_HEIGHT,
675            (TopAppBarVariant::MediumFlexible, false) => AppBarDefaults::MEDIUM_FLEXIBLE_HEIGHT,
676            (TopAppBarVariant::MediumFlexible, true) => {
677                AppBarDefaults::MEDIUM_FLEXIBLE_SUBTITLE_HEIGHT
678            }
679            (TopAppBarVariant::LargeFlexible, false) => AppBarDefaults::LARGE_FLEXIBLE_HEIGHT,
680            (TopAppBarVariant::LargeFlexible, true) => {
681                AppBarDefaults::LARGE_FLEXIBLE_SUBTITLE_HEIGHT
682            }
683        }
684        .to_px();
685        let title_bottom_padding = match (self.variant, self.has_subtitle) {
686            (TopAppBarVariant::Small, _) => Dp::ZERO,
687            (_, false) => Dp(12.0),
688            (TopAppBarVariant::MediumFlexible, true) => Dp(16.0),
689            (TopAppBarVariant::LargeFlexible, true) => Dp(20.0),
690        }
691        .to_px();
692        let large_title = if expanded {
693            Some(children[3].measure(
694                scope,
695                &Constraint::new(
696                    AxisConstraint::at_most(
697                        (width - inset - self.title_inset.to_px().max(right)).max(Px::ZERO),
698                    ),
699                    parent.height.without_min() - top - bottom - toolbar_height,
700                ),
701            )?)
702        } else {
703            None
704        };
705        let content_height = large_title
706            .as_ref()
707            .map(|title| preferred.max(toolbar_height + title.height + title_bottom_padding))
708            .unwrap_or(preferred);
709        let compact_height = if !expanded
710            && self.scroll_state.is_some_and(|state| {
711                state.with(AppBarScrollState::behavior) == AppBarScrollBehavior::EnterAlways
712            }) {
713            Px::ZERO
714        } else {
715            toolbar_height
716        };
717        let offset = if let Some(state) = self.scroll_state {
718            let range = if state.with(AppBarScrollState::behavior) == AppBarScrollBehavior::Pinned {
719                Dp::ZERO
720            } else {
721                Dp::from(content_height - compact_height)
722            };
723            if state.with(AppBarScrollState::height_range) != range {
724                state.with_mut(|value| {
725                    value.configure_height(Dp::from(content_height), Dp::from(compact_height))
726                });
727            }
728            state.with(AppBarScrollState::height_offset).to_px()
729        } else {
730            Px::ZERO
731        };
732        let height = parent
733            .height
734            .clamp((content_height + offset + top + bottom).max(Px::ZERO));
735        let toolbar_height = if !expanded {
736            (height - top - bottom).max(Px::ZERO).min(toolbar_height)
737        } else {
738            toolbar_height
739        };
740        let mut result = LayoutResult::new(ComputedData { width, height });
741        let toolbar_visible = toolbar_height > Px::ZERO && height > top + bottom;
742        if toolbar_visible {
743            result.place_child(
744                children[0],
745                PxPosition::new(left, top + (toolbar_height - navigation.height) / 2),
746            );
747        }
748        let title_x = if self.alignment == AppBarTitleAlignment::Center {
749            ((width - title.width) / 2)
750                .max(title_start)
751                .min((title_end - title.width).max(title_start))
752        } else {
753            title_start
754        };
755        let title_y = top + (toolbar_height - title.height) / 2;
756        let fraction = self
757            .scroll_state
758            .map(|state| state.with(AppBarScrollState::collapsed_fraction))
759            .unwrap_or(0.0);
760        if toolbar_visible
761            && title_width > Px::ZERO
762            && title_y < height
763            && (!expanded || fraction > 0.0)
764        {
765            result.place_child(children[1], PxPosition::new(title_x, title_y));
766        }
767        if let Some(title) = large_title
768            && height > top + toolbar_height
769            && fraction < 1.0
770        {
771            let x = if self.alignment == AppBarTitleAlignment::Center {
772                (width - title.width) / 2
773            } else {
774                inset
775            };
776            result.place_child(children[3], PxPosition::new(x, top + toolbar_height));
777        }
778        if toolbar_visible {
779            result.place_child(
780                children[2],
781                PxPosition::new(
782                    width - right - actions.width,
783                    top + (toolbar_height - actions.height) / 2,
784                ),
785            );
786        }
787        Ok(result)
788    }
789}
790
791#[cfg(test)]
792mod tests;
793
794#[cfg(test)]
795mod scroll_tests;