cranpose_services/navigation.rs
1//! System "back" navigation requests.
2//!
3//! A platform back affordance — Android's back key / gesture, iOS's left-edge
4//! swipe — feeds [`push_back_request`]; the app drains it with
5//! [`take_back_requests`] and pops its own navigation. This gives one API
6//! across platforms for what is otherwise a per-OS gesture.
7//!
8//! Whether the platform *routes* its back control here is governed by
9//! [`set_back_interception`], the analogue of Compose's `BackHandler(enabled)`:
10//!
11//! - **Android**: while interception is enabled the back key/gesture is
12//! consumed and lands in [`push_back_request`]; while disabled it stays with
13//! the system, so the default behavior (leaving the activity) keeps working.
14//! Apps enable it exactly while they have somewhere to navigate back to.
15//! - **iOS**: the left-edge swipe is a framework-drawn gesture with no system
16//! fallback, so it always pushes a request regardless of interception. It
17//! goes back when let go past 30% of the width or on a flick.
18//!
19//! While a gesture moves, Android's predictive back (API 34+) and the iOS
20//! edge swipe also report its steps through [`push_back_gesture`], which a
21//! [`PredictiveBackHandler`] follows.
22//! - **Desktop/web**: no OS back control; apps may map keys themselves and
23//! call [`push_back_request`] directly.
24//!
25//! [`request_exit`] is the other direction: the app, rather than the platform,
26//! deciding that it is time to leave.
27
28use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
29#[cfg(not(target_arch = "wasm32"))]
30use std::sync::{Arc, Mutex, OnceLock};
31
32use portable_atomic::AtomicU64;
33
34#[cfg(not(target_arch = "wasm32"))]
35type BackListener = Arc<dyn Fn(BackSignal) + Send + Sync + 'static>;
36#[cfg(target_arch = "wasm32")]
37type BackListener = std::rc::Rc<dyn Fn(BackSignal) + 'static>;
38
39/// The edge an interactive back gesture starts from.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum BackEdge {
42 Left,
43 Right,
44}
45
46/// Where an interactive back gesture is: Compose's `BackEventCompat`.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct BackEvent {
49 /// How far the gesture went, from 0 to 1.
50 pub progress: f32,
51 /// The edge it started from.
52 pub edge: BackEdge,
53}
54
55/// A step of an interactive back gesture: Android's predictive back, the
56/// iOS edge swipe. When the user lets go past the point of going back, the
57/// gesture ends with a back request instead of a step.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub enum BackGesture {
60 Started(BackEvent),
61 Progressed(BackEvent),
62 /// The user let go without going back.
63 Cancelled,
64}
65
66/// What reaches the innermost back observer.
67#[derive(Clone, Copy, Debug, PartialEq)]
68pub(crate) enum BackSignal {
69 Request,
70 Gesture(BackGesture),
71}
72
73static BACK_REQUESTS: AtomicUsize = AtomicUsize::new(0);
74static BACK_INTERCEPTION: AtomicBool = AtomicBool::new(false);
75static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
76static EXIT_REQUESTED: AtomicBool = AtomicBool::new(false);
77
78/// Record a system back request (called by the platform backend's gesture /
79/// button handler).
80pub fn push_back_request() {
81 BACK_REQUESTS.fetch_add(1, Ordering::SeqCst);
82 if let Some(listener) = latest_back_listener() {
83 listener(BackSignal::Request);
84 }
85}
86
87/// Record a step of an interactive back gesture (called by the platform
88/// backend). The innermost [`PredictiveBackHandler`] receives it; a
89/// [`BackHandler`] there ignores it.
90pub fn push_back_gesture(gesture: BackGesture) {
91 if let Some(listener) = latest_back_listener() {
92 listener(BackSignal::Gesture(gesture));
93 }
94}
95
96/// Registers a callback run whenever a back request arrives, so an app can be
97/// told rather than having to ask.
98///
99/// [`take_back_requests`] alone is a polling API, which quietly assumes the app
100/// is already running a frame loop to poll from. An app that has gone idle —
101/// the correct thing to do on a screen where nothing moves — has no such loop,
102/// and a back gesture would sit in the counter until something unrelated woke
103/// it. The listener closes that gap: it is the nudge, the counter is still the
104/// source of truth, and the app drains it as before.
105///
106/// Called from whatever thread the platform reports back on, which is not
107/// necessarily the UI thread, so the callback must be `Send + Sync`. It should
108/// do as little as possible — waking a parked task is the intended use.
109///
110/// Registrations form a stack. The most recently installed observer receives
111/// requests, matching nested Compose `BackHandler`s. Dropping it restores the
112/// observer beneath it.
113#[cfg(not(target_arch = "wasm32"))]
114pub fn observe_back_requests(listener: impl Fn() + Send + Sync + 'static) -> BackRequestObserver {
115 observe_back_signals(move |signal| {
116 if signal == BackSignal::Request {
117 listener();
118 }
119 })
120}
121
122#[cfg(target_arch = "wasm32")]
123pub fn observe_back_requests(listener: impl Fn() + 'static) -> BackRequestObserver {
124 observe_back_signals(move |signal| {
125 if signal == BackSignal::Request {
126 listener();
127 }
128 })
129}
130
131#[cfg(not(target_arch = "wasm32"))]
132fn observe_back_signals(
133 listener: impl Fn(BackSignal) + Send + Sync + 'static,
134) -> BackRequestObserver {
135 let id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
136 if let Ok(mut listeners) = back_listeners().lock() {
137 listeners.push((id, Arc::new(listener)));
138 }
139 BackRequestObserver { id }
140}
141
142#[cfg(target_arch = "wasm32")]
143fn observe_back_signals(listener: impl Fn(BackSignal) + 'static) -> BackRequestObserver {
144 let id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed);
145 crate::thread_state::with(|state| state.back_listeners.add(id, std::rc::Rc::new(listener)));
146 BackRequestObserver { id }
147}
148
149#[cfg(not(target_arch = "wasm32"))]
150fn back_listeners() -> &'static Mutex<Vec<(u64, BackListener)>> {
151 static LISTENERS: OnceLock<Mutex<Vec<(u64, BackListener)>>> = OnceLock::new();
152 LISTENERS.get_or_init(|| Mutex::new(Vec::new()))
153}
154
155#[cfg(not(target_arch = "wasm32"))]
156fn latest_back_listener() -> Option<BackListener> {
157 back_listeners()
158 .lock()
159 .ok()
160 .and_then(|listeners| listeners.last().map(|(_, listener)| Arc::clone(listener)))
161}
162
163#[cfg(target_arch = "wasm32")]
164fn latest_back_listener() -> Option<BackListener> {
165 crate::thread_state::with(|state| state.back_listeners.last())
166}
167
168/// A back observer installed by [`observe_back_requests`].
169pub struct BackRequestObserver {
170 id: u64,
171}
172
173impl Drop for BackRequestObserver {
174 fn drop(&mut self) {
175 #[cfg(not(target_arch = "wasm32"))]
176 if let Ok(mut listeners) = back_listeners().lock() {
177 listeners.retain(|(id, _)| *id != self.id);
178 }
179 #[cfg(target_arch = "wasm32")]
180 crate::thread_state::with(|state| state.back_listeners.remove(self.id));
181 }
182}
183
184/// Take (and clear) the number of pending back requests. Polled by the app; a
185/// burst collapses into a count the app can coalesce.
186pub fn take_back_requests() -> usize {
187 BACK_REQUESTS.swap(0, Ordering::SeqCst)
188}
189
190/// Declare whether the app currently wants the platform's back control routed
191/// to [`push_back_request`] instead of the platform default. Set it `true`
192/// while there is in-app navigation to pop and `false` when leaving the app is
193/// the right response (mirrors Compose's `BackHandler(enabled)`).
194pub fn set_back_interception(enabled: bool) {
195 BACK_INTERCEPTION.store(enabled, Ordering::SeqCst);
196}
197
198/// Whether the app asked to intercept the platform back control. Read by the
199/// platform input path.
200pub fn back_interception_enabled() -> bool {
201 BACK_INTERCEPTION.load(Ordering::SeqCst)
202}
203
204static ACTIVE_BACK_HANDLERS: AtomicUsize = AtomicUsize::new(0);
205
206/// Handles platform back requests on the UI thread while `enabled` is true.
207/// Nested handlers follow stack order: the innermost active handler receives
208/// the request and dropping it restores the handler beneath it.
209#[expect(non_snake_case)]
210#[track_caller]
211pub fn BackHandler(enabled: bool, on_back: impl FnMut() + 'static) {
212 PredictiveBackHandler(enabled, |_| {}, on_back);
213}
214
215/// [`BackHandler`] that also follows an interactive back gesture while it
216/// moves: Compose's `PredictiveBackHandler`. `on_gesture` receives each step;
217/// a gesture that goes back ends in `on_back`, one that does not ends in
218/// [`BackGesture::Cancelled`].
219#[expect(non_snake_case)]
220#[track_caller]
221pub fn PredictiveBackHandler(
222 enabled: bool,
223 mut on_gesture: impl FnMut(BackGesture) + 'static,
224 mut on_back: impl FnMut() + 'static,
225) {
226 let events = cranpose_core::rememberEventStream(enabled, move |sender| {
227 if !enabled {
228 return None;
229 }
230 if ACTIVE_BACK_HANDLERS.fetch_add(1, Ordering::AcqRel) == 0 {
231 set_back_interception(true);
232 }
233 let registration = observe_back_signals(move |signal| match signal {
234 BackSignal::Request => {
235 let count = take_back_requests();
236 if count > 0 {
237 sender.send(BackDelivery::Requests(count));
238 }
239 }
240 BackSignal::Gesture(gesture) => sender.send(BackDelivery::Gesture(gesture)),
241 });
242 Some(BackInterception {
243 _registration: registration,
244 })
245 });
246 if enabled {
247 cranpose_core::CollectEvents(
248 events,
249 enabled,
250 move |delivery: BackDelivery| match delivery {
251 BackDelivery::Requests(count) => {
252 for _ in 0..count {
253 on_back();
254 }
255 }
256 BackDelivery::Gesture(gesture) => on_gesture(gesture),
257 },
258 );
259 }
260}
261
262/// What a back handler's event stream carries to the UI thread.
263#[derive(Clone, Copy)]
264enum BackDelivery {
265 Requests(usize),
266 Gesture(BackGesture),
267}
268
269struct BackInterception {
270 _registration: BackRequestObserver,
271}
272
273impl Drop for BackInterception {
274 fn drop(&mut self) {
275 if ACTIVE_BACK_HANDLERS.fetch_sub(1, Ordering::AcqRel) == 1 {
276 set_back_interception(false);
277 }
278 }
279}
280
281/// Ask the platform to close the app.
282///
283/// The counterpart to [`set_back_interception`]`(false)`: interception says
284/// "let the platform's own back control take me out of here", and this says
285/// the same thing when the app is the one that decided. An app needs it
286/// whenever it owns the affordance that means "leave":
287///
288/// - a screen-level dismiss gesture the app draws itself. On Android a
289/// `NativeActivity` consumes every pointer event on the display, so the
290/// platform's window-level swipe never fires and the app's own gesture is
291/// the only one there is — completing it has to close the app, and there is
292/// no back key on a watch to fall back on.
293/// - a Quit item in the app's own menu.
294///
295/// It is a request, not a teardown: the platform decides when the frame loop
296/// stops, so it is safe to call from the middle of one — including from a
297/// gesture's settle animation, which is where the decision usually lands.
298///
299/// The backend drains it on its next turn of the loop, which is the following
300/// frame for the usual caller. An app that calls this from another thread while
301/// the loop is parked — nothing animating, no input — should wake it the same
302/// way it would for a back request; registering
303/// [`observe_back_requests`] is enough, because this nudges that listener
304/// too.
305///
306/// Platform behaviour, and where it does nothing:
307///
308/// - **Android**: finishes the activity, the same outcome as Compose's
309/// `backDispatcher.onBackPressed()` on a screen with no `BackHandler`.
310/// - **Desktop**: exits the event loop, closing the window.
311/// - **iOS**: nothing. Apple's guidelines forbid an app terminating itself and
312/// there is no supported API for it; the request is dropped rather than
313/// faked, so an app can call this unconditionally.
314/// - **Web**: nothing. A page cannot close a tab it did not open.
315pub fn request_exit() {
316 EXIT_REQUESTED.store(true, Ordering::SeqCst);
317 if let Some(listener) = latest_back_listener() {
318 listener(BackSignal::Request);
319 }
320}
321
322/// Whether an exit request is outstanding, without consuming it.
323///
324/// For a backend whose way of closing can fail. Consuming the flag and then
325/// discovering the platform call did not land loses the app's only record that
326/// it wanted to close: the app stays open, the gesture the user made did
327/// nothing, and nothing will ever ask again. Such a backend tests with this and
328/// calls [`take_exit_request`] once the request has actually been honoured.
329///
330/// This is also the cheap read for a loop that runs it every turn — see
331/// [`take_exit_request`].
332pub fn exit_requested() -> bool {
333 EXIT_REQUESTED.load(Ordering::SeqCst)
334}
335
336/// Take (and clear) a pending exit request. Drained by the platform backend.
337///
338/// Read before written: this runs on every turn of the platform's loop, and a
339/// bare `swap` would dirty the cache line each time even with nothing to take.
340/// The load is the common case by a very long way — an app asks to close once,
341/// ever.
342pub fn take_exit_request() -> bool {
343 exit_requested() && EXIT_REQUESTED.swap(false, Ordering::SeqCst)
344}
345
346#[cfg(test)]
347#[path = "tests/navigation_tests.rs"]
348mod tests;