1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
37pub enum TopAppBarVariant {
38 #[default]
40 Small,
41 MediumFlexible,
43 LargeFlexible,
45}
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum AppBarTitleAlignment {
50 #[default]
52 Start,
53 Center,
55}
56
57pub struct AppBarDefaults;
59
60impl AppBarDefaults {
61 pub const TOP_APP_BAR_HEIGHT: Dp = Dp(64.0);
63 pub const TOP_APP_BAR_ELEVATION: Dp = Dp(0.0);
65 pub const HORIZONTAL_PADDING: Dp = Dp(4.0);
67 pub const TITLE_INSET: Dp = Dp(16.0);
69 pub const ACTIONS_SPACING: Dp = Dp(0.0);
71 pub const CONTENT_PADDING: Padding = Padding::symmetric(Self::HORIZONTAL_PADDING, Dp(0.0));
73 pub const TOP_APP_BAR_CONTENT_PADDING: Padding = Self::CONTENT_PADDING;
75
76 pub const MEDIUM_FLEXIBLE_HEIGHT: Dp = Dp(112.0);
78 pub const MEDIUM_FLEXIBLE_SUBTITLE_HEIGHT: Dp = Dp(136.0);
80 pub const LARGE_FLEXIBLE_HEIGHT: Dp = Dp(120.0);
82 pub const LARGE_FLEXIBLE_SUBTITLE_HEIGHT: Dp = Dp(152.0);
84
85 pub fn container_color(scheme: &MaterialColorScheme) -> Color {
87 scheme.surface
88 }
89
90 pub fn title_color(scheme: &MaterialColorScheme) -> Color {
92 scheme.on_surface
93 }
94
95 pub fn navigation_icon_color(scheme: &MaterialColorScheme) -> Color {
97 scheme.on_surface
98 }
99
100 pub fn action_icon_color(scheme: &MaterialColorScheme) -> Color {
102 scheme.on_surface_variant
103 }
104}
105
106#[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 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 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 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 pub fn window_control_minimize(&self) -> Self {
226 self.action(|| window_control(WindowControl::Minimize))
227 }
228
229 pub fn window_control_toggle_maximize(&self) -> Self {
231 self.action(|| window_control(WindowControl::Maximize))
232 }
233
234 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#[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;