Skip to main content

frus_widgets/
bottomappbar.rs

1//! [`BottomAppBar`]: a bar of actions along the bottom of a screen, optionally cut
2//! with a **notch** that a docked floating action button sits in.
3//!
4//! It is the other kind of bottom bar. The navigation one
5//! ([`Scaffold::nav`](crate::Scaffold::nav)) answers "which section am I in"; this one
6//! carries the actions that belong to the screen you are already on, and leaves room
7//! for the one action that matters most to float over it.
8//!
9//! ```ignore
10//! Scaffold::new().size(width, height)
11//!     .bottom_app_bar(
12//!         BottomAppBar::new()
13//!             .child(row![menu_button, spacer(), search_button])
14//!     )
15//!     .fab_location(FabLocation::EndDocked)
16//!     .fab(fab_button("+", Msg::Add))
17//!     .build()
18//! ```
19//!
20//! **The notch is not the bar's decision.** Where the button sits, and how big it is,
21//! are the scaffold's business — it places both — so the scaffold cuts the notch. A
22//! bar built by hand and dropped anywhere else is simply a bar.
23
24use frus_core::{Color, Path, Point, Rect, Scene};
25use frus_layout::{Dimension, Style};
26
27use crate::container::Container;
28use crate::interaction::Status;
29use crate::theme::Theme;
30use crate::widget::Widget;
31
32/// The bar's height when nothing else is said.
33const BAR_HEIGHT: f32 = 64.0;
34/// The gap left between the notch and the button it receives.
35const NOTCH_MARGIN: f32 = 4.0;
36/// How far the notch's shoulders reach along the bar before the curve begins. Taken
37/// from the reference, where it is the `s1` of the notch's derivation.
38const SHOULDER: f32 = 15.0;
39/// The reference's `s2`: how far the control points sit outside the guest circle.
40const CLEARANCE: f32 = 1.0;
41
42/// A bottom bar of actions, optionally notched for a docked floating action button.
43pub struct BottomAppBar<Msg = crate::callback::Callback> {
44    children: Vec<Box<dyn Widget<Msg>>>,
45    color: Option<Color>,
46    height: f32,
47    padding: f32,
48    notch_margin: f32,
49    /// Where the notch goes: the button's centre `x` and its radius, in the bar's own
50    /// coordinates. Set by the scaffold, never by the application.
51    notch: Option<(f32, f32)>,
52}
53
54impl<Msg: Clone + 'static> Default for BottomAppBar<Msg> {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl<Msg: Clone + 'static> BottomAppBar<Msg> {
61    /// An empty bar at the conventional height.
62    pub fn new() -> Self {
63        Self {
64            children: Vec::new(),
65            color: None,
66            height: BAR_HEIGHT,
67            padding: 8.0,
68            notch_margin: NOTCH_MARGIN,
69            notch: None,
70        }
71    }
72
73    /// What the bar carries — usually a row of buttons.
74    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
75        self.children.clear();
76        self.children.push(Box::new(child));
77        self
78    }
79
80    /// The bar's colour. The theme's surface by default.
81    pub fn color(mut self, color: Color) -> Self {
82        self.color = Some(color);
83        self
84    }
85
86    /// The bar's height (64 px by default).
87    pub fn height(mut self, height: f32) -> Self {
88        self.height = height;
89        self
90    }
91
92    /// The padding around its content.
93    pub fn padding(mut self, padding: f32) -> Self {
94        self.padding = padding;
95        self
96    }
97
98    /// The gap left between the notch and the button it receives (4 px by default).
99    pub fn notch_margin(mut self, margin: f32) -> Self {
100        self.notch_margin = margin;
101        self
102    }
103
104    /// Cuts the notch. **The scaffold's to call**, once it knows where it is putting
105    /// the button: `centre_x` in the bar's own coordinates, `radius` the button's.
106    pub(crate) fn notched_at(mut self, centre_x: f32, radius: f32) -> Self {
107        self.notch = Some((centre_x, radius));
108        self
109    }
110
111    /// The bar's declared height, which the scaffold needs to know where its top edge
112    /// is before anything is laid out.
113    pub(crate) fn declared_height(&self) -> f32 {
114        self.height
115    }
116}
117
118/// The bar's outline: its rectangle, with a circular notch cut into the top edge
119/// around a circle of `radius` centred at `centre_x` on that edge.
120///
121/// The curve is the reference's: two quadratics joining the top edge to the circle,
122/// and an arc between them, so the bar meets the button tangentially instead of at a
123/// corner. `guest_dy` is how far the circle's centre sits below the top edge — zero
124/// when the button is docked exactly astride it.
125pub fn notched_outline(host: Rect, centre_x: f32, radius: f32, guest_dy: f32) -> Path {
126    let (left, right) = (host.x, host.x + host.width);
127    let (top, bottom) = (host.y, host.y + host.height);
128    // A notch whose circle does not reach the bar is no notch at all.
129    if radius <= 0.0 || guest_dy - radius >= host.height {
130        return Path::rect(host);
131    }
132
133    // The reference's derivation, in the notch's own frame: the origin is the guest
134    // circle's centre, `b` is how far the host's top edge is above it.
135    let r = radius;
136    let a = -r - CLEARANCE;
137    let b = top - (top + guest_dy);
138    let n2 = (b * b * r * r * (a * a + b * b - r * r)).max(0.0).sqrt();
139    let denom = a * a + b * b;
140    if denom.abs() < 1e-6 {
141        return Path::rect(host);
142    }
143    let p2x_a = ((a * r * r) - n2) / denom;
144    let p2x_b = ((a * r * r) + n2) / denom;
145    let p2y_a = (r * r - p2x_a * p2x_a).max(0.0).sqrt();
146    let p2y_b = (r * r - p2x_b * p2x_b).max(0.0).sqrt();
147    let cmp = if b < 0.0 { -1.0 } else { 1.0 };
148    let (p2x, p2y) = if cmp * p2y_a > cmp * p2y_b {
149        (p2x_a, p2y_a)
150    } else {
151        (p2x_b, p2y_b)
152    };
153
154    // Back into the bar's coordinates. The circle's centre sits on the top edge,
155    // pushed down by `guest_dy`.
156    let cx = centre_x;
157    let cy = top + guest_dy;
158    let at = |x: f32, y: f32| Point::new(cx + x, cy + y);
159    let p0 = at(a - SHOULDER, b);
160    let p1 = at(a, b);
161    let p2 = at(p2x, p2y);
162    let p3 = at(-p2x, p2y);
163    let p4 = at(-a, b);
164    let p5 = at(-(a - SHOULDER), b);
165
166    // The arc runs under the circle from p2 to p3, anticlockwise in screen terms.
167    let start = (p2.y - cy).atan2(p2.x - cx);
168    let end = (p3.y - cy).atan2(p3.x - cx);
169
170    Path::new()
171        .move_to(Point::new(left, top))
172        .line_to(p0)
173        .quad_to(p1, p2)
174        .arc_to(Point::new(cx, cy), r, start, end)
175        .quad_to(p4, p5)
176        .line_to(Point::new(right, top))
177        .line_to(Point::new(right, bottom))
178        .line_to(Point::new(left, bottom))
179        .close()
180}
181
182impl<Msg: Clone + 'static> Widget<Msg> for BottomAppBar<Msg> {
183    /// It asks to **fill the width it is offered** rather than declaring one — see
184    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
185    /// width, which a parent that shrink-wraps does not have yet.
186    fn style(&self) -> Style {
187        // The intrusions it was **told** about, consumed here rather than applied from
188        // outside (milestone 418). The reference puts the safe area inside the shape
189        // that carries the colour (`bottom_app_bar.dart:230`), so the bar's surface runs
190        // behind the gesture bar and only its actions are held clear — which is exactly
191        // what `paint` does with `bounds` below.
192        let safe = crate::MediaQuery::of().padding;
193        Style {
194            height: Dimension::Length(self.height + safe.bottom),
195            padding: frus_core::Insets::new(
196                self.padding,
197                self.padding + safe.right,
198                self.padding + safe.bottom,
199                self.padding + safe.left,
200            ),
201            ..Default::default()
202        }
203    }
204
205    /// The width it was **offered**, not the width its parent came out at.
206    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
207        crate::widget::FillAxes::WIDTH
208    }
209
210    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
211        &self.children
212    }
213
214    fn paint(&self, bounds: Rect, _status: Status, theme: &Theme, scene: &mut Scene) {
215        // `bottom_app_bar.dart:327` — a distinct area within the surface.
216        let color = self
217            .color
218            .or(theme.widgets.bottom_app_bar.color)
219            .unwrap_or(theme.scheme.surface_container);
220        match self.notch {
221            Some((centre_x, radius)) => {
222                let path =
223                    notched_outline(bounds, bounds.x + centre_x, radius + self.notch_margin, 0.0);
224                scene.fill_path(&path, color);
225            }
226            None => scene.fill_rect(bounds, color),
227        }
228    }
229
230    fn on_click(&self) -> Option<Msg> {
231        None
232    }
233}
234
235/// A spacer for a bar's row: takes what is left, so what follows goes to the far end.
236pub fn bar_spacer<Msg: Clone + 'static>() -> Container<Msg> {
237    Container::new().flex(1.0)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use frus_core::PathVerb;
244
245    const HOST: Rect = Rect {
246        x: 0.0,
247        y: 100.0,
248        width: 400.0,
249        height: 64.0,
250    };
251
252    /// Every node of the outline stays inside the bar, and the notch's own nodes keep
253    /// clear of the circle they were cut for — which is the whole point of the shape.
254    #[test]
255    fn the_notch_clears_the_circle_it_was_cut_for() {
256        let (cx, r) = (320.0, 28.0);
257        let path = notched_outline(HOST, cx, r, 0.0);
258        let centre = Point::new(cx, HOST.y);
259        let mut nodes = 0;
260        for verb in path.verbs() {
261            let p = match verb {
262                PathVerb::MoveTo(p) | PathVerb::LineTo(p) => *p,
263                PathVerb::QuadTo { to, .. } | PathVerb::CubicTo { to, .. } => *to,
264                PathVerb::Close => continue,
265            };
266            nodes += 1;
267            assert!(
268                p.y >= HOST.y - 0.01 && p.y <= HOST.y + HOST.height + 0.01,
269                "node outside the bar: {p:?}"
270            );
271            let d = ((p.x - centre.x).powi(2) + (p.y - centre.y).powi(2)).sqrt();
272            assert!(
273                d >= r - 0.01,
274                "node inside the button's circle: {p:?} ({d})"
275            );
276        }
277        assert!(nodes > 6, "the notch adds curves, got {nodes} nodes");
278    }
279
280    /// A notch is symmetric about the button's centre.
281    #[test]
282    fn the_notch_is_centred_on_the_button() {
283        let (cx, r) = (200.0, 28.0);
284        let path = notched_outline(HOST, cx, r, 0.0);
285        let on_top: Vec<f32> = path
286            .verbs()
287            .iter()
288            .filter_map(|v| match v {
289                PathVerb::LineTo(p) | PathVerb::QuadTo { to: p, .. }
290                    if (p.y - HOST.y).abs() < 0.01 =>
291                {
292                    Some(p.x)
293                }
294                _ => None,
295            })
296            .collect();
297        // The two shoulders, and the bar's far corner.
298        let left = on_top.iter().cloned().fold(f32::MAX, f32::min);
299        let right = on_top
300            .iter()
301            .cloned()
302            .filter(|x| *x < HOST.width)
303            .fold(f32::MIN, f32::max);
304        assert!(
305            ((left + right) / 2.0 - cx).abs() < 0.5,
306            "shoulders at {left} and {right} are not centred on {cx}"
307        );
308    }
309
310    /// The bar paints **behind** what it carries: its own surface goes down first,
311    /// and the content on top of it. Painted the other way round, a notched bar would
312    /// erase every button on it.
313    #[test]
314    fn the_bar_paints_behind_its_content() {
315        use crate::{build_ui, Container, Runtime, Size, Theme};
316        const MARK: Color = Color {
317            r: 1.0,
318            g: 0.0,
319            b: 1.0,
320            a: 1.0,
321        };
322        let bar = BottomAppBar::<()>::new()
323            .child(Container::new().width(80.0).height(30.0).color(MARK))
324            .notched_at(300.0, 28.0);
325        let ui = build_ui(
326            &bar,
327            Size::new(400.0, 64.0),
328            &Runtime::default(),
329            &Theme::default(),
330        );
331        let order: Vec<&str> = ui
332            .scene()
333            .primitives()
334            .iter()
335            .filter_map(|p| match p {
336                frus_core::Primitive::Path { .. } => Some("bar"),
337                frus_core::Primitive::Rect { color, .. } if *color == MARK => Some("content"),
338                _ => None,
339            })
340            .collect();
341        assert_eq!(order, vec!["bar", "content"], "the bar goes down first");
342    }
343
344    /// No notch asked for, or one that cannot reach the bar: a plain rectangle.
345    #[test]
346    fn without_a_notch_it_is_a_rectangle() {
347        assert_eq!(notched_outline(HOST, 200.0, 0.0, 0.0), Path::rect(HOST));
348        assert_eq!(notched_outline(HOST, 200.0, 10.0, 300.0), Path::rect(HOST));
349    }
350}