Skip to main content

frus_widgets/
safearea.rs

1//! [`SafeArea`] — insets its child away from the parts of the screen the system has
2//! taken: the status bar, the notch or camera cut-out, the gesture handle.
3//!
4//! ```ignore
5//! SafeArea::new(screen)                       // every edge
6//! SafeArea::new(list).edges(Edges::ALL.without_bottom())   // let it run under the handle
7//! SafeArea::new(form).avoid_keyboard()        // and move up when the keyboard opens
8//! ```
9//!
10//! The insets come from the ambient [`MediaQuery`], so nothing has to be threaded down
11//! from the application. On desktop they are zero and this widget is a no-op — which is
12//! the point: the same screen code is correct on both.
13
14use std::cell::Cell;
15use std::rc::Rc;
16
17use frus_core::{Insets, Rect, Scene};
18use frus_layout::Style;
19
20use crate::interaction::Status;
21use crate::media::{Edges, MediaQuery};
22use crate::mediascope::MediaScope;
23use crate::theme::Theme;
24use crate::widget::Widget;
25
26/// Pads its child by the system's occupied edges.
27///
28/// ## Nesting
29///
30/// A `SafeArea` **consumes** the padding it applies: its subtree sees a [`MediaQuery`]
31/// whose consumed edges are already zero, so a second `SafeArea` further down adds
32/// nothing and the notch is not avoided twice — and a widget that clears the status bar
33/// on its own, a [`NavigationBar`](crate::NavigationBar) or a
34/// [`DrawerHeader`](crate::DrawerHeader), is not pushed down by it twice either.
35///
36/// That holds for both constructors. [`SafeArea::new`] used to pad and say nothing, on
37/// the grounds that its child was already built; but the description is resolved **during
38/// the walk**, not at construction, so the child is wrapped in a
39/// [`MediaScope`](crate::MediaScope) that removes the edges — the reference's safe area
40/// does the same with `MediaQuery.removePadding`. [`SafeArea::build`] still hands the
41/// consumed description to its closure, for code that reads it while composing.
42pub struct SafeArea<Msg = crate::callback::Callback> {
43    edges: Edges,
44    minimum: Insets,
45    keyboard: bool,
46    /// Pad the bottom by the intrusion that does **not** move; see
47    /// [`SafeArea::maintain_bottom_view_padding`].
48    maintain_bottom: bool,
49    /// What the child's subtree is told has been consumed: the edges, and whether the
50    /// keyboard's with them. Shared with the scope wrapping the child, because the
51    /// builders below change it after the child was wrapped.
52    consumed: Rc<Cell<(Edges, bool)>>,
53    children: Vec<Box<dyn Widget<Msg>>>,
54}
55
56/// Wraps `child` in the scope that tells its subtree what the safe area took.
57fn consuming<Msg: 'static>(
58    consumed: Rc<Cell<(Edges, bool)>>,
59    child: impl Widget<Msg> + 'static,
60) -> Box<dyn Widget<Msg>> {
61    Box::new(MediaScope::tweak(
62        move |mq: &mut MediaQuery| {
63            let (edges, keyboard) = consumed.get();
64            let mut inner = mq.remove_padding(edges);
65            if keyboard {
66                inner = inner.remove_view_insets(edges);
67            }
68            *mq = inner;
69        },
70        child,
71    ))
72}
73
74impl<Msg: 'static> SafeArea<Msg> {
75    /// Insets `child` away from every occupied edge.
76    pub fn new(child: impl Widget<Msg> + 'static) -> Self {
77        let consumed = Rc::new(Cell::new((Edges::ALL, false)));
78        Self {
79            edges: Edges::ALL,
80            minimum: Insets::ZERO,
81            keyboard: false,
82            maintain_bottom: false,
83            children: vec![consuming(consumed.clone(), child)],
84            consumed,
85        }
86    }
87
88    /// Builds the child **inside** the safe area: `build` receives a [`MediaQuery`]
89    /// whose consumed edges have already been zeroed, and the same value is installed
90    /// as the ambient one for the whole of the call.
91    ///
92    /// Use this whenever a `SafeArea` may end up inside another one.
93    ///
94    /// ```ignore
95    /// SafeArea::build(|mq| Column::new().child(Text::new(format!("{} px", mq.size.width))))
96    /// ```
97    pub fn build<W: Widget<Msg> + 'static>(build: impl FnOnce(MediaQuery) -> W) -> Self {
98        Self::build_with(Edges::ALL, Insets::ZERO, false, build)
99    }
100
101    /// [`build`](Self::build) with the edges, the floor and the keyboard behaviour
102    /// chosen up front — they have to be known **before** the child is built, since
103    /// they decide what the child gets to see.
104    pub fn build_with<W: Widget<Msg> + 'static>(
105        edges: Edges,
106        minimum: Insets,
107        keyboard: bool,
108        build: impl FnOnce(MediaQuery) -> W,
109    ) -> Self {
110        let consumed = Rc::new(Cell::new((edges, keyboard)));
111        let mut area = Self {
112            edges,
113            minimum,
114            keyboard,
115            // Off here, and [`SafeArea::maintain_bottom_view_padding`] still works on
116            // the result: it changes how much this widget pads, not what the child was
117            // told had been consumed, and those are different questions.
118            maintain_bottom: false,
119            consumed: consumed.clone(),
120            children: Vec::new(),
121        };
122
123        let mut inner = MediaQuery::of().remove_padding(edges);
124        if keyboard {
125            inner = inner.remove_view_insets(edges);
126        }
127        let child = inner.scope(|| build(inner));
128        // What the closure was told while composing, the subtree is told again while it
129        // is walked — the two have to agree, or a descendant that reads the surface late
130        // would see the edges the closure was told were gone.
131        area.children.push(consuming(consumed, child));
132        area
133    }
134
135    /// Which edges to inset. The rest are left to run to the screen's border — a list
136    /// that should scroll under the gesture handle, say, keeps its bottom edge free.
137    ///
138    /// Only those are consumed for the subtree: an edge left free is still there for a
139    /// descendant to clear.
140    pub fn edges(mut self, edges: Edges) -> Self {
141        self.edges = edges;
142        self.consumed.set((edges, self.keyboard));
143        self
144    }
145
146    /// A floor for the padding: each side ends up at least this far in, even where the
147    /// system asks for nothing. The usual way to give a screen its margin and its
148    /// notch avoidance in one widget.
149    pub fn minimum(mut self, minimum: Insets) -> Self {
150        self.minimum = minimum;
151        self
152    }
153
154    /// Keeps the bottom padding the keyboard would otherwise consume.
155    ///
156    /// The bottom padding goes to **zero** while the keyboard is up: the navigation bar
157    /// it hides is not an edge anything needs to stay clear of, and that is the right
158    /// answer nearly always. It is the wrong one for a screen holding a flexible child,
159    /// which would then grow by exactly that bar's height the moment a field was tapped
160    /// and shrink again when the keyboard closed — a whole layout twitching because
161    /// somebody started typing.
162    ///
163    /// On, the bottom is padded by [`MediaQuery::view_padding`] instead, which does not
164    /// move. The content sits behind the keyboard rather than above it, which is what it
165    /// was already doing.
166    pub fn maintain_bottom_view_padding(mut self) -> Self {
167        self.maintain_bottom = true;
168        self
169    }
170
171    /// Also avoids the **soft keyboard**, not just the permanent bars.
172    ///
173    /// Off by default, and deliberately: a screen whose content scrolls wants the
174    /// keyboard handled by scrolling the focused field into view, not by shrinking the
175    /// whole screen. Turn it on for a short, non-scrolling form.
176    pub fn avoid_keyboard(mut self) -> Self {
177        self.keyboard = true;
178        self.consumed.set((self.edges, true));
179        self
180    }
181}
182
183/// The resolvers, in an impl of their own: the constructors above wrap the child in a
184/// scope, which asks for `Msg: 'static`, and the `Widget` impl does not — a resolver
185/// declared beside them could not be called from the layout.
186impl<Msg> SafeArea<Msg> {
187    /// The padding to apply: the occupied edges this widget was asked for, floored by
188    /// `minimum`.
189    ///
190    /// Read **when it is asked for**, not when the widget was built. It used to be
191    /// resolved once in the constructor with a comment saying `style` runs outside any
192    /// surface the shell installed — which was true when it was written and stopped being
193    /// true at milestone 408, when the shell began holding one surface across the build,
194    /// the layout *and* the paint. Resolving here is what lets a
195    /// [`MediaScope`](crate::MediaScope) above this widget reach it: a shell that has
196    /// already dealt with the status bar can say so, and the safe area below believes it.
197    fn resolve(&self) -> Insets {
198        let mq = MediaQuery::of();
199        let mut occupied = if self.keyboard { mq.safe() } else { mq.padding };
200        if self.maintain_bottom {
201            occupied.bottom = mq.view_padding.bottom;
202        }
203        let selected = self.edges.select(occupied);
204        Insets::new(
205            selected.top.max(self.minimum.top),
206            selected.right.max(self.minimum.right),
207            selected.bottom.max(self.minimum.bottom),
208            selected.left.max(self.minimum.left),
209        )
210    }
211
212    /// The padding this widget resolves **against the surface in force right now** — for
213    /// tests and for a parent that needs to know how much was taken.
214    pub fn padding(&self) -> Insets {
215        self.resolve()
216    }
217}
218
219impl<Msg: Clone> Widget<Msg> for SafeArea<Msg> {
220    /// The insets on the sides they name, in either reading direction (milestone 588).
221    fn style_themed(&self, theme: &crate::theme::Theme) -> Style {
222        Style {
223            padding: frus_core::InsetsGeometry::Physical(self.resolve()).laid_out(theme.direction),
224            ..self.style()
225        }
226    }
227
228    fn style(&self) -> Style {
229        Style {
230            padding: self.resolve(),
231            // **It fills what it is given.** The reference's is a `Padding` under the
232            // screen's own constraints, which are tight: it is the size of the box it
233            // was handed, and its child is that box less the intrusions. A flex node
234            // that grows nothing would instead hug its content, and a screen wrapped in
235            // one would come out the width of its widest line — the failure milestone
236            // 392 chased. Growing is what makes it the box.
237            flex_grow: 1.0,
238            ..Default::default()
239        }
240    }
241
242    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
243        &self.children
244    }
245
246    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
247        // Pure layout: the safe area has no decoration of its own.
248    }
249
250    fn on_click(&self) -> Option<Msg> {
251        None
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::{Container, Text};
259    use frus_core::{Size, WindowInsets};
260
261    fn phone() -> MediaQuery {
262        MediaQuery::new(Size::new(360.0, 780.0))
263            .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
264    }
265
266    /// The padding is asked for **under the surface**, because that is the question:
267    /// milestone 417 made a safe area resolve when it is asked rather than when it was
268    /// built, so that a `MediaScope` above it can change the answer. Asking outside any
269    /// surface is asking about a screen that is not there.
270    #[test]
271    fn it_pads_by_the_occupied_edges() {
272        phone().scope(|| {
273            let area = SafeArea::<()>::new(Container::new());
274            assert_eq!(area.padding().top, 28.0);
275            assert_eq!(area.padding().bottom, 16.0);
276            assert_eq!(area.padding().left, 0.0);
277        });
278    }
279
280    #[test]
281    fn on_a_bare_surface_it_is_a_no_op() {
282        let area = SafeArea::<()>::new(Container::new());
283        assert_eq!(area.padding(), Insets::ZERO);
284    }
285
286    #[test]
287    fn an_unselected_edge_is_left_free() {
288        phone().scope(|| {
289            let area = SafeArea::<()>::new(Container::new()).edges(Edges::ALL.without_bottom());
290            assert_eq!(area.padding().top, 28.0);
291            assert_eq!(
292                area.padding().bottom,
293                0.0,
294                "the content should run under the gesture handle"
295            );
296        });
297    }
298
299    #[test]
300    fn the_minimum_is_a_floor_and_not_an_addition() {
301        phone().scope(|| {
302            let area = SafeArea::<()>::new(Container::new()).minimum(Insets::uniform(20.0));
303            assert_eq!(area.padding().top, 28.0, "28 already clears 20");
304            assert_eq!(area.padding().bottom, 20.0, "16 is raised to 20");
305            assert_eq!(area.padding().left, 20.0, "nothing occupied, so the floor");
306        });
307    }
308
309    fn with_keyboard() -> MediaQuery {
310        phone().with_insets(WindowInsets::from_baseline(
311            Insets::new(28.0, 0.0, 16.0, 0.0),
312            Insets::new(28.0, 0.0, 320.0, 0.0),
313        ))
314    }
315
316    #[test]
317    fn the_keyboard_is_avoided_only_when_asked() {
318        let with_keyboard = with_keyboard();
319        // **Zero**, not sixteen. The navigation bar is under the keyboard, so there is
320        // nothing left at the bottom to stay clear of, and padding by it as well would
321        // leave a strip of nothing between the content and the keys.
322        with_keyboard.scope(|| {
323            let ignoring = SafeArea::<()>::new(Container::new());
324            assert_eq!(ignoring.padding().bottom, 0.0);
325            let avoiding = SafeArea::<()>::new(Container::new()).avoid_keyboard();
326            assert_eq!(avoiding.padding().bottom, 320.0);
327        });
328    }
329
330    /// The bottom padding going to zero is right for nearly every screen and wrong for
331    /// one holding a flexible child, which would grow by the bar's height the moment a
332    /// field was tapped and shrink again when the keyboard closed. Asked to, the safe
333    /// area keeps the intrusion that does not move.
334    #[test]
335    fn the_bottom_can_be_kept_still_while_the_keyboard_comes_and_goes() {
336        let bottom = |mq: MediaQuery| {
337            mq.scope(|| {
338                SafeArea::<()>::new(Container::new())
339                    .maintain_bottom_view_padding()
340                    .padding()
341                    .bottom
342            })
343        };
344        assert_eq!(bottom(phone()), 16.0);
345        assert_eq!(
346            bottom(with_keyboard()),
347            16.0,
348            "the same, keyboard or no keyboard"
349        );
350        // And it is the *view* padding it keeps, not the keyboard: the content still
351        // sits behind the keys rather than being lifted above them.
352        assert!(bottom(with_keyboard()) < 320.0);
353    }
354
355    #[test]
356    fn a_nested_build_does_not_avoid_the_same_notch_twice() {
357        let inner_padding = std::cell::Cell::new(Insets::ZERO);
358        phone().scope(|| {
359            SafeArea::<()>::build(|_| {
360                let inner = SafeArea::<()>::new(Container::new());
361                inner_padding.set(inner.padding());
362                inner
363            })
364        });
365        assert_eq!(
366            inner_padding.get(),
367            Insets::ZERO,
368            "the outer SafeArea consumed the padding"
369        );
370    }
371
372    #[test]
373    fn build_hands_the_consumed_surface_to_the_closure() {
374        let seen = std::cell::Cell::new(Insets::uniform(-1.0));
375        phone().scope(|| {
376            SafeArea::<()>::build(|mq| {
377                seen.set(mq.padding);
378                Text::new("x")
379            })
380        });
381        assert_eq!(seen.get(), Insets::ZERO);
382    }
383
384    #[test]
385    fn build_with_only_consumes_the_edges_it_insets() {
386        let seen = std::cell::Cell::new(Insets::ZERO);
387        phone().scope(|| {
388            SafeArea::<()>::build_with(Edges::ALL.without_bottom(), Insets::ZERO, false, |mq| {
389                seen.set(mq.padding);
390                Text::new("x")
391            })
392        });
393        assert_eq!(seen.get().top, 0.0);
394        assert_eq!(
395            seen.get().bottom,
396            16.0,
397            "an edge left free is still there for a descendant to use"
398        );
399    }
400
401    const MARK: frus_core::Color = frus_core::Color::rgb(0.9, 0.1, 0.5);
402
403    /// The marked block's box in `root`, built and walked under the phone surface.
404    fn marked_in(root: &dyn Widget<()>) -> Rect {
405        let ui = phone().scope(|| {
406            crate::build_ui(
407                root,
408                Size::new(360.0, 780.0),
409                &crate::Runtime::default(),
410                &Theme::default(),
411            )
412        });
413        ui.scene()
414            .primitives()
415            .iter()
416            .find_map(|p| match p {
417                frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
418                _ => None,
419            })
420            .expect("the marked block is drawn")
421    }
422
423    /// **`SafeArea::new` consumes what it pads**, as `build` always did (milestone 504).
424    ///
425    /// It used to pad and say nothing, on the grounds that its child was built before it
426    /// existed. But the surface is resolved during the walk, so a scope around the child
427    /// can still tell the subtree — and a `SafeArea` that did not left a status bar for a
428    /// navigation bar under it to clear a second time.
429    #[test]
430    fn a_nested_new_does_not_avoid_the_same_notch_twice() {
431        let root = SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK)));
432        assert_eq!(marked_in(&root).y, 28.0, "the status bar once, not twice");
433    }
434
435    /// Only the edges it insets are consumed: one left free is still there for a
436    /// descendant to clear.
437    #[test]
438    fn an_edge_left_free_is_left_for_a_descendant() {
439        let root =
440            SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK))).edges(Edges {
441                top: false,
442                ..Edges::ALL
443            });
444        assert_eq!(
445            marked_in(&root).y,
446            28.0,
447            "the inner one still found the status bar the outer one left free"
448        );
449    }
450
451    #[test]
452    fn the_padding_reaches_the_layout_style() {
453        phone().scope(|| {
454            let area = SafeArea::<()>::new(Container::new());
455            assert_eq!(Widget::<()>::style(&area).padding.top, 28.0);
456        });
457    }
458}