Skip to main content

cranpose_navigation/
controller.rs

1use std::panic::Location;
2
3use cranpose_core::{MutableState, OwnedMutableState, ownedMutableStateOfNeverEqual};
4use cranpose_coroflow::{ViewModelStore, viewModel};
5
6/// One screen on the back stack — Android's `NavBackStackEntry`.
7///
8/// Two entries are equal when they are the same visit, so navigating to the
9/// same route twice gives two entries with their own view models.
10#[derive(Clone)]
11pub(crate) struct Entry<R> {
12    id: u64,
13    pub(crate) route: R,
14    pub(crate) store: ViewModelStore,
15}
16
17impl<R> PartialEq for Entry<R> {
18    fn eq(&self, other: &Self) -> bool {
19        self.id == other.id
20    }
21}
22
23#[derive(Clone)]
24pub(crate) struct BackStack<R> {
25    entries: Vec<Entry<R>>,
26    next_id: u64,
27}
28
29impl<R: Clone + PartialEq> BackStack<R> {
30    fn new(start: R) -> Self {
31        let mut stack = Self {
32            entries: Vec::new(),
33            next_id: 0,
34        };
35        stack.push(start);
36        stack
37    }
38
39    fn push(&mut self, route: R) {
40        self.entries.push(Entry {
41            id: self.next_id,
42            route,
43            store: ViewModelStore::default(),
44        });
45        self.next_id += 1;
46    }
47
48    /// Removes the entries above the topmost `route`, and that entry too when
49    /// `inclusive`. The removed entries are returned so that their view models
50    /// are dropped after the back stack is written, not while it is borrowed.
51    fn split_above(&mut self, route: &R, inclusive: bool) -> Vec<Entry<R>> {
52        let Some(index) = self.entries.iter().rposition(|entry| entry.route == *route) else {
53            return Vec::new();
54        };
55        self.entries
56            .split_off(if inclusive { index } else { index + 1 })
57    }
58
59    fn top_route(&self) -> Option<&R> {
60        self.entries.last().map(|entry| &entry.route)
61    }
62
63    pub(crate) fn top(&self) -> Option<Entry<R>> {
64        self.entries.last().cloned()
65    }
66
67    pub(crate) fn can_pop(&self) -> bool {
68        self.entries.len() > 1
69    }
70}
71
72/// How [`NavController::navigate_with`] changes the back stack — Android's
73/// `NavOptions`.
74pub struct NavOptions<R> {
75    pop_up_to: Option<(R, bool)>,
76    launch_single_top: bool,
77}
78
79impl<R> Default for NavOptions<R> {
80    fn default() -> Self {
81        Self {
82            pop_up_to: None,
83            launch_single_top: false,
84        }
85    }
86}
87
88impl<R> NavOptions<R> {
89    /// Options that change nothing: the route is pushed on top.
90    pub fn new() -> Self {
91        Self::default()
92    }
93
94    /// Pops the entries above the topmost `route` before navigating, and that
95    /// entry too when `inclusive` — Android's `popUpTo(route) { inclusive }`.
96    /// Nothing is popped when `route` is not on the back stack.
97    #[must_use]
98    pub fn pop_up_to(mut self, route: R, inclusive: bool) -> Self {
99        self.pop_up_to = Some((route, inclusive));
100        self
101    }
102
103    /// Does not push the route when it is already on top — Android's
104    /// `launchSingleTop = true`.
105    #[must_use]
106    pub fn launch_single_top(mut self) -> Self {
107        self.launch_single_top = true;
108        self
109    }
110}
111
112/// The back stack of a [`NavHost`](crate::NavHost) — Android's
113/// `NavController`.
114///
115/// It is `Copy`, so it moves into any number of `move` closures:
116/// `move || nav.navigate(Screen::Detail { id })`. `R` is the route type,
117/// usually an enum whose variants carry the screen's arguments.
118pub struct NavController<R: Clone + 'static> {
119    pub(crate) stack: MutableState<BackStack<R>>,
120}
121
122impl<R: Clone + 'static> Clone for NavController<R> {
123    fn clone(&self) -> Self {
124        *self
125    }
126}
127
128impl<R: Clone + 'static> Copy for NavController<R> {}
129
130impl<R: Clone + 'static> PartialEq for NavController<R> {
131    fn eq(&self, other: &Self) -> bool {
132        self.stack == other.stack
133    }
134}
135
136impl<R: Clone + PartialEq + 'static> NavController<R> {
137    /// Pushes `route` on top of the back stack — Android's `navigate(route)`.
138    pub fn navigate(&self, route: R) {
139        self.navigate_with(route, NavOptions::new());
140    }
141
142    /// Navigates to `route` the way `options` say — Android's
143    /// `navigate(route) { popUpTo(...); launchSingleTop = true }`.
144    pub fn navigate_with(&self, route: R, options: NavOptions<R>) {
145        let popped = self.stack.update(|stack| {
146            let popped = options
147                .pop_up_to
148                .map(|(target, inclusive)| stack.split_above(&target, inclusive))
149                .unwrap_or_default();
150            if !(options.launch_single_top && stack.top_route() == Some(&route)) {
151                stack.push(route);
152            }
153            popped
154        });
155        drop(popped);
156    }
157
158    /// Pops the top entry and returns whether there was one — Android's
159    /// `popBackStack()`. Popping the last entry leaves the host empty.
160    pub fn pop_back_stack(&self) -> bool {
161        let popped = self.stack.update(|stack| stack.entries.pop());
162        popped.is_some()
163    }
164
165    /// Pops the entries above the topmost `route`, and that entry too when
166    /// `inclusive`, and returns whether anything was popped — Android's
167    /// `popBackStack(route, inclusive)`.
168    pub fn pop_back_stack_to(&self, route: &R, inclusive: bool) -> bool {
169        let popped = self
170            .stack
171            .update(|stack| stack.split_above(route, inclusive));
172        !popped.is_empty()
173    }
174
175    /// Pops the top entry when there is one beneath it, and returns whether it
176    /// did — Android's `navigateUp()`.
177    pub fn navigate_up(&self) -> bool {
178        let popped = self
179            .stack
180            .update(|stack| stack.can_pop().then(|| stack.entries.pop()).flatten());
181        popped.is_some()
182    }
183
184    /// The route on top of the back stack, `None` once everything is popped.
185    /// Reading it in a composable recomposes that composable when it changes —
186    /// Android's `currentBackStackEntryAsState()`.
187    pub fn current_route(&self) -> Option<R> {
188        self.stack.read(|stack| stack.top_route().cloned())
189    }
190
191    /// Every route on the back stack, bottom first. Reading it in a
192    /// composable recomposes that composable when it changes — Android's
193    /// `currentBackStack`.
194    pub fn back_stack(&self) -> Vec<R> {
195        self.stack.read(|stack| {
196            stack
197                .entries
198                .iter()
199                .map(|entry| entry.route.clone())
200                .collect()
201        })
202    }
203}
204
205struct NavControllerState<R: Clone + 'static> {
206    stack: OwnedMutableState<BackStack<R>>,
207}
208
209/// A [`NavController`] whose back stack starts at `start` — Android's
210/// `rememberNavController()` plus `NavHost(startDestination)`.
211///
212/// The back stack lives in the nearest view model store, so a `NavHost`
213/// nested in a screen keeps its back stack while another screen covers that
214/// screen.
215#[track_caller]
216pub fn rememberNavController<R: Clone + PartialEq + 'static>(start: R) -> NavController<R> {
217    let state = viewModel(Location::caller(), |_| NavControllerState {
218        stack: ownedMutableStateOfNeverEqual(BackStack::new(start)),
219    });
220    NavController {
221        stack: state.get().stack.handle(),
222    }
223}