@apexnative/navigation 1.0.0-alpha.4 → 1.0.0-alpha.5

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/android/CLAUDE.md +9 -2
  3. package/android/CORE.md +25 -15
  4. package/android/src/main/java/dev/apexnative/navigation/NavigationActivity.kt +25 -9
  5. package/android/src/main/java/dev/apexnative/navigation/options/AnimationsOptions.kt +2 -2
  6. package/android/src/main/java/dev/apexnative/navigation/options/HardwareBackButtonOptions.kt +9 -1
  7. package/android/src/main/java/dev/apexnative/navigation/options/StackAnimationOptions.kt +40 -2
  8. package/android/src/main/java/dev/apexnative/navigation/react/ArrangementStates.kt +6 -6
  9. package/android/src/main/java/dev/apexnative/navigation/react/CommandAnswer.kt +28 -13
  10. package/android/src/main/java/dev/apexnative/navigation/react/Hinge.kt +7 -4
  11. package/android/src/main/java/dev/apexnative/navigation/react/ReactView.kt +39 -20
  12. package/android/src/main/java/dev/apexnative/navigation/react/ReloadCoordinator.kt +7 -4
  13. package/android/src/main/java/dev/apexnative/navigation/react/ScreenInsets.kt +6 -6
  14. package/android/src/main/java/dev/apexnative/navigation/react/ScreenRegions.kt +10 -5
  15. package/android/src/main/java/dev/apexnative/navigation/react/events/AppearanceDispatcher.kt +30 -39
  16. package/android/src/main/java/dev/apexnative/navigation/react/events/EventEmitter.kt +34 -7
  17. package/android/src/main/java/dev/apexnative/navigation/react/events/NavigationState.kt +154 -0
  18. package/android/src/main/java/dev/apexnative/navigation/safearea/SafeAreaView.kt +6 -4
  19. package/android/src/main/java/dev/apexnative/navigation/slots/SlotScreenView.kt +12 -12
  20. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/arrangement/ArrangementController.kt +3 -2
  21. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +9 -11
  22. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ComponentViewController.kt +6 -2
  23. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ContentReadiness.kt +1 -1
  24. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ScreenReadiness.kt +103 -56
  25. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/BackCallbackCheck.kt +50 -0
  26. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/BackCallbackState.kt +45 -14
  27. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +21 -5
  28. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/RootCoordinator.kt +2 -2
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitViewController.kt +3 -0
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +9 -5
  31. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/search/ScreenSearch.kt +3 -5
  32. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/search/StackSearchPresenter.kt +2 -5
  33. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/BarComponents.kt +5 -51
  34. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/TopBarPresenter.kt +4 -9
  35. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/CustomBottomTabItemView.kt +0 -5
  36. package/cli/doctor/checks/splash.js +4 -22
  37. package/cli/splash/index.js +3 -1
  38. package/docs-md/containers/modals.md +2 -2
  39. package/docs-md/guides/cli.md +1 -2
  40. package/docs-md/help/troubleshooting.md +2 -2
  41. package/docs-md/screen-ui/back-and-gestures.md +22 -0
  42. package/docs-md/start/installation.md +1 -1
  43. package/ios/CORE.md +6 -6
  44. package/ios/Contract/ANNCommandAnswer.h +9 -2
  45. package/ios/Contract/ANNCommandAnswer.mm +25 -12
  46. package/ios/Core/ANNAppearanceDispatcher.h +5 -4
  47. package/ios/Core/ANNAppearanceDispatcher.mm +12 -1
  48. package/ios/Core/ANNNavigationState.h +4 -6
  49. package/ios/Core/ANNNavigationState.mm +0 -4
  50. package/ios/Layouts/Root/ANNSplashViewController.h +5 -3
  51. package/ios/Layouts/Root/ANNSplashViewController.mm +147 -0
  52. package/ios/Layouts/Stack/ANNPreviewPush.h +5 -5
  53. package/ios/Layouts/Stack/ANNPreviewPush.mm +85 -22
  54. package/lib/module/api/commands/commands.js +54 -6
  55. package/lib/module/api/commands/commands.js.map +1 -1
  56. package/lib/module/engine/places.js +8 -0
  57. package/lib/module/engine/places.js.map +1 -1
  58. package/lib/typescript/api/commands/commands.d.ts.map +1 -1
  59. package/lib/typescript/api/commands/presentation.d.ts +4 -3
  60. package/lib/typescript/api/commands/presentation.d.ts.map +1 -1
  61. package/lib/typescript/engine/places.d.ts +2 -0
  62. package/lib/typescript/engine/places.d.ts.map +1 -1
  63. package/lib/typescript/interfaces/Options.d.ts +1 -1
  64. package/lib/typescript/interfaces/Options.d.ts.map +1 -1
  65. package/lib/typescript/interfaces/options/animations.d.ts +18 -2
  66. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  67. package/lib/typescript/interfaces/options/stack.d.ts +9 -0
  68. package/lib/typescript/interfaces/options/stack.d.ts.map +1 -1
  69. package/package.json +1 -1
  70. package/src/api/commands/commands.ts +48 -7
  71. package/src/api/commands/presentation.ts +4 -3
  72. package/src/engine/places.ts +9 -0
  73. package/src/interfaces/Options.ts +1 -0
  74. package/src/interfaces/options/animations.ts +19 -2
  75. package/src/interfaces/options/stack.ts +9 -0
  76. package/android/src/main/java/dev/apexnative/navigation/react/events/NavigationStateReporter.kt +0 -62
  77. package/android/src/main/java/dev/apexnative/navigation/react/events/StateHold.kt +0 -62
