use core::fmt::{self, Debug};
use waterui::accessibility::{AccessibilityChildren, AccessibilityRole, AccessibilityState};
use waterui::color::Color;
use waterui::layout::{
Layout, ProposalSize, Rect, Size, StretchAxis, SubView, SubviewPlacement,
container::FixedContainer, padding::EdgeInsets,
};
use waterui::prelude::{PositionExt as _, UnitPoint, absolute};
use waterui::reactive::SignalExt as _;
use waterui::shape::{Capsule, FixedUnevenRoundedRectangle, ShapeExt as _};
use waterui::{AnyView, Binding, Environment, Str, View, ViewExt as _};
use waterui_controls::label::{IntoLabel, Label};
use waterui_core::handler::{Handler, SharedAction, boxed_action};
use crate::ModalInteraction;
use crate::color::{
OnSecondaryContainer, OnSurfaceVariant, Scrim, SecondaryContainer, Surface, SurfaceContainerLow,
};
use crate::elevation::{MaterialElevationLevel, material_elevation};
use crate::semantics::{conditional_color, interaction_style, label_plain_text};
use crate::theme::{motion, typography};
const NAVIGATION_DRAWER_CONTAINER_WIDTH: f32 = 360.0;
const NAVIGATION_DRAWER_MODAL_MAX_VIEWPORT_FRACTION: f32 = 0.8;
const NAVIGATION_DRAWER_CONTAINER_SHAPE: f32 = 16.0;
const NAVIGATION_DRAWER_ITEM_HEIGHT: f32 = 56.0;
const NAVIGATION_DRAWER_ITEM_CONTAINER_SHAPE: f32 = NAVIGATION_DRAWER_ITEM_HEIGHT / 2.0;
const NAVIGATION_DRAWER_ITEM_HORIZONTAL_PADDING: f32 = 16.0;
const NAVIGATION_DRAWER_ITEM_ICON_SIZE: f32 = 24.0;
const NAVIGATION_DRAWER_ITEM_ICON_LABEL_SPACE: f32 = 12.0;
pub struct NavigationDrawer<Content> {
opened: Binding<bool>,
content: Content,
accessibility_label: Str,
modal: bool,
close_on_overlay_click: bool,
close_on_escape: bool,
overlay_action: SharedAction,
}
impl<Content> Debug for NavigationDrawer<Content> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NavigationDrawer")
.field("accessibility_label", &self.accessibility_label)
.finish_non_exhaustive()
}
}
impl<Content> NavigationDrawer<Content> {
#[must_use]
pub fn new(opened: &Binding<bool>, content: Content) -> Self {
Self {
opened: opened.clone(),
content,
accessibility_label: "Navigation drawer".into(),
modal: false,
close_on_overlay_click: false,
close_on_escape: false,
overlay_action: SharedAction::new(|_: Environment| {}),
}
}
#[must_use]
pub fn label(mut self, label: impl Into<Str>) -> Self {
self.accessibility_label = label.into();
self
}
#[must_use]
pub const fn modal(mut self) -> Self {
self.modal = true;
self
}
#[must_use]
pub const fn close_on_overlay_click(mut self) -> Self {
self.close_on_overlay_click = true;
self
}
#[must_use]
pub const fn close_on_escape(mut self) -> Self {
self.close_on_escape = true;
self
}
#[must_use]
pub fn overlay_action<F, Args>(mut self, action: F) -> Self
where
F: Handler<Args, ()> + 'static,
{
let mut action = boxed_action(action);
self.overlay_action = SharedAction::new(move |env: Environment| action(&env));
self
}
}
impl<Content> View for NavigationDrawer<Content>
where
Content: View + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let accessibility_state = self.opened.map(|opened| {
AccessibilityState::new()
.expanded(Some(opened))
.hidden(!opened)
});
let offset = motion::navigation_drawer(
self.opened.computed(),
-NAVIGATION_DRAWER_CONTAINER_WIDTH,
0.0,
);
let opened = self.opened;
if self.modal {
let panel_content = FixedContainer::new(NavigationDrawerPanelLayout, (self.content,))
.background(
FixedUnevenRoundedRectangle::new(
0.0,
NAVIGATION_DRAWER_CONTAINER_SHAPE,
0.0,
NAVIGATION_DRAWER_CONTAINER_SHAPE,
)
.fill(SurfaceContainerLow),
)
.a11y_label(self.accessibility_label)
.a11y_role(AccessibilityRole::Group)
.a11y_state_signal(accessibility_state);
let panel = material_elevation(
MaterialElevationLevel::LEVEL1,
NAVIGATION_DRAWER_CONTAINER_SHAPE,
panel_content,
)
.offset(offset, 0.0)
.position_in(UnitPoint::TOP_LEADING);
let close_on_overlay_click = self.close_on_overlay_click;
let opened_for_overlay = opened.clone();
let overlay_action = self.overlay_action;
let scrim = Scrim
.with_opacity(1.0)
.opacity(motion::navigation_drawer_scrim(
opened.computed(),
0.0,
crate::theme::colors::SCRIM_OPACITY,
))
.on_tap(move |env: Environment| {
overlay_action.call(&env);
if close_on_overlay_click {
opened_for_overlay.set(false);
}
})
.a11y_hidden(true)
.install(interaction_style(Scrim.with_opacity(0.0), 0.0).pointer_only());
let opened_for_escape = opened.clone();
let modal = ModalInteraction::new(
self.close_on_escape,
SharedAction::new(move |_: Environment| opened_for_escape.set(false)),
)
.active(opened.clone());
return AnyView::new(absolute((scrim, panel)).hittable(opened).install(modal));
}
AnyView::new(
self.content
.width(NAVIGATION_DRAWER_CONTAINER_WIDTH)
.background(Surface)
.offset(offset, 0.0)
.a11y_label(self.accessibility_label)
.a11y_role(AccessibilityRole::Group)
.a11y_state_signal(accessibility_state),
)
}
}
#[derive(Debug, Clone, Copy)]
struct NavigationDrawerPanelLayout;
impl Layout for NavigationDrawerPanelLayout {
fn size_that_fits(&self, proposal: ProposalSize, children: &[&dyn SubView]) -> Size {
let proposed_width = proposal.width.unwrap_or(NAVIGATION_DRAWER_CONTAINER_WIDTH);
let width = NAVIGATION_DRAWER_CONTAINER_WIDTH
.min(proposed_width * NAVIGATION_DRAWER_MODAL_MAX_VIEWPORT_FRACTION);
let height = proposal.height.unwrap_or_else(|| {
children.first().map_or(0.0, |content| {
content
.measure(ProposalSize::new(Some(width), None))
.size
.height
})
});
Size::new(width, height)
}
fn place(
&self,
bounds: Rect,
proposal: ProposalSize,
children: &[&dyn SubView],
) -> Vec<SubviewPlacement> {
let child_proposal = ProposalSize::new(Some(bounds.width()), proposal.height);
children
.iter()
.map(|_| SubviewPlacement::new(bounds, child_proposal))
.collect()
}
fn stretch_axis(&self, _children: &[StretchAxis]) -> StretchAxis {
StretchAxis::Vertical
}
}
pub struct NavigationDrawerItem<Icon, Action = fn(&Environment)> {
label: Label,
accessibility_label: Str,
icon: Icon,
selected: Binding<bool>,
action: Action,
}
impl<Icon, Action> Debug for NavigationDrawerItem<Icon, Action> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NavigationDrawerItem")
.field("label", &self.label)
.finish_non_exhaustive()
}
}
impl<Icon> NavigationDrawerItem<Icon, fn(&Environment)> {
#[must_use]
pub fn new(label: impl IntoLabel, icon: Icon, selected: &Binding<bool>) -> Self {
let label = label.into_label();
let accessibility_label = label_plain_text(&label);
Self {
label,
accessibility_label,
icon,
selected: selected.clone(),
action: noop,
}
}
}
impl<Icon, Action> NavigationDrawerItem<Icon, Action> {
#[must_use]
pub fn action<F, Args>(self, action: F) -> NavigationDrawerItem<Icon, impl FnMut(&Environment)>
where
F: Handler<Args, ()> + 'static,
{
NavigationDrawerItem {
label: self.label,
accessibility_label: self.accessibility_label,
icon: self.icon,
selected: self.selected,
action: boxed_action(action),
}
}
}
impl<Icon, Action> View for NavigationDrawerItem<Icon, Action>
where
Icon: Clone + View + 'static,
Action: FnMut(&Environment) + 'static,
{
fn body(self, _env: &Environment) -> impl View {
let mut action = self.action;
let accessibility_label = self.accessibility_label.clone();
let accessibility_state = self
.selected
.map(|selected| AccessibilityState::new().selected(selected));
let foreground = conditional_color(
self.selected.clone(),
OnSecondaryContainer,
OnSurfaceVariant,
);
let background = conditional_color(
self.selected.clone(),
SecondaryContainer,
SecondaryContainer.with_opacity(0.0),
);
let state_layer_color =
conditional_color(self.selected, OnSecondaryContainer, OnSurfaceVariant);
drawer_item_content(self.label, self.icon, foreground.into(), background.into())
.on_tap(move |env: Environment| action(&env))
.a11y_label(accessibility_label)
.a11y_role(AccessibilityRole::Button)
.a11y_state_signal(accessibility_state)
.a11y_children(AccessibilityChildren::ExcludeDescendants)
.install(interaction_style(
state_layer_color,
f64::from(NAVIGATION_DRAWER_ITEM_CONTAINER_SHAPE),
))
}
}
fn drawer_item_content(
label: Label,
icon: impl View,
foreground: Color,
background: Color,
) -> impl View {
waterui::component::hstack((
icon.foreground(foreground.clone())
.width(NAVIGATION_DRAWER_ITEM_ICON_SIZE)
.height(NAVIGATION_DRAWER_ITEM_ICON_SIZE),
label.font(typography::label_large()).foreground(foreground),
waterui::component::spacer(),
))
.spacing(NAVIGATION_DRAWER_ITEM_ICON_LABEL_SPACE)
.height(NAVIGATION_DRAWER_ITEM_HEIGHT)
.padding_with(EdgeInsets::new(
0.0,
0.0,
NAVIGATION_DRAWER_ITEM_HORIZONTAL_PADDING,
NAVIGATION_DRAWER_ITEM_HORIZONTAL_PADDING,
))
.background(Capsule.fill(background))
}
const fn noop(_env: &Environment) {}
#[must_use]
pub fn navigation_drawer<Content>(
opened: &Binding<bool>,
content: Content,
) -> NavigationDrawer<Content> {
NavigationDrawer::new(opened, content)
}
#[must_use]
pub fn navigation_drawer_item<Icon>(
label: impl IntoLabel,
icon: Icon,
selected: &Binding<bool>,
) -> NavigationDrawerItem<Icon> {
NavigationDrawerItem::new(label, icon, selected)
}
#[cfg(test)]
mod tests {
use super::{
NAVIGATION_DRAWER_CONTAINER_SHAPE, NAVIGATION_DRAWER_CONTAINER_WIDTH,
NAVIGATION_DRAWER_ITEM_HEIGHT, NAVIGATION_DRAWER_ITEM_HORIZONTAL_PADDING,
NAVIGATION_DRAWER_ITEM_ICON_LABEL_SPACE, NAVIGATION_DRAWER_ITEM_ICON_SIZE,
NAVIGATION_DRAWER_MODAL_MAX_VIEWPORT_FRACTION,
};
#[test]
fn navigation_drawer_tokens_match_compose_navigation_drawer_tokens() {
assert_eq!(NAVIGATION_DRAWER_CONTAINER_WIDTH, 360.0);
assert_eq!(NAVIGATION_DRAWER_CONTAINER_SHAPE, 16.0);
assert_eq!(NAVIGATION_DRAWER_MODAL_MAX_VIEWPORT_FRACTION, 0.8);
assert_eq!(NAVIGATION_DRAWER_ITEM_HEIGHT, 56.0);
assert_eq!(NAVIGATION_DRAWER_ITEM_ICON_SIZE, 24.0);
assert_eq!(NAVIGATION_DRAWER_ITEM_ICON_LABEL_SPACE, 12.0);
assert_eq!(NAVIGATION_DRAWER_ITEM_HORIZONTAL_PADDING, 16.0);
}
#[test]
fn layout_contract_drawer_distinguishes_equal_height_offers() {
use super::NavigationDrawerPanelLayout;
use crate::layout_test_support::FixedLeaf;
use waterui::layout::{Layout, ProposalSize, Rect, Size};
let child = FixedLeaf(Size::new(240.0, 100.0));
let layout = NavigationDrawerPanelLayout;
for height in [None, Some(100.0), None] {
let proposal = ProposalSize::new(Some(300.0), height);
let size = layout.size_that_fits(proposal, &[&child]);
let placements = layout.place(Rect::from_size(size), proposal, &[&child]);
assert_eq!(size, child.0);
assert_eq!(
placements[0].proposal,
ProposalSize::new(Some(240.0), height)
);
}
}
}