Skip to main content

cranpose_navigation/
host.rs

1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_animation::{AnimationType, Easing, tween};
4use cranpose_coroflow::ProvideViewModelStore;
5use cranpose_services::{BackGesture, PredictiveBackHandler, ProvideSaveableScope};
6use cranpose_ui::{
7    ContentSeek, SeekableCrossfade, composable,
8    widgets::{Dialog, DialogSpec},
9};
10
11use crate::controller::{Entry, HostView, NavController};
12
13/// How long Compose's `NavHost` fades between destinations.
14const DEFAULT_TRANSITION_MILLIS: u64 = 700;
15
16/// Shows the screen on top of `controller`'s back stack — Android's
17/// `NavHost`.
18///
19/// `content` receives the route and composes its screen, usually with a
20/// `match`, so the compiler checks that every route has one:
21///
22/// ```
23/// use cranpose_navigation::{NavHost, rememberNavController};
24///
25/// #[derive(Clone, PartialEq)]
26/// enum Screen {
27///     Home,
28///     Detail { id: u32 },
29/// }
30///
31/// # #[allow(non_snake_case)]
32/// fn App() {
33///     let nav = rememberNavController(Screen::Home);
34///     NavHost(nav, move |screen| match screen {
35///         Screen::Home => HomeScreen(move |id| nav.navigate(Screen::Detail { id })),
36///         Screen::Detail { id } => DetailScreen(id),
37///     });
38/// }
39/// # #[allow(non_snake_case)]
40/// # fn HomeScreen(_open: impl Fn(u32) + 'static) {}
41/// # #[allow(non_snake_case)]
42/// # fn DetailScreen(_id: u32) {}
43/// ```
44///
45/// Screens crossfade the way Compose's do. The platform back gesture pops the
46/// back stack while there is a screen beneath the top one. While an
47/// interactive back gesture moves (Android's predictive back, the iOS edge
48/// swipe), the screen beneath shows through as far as the gesture went; when
49/// the user lets go, the fade finishes or goes back from there. Every entry has
50/// its own view model store: `viewModel` in a screen finds the screen's view
51/// models, which are kept while other screens cover it and dropped once it is
52/// popped and has faded out. Every entry also has its own
53/// [`SaveableScope`](cranpose_services::SaveableScope), so `rememberSaveable`
54/// in a screen keeps that visit's state while it is covered and forgets it
55/// once it is popped.
56#[composable(no_skip)]
57pub fn NavHost<R, F>(controller: NavController<R>, content: F)
58where
59    R: Clone + PartialEq + 'static,
60    F: FnMut(R) + 'static,
61{
62    NavHostWith(
63        controller,
64        tween(DEFAULT_TRANSITION_MILLIS, Easing::FastOutSlowInEasing),
65        content,
66    );
67}
68
69/// [`NavHost`] with its own `transition` between screens — Compose's
70/// `NavHost(enterTransition, exitTransition)` for a fade.
71#[composable(no_skip)]
72pub fn NavHostWith<R, F>(controller: NavController<R>, transition: AnimationType, content: F)
73where
74    R: Clone + PartialEq + 'static,
75    F: FnMut(R) + 'static,
76{
77    NavHostWithDialogs(controller, transition, |_| None, content);
78}
79
80/// [`NavHostWith`] where `dialog` gives the routes that show as dialogs
81/// their [`DialogSpec`] — Compose's `dialog(route)` destinations.
82///
83/// A dialog entry shows in a [`Dialog`] over the screen beneath it, which
84/// stays composed; dialogs above one another stack. Dismissing a dialog, by
85/// the back gesture or a tap outside when its spec allows, pops it and the
86/// entries above it. A dialog entry has its own view model store and saved
87/// state, as a screen has.
88#[composable(no_skip)]
89pub fn NavHostWithDialogs<R, D, F>(
90    controller: NavController<R>,
91    transition: AnimationType,
92    dialog: D,
93    content: F,
94) where
95    R: Clone + PartialEq + 'static,
96    D: Fn(&R) -> Option<DialogSpec> + 'static,
97    F: FnMut(R) + 'static,
98{
99    let HostView {
100        screen,
101        toward,
102        dialogs,
103    } = controller
104        .stack
105        .read(|stack| stack.screens_and_dialogs(&dialog));
106    let gesture = cranpose_core::rememberMutableStateOf(|| None::<f32>);
107    // A dialog on top takes the back gesture itself.
108    PredictiveBackHandler(
109        dialogs.is_empty() && toward.is_some(),
110        move |step| {
111            gesture.set(match step {
112                BackGesture::Started(event) | BackGesture::Progressed(event) => {
113                    Some(event.progress)
114                }
115                BackGesture::Cancelled => None,
116            });
117        },
118        move || {
119            gesture.set(None);
120            controller.navigate_up();
121        },
122    );
123    let seek = gesture
124        .get()
125        .zip(toward)
126        .map(|(fraction, toward)| ContentSeek {
127            toward: Some(toward),
128            fraction,
129        });
130    let content = Rc::new(RefCell::new(content));
131    let shown = Rc::clone(&content);
132    SeekableCrossfade(screen, seek, transition, move |entry| {
133        if let Some(entry) = entry {
134            compose_entry(entry, &shown);
135        }
136    });
137    for (depth, entry, spec) in dialogs {
138        let content = Rc::clone(&content);
139        cranpose_core::key(entry.id(), move || {
140            Dialog(
141                spec,
142                move |_| controller.pop_to_depth(depth),
143                move || compose_entry(entry.clone(), &content),
144            );
145        });
146    }
147}
148
149/// Composes `entry`'s route with `content`, in the entry's saved state and
150/// view model store.
151fn compose_entry<R, F: FnMut(R)>(entry: Entry<R>, content: &RefCell<F>) {
152    let Entry {
153        route,
154        store,
155        saveables,
156        ..
157    } = entry;
158    ProvideSaveableScope(saveables, || {
159        ProvideViewModelStore(store, || (content.borrow_mut())(route));
160    });
161}