@@ -23,11 +23,15 @@ import dev.apexnative.navigation.viewcontrollers.viewcontroller.ScrollEventListe
23
23
  * is made with the view, from the app's React host, and fills it; it starts once, as the view joins a window, and stops
24
24
  * once, on the main thread, as the view is destroyed.
25
25
  *
26
- * Ready once the safe area view of the screen told [ScreenReadiness] that the content rendered into it; a screen
27
- * without a safe area view (no view ever told anything of its id) once the root view of its surface has children, read
28
- * on this view's own layout passes. Ready once, ready for good; what waits for it ([whenReady]) runs then, once.
29
- * Without either, the limits of what waits decide: the view has no timer of its own. It owns the record of its
30
- * appearance, which the dispatcher of the appearance events holds until the content is ready (`AppearanceDispatcher`).
26
+ * Ready once the safe area view of the screen, in this view's own surface, told [ScreenReadiness] that the content
27
+ * rendered into it: the signal of another root with the same id (an old one, another live root of a bar component)
28
+ * never makes this one ready. A screen without a safe area view (no view ever told anything of its id) is ready once
29
+ * the root view of its surface has children, read on this view's own layout passes. A root that renders at once
30
+ * ([rendersAtOnce]: a component of a bar, a tab's cell) marks its id so from before its surface starts until it stops.
31
+ * Ready once, ready for good; what waits for it ([whenReady]) runs then, once.
32
+ * Without a signal or children, the limits of what waits decide: the view has no timer of its own. It owns the
33
+ * record of its appearance, which the dispatcher of the appearance events holds until the content is ready
34
+ * (`AppearanceDispatcher`).
31
35
  */
32
36
  @SuppressLint("ViewConstructor")
33
37
  class ReactView @JvmOverloads constructor(
@@ -36,6 +40,8 @@ class ReactView @JvmOverloads constructor(
36
40
  override val componentName: String,
37
41
  /** Props given by the native side, over `componentId`: the state of a tab's cell. */
38
42
  initialProps: Bundle? = null,
43
+ /** The root renders at once, without waiting for its layout: a component of a bar or a tab's cell. */
44
+ private val rendersAtOnce: Boolean = false,
39
45
  ) : FrameLayout(context), IReactView {
40
46
  private val scrollEventListener = ScrollEventListener()
41
47
  private var destroyed = false
@@ -44,7 +50,14 @@ class ReactView @JvmOverloads constructor(
44
50
  // What waited for the readiness was told; a question of isReady alone tells nothing, so nothing runs inside it.
45
51
  private var readyTold = false
46
52
  private val readyListeners = mutableListOf<Runnable>()
47
- private var readinessListener: ScreenReadiness.Listener? = null
53
+ private var listening = false
54
+
55
+ // What this root hears of its id: only the signals of its own surface.
56
+ private val readiness = object : ScreenReadiness.Listener {
57
+ override fun accepts(source: Int): Boolean = ownSource()?.let { it == source } ?: true
58
+
59
+ override fun onContentRendered() = checkReadiness()
60
+ }
48
61
 
49
62
  // None without the app's host (the unit tests).
50
63
  private val surface: ReactSurface?
@@ -59,8 +72,17 @@ class ReactView @JvmOverloads constructor(
59
72
  */
60
73
  internal var contentChildCount: () -> Int = { (surface?.view as? ViewGroup)?.childCount ?: 0 }
61
74
 
75
+ /**
76
+ * The id of this root's React surface, which the signals of its safe area view carry: React Native gives it as
77
+ * the surface starts (none matches before) and never gives it again. Null without a surface: every signal of the
78
+ * id is its own. The unit tests give their own.
79
+ */
80
+ internal var ownSource: () -> Int? = { surface?.surfaceID }
81
+
62
82
  init {
63
83
  require(componentId.isNotEmpty()) { "a React root needs the id of its screen" }
84
+ // Before the surface exists, so its safe area view never asks before the mark is there.
85
+ if (rendersAtOnce) ScreenReadiness.setRendersAtOnce(componentId, this, true)
64
86
  val props = Bundle().apply {
65
87
  initialProps?.let(::putAll)
66
88
  putString(COMPONENT_ID, componentId)
@@ -103,14 +125,9 @@ class ReactView @JvmOverloads constructor(
103
125
  if (readyTold) return listener.run()
104
126
  readyListeners += listener
105
127
  if (isReady()) return checkReadiness()
106
- if (readinessListener != null) return
107
- val readiness = object : ScreenReadiness.Listener {
108
- override fun onLaidOut() = Unit
109
-
110
- override fun onContentRendered() = checkReadiness()
111
- }
112
- readinessListener = readiness
113
- ScreenReadiness.setListener(componentId, readiness)
128
+ if (listening) return
129
+ listening = true
130
+ ScreenReadiness.addListener(componentId, this, readiness)
114
131
  }
115
132
 
116
133
  /**
@@ -126,23 +143,24 @@ class ReactView @JvmOverloads constructor(
126
143
  waiting.forEach(Runnable::run)
127
144
  }
128
145
 
129
- // The safe area view said so; without one, the root view of the surface has children.
130
- private fun hasRendered(): Boolean = when (ScreenReadiness.isContentRendered(componentId)) {
146
+ // The safe area view of this root's surface said so; without one, the root view of the surface has children.
147
+ private fun hasRendered(): Boolean = when (ScreenReadiness.isContentRendered(componentId, readiness)) {
131
148
  true -> true
132
149
  false -> false
133
150
  null -> contentChildCount() > 0
134
151
  }
135
152
 
136
153
  private fun stopListening() {
137
- val readiness = readinessListener ?: return
138
- readinessListener = null
139
- ScreenReadiness.removeListener(componentId, readiness)
154
+ if (!listening) return
155
+ listening = false
156
+ ScreenReadiness.removeListener(componentId, this)
140
157
  }
141
158
 
142
159
  /**
143
160
  * Stops the content, once, on the main thread: its appearance ends first (DidDisappear if its WillAppear went,
144
161
  * else what is held goes with it), then the surface stops and leaves the host, so a reload doesn't start it again
145
- * in the new JavaScript.
162
+ * in the new JavaScript. A root that renders at once takes its mark away: the last one of its id takes what was
163
+ * said of the id with it.
146
164
  */
147
165
  override fun destroy() {
148
166
  if (destroyed) return
@@ -150,6 +168,7 @@ class ReactView @JvmOverloads constructor(
150
168
  destroyed = true
151
169
  readyListeners.clear()
152
170
  stopListening()
171
+ if (rendersAtOnce) ScreenReadiness.setRendersAtOnce(componentId, this, false)
153
172
  val surface = surface ?: return
154
173
  if (started) surface.stop()
155
174
  surface.detach()
@@ -4,7 +4,7 @@ import com.facebook.react.bridge.ReactContext
4
4
  import dev.apexnative.navigation.NavigationActivity
5
5
  import dev.apexnative.navigation.nativescreen.NativeScreenCalls
6
6
  import dev.apexnative.navigation.react.events.EventEmitter
7
- import dev.apexnative.navigation.react.events.NavigationStateReporter
7
+ import dev.apexnative.navigation.react.events.NavigationState
8
8
  import dev.apexnative.navigation.utils.UiThread
9
9
  import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackState
10
10
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
@@ -39,7 +39,7 @@ internal object ReloadCoordinator {
39
39
  /**
40
40
  * The JavaScript goes (a reload, or the host is destroyed). Any thread: the number grows at once; the layouts of
41
41
  * every navigation activity go on the main thread, with the "reloading" mark, and so do the listeners of the
42
- * JavaScript that went (back, hinge).
42
+ * JavaScript that went (back, hinge) and the holds of the navigation state its `setRoot` left.
43
43
  */
44
44
  fun onJavaScriptGoes() {
45
45
  advance()
@@ -48,6 +48,7 @@ internal object ReloadCoordinator {
48
48
  launches.contextGone()
49
49
  try {
50
50
  Navigators.all().forEach { it.onReload() }
51
+ NavigationState.javaScriptWent()
51
52
  BackCallbackState.setJsListeners(false)
52
53
  Hinge.setEnabled(Navigators.current(), false)
53
54
  } finally {
@@ -59,13 +60,15 @@ internal object ReloadCoordinator {
59
60
  /**
60
61
  * The developer tools of [activity] reload the JavaScript ("R R", the reload broadcast): the number grows first, so
61
62
  * the navigation of the activity goes without a word to the JavaScript that goes, then its layers go
62
- * ([NavigationActivity.onReload]). The host's own reset follows as the reload destroys it. Main thread.
63
+ * ([NavigationActivity.onReload]) with the holds of the navigation state. The host's own reset follows as the
64
+ * reload destroys it. Main thread.
63
65
  */
64
66
  fun resetForDeveloperReload(activity: NavigationActivity) {
65
67
  advance()
66
68
  Navigator.setReloading(true)
67
69
  try {
68
70
  activity.onReload()
71
+ NavigationState.javaScriptWent()
69
72
  } finally {
70
73
  Navigator.setReloading(false)
71
74
  }
@@ -92,7 +95,7 @@ internal object ReloadCoordinator {
92
95
  */
93
96
  fun launchIfDue(activity: NavigationActivity) {
94
97
  val context = launches.takeDue(activity, activity.isNavigationResumed) ?: return
95
- NavigationStateReporter.reset()
98
+ NavigationState.reset()
96
99
  EventEmitter(context).emitAppLaunched()
97
100
  }
98
101
  }
@@ -1,7 +1,5 @@
1
1
  package dev.apexnative.navigation.react
2
2
 
3
- import android.content.Context
4
- import com.facebook.react.ReactApplication
5
3
  import com.facebook.react.bridge.Arguments
6
4
  import com.facebook.react.bridge.WritableMap
7
5
  import dev.apexnative.navigation.react.events.EventContract
@@ -36,8 +34,11 @@ object ScreenInsets {
36
34
  }
37
35
  }
38
36
 
39
- /** Keeps the screen's insets and padding, and tells when they changed. */
40
- fun update(context: Context, screenId: String, value: Insets, paddingValue: Insets) {
37
+ /**
38
+ * Keeps the screen's insets and padding, and tells when they changed through [emitter], the one of the JavaScript
39
+ * the screen was made for: silent once it went.
40
+ */
41
+ fun update(screenId: String, value: Insets, paddingValue: Insets, emitter: EventEmitter) {
41
42
  if (padding.put(screenId, paddingValue) != paddingValue) {
42
43
  paddingListeners[screenId]?.toList()?.forEach { it() }
43
44
  }
@@ -49,8 +50,7 @@ object ScreenInsets {
49
50
  left = (value.left - paddingValue.left).coerceAtLeast(0.0),
50
51
  )
51
52
  if (insets.put(screenId, uncovered) == uncovered) return
52
- val reactContext = (context.applicationContext as? ReactApplication)?.reactHost?.currentReactContext ?: return
53
- EventEmitter(reactContext).emitScreenInsetsChanged(screenId, toMap(uncovered))
53
+ emitter.emitScreenInsetsChanged(screenId, toMap(uncovered))
54
54
  }
55
55
 
56
56
  /** `{ top, right, bottom, left }`, empty before the screen was laid out. Any thread. */
@@ -3,7 +3,6 @@ package dev.apexnative.navigation.react
3
3
  import android.app.Activity
4
4
  import android.graphics.Rect
5
5
  import androidx.core.view.WindowInsetsCompat
6
- import com.facebook.react.ReactApplication
7
6
  import com.facebook.react.bridge.Arguments
8
7
  import com.facebook.react.bridge.WritableArray
9
8
  import com.facebook.react.bridge.WritableMap
@@ -32,9 +31,16 @@ internal object ScreenRegions {
32
31
 
33
32
  /**
34
33
  * Measures the regions of the screen whose content the layout puts at [content] in the window (pixels), with the
35
- * window's [insets] (the camera cutout), and tells JavaScript when they changed. Main thread only.
34
+ * window's [insets] (the camera cutout), and tells JavaScript when they changed, through [emitter], the one of the
35
+ * JavaScript the screen was made for: silent once it went. Main thread only.
36
36
  */
37
- fun update(activity: Activity, screenId: String, content: Rect, insets: WindowInsetsCompat?) {
37
+ fun update(
38
+ activity: Activity,
39
+ screenId: String,
40
+ content: Rect,
41
+ insets: WindowInsetsCompat?,
42
+ emitter: EventEmitter,
43
+ ) {
38
44
  val folds = WindowFolds.of(activity)?.folds.orEmpty()
39
45
  val cutouts = insets?.displayCutout?.boundingRects.orEmpty()
40
46
  val density = activity.resources.displayMetrics.density
@@ -46,8 +52,7 @@ internal object ScreenRegions {
46
52
  val previous = regions.put(screenId, value)
47
53
  // None at first is what JavaScript reads before the first layout.
48
54
  if (previous == value || (previous == null && value.isEmpty)) return
49
- val reactContext = (activity.application as? ReactApplication)?.reactHost?.currentReactContext ?: return
50
- EventEmitter(reactContext).emitScreenRegionsChanged(screenId, toMap(value))
55
+ emitter.emitScreenRegionsChanged(screenId, toMap(value))
51
56
  }
52
57
 
53
58
  /** `{ division, occlusion }`, empty lists for an unknown screen and before its first layout. Any thread. */
@@ -90,7 +90,7 @@ internal object AppearanceDispatcher {
90
90
  val appearanceRecord: ScreenAppearance?
91
91
  }
92
92
 
93
- /** The navigation state (`NavigationStateReporter`): one update in this pass of the main loop, or one now. */
93
+ /** The navigation state ([NavigationState]): one update in this pass of the main loop, or one now. */
94
94
  interface StateUpdates {
95
95
  /**
96
96
  * Asks for one update in this pass of the main loop: the calls of one pass make one, after a DidAppear or a
@@ -98,7 +98,10 @@ internal object AppearanceDispatcher {
98
98
  */
99
99
  fun setNeedsUpdate()
100
100
 
101
- /** Computes the state now and sends it when it changed: before an answer, at the end of a transition. */
101
+ /**
102
+ * Computes the state now and sends it when it changed: a command's answer, once
103
+ * (`CommandAnswer.reportStateNow`), the end of a user's transition, a tab chosen from code.
104
+ */
102
105
  fun updateNow()
103
106
 
104
107
  /**
@@ -157,33 +160,6 @@ internal object AppearanceDispatcher {
157
160
  EventEmitter.current().emitSheetDetentChanged(componentId, detent)
158
161
  }
159
162
 
160
- /** The state of `NavigationStateReporter`, sent through the JavaScript that runs now, when it can be. */
161
- object ReporterState : StateUpdates {
162
- private val handler by lazy { Handler(Looper.getMainLooper()) }
163
-
164
- // The one posted update of a pass of the main loop, which checks the hold when it runs.
165
- private val held = StateHold(
166
- post = { task -> handler.post(task) },
167
- generation = { ReloadCoordinator.generation },
168
- ) {
169
- val emitter = EventEmitter.current()
170
- if (emitter.canSend) NavigationStateReporter.updateNow(emitter)
171
- }
172
-
173
- override fun setNeedsUpdate() {
174
- if (EventEmitter.current().canSend) held.setNeedsUpdate()
175
- }
176
-
177
- override fun hold() = held.hold()
178
-
179
- override fun release() = held.release()
180
-
181
- override fun updateNow() {
182
- val emitter = EventEmitter.current()
183
- if (emitter.canSend) NavigationStateReporter.updateNow(emitter)
184
- }
185
- }
186
-
187
163
  /** The main thread's loop. */
188
164
  object MainScheduler : Scheduler {
189
165
  private val handler by lazy { Handler(Looper.getMainLooper()) }
@@ -198,8 +174,8 @@ internal object AppearanceDispatcher {
198
174
  /** Where the events go: [EmitterSender]; the tests put a journal here. */
199
175
  var sender: Sender = EmitterSender
200
176
 
201
- /** The navigation state the sent appearance events and transitions update: [ReporterState]; a journal in tests. */
202
- var state: StateUpdates = ReporterState
177
+ /** The navigation state the appearance events and transitions update: [NavigationState]; a journal in tests. */
178
+ var state: StateUpdates = NavigationState
203
179
 
204
180
  /** The clock of the hold limit: [MainScheduler]; the tests put a clock that moves when they say. */
205
181
  var scheduler: Scheduler = MainScheduler
@@ -337,12 +313,15 @@ internal object AppearanceDispatcher {
337
313
  }
338
314
 
339
315
  /**
340
- * A transition ended,: what went disappears ([hiding]), what stands appears ([showing], with its WillAppear first
316
+ * A transition ended: what went disappears ([hiding]), what stands appears ([showing], with its WillAppear first
341
317
  * where it wasn't asked), the navigation state goes when it changed, then [events] (the transition's own:
342
318
  * `ANN.ScreenPopped`, `ANN.ModalDismissed`, and what must follow them, like stopping the content of what went),
343
- * then [answer] resolves with [value] (the promise, then `ANN.CommandCompleted`). A user's action has no answer. A
344
- * state that fails is logged ([log]) and the transition goes on: [events] and the answer always come. Without
345
- * [reportsState] the caller sends the state itself, later (a tab switch: after `ANN.BottomTabSelected`).
319
+ * then [answer] resolves with [value] (the promise, then `ANN.CommandCompleted`; no second state). The state is the
320
+ * answer's one state step (`CommandAnswer.reportStateNow`), which sends nothing for a JavaScript that went; a
321
+ * user's action has no answer, and a command answered before its transition ended had its state then: the state
322
+ * of the end goes all the same, unless its JavaScript went. A state that fails is logged and the transition goes
323
+ * on: [events] and the answer always come. Without [reportsState] the caller sends the state itself, later (a tab
324
+ * switch: after `ANN.BottomTabSelected`; a `setRoot` with layers: from its answer).
346
325
  */
347
326
  fun endTransition(
348
327
  hiding: List<ViewController<*>>,
@@ -360,18 +339,30 @@ internal object AppearanceDispatcher {
360
339
  holdsState = false
361
340
  }
362
341
  showing.forEach { it.onViewDidAppear() }
363
- if (reportsState) reportState(answer?.commandName)
342
+ if (reportsState) {
343
+ // The state before the transition's events, once per answer: the answer's own step before its promise
344
+ // finds it sent. Without an answer to wait for, the dispatcher sends it; nothing for a JavaScript that
345
+ // went.
346
+ when {
347
+ answer == null -> reportState()
348
+ answer.isAnswered -> if (ReloadCoordinator.isCurrent(answer.generation)) reportState()
349
+ else -> answer.reportStateNow()
350
+ }
351
+ }
364
352
  events?.invoke()
365
353
  answer?.resolve(value)
366
354
  }
367
355
 
368
- /** The navigation state now, when it changed; a failure is logged ([log]), nothing else stops for it. */
369
- fun reportState(commandName: String? = null) {
356
+ /**
357
+ * The navigation state now, when it changed, without a command's answer (a command's goes through its answer,
358
+ * `CommandAnswer.reportStateNow`); a failure is logged ([log]), nothing else stops for it.
359
+ */
360
+ fun reportState() {
370
361
  try {
371
362
  state.updateNow()
372
363
  } catch (e: Throwable) {
373
364
  val reason = ErrorTexts.couldNot("report the navigation state", ErrorTexts.reasonOf(e))
374
- log("[ApexNavigation] ${commandName ?: "transition"}: $reason")
365
+ log("[ApexNavigation] transition: $reason")
375
366
  }
376
367
  }
377
368
 
@@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactContext
6
6
  import com.facebook.react.bridge.WritableMap
7
7
  import dev.apexnative.navigation.NavigationApplication
8
8
  import dev.apexnative.navigation.nativescreen.NativeScreenCalls
9
+ import dev.apexnative.navigation.react.ReloadCoordinator
9
10
  import dev.apexnative.navigation.utils.NavigationLog
10
11
  import org.json.JSONArray
11
12
 
@@ -13,12 +14,26 @@ import org.json.JSONArray
13
14
  * Sends the events `ANN.<Name>` to the JavaScript of [reactContext] through React Native's device event emitter, in
14
15
  * the order they are sent: sent from the main thread only. Without a context, or when its React instance is gone, an
15
16
  * event is dropped with one line in the log. The names and the keys come from [EventContract], made from the types of
16
- * `src/interfaces/`. The core sends through [current]; modules keep making their own.
17
+ * `src/interfaces/`.
18
+ *
19
+ * An emitter belongs to one JavaScript, as on iOS (`ANNEventEmitter`): it takes the number of the JavaScript
20
+ * ([generation], `ReloadCoordinator`) when it is made and drops every event silently once that number moved. React
21
+ * Native keeps one context across a reload (bridgeless), so without it an event of a screen of the JavaScript that
22
+ * went would reach the new one, which may have a screen with the same id. The core and its modules send through
23
+ * [current], or [of] with the number of the JavaScript a node was made for.
17
24
  */
18
- class EventEmitter(private val reactContext: ReactContext?) {
19
- /** Whether an event sent now reaches JavaScript. */
25
+ class EventEmitter @JvmOverloads constructor(
26
+ private val reactContext: ReactContext?,
27
+ /** The number of the JavaScript this emitter sends to: by default the one that runs as it is made. */
28
+ val generation: Int = ReloadCoordinator.generation,
29
+ ) {
30
+ /** Whether the JavaScript of this emitter still runs: its events go only then. Any thread. */
31
+ val isCurrent: Boolean
32
+ get() = ReloadCoordinator.isCurrent(generation)
33
+
34
+ /** Whether an event sent now reaches JavaScript: its JavaScript still runs, in a live React instance. */
20
35
  val canSend: Boolean
21
- get() = reactContext?.hasActiveReactInstance() == true
36
+ get() = isCurrent && reactContext?.hasActiveReactInstance() == true
22
37
 
23
38
  fun emitAppLaunched() = emit(EventContract.AppLaunched.EVENT, Arguments.createMap())
24
39
 
@@ -167,6 +182,8 @@ class EventEmitter(private val reactContext: ReactContext?) {
167
182
  Arguments.createMap().apply { putString(EventContract.ScreenPopped.COMPONENT_ID, componentId) }
168
183
 
169
184
  private fun emit(name: String, data: WritableMap) {
185
+ // The JavaScript of this emitter went: the new one never had its screens.
186
+ if (!isCurrent) return
170
187
  val context = reactContext
171
188
  if (context == null || !context.hasActiveReactInstance()) {
172
189
  Log.w(NavigationLog.TAG, "Could not send event $name. React context is null!")
@@ -176,10 +193,20 @@ class EventEmitter(private val reactContext: ReactContext?) {
176
193
  }
177
194
 
178
195
  companion object {
179
- /** The sender to the JavaScript that runs now: the React context of the app's `ReactGateway`. */
196
+ /**
197
+ * The sender to the JavaScript that runs now: the React context of the app's `ReactGateway`. It goes silent
198
+ * once that JavaScript goes (a reload): one kept for later sends nothing to the next.
199
+ */
180
200
  @JvmStatic
181
- fun current(): EventEmitter =
182
- EventEmitter(NavigationApplication.instanceOrNull?.reactGatewayOrNull?.currentReactContext)
201
+ fun current(): EventEmitter = of(ReloadCoordinator.generation)
202
+
203
+ /**
204
+ * The sender to the JavaScript [generation] (a node's `jsGeneration`): the React context of the app's
205
+ * `ReactGateway` while that JavaScript runs, silent once it went, so an event of a screen made for the
206
+ * JavaScript that went never reaches the new one.
207
+ */
208
+ internal fun of(generation: Int): EventEmitter =
209
+ EventEmitter(NavigationApplication.instanceOrNull?.reactGatewayOrNull?.currentReactContext, generation)
183
210
 
184
211
  // The names of the events, for modules outside the library; inside it, [EventContract] has them with their
185
212
  // keys.
@@ -0,0 +1,154 @@
1
+ package dev.apexnative.navigation.react.events
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import dev.apexnative.navigation.react.ReloadCoordinator
6
+ import dev.apexnative.navigation.viewcontrollers.bottomtabs.BottomTabsController
7
+ import dev.apexnative.navigation.viewcontrollers.navigator.Navigators
8
+ import dev.apexnative.navigation.viewcontrollers.parent.ParentController
9
+ import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
10
+
11
+ /**
12
+ * The one place of the navigation state JavaScript hears (`ANN.NavigationStateChanged`): the screen the user sees and
13
+ * the selected tab, the last state sent, the update posted for a pass of the main loop and its holds. Every change of
14
+ * the screen the user sees comes with a screen appearing: a push, a pop, a modal, a tab, a menu.
15
+ *
16
+ * Two ways ask for it. The dispatcher asks for one update after the events of a pass ([setNeedsUpdate], coalesced and
17
+ * held while a `setRoot` shows the modals and the overlays of its layout). A command's answer sends it at once
18
+ * ([updateNow], never held), once per answer (`CommandAnswer.reportStateNow`): at the end of its transition, else
19
+ * right before its promise. Each goes only when the state differs from the last one sent, through the JavaScript that
20
+ * runs now. Main thread only.
21
+ */
22
+ internal object NavigationState : AppearanceDispatcher.StateUpdates {
23
+ /** What JavaScript hears: the screen the user sees (`null` for none) and the selected tab (`null` out of tabs). */
24
+ data class State(val screenId: String?, val screenName: String?, val tab: Int?)
25
+
26
+ private val handler by lazy { Handler(Looper.getMainLooper()) }
27
+
28
+ // The last state sent to the JavaScript that runs now; none before its first.
29
+ private var sent: State? = null
30
+
31
+ // The one posted update of a pass of the main loop, which checks the holds when it runs.
32
+ private val updates = Updates(
33
+ post = { task -> handler.post(task) },
34
+ generation = { ReloadCoordinator.generation },
35
+ update = ::updateNow,
36
+ )
37
+
38
+ override fun setNeedsUpdate() {
39
+ if (EventEmitter.current().canSend) updates.setNeedsUpdate()
40
+ }
41
+
42
+ override fun hold() = updates.hold()
43
+
44
+ override fun release() = updates.release()
45
+
46
+ /**
47
+ * Sends the state at once when it differs from the last one sent. A command's answer calls it once, at the end of
48
+ * its transition or right before its promise: the event and the resolution reach JavaScript in that order, so the
49
+ * state is new when the `await` of the command goes on. The update posted for the pass then finds nothing new.
50
+ */
51
+ override fun updateNow() {
52
+ val emitter = EventEmitter.current()
53
+ if (!emitter.canSend) return
54
+ // The navigation of the activity the commands run in (`Navigators.current`).
55
+ val navigator = Navigators.current()?.navigator ?: return
56
+ val next = stateOf(navigator.visibleLayout)
57
+ if (next == sent) return
58
+ sent = next
59
+ emitter.emitNavigationStateChanged(next.screenId, next.screenName, next.tab)
60
+ }
61
+
62
+ /** The new JavaScript has no state yet (`ANN.AppLaunched`): the next one goes to it whatever it is. */
63
+ fun reset() {
64
+ sent = null
65
+ }
66
+
67
+ /**
68
+ * The JavaScript went with its layouts: a hold its `setRoot` left, and an update wanted under it, go with it, so
69
+ * the new JavaScript's updates are never held by the old one's.
70
+ */
71
+ fun javaScriptWent() = updates.dropHolds()
72
+
73
+ // The top modal, or the root ([layout]), down to the screen the user sees; overlays don't count. The tab is the
74
+ // selected one of the nearest tabs the screen is in, the last met on the way down, in a modal too: the tabs
75
+ // `selectTab` acts on from it. None for a screen out of tabs.
76
+ internal fun stateOf(layout: ViewController<*>?): State {
77
+ var tab: Int? = null
78
+ var current = layout ?: return State(null, null, null)
79
+ while (current is ParentController<*>) {
80
+ if (current is BottomTabsController) tab = current.selectedIndex
81
+ current = current.currentChild ?: return State(null, null, null)
82
+ }
83
+ return State(current.id, current.currentComponentName, tab)
84
+ }
85
+
86
+ /**
87
+ * The posted update and its holds. [setNeedsUpdate] posts one update ([post]), coalesced: the asks of one pass of
88
+ * the main loop make one, which runs [update] in a later pass. While held, an ask, or a posted update that runs
89
+ * while held (asked before the hold was taken), only marks one as wanted, and the last release posts it. Released
90
+ * right before the answer, that update finds nothing new: the state before the answer is sent at once, never
91
+ * held, and an identical state is never sent twice. Every [hold] has one [release]; a release without a hold does
92
+ * nothing, so the count never goes below zero. An update keeps the JavaScript it was asked for ([generation]): one
93
+ * asked by a JavaScript that went before it runs is dropped; a newer ask of the new JavaScript still goes.
94
+ */
95
+ internal class Updates(
96
+ private val post: (() -> Unit) -> Unit,
97
+ private val generation: () -> Int = { 0 },
98
+ private val update: () -> Unit,
99
+ ) {
100
+ /** The holds not released yet. */
101
+ var count = 0
102
+ private set
103
+
104
+ // An update was asked while held.
105
+ private var wanted = false
106
+ // An update is posted and hasn't run yet.
107
+ private var scheduled = false
108
+ // The JavaScript the last ask was for.
109
+ private var askedIn = 0
110
+
111
+ /** Asks for one update in a later pass of the main loop; while held, once the last hold is released. */
112
+ fun setNeedsUpdate() {
113
+ askedIn = generation()
114
+ if (count > 0) wanted = true else schedule()
115
+ }
116
+
117
+ /** The updates asked from now on, and those posted that run from now on, wait for as many [release]s. */
118
+ fun hold() {
119
+ count++
120
+ }
121
+
122
+ /** One hold ends; with the last, the update asked while held is posted now. */
123
+ fun release() {
124
+ if (count == 0) return
125
+ count--
126
+ if (count > 0 || !wanted) return
127
+ wanted = false
128
+ schedule()
129
+ }
130
+
131
+ /**
132
+ * The holds and the update wanted under them go, unreleased: the JavaScript that took them went. An update
133
+ * already posted stays; it runs only for the JavaScript it was asked for.
134
+ */
135
+ fun dropHolds() {
136
+ count = 0
137
+ wanted = false
138
+ }
139
+
140
+ private fun schedule() {
141
+ if (scheduled) return
142
+ scheduled = true
143
+ post {
144
+ scheduled = false
145
+ // A hold taken since it was posted: the update waits for the release. The JavaScript it was asked for
146
+ // went: nothing goes to the new one, which hears its first state after its own changes.
147
+ when {
148
+ count > 0 -> wanted = true
149
+ askedIn == generation() -> update()
150
+ }
151
+ }
152
+ }
153
+ }
154
+ }
@@ -95,8 +95,9 @@ class SafeAreaView(reactContext: ThemedReactContext) : ReactViewGroup(reactConte
95
95
  val reactContext = context as? ReactContext ?: return
96
96
  val dispatcher = UIManagerHelper.getEventDispatcher(reactContext) ?: return
97
97
  readySent = true
98
- dispatcher.dispatchEvent(SafeAreaReadyEvent(UIManagerHelper.getSurfaceId(this), id))
99
- screenId?.let(ScreenReadiness::laidOut)
98
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
99
+ dispatcher.dispatchEvent(SafeAreaReadyEvent(surfaceId, id))
100
+ screenId?.let { ScreenReadiness.laidOut(it, surfaceId) }
100
101
  }
101
102
 
102
103
  // The navigation tells whether this view's screen waits: a component of a bar (title, button, background, tab)
@@ -111,12 +112,13 @@ class SafeAreaView(reactContext: ThemedReactContext) : ReactViewGroup(reactConte
111
112
  }
112
113
 
113
114
  // Once, by the screen's id: the container asks the screen's view for the layout React mounts the content without.
114
- // On attach too: content added before this view was attached is signalled then.
115
+ // On attach too: content added before this view was attached is signalled then. The signal names this view's
116
+ // surface: only the root that shows it takes it as its own.
115
117
  private fun signalContent() {
116
118
  if (contentSignalled) return
117
119
  val id = screenId ?: return
118
120
  contentSignalled = true
119
- ScreenReadiness.contentRendered(id)
121
+ ScreenReadiness.contentRendered(id, UIManagerHelper.getSurfaceId(this))
120
122
  }
121
123
 
122
124
  fun drop() {