Skip to main content

ferroui_ios/
view_controller.rs

1//! The view controller of a view: the safe area of the view and the
2//! status bar.
3
4use ferroui_base::reactive::{Disposable, IDisposable};
5use ferroui_base::utilities::HandlerList;
6use ferroui_base::{Rect, Size, Thickness};
7use std::cell::Cell;
8use std::rc::Rc;
9
10/// The style of the status bar (`UIStatusBarStyle`, with its values).
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
12#[repr(isize)]
13pub enum StatusBarStyle {
14    /// The style the system chooses.
15    #[default]
16    Default = 0,
17    /// Light content, for a dark background.
18    LightContent = 1,
19    /// Dark content, for a light background.
20    DarkContent = 3,
21}
22
23/// What a view needs of the view controller it is shown by. Unstable.
24pub trait IFerroViewController {
25    /// The preferred style of the status bar.
26    fn preferred_status_bar_style(&self) -> StatusBarStyle;
27
28    /// Sets the preferred style of the status bar.
29    fn set_preferred_status_bar_style(&self, value: StatusBarStyle);
30
31    /// Whether the status bar is hidden.
32    fn prefers_status_bar_hidden(&self) -> bool;
33
34    /// Hides or shows the status bar.
35    fn set_prefers_status_bar_hidden(&self, value: bool);
36
37    /// The padding that keeps content out of the areas the system covers.
38    fn safe_area_padding(&self) -> Thickness;
39
40    /// The safe area padding changed.
41    fn safe_area_padding_changed(&self, handler: Rc<dyn Fn()>) -> Rc<dyn IDisposable>;
42}
43
44/// The safe area padding of a view of the given size whose safe area
45/// layout guide has the given frame.
46pub fn safe_area_padding_of(size: Size, frame: Rect) -> Thickness {
47    Thickness::new(frame.left(), frame.top(), size.width - frame.right(), size.height - frame.bottom())
48}
49
50/// The state of the default view controller: what the view of the port
51/// holds of it. The view controller object of UIKit forwards to it.
52pub struct ViewControllerState {
53    preferred_status_bar_style: Cell<Option<StatusBarStyle>>,
54    prefers_status_bar_hidden: Cell<Option<bool>>,
55    safe_area_padding: Cell<Thickness>,
56    safe_area_padding_changed: Rc<HandlerList<dyn Fn()>>,
57    /// Asks the system to read the status bar properties again.
58    needs_status_bar_appearance_update: Box<dyn Fn()>,
59}
60
61impl ViewControllerState {
62    /// Creates the state; `needs_status_bar_appearance_update` is called
63    /// when a property of the status bar was set.
64    pub fn new(needs_status_bar_appearance_update: Box<dyn Fn()>) -> Rc<Self> {
65        Rc::new(Self {
66            preferred_status_bar_style: Cell::new(None),
67            prefers_status_bar_hidden: Cell::new(None),
68            safe_area_padding: Cell::new(Thickness::default()),
69            safe_area_padding_changed: Rc::new(HandlerList::new()),
70            needs_status_bar_appearance_update,
71        })
72    }
73
74    /// The style that was set, if one was.
75    pub fn preferred_status_bar_style_override(&self) -> Option<StatusBarStyle> {
76        self.preferred_status_bar_style.get()
77    }
78
79    /// Whether the status bar is hidden; the first call takes the answer of
80    /// the system (`base`) and keeps it.
81    pub fn prefers_status_bar_hidden_or(&self, base: impl FnOnce() -> bool) -> bool {
82        match self.prefers_status_bar_hidden.get() {
83            Some(value) => value,
84            None => {
85                let value = base();
86                self.prefers_status_bar_hidden.set(Some(value));
87                value
88            }
89        }
90    }
91
92    /// Called when the view was laid out, with its size and the frame of
93    /// its safe area layout guide.
94    pub fn view_did_layout_subviews(&self, size: Size, frame: Rect) {
95        let safe_area = safe_area_padding_of(size, frame);
96        if self.safe_area_padding.get() != safe_area {
97            self.safe_area_padding.set(safe_area);
98            for (_, handler) in self.safe_area_padding_changed.snapshot().iter() {
99                handler();
100            }
101        }
102    }
103}
104
105impl IFerroViewController for ViewControllerState {
106    fn preferred_status_bar_style(&self) -> StatusBarStyle {
107        self.preferred_status_bar_style.get().unwrap_or_default()
108    }
109
110    fn set_preferred_status_bar_style(&self, value: StatusBarStyle) {
111        self.preferred_status_bar_style.set(Some(value));
112        (self.needs_status_bar_appearance_update)();
113    }
114
115    fn prefers_status_bar_hidden(&self) -> bool {
116        // False is the default on iOS and iPadOS.
117        self.prefers_status_bar_hidden.get().unwrap_or(false)
118    }
119
120    fn set_prefers_status_bar_hidden(&self, value: bool) {
121        self.prefers_status_bar_hidden.set(Some(value));
122        (self.needs_status_bar_appearance_update)();
123    }
124
125    fn safe_area_padding(&self) -> Thickness {
126        self.safe_area_padding.get()
127    }
128
129    fn safe_area_padding_changed(&self, handler: Rc<dyn Fn()>) -> Rc<dyn IDisposable> {
130        let token = self.safe_area_padding_changed.add(handler);
131        let handlers = self.safe_area_padding_changed.clone();
132        Disposable::create(move || {
133            handlers.remove(token);
134        })
135    }
136}
137
138#[cfg(target_os = "ios")]
139pub use uikit::DefaultFerroViewController;
140
141#[cfg(target_os = "ios")]
142mod uikit {
143    use super::{IFerroViewController, StatusBarStyle, ViewControllerState};
144    use crate::extensions::{to_rect, to_size};
145    use ferroui_base::reactive::IDisposable;
146    use ferroui_base::{Rect, Size, Thickness};
147    use objc2::rc::{Retained, Weak};
148    use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly};
149    use objc2_foundation::NSObjectProtocol;
150    use objc2_ui_kit::{UIStatusBarStyle, UIViewController};
151    use std::cell::OnceCell;
152    use std::rc::Rc;
153
154    /// The instance variables of the view controller.
155    #[derive(Default)]
156    pub struct DefaultFerroViewControllerIvars {
157        state: OnceCell<Rc<ViewControllerState>>,
158    }
159
160    define_class!(
161        // SAFETY: `UIViewController` may be subclassed; the overrides call
162        // the superclass where UIKit asks for it, and the class does not
163        // implement `Drop`.
164        #[unsafe(super(UIViewController))]
165        #[thread_kind = MainThreadOnly]
166        #[name = "DefaultFerroViewController"]
167        #[ivars = DefaultFerroViewControllerIvars]
168        pub struct DefaultFerroViewController;
169
170        impl DefaultFerroViewController {
171            #[unsafe(method(viewDidLayoutSubviews))]
172            fn view_did_layout_subviews(&self) {
173                // SAFETY: the method of the superclass this one overrides.
174                let _: () = unsafe { msg_send![super(self), viewDidLayoutSubviews] };
175                let (size, frame) = match self.viewIfLoaded() {
176                    Some(view) => (to_size(view.frame().size), to_rect(view.safeAreaLayoutGuide().layoutFrame())),
177                    None => (Size::default(), Rect::default()),
178                };
179                self.state().view_did_layout_subviews(size, frame);
180            }
181
182            #[unsafe(method(prefersStatusBarHidden))]
183            fn prefers_status_bar_hidden(&self) -> bool {
184                self.state().prefers_status_bar_hidden_or(|| {
185                    // SAFETY: the method of the superclass this one overrides.
186                    unsafe { msg_send![super(self), prefersStatusBarHidden] }
187                })
188            }
189
190            #[unsafe(method(preferredStatusBarStyle))]
191            fn preferred_status_bar_style(&self) -> UIStatusBarStyle {
192                // The value stays unset while no style was set, so that
193                // "default" is kept and not the style of the application.
194                match self.state().preferred_status_bar_style_override() {
195                    Some(style) => UIStatusBarStyle(style as isize),
196                    // SAFETY: the method of the superclass this one
197                    // overrides.
198                    None => unsafe { msg_send![super(self), preferredStatusBarStyle] },
199                }
200            }
201        }
202
203        unsafe impl NSObjectProtocol for DefaultFerroViewController {}
204    );
205
206    impl DefaultFerroViewController {
207        /// Creates a view controller.
208        pub fn new(mtm: MainThreadMarker) -> Retained<Self> {
209            let this = mtm.alloc::<Self>().set_ivars(DefaultFerroViewControllerIvars::default());
210            // SAFETY: `init` of the superclass, on the object that was just
211            // allocated and whose instance variables are set.
212            let this: Retained<Self> = unsafe { msg_send![super(this), init] };
213
214            let weak = Weak::from_retained(&this);
215            let state = ViewControllerState::new(Box::new(move || {
216                if let Some(controller) = weak.load() {
217                    controller.setNeedsStatusBarAppearanceUpdate();
218                }
219            }));
220            let _ = this.ivars().state.set(state);
221            this
222        }
223
224        fn state(&self) -> &Rc<ViewControllerState> {
225            match self.ivars().state.get() {
226                Some(state) => state,
227                None => panic!("The view controller was not created by DefaultFerroViewController::new."),
228            }
229        }
230
231        /// The view controller as the view of the port sees it.
232        pub fn controller(&self) -> Rc<dyn IFerroViewController> {
233            self.state().clone()
234        }
235    }
236
237    /// The contract of a view controller, which the class implements as the
238    /// reference's does, through its state. The getters of the status bar
239    /// are the values that were set (or the defaults), not the answers to
240    /// UIKit (`preferredStatusBarStyle` and `prefersStatusBarHidden` of the
241    /// class), as in the reference, where the two are implemented
242    /// explicitly.
243    impl IFerroViewController for DefaultFerroViewController {
244        fn preferred_status_bar_style(&self) -> StatusBarStyle {
245            self.state().preferred_status_bar_style()
246        }
247
248        fn set_preferred_status_bar_style(&self, value: StatusBarStyle) {
249            self.state().set_preferred_status_bar_style(value);
250        }
251
252        fn prefers_status_bar_hidden(&self) -> bool {
253            IFerroViewController::prefers_status_bar_hidden(&**self.state())
254        }
255
256        fn set_prefers_status_bar_hidden(&self, value: bool) {
257            self.state().set_prefers_status_bar_hidden(value);
258        }
259
260        fn safe_area_padding(&self) -> Thickness {
261            self.state().safe_area_padding()
262        }
263
264        fn safe_area_padding_changed(&self, handler: Rc<dyn Fn()>) -> Rc<dyn IDisposable> {
265            self.state().safe_area_padding_changed(handler)
266        }
267    }
268
269    const _: () = {
270        // The styles of the port have the values of UIKit.
271        assert!(StatusBarStyle::Default as isize == UIStatusBarStyle::Default.0);
272        assert!(StatusBarStyle::LightContent as isize == UIStatusBarStyle::LightContent.0);
273        assert!(StatusBarStyle::DarkContent as isize == UIStatusBarStyle::DarkContent.0);
274    };
275}
276
277#[cfg(test)]
278mod tests {
279    // Not from the reference, which has no tests of this file.
280    use super::*;
281    use std::cell::Cell;
282
283    fn state() -> (Rc<ViewControllerState>, Rc<Cell<u32>>) {
284        let updates = Rc::new(Cell::new(0));
285        let state = ViewControllerState::new({
286            let updates = updates.clone();
287            Box::new(move || updates.set(updates.get() + 1))
288        });
289        (state, updates)
290    }
291
292    #[test]
293    fn the_safe_area_padding_is_what_the_layout_guide_leaves_free() {
294        // A phone in portrait: the status bar above, the home indicator
295        // below.
296        let padding = safe_area_padding_of(Size::new(393.0, 852.0), Rect::new(0.0, 59.0, 393.0, 759.0));
297        assert_eq!(Thickness::new(0.0, 59.0, 0.0, 34.0), padding);
298
299        // In landscape: the sensor housing at the side.
300        let padding = safe_area_padding_of(Size::new(852.0, 393.0), Rect::new(59.0, 0.0, 734.0, 372.0));
301        assert_eq!(Thickness::new(59.0, 0.0, 59.0, 21.0), padding);
302    }
303
304    #[test]
305    fn a_layout_raises_the_change_only_when_the_padding_changed() {
306        let (state, _) = state();
307        let changes = Rc::new(Cell::new(0));
308        let subscription = state.safe_area_padding_changed({
309            let changes = changes.clone();
310            Rc::new(move || changes.set(changes.get() + 1))
311        });
312
313        state.view_did_layout_subviews(Size::new(393.0, 852.0), Rect::new(0.0, 59.0, 393.0, 759.0));
314        assert_eq!(1, changes.get());
315        assert_eq!(Thickness::new(0.0, 59.0, 0.0, 34.0), state.safe_area_padding());
316
317        state.view_did_layout_subviews(Size::new(393.0, 852.0), Rect::new(0.0, 59.0, 393.0, 759.0));
318        assert_eq!(1, changes.get());
319
320        subscription.dispose();
321        state.view_did_layout_subviews(Size::new(852.0, 393.0), Rect::new(59.0, 0.0, 734.0, 372.0));
322        assert_eq!(1, changes.get());
323    }
324
325    #[test]
326    fn the_status_bar_properties_have_the_defaults_of_the_system_until_set() {
327        let (state, updates) = state();
328
329        assert_eq!(StatusBarStyle::Default, state.preferred_status_bar_style());
330        assert_eq!(None, state.preferred_status_bar_style_override());
331        assert!(!state.prefers_status_bar_hidden());
332
333        state.set_preferred_status_bar_style(StatusBarStyle::LightContent);
334        assert_eq!(Some(StatusBarStyle::LightContent), state.preferred_status_bar_style_override());
335        state.set_prefers_status_bar_hidden(true);
336        assert!(state.prefers_status_bar_hidden());
337        assert_eq!(2, updates.get());
338    }
339
340    #[test]
341    fn the_answer_of_the_system_for_the_hidden_status_bar_is_kept() {
342        let (state, _) = state();
343
344        assert!(state.prefers_status_bar_hidden_or(|| true));
345        // The answer of the first call is the answer from then on.
346        assert!(state.prefers_status_bar_hidden_or(|| false));
347        assert!(state.prefers_status_bar_hidden());
348    }
349}