Skip to main content

iced_cube/primitives/
fab.rs

1//! A floating action button: the one main action of a screen, drawn raised
2//! above the content.
3//!
4//! A fab does not position itself. Place it over the content with an iced
5//! `stack` and a container aligned to a corner:
6//!
7//! ```no_run
8//! use iced::widget::{container, stack, text};
9//! use iced::Length;
10//! use iced_cube::{fab, lucide};
11//!
12//! #[derive(Debug, Clone)]
13//! enum Message { New }
14//!
15//! let screen: iced::Element<'_, Message> = stack![
16//!     text("The list"),
17//!     container(fab(lucide!(Plus)).label("New note").on_press(Message::New))
18//!         .width(Length::Fill)
19//!         .height(Length::Fill)
20//!         .align_right(Length::Fill)
21//!         .align_bottom(Length::Fill)
22//!         .padding(16),
23//! ]
24//! .into();
25//! ```
26
27use iced::widget::{self, button::Status, container, row, text};
28use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Vector};
29
30use crate::after_touch::after_touch;
31use crate::icon::{Glyph, opacity, themed};
32use crate::natural::natural;
33use crate::overlay::tooltip::{Position, tooltip};
34use crate::primitives::button::{self, Variant};
35use crate::theme::{Tokens, fade, text_size};
36
37/// Height of a fab, and the side of an icon-only one.
38pub const SIZE: f32 = 56.0;
39/// Corner radius.
40pub const RADIUS: f32 = 16.0;
41/// Icon size.
42pub const ICON: f32 = 24.0;
43/// Horizontal padding of an extended fab.
44pub const PADDING_X: f32 = 16.0;
45
46/// A fab builder. Convert it into an [`Element`] to render.
47///
48/// A fab without a message is rendered disabled.
49#[derive(Debug)]
50pub struct Fab<'a, Message> {
51    glyph: Glyph,
52    label: Option<text::Fragment<'a>>,
53    tooltip: Option<text::Fragment<'a>>,
54    id: Option<widget::Id>,
55    on_press: Option<Message>,
56}
57
58/// Creates a square fab showing `glyph`.
59pub fn fab<'a, Message>(glyph: Glyph) -> Fab<'a, Message> {
60    Fab {
61        glyph,
62        label: None,
63        tooltip: None,
64        id: None,
65        on_press: None,
66    }
67}
68
69impl<'a, Message> Fab<'a, Message> {
70    /// Shows a label beside the icon, making the fab an extended pill.
71    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
72        self.label = Some(label.into_fragment());
73        self
74    }
75
76    /// Names the action of an icon-only fab, such as "New note", shown as a
77    /// tooltip on hover. An extended fab already shows its label and
78    /// ignores this.
79    pub fn tooltip(mut self, tooltip: impl text::IntoFragment<'a>) -> Self {
80        self.tooltip = Some(tooltip.into_fragment());
81        self
82    }
83
84    /// Sets a widget id, for finding the fab in tests.
85    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
86        self.id = Some(id.into());
87        self
88    }
89
90    pub fn on_press(mut self, message: Message) -> Self {
91        self.on_press = Some(message);
92        self
93    }
94
95    pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
96        self.on_press = message;
97        self
98    }
99
100    pub fn is_enabled(&self) -> bool {
101        self.on_press.is_some()
102    }
103
104    pub fn is_extended(&self) -> bool {
105        self.label.is_some()
106    }
107}
108
109impl<'a, Message: Clone + 'a> From<Fab<'a, Message>> for Element<'a, Message> {
110    fn from(fab: Fab<'a, Message>) -> Self {
111        let Fab {
112            glyph,
113            label,
114            tooltip: hint,
115            id,
116            on_press,
117        } = fab;
118        let enabled = on_press.is_some();
119        let resting = if enabled {
120            Status::Active
121        } else {
122            Status::Disabled
123        };
124        let icon = themed(glyph, ICON, opacity(enabled), move |theme| {
125            style(&Tokens::of(theme), resting).text_color
126        });
127
128        let extended = label.is_some();
129        let content: Element<'a, Message> = match label {
130            Some(label) => row![
131                icon,
132                text(label)
133                    .size(text_size::SM)
134                    .font(crate::theme::semibold())
135                    .wrapping(text::Wrapping::None)
136            ]
137            .spacing(12)
138            .align_y(Alignment::Center)
139            .into(),
140            None => icon.into(),
141        };
142
143        let (width, padding) = if extended {
144            (Length::Shrink, Padding::from([0.0, PADDING_X]))
145        } else {
146            (Length::Fixed(SIZE), Padding::from((SIZE - ICON) / 2.0))
147        };
148
149        let button = widget::button(
150            container(content)
151                .height(Length::Fill)
152                .align_y(Alignment::Center)
153                .align_x(Alignment::Center),
154        )
155        .width(width)
156        .height(Length::Fixed(SIZE))
157        .padding(padding)
158        .on_press_maybe(on_press)
159        .style(|theme, status| style(&Tokens::of(theme), status));
160
161        let mut element: Element<'a, Message> = match id {
162            Some(id) => container(after_touch(button)).id(id).into(),
163            None => after_touch(button),
164        };
165        if let (Some(hint), false) = (hint, extended) {
166            element = tooltip(element, hint).position(Position::Left).into();
167        }
168        natural(element)
169    }
170}
171
172/// The shadow under a fab, deeper while hovered and shallower while
173/// pressed, as if it moved towards the finger.
174pub fn shadow(tokens: &Tokens, status: Status) -> Shadow {
175    let alpha = if tokens.is_dark { 0.5 } else { 0.18 };
176    let (offset, blur) = match status {
177        Status::Hovered => (6.0, 16.0),
178        Status::Pressed => (2.0, 6.0),
179        Status::Active | Status::Disabled => (4.0, 12.0),
180    };
181    Shadow {
182        color: fade(Color::BLACK, alpha),
183        offset: Vector::new(0.0, offset),
184        blur_radius: blur,
185    }
186}
187
188/// The iced button style of a fab: primary colours, a large radius and a
189/// soft shadow. A disabled fab is faded and flat.
190pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
191    let colours = button::colours(tokens, Variant::Primary, status);
192    widget::button::Style {
193        background: colours.background.map(Background::Color),
194        text_color: colours.foreground,
195        border: Border {
196            color: Color::TRANSPARENT,
197            width: 0.0,
198            radius: RADIUS.into(),
199        },
200        shadow: if status == Status::Disabled {
201            Shadow::default()
202        } else {
203            shadow(tokens, status)
204        },
205        snap: true,
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::theme::{dark, light};
213
214    const STATES: [Status; 4] = [
215        Status::Active,
216        Status::Hovered,
217        Status::Pressed,
218        Status::Disabled,
219    ];
220
221    #[test]
222    fn builder_defaults() {
223        let f: Fab<'_, u8> = fab(crate::lucide!(Plus));
224        assert!(!f.is_enabled());
225        assert!(!f.is_extended());
226        assert!(fab::<u8>(crate::lucide!(Plus)).label("New").is_extended());
227        assert!(fab::<u8>(crate::lucide!(Plus)).on_press(1).is_enabled());
228        assert!(
229            !fab::<u8>(crate::lucide!(Plus))
230                .on_press(1)
231                .on_press_maybe(None)
232                .is_enabled()
233        );
234    }
235
236    #[test]
237    fn colours_are_the_primary_button_in_every_state() {
238        for theme in [light(), dark()] {
239            let tokens = Tokens::of(&theme);
240            for status in STATES {
241                let style = style(&tokens, status);
242                let primary = button::colours(&tokens, Variant::Primary, status);
243                assert_eq!(style.background, primary.background.map(Background::Color));
244                assert_eq!(style.text_color, primary.foreground);
245                assert_eq!(style.border.radius, RADIUS.into());
246            }
247        }
248    }
249
250    #[test]
251    fn hovered_and_pressed_differ_from_rest() {
252        let tokens = Tokens::of(&light());
253        let rest = style(&tokens, Status::Active);
254        let hovered = style(&tokens, Status::Hovered);
255        let pressed = style(&tokens, Status::Pressed);
256        assert_ne!(rest.background, hovered.background);
257        assert_ne!(hovered.background, pressed.background);
258        assert!(hovered.shadow.blur_radius > rest.shadow.blur_radius);
259        assert!(pressed.shadow.blur_radius < rest.shadow.blur_radius);
260    }
261
262    #[test]
263    fn only_a_disabled_fab_is_flat_and_dark_shadows_are_stronger() {
264        let light = Tokens::of(&light());
265        let dark = Tokens::of(&dark());
266        assert_eq!(style(&light, Status::Disabled).shadow, Shadow::default());
267        assert!(style(&light, Status::Active).shadow.color.a > 0.0);
268        assert!(
269            style(&dark, Status::Active).shadow.color.a
270                > style(&light, Status::Active).shadow.color.a
271        );
272    }
273}