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    fn style(&self) -> Style {
221        Style {
222            padding: self.resolve(),
223            // **It fills what it is given.** The reference's is a `Padding` under the
224            // screen's own constraints, which are tight: it is the size of the box it
225            // was handed, and its child is that box less the intrusions. A flex node
226            // that grows nothing would instead hug its content, and a screen wrapped in
227            // one would come out the width of its widest line — the failure milestone
228            // 392 chased. Growing is what makes it the box.
229            flex_grow: 1.0,
230            ..Default::default()
231        }
232    }
233
234    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
235        &self.children
236    }
237
238    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {
239        // Pure layout: the safe area has no decoration of its own.
240    }
241
242    fn on_click(&self) -> Option<Msg> {
243        None
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::{Container, Text};
251    use frus_core::{Size, WindowInsets};
252
253    fn phone() -> MediaQuery {
254        MediaQuery::new(Size::new(360.0, 780.0))
255            .with_insets(WindowInsets::bars(Insets::new(28.0, 0.0, 16.0, 0.0)))
256    }
257
258    /// The padding is asked for **under the surface**, because that is the question:
259    /// milestone 417 made a safe area resolve when it is asked rather than when it was
260    /// built, so that a `MediaScope` above it can change the answer. Asking outside any
261    /// surface is asking about a screen that is not there.
262    #[test]
263    fn it_pads_by_the_occupied_edges() {
264        phone().scope(|| {
265            let area = SafeArea::<()>::new(Container::new());
266            assert_eq!(area.padding().top, 28.0);
267            assert_eq!(area.padding().bottom, 16.0);
268            assert_eq!(area.padding().left, 0.0);
269        });
270    }
271
272    #[test]
273    fn on_a_bare_surface_it_is_a_no_op() {
274        let area = SafeArea::<()>::new(Container::new());
275        assert_eq!(area.padding(), Insets::ZERO);
276    }
277
278    #[test]
279    fn an_unselected_edge_is_left_free() {
280        phone().scope(|| {
281            let area = SafeArea::<()>::new(Container::new()).edges(Edges::ALL.without_bottom());
282            assert_eq!(area.padding().top, 28.0);
283            assert_eq!(
284                area.padding().bottom,
285                0.0,
286                "the content should run under the gesture handle"
287            );
288        });
289    }
290
291    #[test]
292    fn the_minimum_is_a_floor_and_not_an_addition() {
293        phone().scope(|| {
294            let area = SafeArea::<()>::new(Container::new()).minimum(Insets::uniform(20.0));
295            assert_eq!(area.padding().top, 28.0, "28 already clears 20");
296            assert_eq!(area.padding().bottom, 20.0, "16 is raised to 20");
297            assert_eq!(area.padding().left, 20.0, "nothing occupied, so the floor");
298        });
299    }
300
301    fn with_keyboard() -> MediaQuery {
302        phone().with_insets(WindowInsets::from_baseline(
303            Insets::new(28.0, 0.0, 16.0, 0.0),
304            Insets::new(28.0, 0.0, 320.0, 0.0),
305        ))
306    }
307
308    #[test]
309    fn the_keyboard_is_avoided_only_when_asked() {
310        let with_keyboard = with_keyboard();
311        // **Zero**, not sixteen. The navigation bar is under the keyboard, so there is
312        // nothing left at the bottom to stay clear of, and padding by it as well would
313        // leave a strip of nothing between the content and the keys.
314        with_keyboard.scope(|| {
315            let ignoring = SafeArea::<()>::new(Container::new());
316            assert_eq!(ignoring.padding().bottom, 0.0);
317            let avoiding = SafeArea::<()>::new(Container::new()).avoid_keyboard();
318            assert_eq!(avoiding.padding().bottom, 320.0);
319        });
320    }
321
322    /// The bottom padding going to zero is right for nearly every screen and wrong for
323    /// one holding a flexible child, which would grow by the bar's height the moment a
324    /// field was tapped and shrink again when the keyboard closed. Asked to, the safe
325    /// area keeps the intrusion that does not move.
326    #[test]
327    fn the_bottom_can_be_kept_still_while_the_keyboard_comes_and_goes() {
328        let bottom = |mq: MediaQuery| {
329            mq.scope(|| {
330                SafeArea::<()>::new(Container::new())
331                    .maintain_bottom_view_padding()
332                    .padding()
333                    .bottom
334            })
335        };
336        assert_eq!(bottom(phone()), 16.0);
337        assert_eq!(
338            bottom(with_keyboard()),
339            16.0,
340            "the same, keyboard or no keyboard"
341        );
342        // And it is the *view* padding it keeps, not the keyboard: the content still
343        // sits behind the keys rather than being lifted above them.
344        assert!(bottom(with_keyboard()) < 320.0);
345    }
346
347    #[test]
348    fn a_nested_build_does_not_avoid_the_same_notch_twice() {
349        let inner_padding = std::cell::Cell::new(Insets::ZERO);
350        phone().scope(|| {
351            SafeArea::<()>::build(|_| {
352                let inner = SafeArea::<()>::new(Container::new());
353                inner_padding.set(inner.padding());
354                inner
355            })
356        });
357        assert_eq!(
358            inner_padding.get(),
359            Insets::ZERO,
360            "the outer SafeArea consumed the padding"
361        );
362    }
363
364    #[test]
365    fn build_hands_the_consumed_surface_to_the_closure() {
366        let seen = std::cell::Cell::new(Insets::uniform(-1.0));
367        phone().scope(|| {
368            SafeArea::<()>::build(|mq| {
369                seen.set(mq.padding);
370                Text::new("x")
371            })
372        });
373        assert_eq!(seen.get(), Insets::ZERO);
374    }
375
376    #[test]
377    fn build_with_only_consumes_the_edges_it_insets() {
378        let seen = std::cell::Cell::new(Insets::ZERO);
379        phone().scope(|| {
380            SafeArea::<()>::build_with(Edges::ALL.without_bottom(), Insets::ZERO, false, |mq| {
381                seen.set(mq.padding);
382                Text::new("x")
383            })
384        });
385        assert_eq!(seen.get().top, 0.0);
386        assert_eq!(
387            seen.get().bottom,
388            16.0,
389            "an edge left free is still there for a descendant to use"
390        );
391    }
392
393    const MARK: frus_core::Color = frus_core::Color::rgb(0.9, 0.1, 0.5);
394
395    /// The marked block's box in `root`, built and walked under the phone surface.
396    fn marked_in(root: &dyn Widget<()>) -> Rect {
397        let ui = phone().scope(|| {
398            crate::build_ui(
399                root,
400                Size::new(360.0, 780.0),
401                &crate::Runtime::default(),
402                &Theme::default(),
403            )
404        });
405        ui.scene()
406            .primitives()
407            .iter()
408            .find_map(|p| match p {
409                frus_core::Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
410                _ => None,
411            })
412            .expect("the marked block is drawn")
413    }
414
415    /// **`SafeArea::new` consumes what it pads**, as `build` always did (milestone 504).
416    ///
417    /// It used to pad and say nothing, on the grounds that its child was built before it
418    /// existed. But the surface is resolved during the walk, so a scope around the child
419    /// can still tell the subtree — and a `SafeArea` that did not left a status bar for a
420    /// navigation bar under it to clear a second time.
421    #[test]
422    fn a_nested_new_does_not_avoid_the_same_notch_twice() {
423        let root = SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK)));
424        assert_eq!(marked_in(&root).y, 28.0, "the status bar once, not twice");
425    }
426
427    /// Only the edges it insets are consumed: one left free is still there for a
428    /// descendant to clear.
429    #[test]
430    fn an_edge_left_free_is_left_for_a_descendant() {
431        let root =
432            SafeArea::new(SafeArea::new(Container::new().height(10.0).color(MARK))).edges(Edges {
433                top: false,
434                ..Edges::ALL
435            });
436        assert_eq!(
437            marked_in(&root).y,
438            28.0,
439            "the inner one still found the status bar the outer one left free"
440        );
441    }
442
443    #[test]
444    fn the_padding_reaches_the_layout_style() {
445        phone().scope(|| {
446            let area = SafeArea::<()>::new(Container::new());
447            assert_eq!(Widget::<()>::style(&area).padding.top, 28.0);
448        });
449    }
450}