@react-native-gpui/screens 0.0.0-stage → 4.28.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,396 @@
1
+ //! The stack observer: everything react-native-screens does between a mount
2
+ //! batch and a paint.
3
+ //!
4
+ //! Core tells an observer about a batch before it applies it, about each insert
5
+ //! and removal as it applies them, and about the batch once it is done. All the
6
+ //! navigation decisions — which stack is nested, which card is leaving, when a
7
+ //! lifecycle event fires, when a card is kept alive for its exit — are made here
8
+ //! and remembered in `state`.
9
+ use crate::props;
10
+ use crate::state::{self, PopPlan, Role};
11
+ use crate::transition::{self, Role as TransitionRole};
12
+ use react_native_gpui::extensions::{
13
+ BatchOpKind, BatchView, ComponentNode, StoreHandle, StoreObserver, TreeView,
14
+ };
15
+ use std::collections::HashSet;
16
+
17
+ /// The component names this module owns.
18
+ pub const STACK: &str = "RNSScreenStack";
19
+ pub const SCREEN: &str = "RNSScreen";
20
+ pub const HEADER_CONFIG: &str = "RNSScreenStackHeaderConfig";
21
+ pub const HEADER_SUBVIEW: &str = "RNSScreenStackHeaderSubview";
22
+ pub const CONTENT_WRAPPER: &str = "RNSScreenContentWrapper";
23
+ pub const MODAL_SCREEN: &str = "RNSModalScreen";
24
+ pub const FULL_WINDOW_OVERLAY: &str = "RNSFullWindowOverlay";
25
+
26
+ /// Watches the batch and drives the stack.
27
+ pub struct StackObserver;
28
+
29
+ impl StoreObserver for StackObserver {
30
+ fn before_batch(
31
+ &self,
32
+ batch: &BatchView,
33
+ tree: &TreeView,
34
+ handle: &StoreHandle,
35
+ cx: &mut gpui::App,
36
+ ) {
37
+ let nested = nested_stacks(batch, tree);
38
+ if std::env::var_os("RN_GPUI_DEBUG_SCREENS").is_some() && !nested.is_empty() {
39
+ log::info!("[screens] nested stack tags={nested:?}");
40
+ }
41
+ let plans = pop_plans(batch, tree, &nested, handle, cx);
42
+ for (tag, plan) in &plans {
43
+ if plan.retain {
44
+ handle.retain_subtree(*tag);
45
+ }
46
+ }
47
+ state::with(|state| {
48
+ state.nested_stacks = nested;
49
+ state.pop_plans = plans;
50
+ });
51
+ }
52
+
53
+ fn on_mount(&self, node: &ComponentNode, _handle: &StoreHandle, _cx: &mut gpui::App) {
54
+ if node.component == FULL_WINDOW_OVERLAY {
55
+ log::info!(
56
+ "[screens] full window overlay created tag={} frame={}x{}",
57
+ node.tag,
58
+ node.frame.w,
59
+ node.frame.h
60
+ );
61
+ } else if crate::unsupported::unsupported_component(&node.component) {
62
+ log::error!(
63
+ "[screens] component {} is not implemented on gpui",
64
+ node.component
65
+ );
66
+ }
67
+ }
68
+
69
+ fn on_insert(
70
+ &self,
71
+ child: &ComponentNode,
72
+ parent: i32,
73
+ previous_children: &[i32],
74
+ tree: &TreeView,
75
+ handle: &StoreHandle,
76
+ cx: &mut gpui::App,
77
+ ) {
78
+ if child.component != SCREEN || !is_outer_stack(parent) {
79
+ return;
80
+ }
81
+ let animation = props::stack_animation(child).to_string();
82
+ let presentation = props::stack_presentation(child).to_string();
83
+ log::info!(
84
+ "[screens] screen inserted tag={} animation={} presentation={} reduce_motion={}",
85
+ child.tag,
86
+ animation,
87
+ presentation,
88
+ !tree.animations_enabled()
89
+ );
90
+ let previous_top = previous_children
91
+ .iter()
92
+ .rev()
93
+ .copied()
94
+ .find(|tag| tree.node(*tag).is_some_and(|node| node.component == SCREEN));
95
+ let mode = transition::transition_mode(&animation).filter(|_| tree.animations_enabled());
96
+ if let Some(mode) = mode {
97
+ log::info!(
98
+ "[screens] push transition incoming={} outgoing={:?} animation={} mode={mode:?}",
99
+ child.tag,
100
+ previous_top,
101
+ animation
102
+ );
103
+ if presentation == "push" {
104
+ if let Some(previous) = previous_top {
105
+ let distance = transition::PARALLAX_DISTANCE;
106
+ handle.start_transition(
107
+ previous,
108
+ transition::transition(mode, TransitionRole::Exit, distance),
109
+ cx,
110
+ );
111
+ state::with(|state| {
112
+ state.roles.insert(previous, (Role::Exit, distance));
113
+ });
114
+ }
115
+ }
116
+ handle.start_transition(
117
+ child.tag,
118
+ transition::transition(mode, TransitionRole::Enter, 1.0),
119
+ cx,
120
+ );
121
+ state::with(|state| {
122
+ state.roles.insert(child.tag, (Role::Enter, 1.0));
123
+ });
124
+ }
125
+ crate::lifecycle::dispatch_will(handle, previous_top, Some(child.tag), cx);
126
+ match mode {
127
+ Some(mode) => {
128
+ let outgoing = previous_top;
129
+ let incoming = Some(child.tag);
130
+ let handle = handle.clone();
131
+ handle.after(mode.duration(), cx, move |handle, cx| {
132
+ if let Some(outgoing) = outgoing {
133
+ handle.clear_transition(outgoing, cx);
134
+ }
135
+ crate::lifecycle::dispatch_did(handle, outgoing, incoming, cx);
136
+ });
137
+ }
138
+ None => crate::lifecycle::dispatch_did(handle, previous_top, Some(child.tag), cx),
139
+ }
140
+ }
141
+
142
+ fn on_remove_child(
143
+ &self,
144
+ child: i32,
145
+ parent: i32,
146
+ retained: bool,
147
+ tree: &TreeView,
148
+ handle: &StoreHandle,
149
+ cx: &mut gpui::App,
150
+ ) {
151
+ let Some(node) = tree.node(child) else {
152
+ return;
153
+ };
154
+ if node.component != SCREEN {
155
+ return;
156
+ }
157
+ let plan = state::with(|state| state.pop_plans.remove(&child));
158
+ let Some(plan) = plan else {
159
+ return;
160
+ };
161
+ let _ = parent;
162
+ if let Some(revealed) = plan.revealed {
163
+ handle.clear_transition(revealed, cx);
164
+ }
165
+ let native_dismiss = state::with(|state| state.native_dismiss_pending == Some(child));
166
+ let mode = transition::transition_mode(&plan.animation)
167
+ .filter(|_| tree.animations_enabled())
168
+ .map(transition::TransitionMode::reverse);
169
+ let retain = plan.retain && mode.is_some();
170
+ log::info!(
171
+ "[screens] pop transition outgoing={} incoming={:?} animation={} retained={retain}",
172
+ child,
173
+ plan.revealed,
174
+ plan.animation
175
+ );
176
+ if retain {
177
+ if let Some(mode) = mode {
178
+ handle.start_transition(
179
+ child,
180
+ transition::transition(mode, TransitionRole::Exit, 1.0),
181
+ cx,
182
+ );
183
+ state::with(|state| {
184
+ state.roles.insert(child, (Role::Exit, 1.0));
185
+ });
186
+ }
187
+ }
188
+ if !native_dismiss {
189
+ crate::lifecycle::dispatch_will(handle, Some(child), plan.revealed, cx);
190
+ }
191
+ if retained {
192
+ let duration = mode.map(|mode| mode.duration()).unwrap_or_default();
193
+ let revealed = plan.revealed;
194
+ let handle = handle.clone();
195
+ handle.after(duration, cx, move |handle, cx| {
196
+ handle.release_retained(child, cx);
197
+ log::info!("[screens] pop finished outgoing={child}");
198
+ crate::lifecycle::dispatch_did(handle, Some(child), revealed, cx);
199
+ });
200
+ } else if !native_dismiss {
201
+ crate::lifecycle::dispatch_did(handle, Some(child), plan.revealed, cx);
202
+ }
203
+ state::with(|state| {
204
+ if state.native_dismiss_pending == Some(child) {
205
+ state.native_dismiss_pending = None;
206
+ }
207
+ });
208
+ }
209
+
210
+ fn on_remove(&self, tag: i32, _handle: &StoreHandle, _cx: &mut gpui::App) {
211
+ state::with(|state| {
212
+ state.pop_plans.remove(&tag);
213
+ state.roles.remove(&tag);
214
+ state.headers.remove(&tag);
215
+ });
216
+ }
217
+
218
+ fn on_update(
219
+ &self,
220
+ node: &ComponentNode,
221
+ previous: &ComponentNode,
222
+ handle: &StoreHandle,
223
+ cx: &mut gpui::App,
224
+ ) {
225
+ if node.component != SCREEN {
226
+ return;
227
+ }
228
+ let before = props::activity_state(previous);
229
+ let after = props::activity_state(node);
230
+ if before == after {
231
+ return;
232
+ }
233
+ for event in crate::lifecycle::lifecycle_events(before, after) {
234
+ log::info!(
235
+ "[screens] tag={} activity={}->{} event={event}",
236
+ node.tag,
237
+ before,
238
+ after
239
+ );
240
+ handle.dispatch_event(node.tag, event, &serde_json::json!({}), cx);
241
+ }
242
+ }
243
+
244
+ fn after_batch(&self, _tree: &TreeView, _handle: &StoreHandle, _cx: &mut gpui::App) {
245
+ state::clear_batch_state();
246
+ }
247
+
248
+ fn on_transition_frame(
249
+ &self,
250
+ tag: i32,
251
+ progress: f32,
252
+ handle: &StoreHandle,
253
+ cx: &mut gpui::App,
254
+ ) {
255
+ let forward = state::with(|state| {
256
+ state
257
+ .roles
258
+ .get(&tag)
259
+ .is_none_or(|(role, distance)| *role == Role::Enter || *distance < 1.0)
260
+ });
261
+ handle.dispatch_event(
262
+ tag,
263
+ "topTransitionProgress",
264
+ &serde_json::json!({
265
+ "progress": progress,
266
+ "closing": if forward { 0 } else { 1 },
267
+ "goingForward": if forward { 1 } else { 0 }
268
+ }),
269
+ cx,
270
+ );
271
+ }
272
+ }
273
+
274
+ /// Whether `stack` is the outer stack, the only one that animates.
275
+ fn is_outer_stack(stack: i32) -> bool {
276
+ !state::with(|state| state.nested_stacks.contains(&stack))
277
+ }
278
+
279
+ /// Every stack that sits inside a screen, found from the batch topology before
280
+ /// any of it is applied.
281
+ fn nested_stacks(batch: &BatchView, tree: &TreeView) -> HashSet<i32> {
282
+ let stacks: Vec<i32> = batch
283
+ .ops
284
+ .iter()
285
+ .filter(|op| op.kind == BatchOpKind::Create && op.component == STACK)
286
+ .map(|op| op.tag)
287
+ .chain(
288
+ batch
289
+ .ops
290
+ .iter()
291
+ .filter(|op| op.kind != BatchOpKind::Create && op.component == STACK)
292
+ .map(|op| op.tag),
293
+ )
294
+ .collect();
295
+ let mut nested = HashSet::new();
296
+ for stack in stacks {
297
+ let mut cursor = Some(stack);
298
+ let mut visited = HashSet::new();
299
+ while let Some(tag) = cursor {
300
+ if !visited.insert(tag) {
301
+ break;
302
+ }
303
+ let Some(parent) = batch.parent_of(tag).or_else(|| tree.parent(tag)) else {
304
+ break;
305
+ };
306
+ if batch.component_of(parent, tree).as_deref() == Some(SCREEN) {
307
+ nested.insert(stack);
308
+ break;
309
+ }
310
+ cursor = Some(parent);
311
+ }
312
+ }
313
+ nested
314
+ }
315
+
316
+ /// The cards leaving in this batch that should animate their exit.
317
+ fn pop_plans(
318
+ batch: &BatchView,
319
+ tree: &TreeView,
320
+ nested: &HashSet<i32>,
321
+ handle: &StoreHandle,
322
+ cx: &mut gpui::App,
323
+ ) -> std::collections::HashMap<i32, PopPlan> {
324
+ let removed: HashSet<i32> = batch
325
+ .ops
326
+ .iter()
327
+ .filter(|op| op.kind == BatchOpKind::Remove && op.component == SCREEN)
328
+ .map(|op| op.tag)
329
+ .collect();
330
+ let mut plans = std::collections::HashMap::new();
331
+ for op in batch
332
+ .ops
333
+ .iter()
334
+ .filter(|op| op.kind == BatchOpKind::Remove && op.component == SCREEN)
335
+ {
336
+ if nested.contains(&op.parent_tag) {
337
+ continue;
338
+ }
339
+ let Some(stack) = tree.node(op.parent_tag) else {
340
+ continue;
341
+ };
342
+ if stack.component != STACK {
343
+ continue;
344
+ }
345
+ let live: Vec<i32> = tree
346
+ .children(op.parent_tag)
347
+ .into_iter()
348
+ .filter(|tag| {
349
+ tree.is_live(*tag) && tree.node(*tag).is_some_and(|node| node.component == SCREEN)
350
+ })
351
+ .collect();
352
+ if live.last().copied() != Some(op.tag) {
353
+ continue;
354
+ }
355
+ let revealed = live
356
+ .iter()
357
+ .rev()
358
+ .find(|tag| !removed.contains(tag))
359
+ .copied();
360
+ let Some(screen) = tree.node(op.tag) else {
361
+ continue;
362
+ };
363
+ let animation = props::stack_animation(&screen).to_string();
364
+ let native_dismiss = state::with(|state| state.native_dismiss_pending == Some(op.tag));
365
+ let mode = transition::transition_mode(&animation).filter(|_| tree.animations_enabled());
366
+ let retain = !native_dismiss && revealed.is_some() && mode.is_some();
367
+ if retain {
368
+ log::info!(
369
+ "[screens] preserve outgoing={} descendants={}",
370
+ op.tag,
371
+ descendant_count(op.tag, tree)
372
+ );
373
+ }
374
+ plans.insert(
375
+ op.tag,
376
+ PopPlan {
377
+ revealed,
378
+ animation,
379
+ duration: mode.map(|mode| mode.duration()).unwrap_or_default(),
380
+ retain,
381
+ },
382
+ );
383
+ }
384
+ plans
385
+ }
386
+
387
+ /// How many nodes sit below `tag`, at any depth.
388
+ fn descendant_count(tag: i32, tree: &TreeView) -> usize {
389
+ let mut total = 0;
390
+ let mut stack = tree.children(tag);
391
+ while let Some(next) = stack.pop() {
392
+ total += 1;
393
+ stack.extend(tree.children(next));
394
+ }
395
+ total
396
+ }
@@ -0,0 +1,162 @@
1
+ //! The module's own state, kept outside core.
2
+ //!
3
+ //! Core hands an observer facts about a batch and forgets them; everything a
4
+ //! stack has to remember between batches — which stacks are nested, which cards
5
+ //! are leaving, what a dismissal already animated — is here.
6
+ use std::collections::{HashMap, HashSet};
7
+ use std::sync::{Mutex, OnceLock};
8
+ use std::time::Duration;
9
+
10
+ /// What `decorate` learned about a header, for `content` to use.
11
+ #[derive(Clone, Copy, Debug, Default)]
12
+ pub struct HeaderFacts {
13
+ pub is_top: bool,
14
+ pub shows_back: bool,
15
+ pub has_title_subview: bool,
16
+ /// The screen this header belongs to, which is where a back-button event
17
+ /// has to go: React Native declares `onHeaderBackButtonClicked` on
18
+ /// `RNSScreen`, not on the header config.
19
+ pub screen: Option<i32>,
20
+ }
21
+
22
+ /// Records what `decorate` learned, so `content` can read it.
23
+ pub fn set_header_facts(
24
+ tag: i32,
25
+ is_top: bool,
26
+ shows_back: bool,
27
+ has_title_subview: bool,
28
+ screen: Option<i32>,
29
+ ) {
30
+ with(|state| {
31
+ state.headers.insert(
32
+ tag,
33
+ HeaderFacts {
34
+ is_top,
35
+ shows_back,
36
+ has_title_subview,
37
+ screen,
38
+ },
39
+ );
40
+ });
41
+ }
42
+
43
+ /// Whether this header is the top screen's, as of the last `decorate` call.
44
+ pub fn header_is_top(tag: i32) -> bool {
45
+ with(|state| state.headers.get(&tag).is_some_and(|facts| facts.is_top))
46
+ }
47
+
48
+ /// Whether this header carries a `title` subview, as of the last `decorate` call.
49
+ ///
50
+ /// When it does, that subview paints the title and core must not paint its own.
51
+ pub fn header_has_title_subview(tag: i32) -> bool {
52
+ with(|state| {
53
+ state
54
+ .headers
55
+ .get(&tag)
56
+ .is_some_and(|facts| facts.has_title_subview)
57
+ })
58
+ }
59
+
60
+ /// The screen a header belongs to, as of the last `decorate` call.
61
+ pub fn header_screen(tag: i32) -> Option<i32> {
62
+ with(|state| state.headers.get(&tag).and_then(|facts| facts.screen))
63
+ }
64
+
65
+ /// Whether this header shows a back button, as of the last `decorate` call.
66
+ pub fn header_shows_back(tag: i32) -> bool {
67
+ with(|state| {
68
+ state
69
+ .headers
70
+ .get(&tag)
71
+ .is_some_and(|facts| facts.shows_back)
72
+ })
73
+ }
74
+
75
+ /// A card that is leaving, and what should happen when it is gone.
76
+ #[derive(Clone, Debug)]
77
+ pub struct PopPlan {
78
+ /// The screen that becomes top when this one is gone.
79
+ pub revealed: Option<i32>,
80
+ /// The animation name React Native asked for, unparsed.
81
+ pub animation: String,
82
+ /// How long the exit runs.
83
+ pub duration: Duration,
84
+ /// Whether the card must stay attached so its exit can paint.
85
+ pub retain: bool,
86
+ }
87
+
88
+ /// Everything the module remembers between batches.
89
+ #[derive(Default)]
90
+ pub struct State {
91
+ /// A native dismissal already ran the exit animation, so the removal must
92
+ /// not run a second one and must not send the "will" events again.
93
+ pub native_dismiss_pending: Option<i32>,
94
+ /// Stacks that sit inside a screen, so only the outer stack animates.
95
+ pub nested_stacks: HashSet<i32>,
96
+ /// Cards leaving in this batch, by tag.
97
+ pub pop_plans: HashMap<i32, PopPlan>,
98
+ /// The cards this module started a transition on: whether it is an entry
99
+ /// (the screen is arriving) or an exit, and how far it travels.
100
+ pub roles: HashMap<i32, (Role, f32)>,
101
+ /// The latest facts about each header, written by `decorate`.
102
+ pub headers: HashMap<i32, HeaderFacts>,
103
+ }
104
+
105
+ /// Whether a transition the module started is an arrival or a departure.
106
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
107
+ pub enum Role {
108
+ Enter,
109
+ Exit,
110
+ }
111
+
112
+ fn state() -> &'static Mutex<State> {
113
+ static STATE: OnceLock<Mutex<State>> = OnceLock::new();
114
+ STATE.get_or_init(|| Mutex::new(State::default()))
115
+ }
116
+
117
+ /// Runs `f` with the module state.
118
+ pub fn with<R>(f: impl FnOnce(&mut State) -> R) -> R {
119
+ let mut state = state()
120
+ .lock()
121
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
122
+ f(&mut state)
123
+ }
124
+
125
+ /// Forgets the per-batch decisions. Call from `after_batch`.
126
+ pub fn clear_batch_state() {
127
+ with(|state| {
128
+ state.pop_plans.clear();
129
+ });
130
+ }
131
+
132
+ #[cfg(test)]
133
+ mod tests {
134
+ use super::*;
135
+
136
+ #[test]
137
+ fn batch_state_is_cleared_but_lifetimes_are_not() {
138
+ with(|state| {
139
+ state.pop_plans.insert(
140
+ 7,
141
+ PopPlan {
142
+ revealed: Some(3),
143
+ animation: "slide_from_right".to_string(),
144
+ duration: Duration::from_millis(240),
145
+ retain: true,
146
+ },
147
+ );
148
+ state.nested_stacks.insert(9);
149
+ state.native_dismiss_pending = Some(7);
150
+ });
151
+ clear_batch_state();
152
+ with(|state| {
153
+ assert!(state.pop_plans.is_empty());
154
+ assert!(state.nested_stacks.contains(&9));
155
+ assert_eq!(state.native_dismiss_pending, Some(7));
156
+ });
157
+ with(|state| {
158
+ state.nested_stacks.clear();
159
+ state.native_dismiss_pending = None;
160
+ });
161
+ }
162
+ }