@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-alpha.5](https://github.com/apexnative/navigation/compare/v1.0.0-alpha.4...v1.0.0-alpha.5) (2026-10-23)
4
+
5
+
6
+ ### Features
7
+
8
+ * android back options, a launch screen copy from UILaunchScreen, modal and back fixes ([#43](https://github.com/apexnative/navigation/issues/43)) ([29f311e](https://github.com/apexnative/navigation/commit/29f311e318134f284a0c6279e6c86e15612c9343))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **android:** send the state of a tab switch from code at its start ([#44](https://github.com/apexnative/navigation/issues/44)) ([34d03d0](https://github.com/apexnative/navigation/commit/34d03d0214cf09e14329d0bae51e47d46063f054))
14
+ * **hooks:** measure agent folder scopes from the worktree a file is in ([#42](https://github.com/apexnative/navigation/issues/42)) ([d306137](https://github.com/apexnative/navigation/commit/d306137534d3ba551fab8a7346c7ad7300c0faf7))
15
+
3
16
  ## [1.0.0-alpha.4](https://github.com/apexnative/navigation/compare/v1.0.0-alpha.3...v1.0.0-alpha.4) (2026-10-23)
4
17
 
5
18
 
package/android/CLAUDE.md CHANGED
@@ -25,7 +25,7 @@ top-level folder. Main thread only, unless a line says otherwise.
25
25
  `ReactView` (a React root), the stores (`ScreenInsets`, `ScreenRegions`, `ArrangementStates`, `Hinge`, `StartInfo`),
26
26
  `LaunchLink`, `ScrollEvents`.
27
27
  - `react/events/`: `AppearanceDispatcher`, `ScreenAppearance`, `EventEmitter`, `EventContract` (made by
28
- `scripts/contract.mjs`), `NavigationStateReporter`, `ComponentType`.
28
+ `scripts/contract.mjs`), `NavigationState`, `ComponentType`.
29
29
  - `viewcontrollers/viewcontroller/`: `ViewController` (the base of every node), `Presenter`, `IReactView` (what a
30
30
  screen shows), `ScrollEventListener`.
31
31
  - `viewcontrollers/child/`, `parent/`: `ChildController` and the registry; `ParentController` (the base of layouts),
@@ -105,7 +105,7 @@ top-level folder. Main thread only, unless a line says otherwise.
105
105
  - System bars: `system/SystemUiOwner.kt`, `SystemUiLayers.kt`, `SystemUiRules.kt`, `SystemUiWindow.kt`,
106
106
  `AppDirection.kt`; `viewcontrollers/parent/ContainerBackground.kt`.
107
107
  - Events and state: `react/events/AppearanceDispatcher.kt`, `ScreenAppearance.kt`, `EventEmitter.kt`,
108
- `NavigationStateReporter.kt`; `react/CommandAnswer.kt`.
108
+ `NavigationState.kt`; `react/CommandAnswer.kt`.
109
109
  - Options (parsing, merging): `options/Options.kt`, `OptionsReader.kt`, `OptionsCache.kt`, `DefaultOptions.kt`,
110
110
  `params/`, `parsers/`; `viewcontrollers/viewcontroller/ViewController.kt` (resolution).
111
111
  - Error texts and answers: `react/ErrorTexts.kt` and `react/CommandContract.kt`, made by `scripts/contract.mjs` from
@@ -172,6 +172,9 @@ top-level folder. Main thread only, unless a line says otherwise.
172
172
  `cancel` it on every exit.
173
173
  - A layer that waits to show must be a `WaitingLayer` and leave on every exit (show, supersede, destroy, root gone).
174
174
  - Never resolve a promise yourself: only `CommandAnswer` knows whether its JavaScript went and sends the state first.
175
+ - An event of a screen goes to the JavaScript the screen was made for: `EventEmitter.of(node.jsGeneration)`, or an
176
+ emitter kept from then (it goes silent after a reload). React Native keeps one context across a reload, so a sender
177
+ taken from the context at event time tells the new JavaScript about a screen it never had.
175
178
  - A signal that comes inside a layout pass or a view's attach must not move views there; the stack posts readiness
176
179
  (`deliver`).
177
180
  - A step that runs later (a timer, a readiness signal, an animator's end, a user callback) runs under no runner: it
@@ -206,6 +209,10 @@ top-level folder. Main thread only, unless a line says otherwise.
206
209
  - The back gesture's preview shows the screen under the top outside any operation of the queue: it may only reveal
207
210
  it and put it back, and its release goes through `StackQueue.cut(gesture = true)`. A command that comes meanwhile
208
211
  stops it through `StackDisplay.stop`, like any transition it takes over.
212
+ - What back does (`Navigator.canHandleBack`, `keepsBackFromSystem`) is computed on events, not before each frame: a
213
+ new path that changes it without a command (a gesture, an animator's end, a layout's switch) calls
214
+ `BackCallbackState.update`, or the system's back leaves the app where it should go back. Debug builds log a missed
215
+ one (`BackCallbackCheck`).
209
216
  - Without edge to edge the window resizes for the keyboard by the manifest's soft input mode, so
210
217
  `layout.adjustResize: false` goes through the system UI owner's keyboard group (`SystemUiWindow.setKeyboardCovers`),
211
218
  not through the screen's padding.
package/android/CORE.md CHANGED
@@ -31,7 +31,7 @@ coordinators: RootCoordinator · StackQueue (one per stack) · ModalCoordinator
31
31
  presenters: ComponentPresenter · StackPresenter + TopBarPresenter · BottomTabsPresenter · SideMenuPresenter ·
32
32
  SplitViewPresenter · system/SystemUiOwner (status bar, navigation bar, orientation, keyboard)
33
33
  ▼
34
- react/events/AppearanceDispatcher ─► NavigationStateReporter ─► EventEmitter ─► ANN.* events ─► JS
34
+ react/events/AppearanceDispatcher ─► NavigationState ─► EventEmitter ─► ANN.* events ─► JS
35
35
  ```
36
36
 
37
37
  - **Way in.** `NavigationTurboModule` only turns a method into a `CommandRunner` call; it holds no navigation logic. It
@@ -84,11 +84,12 @@ react/events/AppearanceDispatcher ─► NavigationStateReporter ─► EventEmi
84
84
  `AppearanceDispatcher.beginTransition`: **`ComponentWillAppear`** of Profile. The transition runs
85
85
  (`StackTransitions`, `SystemTransition`, or the screens' own animation, `AnimationRules.stackIsOwn`).
86
86
  8. **End.** `finishPush` calls `AppearanceDispatcher.endTransition`: **`ComponentDidDisappear`** of Home, then
87
- **`ComponentDidAppear`** of Profile, then `reportState` (**`NavigationStateChanged`**, only if it changed), then the
88
- transition's own events (none for a push), then `CommandAnswer.resolve` with Profile's id.
89
- 9. **Answer.** `resolve` sends the state once more if it changed (`AnswerEvents.beforeAnswer`), resolves the promise,
90
- then sends **`CommandCompleted`** (`CommandContract.COMPLETED`). After the task, `BackCallbackState.update` recomputes what
91
- back does.
87
+ **`ComponentDidAppear`** of Profile, then the answer's one state step (`CommandAnswer.reportStateNow`:
88
+ **`NavigationStateChanged`**, only if it changed), then the transition's own events (none for a push), then
89
+ `CommandAnswer.resolve` with Profile's id.
90
+ 9. **Answer.** `resolve` sends the state only when no transition sent it (`reportStateNow` runs once per answer: a
91
+ `setRoot` with layout modals sends it here), resolves the promise, then sends **`CommandCompleted`**
92
+ (`CommandContract.COMPLETED`). After the task, `BackCallbackState.update` recomputes what back does.
92
93
 
93
94
  The events go from the main thread, synchronously, through `EventEmitter.current()`; the promise is answered once.
94
95
 
@@ -127,13 +128,16 @@ Each rule has one owner. Change the owner, not a caller; if a new feature needs
127
128
  `beginTransition` starts the appearances; `endTransition` runs what left, then what came, then the state, then the
128
129
  transition's events, then the answer. Events asked before a screen's content is ready are held in its
129
130
  `ScreenAppearance` (until the content mounts or `HOLD_LIMIT_MILLIS`).
130
- - **Navigation state.** `AppearanceDispatcher.state` (`StateUpdates`) is the one way to ask for it: `StateHold`
131
- coalesces the asks to one posted update per pass of the main loop and keeps it while held; the update calls
132
- `NavigationStateReporter.updateNow`. `updateNow` also runs before every answer of a command that changes what shows
133
- (`CommandAnswer`) and is never held. It reads the navigator through `Navigators.current()`, and `stateOf` finds the
131
+ - **Navigation state.** `NavigationState` owns it: the last state sent, the walk (`stateOf`), the posted update and its
132
+ holds (`NavigationState.Updates`). `AppearanceDispatcher.state` (`StateUpdates`) is the one way to ask for it:
133
+ `setNeedsUpdate` coalesces the asks to one posted update per pass of the main loop and keeps it while held; the
134
+ update calls `updateNow`. A command's answer sends it once, at once and never held (`CommandAnswer.reportStateNow`):
135
+ at the end of its transition, else right before its promise for the commands that change what shows; nothing for a
136
+ JavaScript that went. It reads the navigator through `Navigators.current()`, and `stateOf` finds the
134
137
  `tab` as the nearest tabs of the visible screen (the last tabs met walking down from the visible layout). A `setRoot`
135
138
  with layout modals or overlays takes `AppearanceDispatcher.StateUpdates.hold()` and releases it right before its
136
- answer, so JavaScript hears one state; every hold has one `release()`.
139
+ answer, so JavaScript hears one state; every hold has one `release()`. The holds go when the JavaScript goes
140
+ (`NavigationState.javaScriptWent`), so one left by its `setRoot` never holds the next JavaScript's updates.
137
141
  - **Answer.** `CommandAnswer`: resolves or rejects once, and nothing once the JavaScript went. The id a stack entry
138
142
  answers with is one rule, `StackController.answerIdOf` (the queue asks it through `StackQueue.Host.answerIdOf` with
139
143
  an `AnswerKind`): `push` answers the entry's own id (a pair's), every other command the screen it stands for (a
@@ -141,7 +145,11 @@ Each rule has one owner. Change the owner, not a caller; if a new feature needs
141
145
  - **Stack queue.** `StackQueue`: every command, back and return of a container goes through it; it keeps `entries`
142
146
  (what JavaScript sees) and `shown` (what the container shows) and answers once.
143
147
  - **Screen readiness.** `ScreenReadiness` (by id; written by `SafeAreaView`) and `ContentReadiness` (the `Readiness`
144
- the waits ask, through `IReactView.isReady`).
148
+ the waits ask, through `IReactView.isReady`). As on iOS, the signals are kept (a listener that comes later hears them
149
+ at once), an id has a listener per key (several roots of one bar component), and each signal carries the React
150
+ surface it came from: a `ReactView` takes only its own surface's, so an old root with the same id never makes a new
151
+ one ready. An id renders at once while a root marks it (`ReactView`'s `rendersAtOnce`); the last one takes the id's
152
+ signals with it. A screen's id is cleared as the screen is made and destroyed, everything on reload.
145
153
  - **Render wait.** `RenderWait`: the one wait for screens to render, used by `RootCoordinator`, `ModalCoordinator`,
146
154
  `OverlayCoordinator` and `StackQueue`; `RenderWait.waitedScreens` walks `ParentController.waitedChildren()` at every
147
155
  level. What differs between users are values (limit, bar phase, unknown-content rule), never steps.
@@ -171,7 +179,8 @@ Each rule has one owner. Change the owner, not a caller; if a new feature needs
171
179
  - **Back.** `NavigationActivity.passBack` → `ReactGateway.onBackPressed` (JavaScript's `BackHandler`) →
172
180
  `invokeDefaultOnBackPressed` → `Navigator.handleBack` → `ModalCoordinator.handleBack` → the root's
173
181
  `ParentController.handleBack` → `StackController.handleBack` → `StackQueue.cut`. `BackCallbackState` keeps the
174
- system callback enabled only while back has something to do.
182
+ system callback enabled only while back has something to do, computed again on the events that change it (its KDoc
183
+ lists them), never before each frame; debug builds check it (`BackCallbackCheck`).
175
184
  - **Error texts.** `ErrorTexts`: one function per text, the same words as iOS, made with iOS's from
176
185
  `src/interfaces/contract/errors.json` (`scripts/contract.mjs`). JavaScript adds `[navigation] <command>:`.
177
186
  - **Event names and keys.** `EventContract`, made from the event types of `src/interfaces/`; `EventEmitter` and the
@@ -201,7 +210,7 @@ Modules are the feature classes that plug into the core through its contract (th
201
210
  (`splash/`), animations (`FadeThrough`, `SystemTransition`, `TransitionTargets`, three element animators), utils
202
211
  (`MaterialTheme`, `NavigationLog`, `awaitBarComponents`, `whenDrawn`).
203
212
  - **Stores and singletons:** regions (`Hinge`, `ScreenInsets`, `ScreenRegions`, `WindowFolds`), pair state
204
- (`ArrangementStates`), links (`LaunchLink`, `StartInfo`), events (`NavigationStateReporter`), commands
213
+ (`ArrangementStates`), links (`LaunchLink`, `StartInfo`), events (`NavigationState`), commands
205
214
  (`BackCallbackState`), stack (`PreparedFrame`).
206
215
 
207
216
  How they plug in:
@@ -226,4 +235,5 @@ How they plug in:
226
235
  - **Core code in a module's folder:** `viewcontrollers/splitview/SplitViewPresenter.kt`, `slots/SlotScreenView.kt`,
227
236
  and in `views/animations/` and `views/element/` everything except the files named above.
228
237
  - **Stores** are written on the main thread by the core and read from any thread; they are cleared with their nodes and on
229
- reload.
238
+ reload. Their events, and the search's, go through the emitter of the JavaScript the screen was made for
239
+ (`EventEmitter.of(node.jsGeneration)`); the hinge keeps the `EventEmitter.current()` of the call that turned it on.
@@ -35,6 +35,7 @@ import dev.apexnative.navigation.splash.SplashTheme
35
35
  import dev.apexnative.navigation.splash.SplashView
36
36
  import dev.apexnative.navigation.system.SystemUiWindow
37
37
  import dev.apexnative.navigation.utils.MaterialTheme
38
+ import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackCheck
38
39
  import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackState
39
40
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
40
41
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigators
@@ -75,6 +76,7 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
75
76
  private val backCallback = object : OnBackPressedCallback(false) {
76
77
  override fun handleOnBackStarted(backEvent: BackEventCompat) {
77
78
  backGesture = null
79
+ navigator?.let { backCheck?.atBack(it) }
78
80
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || BackCallbackState.hasJsListeners) return
79
81
  val stack = navigator?.stackPoppedByBack() ?: return
80
82
  backGesture = stack.startBackGesture(backEvent.swipeEdge == BackEventCompat.EDGE_LEFT, backEvent.touchY)
@@ -87,9 +89,11 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
87
89
  override fun handleOnBackCancelled() {
88
90
  backGesture?.cancel()
89
91
  backGesture = null
92
+ navigator?.let(BackCallbackState::update)
90
93
  }
91
94
 
92
95
  override fun handleOnBackPressed() {
96
+ navigator?.let { backCheck?.atBack(it) }
93
97
  val gesture = backGesture
94
98
  backGesture = null
95
99
  if (gesture?.commit() == true) BackCallbackState.update() else passBack()
@@ -99,10 +103,12 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
99
103
  // The back gesture that previews a pop now; none.
100
104
  private var backGesture: StackController.BackGesture? = null
101
105
 
102
- // Before each frame of the window: what back does may have changed, and the system decides at the start of a
103
- // gesture whether the app takes it.
104
- private val backBeforeFrame = ViewTreeObserver.OnPreDrawListener {
105
- navigator?.let(BackCallbackState::update)
106
+ // Debug builds only: the check that what back does never changes without its trigger (BackCallbackState), sampled
107
+ // before frames and at back events. It only logs; release builds have neither the check nor the listener.
108
+ private val backCheck = if (BuildConfig.DEBUG) BackCallbackCheck() else null
109
+ private val backCheckBeforeFrame = ViewTreeObserver.OnPreDrawListener {
110
+ val navigator = navigator
111
+ if (navigator != null) backCheck?.sample(navigator, SystemClock.uptimeMillis())
106
112
  true
107
113
  }
108
114
  private var frameObserver: ViewTreeObserver? = null
@@ -143,7 +149,9 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
143
149
  SlotHost.attachIfStarted(this)
144
150
  reactGateway.onActivityCreated(this)
145
151
  onBackPressedDispatcher.addCallback(this, backCallback)
146
- frameObserver = window.decorView.viewTreeObserver.also { it.addOnPreDrawListener(backBeforeFrame) }
152
+ if (backCheck != null) {
153
+ frameObserver = window.decorView.viewTreeObserver.also { it.addOnPreDrawListener(backCheckBeforeFrame) }
154
+ }
147
155
  // The activity's system UI: its owner remembers the theme's colors and the manifest's orientation now, before
148
156
  // anything changes them.
149
157
  navigator.systemUi.start(SystemUiWindow.of(this))
@@ -218,6 +226,8 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
218
226
  isNavigationResumed = true
219
227
  // The root, the modals and the overlays lay out again; nothing is sent.
220
228
  navigator?.onResume()
229
+ // A safety net: what back does changes on its own triggers, and the system asks the callback from here on.
230
+ navigator?.let(BackCallbackState::update)
221
231
  if (developerSupport() != null) reloadBroadcast.register()
222
232
  ReloadCoordinator.launchIfDue(this)
223
233
  }
@@ -239,6 +249,8 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
239
249
  override fun onWindowFocusChanged(hasFocus: Boolean) {
240
250
  super.onWindowFocusChanged(hasFocus)
241
251
  if (reactGateway.currentReactContext != null) reactGateway.host.onWindowFocusChange(hasFocus)
252
+ // A safety net, as at onResume: back after a dialog or another window over the activity.
253
+ if (hasFocus) navigator?.let(BackCallbackState::update)
242
254
  }
243
255
 
244
256
  override fun onUserLeaveHint() {
@@ -302,9 +314,11 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
302
314
  splashHold = null
303
315
  reloadBroadcast.release()
304
316
  navigator?.let(BackCallbackState::detach)
305
- // The window's observer now, and the one the listener was added to, if they differ.
306
- val observers = listOfNotNull(frameObserver, window?.peekDecorView()?.viewTreeObserver).distinct()
307
- observers.forEach { if (it.isAlive) it.removeOnPreDrawListener(backBeforeFrame) }
317
+ // The debug check's listener: from the window's observer now, and the one it was added to, if they differ.
318
+ if (backCheck != null) {
319
+ val observers = listOfNotNull(frameObserver, window?.peekDecorView()?.viewTreeObserver).distinct()
320
+ observers.forEach { if (it.isAlive) it.removeOnPreDrawListener(backCheckBeforeFrame) }
321
+ }
308
322
  frameObserver = null
309
323
  SlotHost.detach(this)
310
324
  navigator?.destroyWithActivity()
@@ -317,7 +331,9 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
317
331
  /**
318
332
  * Back that JavaScript's listeners didn't take: the navigation does its step first — a stack's top leaves or holds,
319
333
  * a pane closes —; with nothing to do there, the activity's own back, as without the navigation. An app may
320
- * override it (e.g. `moveTaskToBack(true)`).
334
+ * override it (e.g. `moveTaskToBack(true)`). The callback is off for that back only: the dispatcher's own back is
335
+ * the activity's, not [onBackPressed]; then it is computed again, and `hardwareBackButton.systemAnimation: false`
336
+ * turns it on again.
321
337
  */
322
338
  override fun invokeDefaultOnBackPressed() {
323
339
  val handled = navigator?.handleBack(CommandListenerAdapter()) == true
@@ -8,7 +8,7 @@ import com.facebook.react.bridge.ReadableMap
8
8
  */
9
9
  class AnimationsOptions {
10
10
  @JvmField var push = StackPushAnimationOptions()
11
- @JvmField var pop = StackAnimationOptions()
11
+ @JvmField var pop = StackPopAnimationOptions()
12
12
  @JvmField var setStackRoot = SetStackRootAnimationOptions()
13
13
  @JvmField var setRoot = EnterExitAnimationOptions()
14
14
  @JvmField var showModal = ModalAnimationOptions()
@@ -43,7 +43,7 @@ class AnimationsOptions {
43
43
  val options = AnimationsOptions()
44
44
  if (map == null) return options
45
45
  options.push = map.group("push") { StackPushAnimationOptions.parse(it) }
46
- options.pop = map.group("pop") { StackAnimationOptions.parse(it) }
46
+ options.pop = map.group("pop") { StackPopAnimationOptions.parse(it) }
47
47
  options.setStackRoot = map.group("setStackRoot") { SetStackRootAnimationOptions.parse(it) }
48
48
  options.setRoot = map.group("setRoot") { EnterExitAnimationOptions.parse(it) }
49
49
  options.showModal = map.group("showModal") { ModalAnimationOptions.parse(it) }
@@ -15,20 +15,27 @@ class HardwareBackButtonOptions {
15
15
  @JvmField var popStackOnPress: Bool = NullBool()
16
16
  /** Internal. */
17
17
  @JvmField var dismissModalOnPress: Bool = NullBool()
18
+ /**
19
+ * `systemAnimation`: `false` keeps back in the app on the first screen too, so the system doesn't play its
20
+ * animation of going home; the press then does the activity's own back.
21
+ */
22
+ @JvmField var systemAnimation: Bool = NullBool()
18
23
 
19
24
  fun hasValue(): Boolean = bottomTabsOnPress.hasValue() || popStackOnPress.hasValue() ||
20
- dismissModalOnPress.hasValue()
25
+ dismissModalOnPress.hasValue() || systemAnimation.hasValue()
21
26
 
22
27
  fun mergeWith(other: HardwareBackButtonOptions) {
23
28
  if (other.bottomTabsOnPress.hasValue()) bottomTabsOnPress = other.bottomTabsOnPress
24
29
  if (other.popStackOnPress.hasValue()) popStackOnPress = other.popStackOnPress
25
30
  if (other.dismissModalOnPress.hasValue()) dismissModalOnPress = other.dismissModalOnPress
31
+ if (other.systemAnimation.hasValue()) systemAnimation = other.systemAnimation
26
32
  }
27
33
 
28
34
  fun mergeWithDefault(defaults: HardwareBackButtonOptions) {
29
35
  if (!bottomTabsOnPress.hasValue()) bottomTabsOnPress = defaults.bottomTabsOnPress
30
36
  if (!popStackOnPress.hasValue()) popStackOnPress = defaults.popStackOnPress
31
37
  if (!dismissModalOnPress.hasValue()) dismissModalOnPress = defaults.dismissModalOnPress
38
+ if (!systemAnimation.hasValue()) systemAnimation = defaults.systemAnimation
32
39
  }
33
40
 
34
41
  fun copy() = HardwareBackButtonOptions().also { it.mergeWith(this) }
@@ -43,6 +50,7 @@ class HardwareBackButtonOptions {
43
50
  options.bottomTabsOnPress = TextParser.parse(map, "bottomTabsOnPress", BOTTOM_TABS_ON_PRESS)
44
51
  options.popStackOnPress = BoolParser.parse(map, "popStackOnPress")
45
52
  options.dismissModalOnPress = BoolParser.parse(map, "dismissModalOnPress")
53
+ options.systemAnimation = BoolParser.parse(map, "systemAnimation")
46
54
  return options
47
55
  }
48
56
  }
@@ -99,8 +99,8 @@ class BottomTabsEnterExitAnimationOptions {
99
99
  }
100
100
 
101
101
  /**
102
- * `StackAnimationOptions`: the animation of `pop`, and the base of `push` and `setStackRoot`; over the shared elements
103
- * and element transitions of [LayoutAnimation].
102
+ * `StackAnimationOptions`: the base of the animations of `push`, `pop` and `setStackRoot`; over the shared elements and
103
+ * element transitions of [LayoutAnimation].
104
104
  */
105
105
  open class StackAnimationOptions : LayoutAnimation() {
106
106
  @JvmField var enabled: Bool = NullBool()
@@ -188,6 +188,44 @@ class StackPushAnimationOptions : StackAnimationOptions() {
188
188
  }
189
189
  }
190
190
 
191
+ /**
192
+ * `StackPopAnimationOptions`: the animation of `pop`, and `preview`: whether a back gesture over the stack previews the
193
+ * pop of its top (Android 14+); `false` pops it when the gesture is released, as on Android 13.
194
+ */
195
+ class StackPopAnimationOptions : StackAnimationOptions() {
196
+ @JvmField var preview: Bool = NullBool()
197
+
198
+ override fun hasValue(): Boolean = super.hasValue() || preview.hasValue()
199
+
200
+ fun mergeWith(other: StackPopAnimationOptions) {
201
+ super.mergeWith(other)
202
+ if (other.preview.hasValue()) preview = other.preview
203
+ }
204
+
205
+ fun mergeWithDefault(defaults: StackPopAnimationOptions) {
206
+ super.mergeWithDefault(defaults)
207
+ if (!preview.hasValue()) preview = defaults.preview
208
+ }
209
+
210
+ override fun copy(): StackPopAnimationOptions = StackPopAnimationOptions().also { it.mergeWith(this) }
211
+
212
+ // Reads the fields of this class from [map], after those of its parent.
213
+ private fun readStackPopAnimationOptions(map: ReadableMap) {
214
+ readStackAnimationOptions(map)
215
+ preview = BoolParser.parse(map, "preview")
216
+ }
217
+
218
+ companion object {
219
+ @JvmStatic
220
+ fun parse(map: ReadableMap?): StackPopAnimationOptions {
221
+ val options = StackPopAnimationOptions()
222
+ if (map == null) return options
223
+ options.readStackPopAnimationOptions(map)
224
+ return options
225
+ }
226
+ }
227
+ }
228
+
191
229
  /** `SetStackRootAnimationOptions`: the animation of `setStackRoot`. */
192
230
  class SetStackRootAnimationOptions : StackAnimationOptions() {
193
231
  @JvmField var asPush: Bool = NullBool()
@@ -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
@@ -19,11 +17,13 @@ internal object ArrangementStates {
19
17
 
20
18
  private val states = ConcurrentHashMap<String, State>()
21
19
 
22
- /** Keeps the screen's state, and tells JavaScript when it changed and [notify] holds. Main thread only. */
23
- fun set(context: Context, screenId: String, state: State, notify: Boolean) {
20
+ /**
21
+ * Keeps the screen's state, and tells JavaScript when it changed and [notify] holds, through [emitter], the one of
22
+ * the JavaScript the screen was made for: silent once it went. Main thread only.
23
+ */
24
+ fun set(screenId: String, state: State, notify: Boolean, emitter: EventEmitter) {
24
25
  if (states.put(screenId, state) == state || !notify) return
25
- val reactContext = (context.applicationContext as? ReactApplication)?.reactHost?.currentReactContext ?: return
26
- EventEmitter(reactContext).emitArrangementChanged(screenId, toMap(state))
26
+ emitter.emitArrangementChanged(screenId, toMap(state))
27
27
  }
28
28
 
29
29
  /** `{ placement, layout, front, hidden }`, empty for a screen outside a pair. Any thread. */
@@ -3,13 +3,14 @@ package dev.apexnative.navigation.react
3
3
  import android.util.Log
4
4
  import com.facebook.react.bridge.Promise
5
5
  import dev.apexnative.navigation.react.events.EventEmitter
6
- import dev.apexnative.navigation.react.events.NavigationStateReporter
6
+ import dev.apexnative.navigation.react.events.NavigationState
7
7
  import dev.apexnative.navigation.utils.NavigationLog
8
8
 
9
9
  /**
10
10
  * The one answer of a command with a promise: [resolve] or [reject], once; after that, and once the JavaScript it came
11
- * from went ([generation]), nothing. [CommandRunner] makes it on the main thread for each command; area code that takes
12
- * a [CommandListener] answers through [asListener]. Main thread only.
11
+ * from went ([generation]), nothing. It owns the command's one state step ([reportStateNow]). [CommandRunner] makes it
12
+ * on the main thread for each command; area code that takes a [CommandListener] answers through [asListener]. Main
13
+ * thread only.
13
14
  */
14
15
  internal class CommandAnswer(
15
16
  /** The method of the command: `push`, `setRoot`, … */
@@ -27,19 +28,36 @@ internal class CommandAnswer(
27
28
  var isAnswered = false
28
29
  private set
29
30
 
31
+ // The state step of this answer ran ([reportStateNow]).
32
+ private var stateReported = false
33
+
30
34
  /** Whether an answer now would reach the JavaScript the command came from. */
31
35
  val canAnswer: Boolean
32
36
  get() = !isAnswered && ReloadCoordinator.isCurrent(generation)
33
37
 
34
38
  /**
35
- * Resolves the promise with [value]. For the commands that change what shows, the navigation state goes first,
36
- * when it changed, so JavaScript has it when its `await` goes on; `ANN.CommandCompleted` follows the promise, for
37
- * the commands that show or dismiss. A failure of either is logged; the promise goes all the same.
39
+ * The navigation state now, sent when it changed, once per answer: the end of the command's transition asks for it
40
+ * after the DidAppear it sends and before the transition's own events (`AppearanceDispatcher.endTransition`);
41
+ * otherwise [resolve] asks for it before the promise. A later call sends nothing, and nothing goes for a JavaScript
42
+ * that went. A failure of the step is logged and costs only the state.
43
+ */
44
+ fun reportStateNow() {
45
+ if (stateReported) return
46
+ stateReported = true
47
+ if (!ReloadCoordinator.isCurrent(generation)) return
48
+ guarded("report the navigation state") { events.stateNow() }
49
+ }
50
+
51
+ /**
52
+ * Resolves the promise with [value]. For the commands that change what shows (`CommandContract.STATE`), the
53
+ * navigation state goes first, when it changed and the command's transition didn't send it already
54
+ * ([reportStateNow]), so JavaScript has it when its `await` goes on; `ANN.CommandCompleted` follows the promise,
55
+ * for the commands that show or dismiss. A failure of either is logged; the promise goes all the same.
38
56
  */
39
57
  fun resolve(value: Any?) {
40
58
  if (!canAnswer) return
41
59
  isAnswered = true
42
- if (commandName in CommandContract.STATE) guarded("report the navigation state") { events.beforeAnswer() }
60
+ if (commandName in CommandContract.STATE) reportStateNow()
43
61
  promise.resolve(value)
44
62
  val commandId = commandId
45
63
  if (commandName in CommandContract.COMPLETED && commandId != null) {
@@ -69,18 +87,15 @@ internal class CommandAnswer(
69
87
 
70
88
  /** What goes to JavaScript around the answer of a command. Main thread only. */
71
89
  internal interface AnswerEvents {
72
- /** Right before the promise resolves: the navigation state, when it changed. */
73
- fun beforeAnswer()
90
+ /** The answer's one state step ([CommandAnswer.reportStateNow]): the navigation state now, when it changed. */
91
+ fun stateNow()
74
92
 
75
93
  /** After the promise resolved: `ANN.CommandCompleted`. */
76
94
  fun commandCompleted(commandName: String, commandId: String, completionTime: Long)
77
95
 
78
96
  /** Through the sender of the JavaScript that runs now. */
79
97
  object Current : AnswerEvents {
80
- override fun beforeAnswer() {
81
- val emitter = EventEmitter.current()
82
- if (emitter.canSend) NavigationStateReporter.updateNow(emitter)
83
- }
98
+ override fun stateNow() = NavigationState.updateNow()
84
99
 
85
100
  override fun commandCompleted(commandName: String, commandId: String, completionTime: Long) {
86
101
  EventEmitter.current().emitCommandCompleted(commandName, commandId, completionTime)
@@ -9,7 +9,6 @@ import android.os.Build
9
9
  import androidx.lifecycle.DefaultLifecycleObserver
10
10
  import androidx.lifecycle.Lifecycle
11
11
  import androidx.lifecycle.LifecycleOwner
12
- import com.facebook.react.ReactApplication
13
12
  import com.facebook.react.bridge.Arguments
14
13
  import com.facebook.react.bridge.WritableMap
15
14
  import dev.apexnative.navigation.react.events.EventContract
@@ -22,7 +21,8 @@ import java.lang.ref.WeakReference
22
21
  * sensor (Android 11+), in degrees to a tenth as on iOS. Android has no public state of a closed device: closed is a
23
22
  * window without a fold, or an angle under 90° ([valueOf]). No sensor and no fold: no hinge, `{}`. It runs while
24
23
  * JavaScript has a hook mounted (`setHingeUpdates`), the sensor only while the activity is resumed too; the same value
25
- * is not sent twice. Main thread only, but [get].
24
+ * is not sent twice. The values go to the JavaScript that turned the updates on (`EventEmitter.current` then): after a
25
+ * reload, until the updates stop, they go nowhere. Main thread only, but [get].
26
26
  */
27
27
  internal object Hinge {
28
28
  /** A value of the hinge: its state, and its angle in degrees to a tenth; none while the state is unknown. */
@@ -33,6 +33,8 @@ internal object Hinge {
33
33
  private const val CLOSED_BELOW = 90f
34
34
 
35
35
  private var enabled = false
36
+ // The sender of the JavaScript that turned the updates on; none while they are off.
37
+ private var emitter: EventEmitter? = null
36
38
  private var activity: WeakReference<Activity>? = null
37
39
  private var folds: WindowFolds? = null
38
40
  private var sensorManager: SensorManager? = null
@@ -63,9 +65,11 @@ internal object Hinge {
63
65
  fun setEnabled(activity: Activity?, enabled: Boolean) {
64
66
  this.enabled = enabled
65
67
  if (!enabled) {
68
+ emitter = null
66
69
  detach()
67
70
  return
68
71
  }
72
+ emitter = EventEmitter.current()
69
73
  activity?.let(::attachTo)
70
74
  update()
71
75
  }
@@ -134,8 +138,7 @@ internal object Hinge {
134
138
  val value = valueOf(folds.folds.firstOrNull(), sensor != null, angle)
135
139
  if (value == last) return
136
140
  last = value
137
- val reactContext = (activity?.get()?.application as? ReactApplication)?.reactHost?.currentReactContext ?: return
138
- EventEmitter(reactContext).emitHingeChanged(toMap(value))
141
+ emitter?.emitHingeChanged(toMap(value))
139
142
  }
140
143
 
141
144
  /**