Skip to main content

material_ui_rs/widget/component/
sheet.rs

1//! Material 3 bottom sheet surface constructors.
2
3use iced_widget::core::text as core_text;
4use iced_widget::core::time::Instant;
5use iced_widget::core::{Background, Color, Element, Length, Padding, alignment, border};
6use iced_widget::{Column, Container, Space};
7
8use super::support::{AnimatedScalar, alpha_color, duration_ms, lerp};
9use crate::utils::shadow_from_level;
10use crate::{Theme, tokens};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Value {
14    Hidden,
15    PartiallyExpanded,
16    Expanded,
17}
18
19impl Value {
20    const fn progress(self) -> f32 {
21        match self {
22            Self::Hidden => 0.0,
23            Self::PartiallyExpanded => 0.5,
24            Self::Expanded => 1.0,
25        }
26    }
27}
28
29#[derive(Debug, Clone)]
30pub struct State {
31    from: Value,
32    target: Value,
33    progress: AnimatedScalar,
34}
35
36impl State {
37    pub fn new(value: Value) -> Self {
38        Self {
39            from: value,
40            target: value,
41            progress: AnimatedScalar::new(1.0),
42        }
43    }
44
45    pub const fn target(&self) -> Value {
46        self.target
47    }
48
49    pub fn show(&mut self, now: Instant) {
50        self.set_target(Value::PartiallyExpanded, now);
51    }
52
53    pub fn expand(&mut self, now: Instant) {
54        self.set_target(Value::Expanded, now);
55    }
56
57    pub fn partial_expand(&mut self, now: Instant) {
58        self.set_target(Value::PartiallyExpanded, now);
59    }
60
61    pub fn hide(&mut self, now: Instant) {
62        self.set_target(Value::Hidden, now);
63    }
64
65    pub fn set_target(&mut self, target: Value, now: Instant) {
66        if self.target == target && !self.progress.is_animating() {
67            return;
68        }
69
70        self.from = self.current_value();
71        self.target = target;
72        self.progress = AnimatedScalar::new(0.0);
73        self.progress.set_target(
74            1.0,
75            now,
76            duration_ms(tokens::component::bottom_sheet::ANIMATION_DURATION_MS),
77            tokens::component::bottom_sheet::ANIMATION_EASING,
78        );
79    }
80
81    pub fn advance(&mut self, now: Instant) -> bool {
82        self.progress.advance(now)
83    }
84
85    pub fn is_animating(&self) -> bool {
86        self.progress.is_animating()
87    }
88
89    pub fn visibility_progress(&self) -> f32 {
90        lerp(
91            self.from.progress(),
92            self.target.progress(),
93            self.progress.value.clamp(0.0, 1.0),
94        )
95    }
96
97    fn current_value(&self) -> Value {
98        if self.progress.value >= 1.0 {
99            self.target
100        } else {
101            self.from
102        }
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Side {
108    Left,
109    Right,
110}
111
112impl Side {
113    const fn alignment(self) -> alignment::Horizontal {
114        match self {
115            Self::Left => alignment::Horizontal::Left,
116            Self::Right => alignment::Horizontal::Right,
117        }
118    }
119
120    const fn hidden_translation_fraction(self) -> f32 {
121        match self {
122            Self::Left => -1.0,
123            Self::Right => 1.0,
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum SideValue {
130    Hidden,
131    Expanded,
132}
133
134impl SideValue {
135    const fn progress(self) -> f32 {
136        match self {
137            Self::Hidden => 0.0,
138            Self::Expanded => 1.0,
139        }
140    }
141}
142
143#[derive(Debug, Clone)]
144pub struct SideState {
145    from: SideValue,
146    target: SideValue,
147    progress: AnimatedScalar,
148}
149
150impl SideState {
151    pub fn new(value: SideValue) -> Self {
152        Self {
153            from: value,
154            target: value,
155            progress: AnimatedScalar::new(1.0),
156        }
157    }
158
159    pub const fn target(&self) -> SideValue {
160        self.target
161    }
162
163    pub fn show(&mut self, now: Instant) {
164        self.set_target(SideValue::Expanded, now);
165    }
166
167    pub fn hide(&mut self, now: Instant) {
168        self.set_target(SideValue::Hidden, now);
169    }
170
171    pub fn set_target(&mut self, target: SideValue, now: Instant) {
172        if self.target == target && !self.progress.is_animating() {
173            return;
174        }
175
176        self.from = self.current_value();
177        self.target = target;
178        self.progress = AnimatedScalar::new(0.0);
179        self.progress.set_target(
180            1.0,
181            now,
182            duration_ms(tokens::component::side_sheet::ANIMATION_DURATION_MS),
183            tokens::component::side_sheet::ANIMATION_EASING,
184        );
185    }
186
187    pub fn advance(&mut self, now: Instant) -> bool {
188        self.progress.advance(now)
189    }
190
191    pub fn is_animating(&self) -> bool {
192        self.progress.is_animating()
193    }
194
195    pub fn visibility_progress(&self) -> f32 {
196        lerp(
197            self.from.progress(),
198            self.target.progress(),
199            self.progress.value.clamp(0.0, 1.0),
200        )
201    }
202
203    pub fn translation_fraction(&self, side: Side) -> f32 {
204        side.hidden_translation_fraction() * (1.0 - self.visibility_progress())
205    }
206
207    fn current_value(&self) -> SideValue {
208        if self.progress.value >= 1.0 {
209            self.target
210        } else {
211            self.from
212        }
213    }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Kind {
218    Modal,
219    Standard,
220}
221
222pub fn modal_bottom<'a, Message, Renderer>(
223    content: impl Into<Element<'a, Message, Theme, Renderer>>,
224) -> Container<'a, Message, Theme, Renderer>
225where
226    Message: 'a,
227    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
228{
229    bottom_sheet(Kind::Modal, content, true)
230}
231
232pub fn standard_bottom<'a, Message, Renderer>(
233    content: impl Into<Element<'a, Message, Theme, Renderer>>,
234) -> Container<'a, Message, Theme, Renderer>
235where
236    Message: 'a,
237    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
238{
239    bottom_sheet(Kind::Standard, content, true)
240}
241
242pub fn bottom_sheet<'a, Message, Renderer>(
243    kind: Kind,
244    content: impl Into<Element<'a, Message, Theme, Renderer>>,
245    show_drag_handle: bool,
246) -> Container<'a, Message, Theme, Renderer>
247where
248    Message: 'a,
249    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
250{
251    let mut column = Column::new().width(Length::Fill);
252
253    if show_drag_handle {
254        column = column.push(drag_handle());
255    }
256
257    column = column.push(content.into());
258
259    Container::new(column)
260        .width(Length::Fill)
261        .max_width(tokens::component::bottom_sheet::SHEET_MAX_WIDTH)
262        .style(move |theme| bottom_sheet_style(theme, kind))
263}
264
265pub fn bottom_content<'a, Message, Renderer>(
266    content: impl Into<Element<'a, Message, Theme, Renderer>>,
267) -> Container<'a, Message, Theme, Renderer>
268where
269    Message: 'a,
270    Renderer: iced_widget::core::Renderer + 'a,
271{
272    Container::new(content)
273        .padding(Padding {
274            top: 0.0,
275            right: tokens::component::bottom_sheet::CONTENT_PADDING,
276            bottom: tokens::component::bottom_sheet::CONTENT_PADDING,
277            left: tokens::component::bottom_sheet::CONTENT_PADDING,
278        })
279        .width(Length::Fill)
280}
281
282pub fn drag_handle<'a, Message, Renderer>() -> Container<'a, Message, Theme, Renderer>
283where
284    Message: 'a,
285    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
286{
287    let handle = Container::new(
288        Space::new()
289            .width(Length::Fixed(
290                tokens::component::bottom_sheet::DRAG_HANDLE_WIDTH,
291            ))
292            .height(Length::Fixed(
293                tokens::component::bottom_sheet::DRAG_HANDLE_HEIGHT,
294            )),
295    )
296    .style(drag_handle_style);
297
298    Container::new(handle)
299        .width(Length::Fill)
300        .padding(Padding {
301            top: tokens::component::bottom_sheet::DRAG_HANDLE_VERTICAL_PADDING,
302            right: 0.0,
303            bottom: tokens::component::bottom_sheet::DRAG_HANDLE_VERTICAL_PADDING,
304            left: 0.0,
305        })
306        .align_x(alignment::Horizontal::Center)
307}
308
309pub fn scrim<'a, Message, Renderer>(
310    content: impl Into<Element<'a, Message, Theme, Renderer>>,
311) -> Container<'a, Message, Theme, Renderer>
312where
313    Message: 'a,
314    Renderer: iced_widget::core::Renderer + 'a,
315{
316    Container::new(content)
317        .width(Length::Fill)
318        .height(Length::Fill)
319        .style(scrim_style)
320}
321
322pub fn modal_overlay<'a, Message, Renderer>(
323    content: impl Into<Element<'a, Message, Theme, Renderer>>,
324) -> Container<'a, Message, Theme, Renderer>
325where
326    Message: 'a,
327    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
328{
329    scrim(
330        Container::new(modal_bottom(content))
331            .width(Length::Fill)
332            .height(Length::Fill)
333            .align_x(alignment::Horizontal::Center)
334            .align_y(alignment::Vertical::Bottom),
335    )
336}
337
338pub fn modal_side<'a, Message, Renderer>(
339    content: impl Into<Element<'a, Message, Theme, Renderer>>,
340) -> Container<'a, Message, Theme, Renderer>
341where
342    Message: 'a,
343    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
344{
345    modal_side_on(Side::Right, content)
346}
347
348pub fn modal_side_on<'a, Message, Renderer>(
349    side: Side,
350    content: impl Into<Element<'a, Message, Theme, Renderer>>,
351) -> Container<'a, Message, Theme, Renderer>
352where
353    Message: 'a,
354    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
355{
356    side_sheet(Kind::Modal, side, false, content)
357}
358
359pub fn detached_modal_side<'a, Message, Renderer>(
360    content: impl Into<Element<'a, Message, Theme, Renderer>>,
361) -> Container<'a, Message, Theme, Renderer>
362where
363    Message: 'a,
364    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
365{
366    detached_modal_side_on(Side::Right, content)
367}
368
369pub fn detached_modal_side_on<'a, Message, Renderer>(
370    side: Side,
371    content: impl Into<Element<'a, Message, Theme, Renderer>>,
372) -> Container<'a, Message, Theme, Renderer>
373where
374    Message: 'a,
375    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
376{
377    side_sheet(Kind::Modal, side, true, content)
378}
379
380pub fn standard_side<'a, Message, Renderer>(
381    content: impl Into<Element<'a, Message, Theme, Renderer>>,
382) -> Container<'a, Message, Theme, Renderer>
383where
384    Message: 'a,
385    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
386{
387    standard_side_on(Side::Right, content)
388}
389
390pub fn standard_side_on<'a, Message, Renderer>(
391    side: Side,
392    content: impl Into<Element<'a, Message, Theme, Renderer>>,
393) -> Container<'a, Message, Theme, Renderer>
394where
395    Message: 'a,
396    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
397{
398    side_sheet(Kind::Standard, side, false, content)
399}
400
401pub fn detached_standard_side<'a, Message, Renderer>(
402    content: impl Into<Element<'a, Message, Theme, Renderer>>,
403) -> Container<'a, Message, Theme, Renderer>
404where
405    Message: 'a,
406    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
407{
408    detached_standard_side_on(Side::Right, content)
409}
410
411pub fn detached_standard_side_on<'a, Message, Renderer>(
412    side: Side,
413    content: impl Into<Element<'a, Message, Theme, Renderer>>,
414) -> Container<'a, Message, Theme, Renderer>
415where
416    Message: 'a,
417    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
418{
419    side_sheet(Kind::Standard, side, true, content)
420}
421
422pub fn side_sheet<'a, Message, Renderer>(
423    kind: Kind,
424    side: Side,
425    detached: bool,
426    content: impl Into<Element<'a, Message, Theme, Renderer>>,
427) -> Container<'a, Message, Theme, Renderer>
428where
429    Message: 'a,
430    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
431{
432    let surface = Container::new(content)
433        .width(Length::Fixed(
434            tokens::component::side_sheet::DOCKED_CONTAINER_WIDTH,
435        ))
436        .height(Length::Fill)
437        .style(move |theme| side_sheet_style(theme, kind, side, detached));
438
439    Container::new(surface)
440        .height(Length::Fill)
441        .padding(if detached {
442            tokens::component::side_sheet::DETACHED_MARGIN
443        } else {
444            0.0
445        })
446}
447
448pub fn side_content<'a, Message, Renderer>(
449    content: impl Into<Element<'a, Message, Theme, Renderer>>,
450) -> Container<'a, Message, Theme, Renderer>
451where
452    Message: 'a,
453    Renderer: iced_widget::core::Renderer + 'a,
454{
455    Container::new(content)
456        .padding(tokens::component::side_sheet::CONTENT_PADDING)
457        .width(Length::Fill)
458        .height(Length::Fill)
459}
460
461pub fn modal_side_overlay<'a, Message, Renderer>(
462    side: Side,
463    content: impl Into<Element<'a, Message, Theme, Renderer>>,
464) -> Container<'a, Message, Theme, Renderer>
465where
466    Message: 'a,
467    Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
468{
469    side_scrim(
470        Container::new(modal_side_on(side, content))
471            .width(Length::Fill)
472            .height(Length::Fill)
473            .align_x(side.alignment()),
474    )
475}
476
477pub fn side_scrim<'a, Message, Renderer>(
478    content: impl Into<Element<'a, Message, Theme, Renderer>>,
479) -> Container<'a, Message, Theme, Renderer>
480where
481    Message: 'a,
482    Renderer: iced_widget::core::Renderer + 'a,
483{
484    Container::new(content)
485        .width(Length::Fill)
486        .height(Length::Fill)
487        .style(side_scrim_style)
488}
489
490fn bottom_sheet_radius() -> border::Radius {
491    border::Radius {
492        top_left: tokens::component::bottom_sheet::CONTAINER_SHAPE_TOP,
493        top_right: tokens::component::bottom_sheet::CONTAINER_SHAPE_TOP,
494        bottom_right: tokens::component::bottom_sheet::CONTAINER_SHAPE_BOTTOM,
495        bottom_left: tokens::component::bottom_sheet::CONTAINER_SHAPE_BOTTOM,
496    }
497}
498
499fn side_sheet_radius(kind: Kind, side: Side, detached: bool) -> border::Radius {
500    if detached {
501        return border::rounded(tokens::component::side_sheet::DETACHED_CONTAINER_SHAPE).radius;
502    }
503
504    match kind {
505        Kind::Standard => {
506            border::rounded(tokens::component::side_sheet::DOCKED_STANDARD_CONTAINER_SHAPE).radius
507        }
508        Kind::Modal => {
509            let radius = tokens::component::side_sheet::DOCKED_MODAL_CONTAINER_SHAPE;
510
511            match side {
512                Side::Left => border::Radius {
513                    top_left: 0.0,
514                    top_right: radius,
515                    bottom_right: radius,
516                    bottom_left: 0.0,
517                },
518                Side::Right => border::Radius {
519                    top_left: radius,
520                    top_right: 0.0,
521                    bottom_right: 0.0,
522                    bottom_left: radius,
523                },
524            }
525        }
526    }
527}
528
529fn bottom_sheet_style(theme: &Theme, kind: Kind) -> iced_widget::container::Style {
530    let colors = theme.colors();
531    let level = match kind {
532        Kind::Modal => tokens::component::bottom_sheet::MODAL_CONTAINER_ELEVATION_LEVEL,
533        Kind::Standard => tokens::component::bottom_sheet::STANDARD_CONTAINER_ELEVATION_LEVEL,
534    };
535
536    iced_widget::container::Style {
537        background: Some(Background::Color(colors.surface.container.low)),
538        text_color: Some(colors.surface.text),
539        border: border::rounded(bottom_sheet_radius()),
540        shadow: shadow_from_level(level, colors.shadow),
541        snap: cfg!(feature = "crisp"),
542    }
543}
544
545fn side_sheet_style(
546    theme: &Theme,
547    kind: Kind,
548    side: Side,
549    detached: bool,
550) -> iced_widget::container::Style {
551    let colors = theme.colors();
552    let (container, level) = match kind {
553        Kind::Modal => (
554            colors.surface.container.low,
555            tokens::component::side_sheet::MODAL_CONTAINER_ELEVATION_LEVEL,
556        ),
557        Kind::Standard => (
558            colors.surface.color,
559            tokens::component::side_sheet::STANDARD_CONTAINER_ELEVATION_LEVEL,
560        ),
561    };
562
563    iced_widget::container::Style {
564        background: Some(Background::Color(container)),
565        text_color: Some(colors.surface.text),
566        border: border::rounded(side_sheet_radius(kind, side, detached)),
567        shadow: shadow_from_level(level, colors.shadow),
568        snap: cfg!(feature = "crisp"),
569    }
570}
571
572fn drag_handle_style(theme: &Theme) -> iced_widget::container::Style {
573    iced_widget::container::Style {
574        background: Some(Background::Color(theme.colors().surface.text_variant)),
575        border: border::rounded(tokens::shape::CORNER_FULL),
576        snap: cfg!(feature = "crisp"),
577        ..iced_widget::container::Style::default()
578    }
579}
580
581fn scrim_style(theme: &Theme) -> iced_widget::container::Style {
582    iced_widget::container::Style {
583        background: Some(Background::Color(alpha_color(
584            Color {
585                a: 1.0,
586                ..theme.colors().scrim
587            },
588            tokens::component::bottom_sheet::SCRIM_OPACITY,
589        ))),
590        text_color: Some(theme.colors().surface.text),
591        ..iced_widget::container::Style::default()
592    }
593}
594
595fn side_scrim_style(theme: &Theme) -> iced_widget::container::Style {
596    iced_widget::container::Style {
597        background: Some(Background::Color(alpha_color(
598            Color {
599                a: 1.0,
600                ..theme.colors().scrim
601            },
602            tokens::component::side_sheet::SCRIM_OPACITY,
603        ))),
604        text_color: Some(theme.colors().surface.text),
605        ..iced_widget::container::Style::default()
606    }
607}
608
609#[cfg(test)]
610#[path = "../../../tests/widget/component/sheet.rs"]
611mod tests;