@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
@@ -270,8 +270,8 @@ class StackController(
270
270
 
271
271
  /**
272
272
  * The default pop of the top is what back does now and it can be previewed: two entries or more, nothing running
273
- * or waiting, no open search, the top doesn't hold back and pops with the default transition, the screen under it
274
- * has its view, and the stack shows.
273
+ * or waiting, no open search, the top doesn't hold back, pops with the default transition and doesn't turn the
274
+ * preview off (`animations.pop.preview: false`), the screen under it has its view, and the stack shows.
275
275
  */
276
276
  override fun stackPoppedByBack(): StackController? {
277
277
  if (topBar.isSearchOpen() || !queue.isAtRest) return null
@@ -282,9 +282,8 @@ class StackController(
282
282
  if (!queue.isRendered(target) || backGesture != null) return null
283
283
  val options = resolvedOf(top)
284
284
  val pop = options.animations.pop
285
- if (options.hardwareBackButton.popStackOnPress.isFalse || pop.enabled.isFalse || AnimationRules.stackIsOwn(pop)) {
286
- return null
287
- }
285
+ if (options.hardwareBackButton.popStackOnPress.isFalse || pop.enabled.isFalse) return null
286
+ if (pop.preview.isFalse || AnimationRules.stackIsOwn(pop)) return null
288
287
  val display = queue.display
289
288
  return this.takeIf { display.isOnScreen && display.canAnimate }
290
289
  }
@@ -694,15 +693,20 @@ class StackController(
694
693
  private fun optionsScreen(node: ViewController<*>): ViewController<*> =
695
694
  (node as? OptionsScreen)?.optionsScreen ?: node
696
695
 
696
+ // What back does is computed again on each change of the queue (BackCallbackState): its entries change as an
697
+ // operation is accepted, the screen whose options count as its current child changes (a push that waited for its
698
+ // screen begins after its command), and an operation ends or rejects.
697
699
  private inner class QueueHost : StackQueue.Host {
698
700
  override fun operationAccepted() {
699
701
  backGesture?.abort()
702
+ BackCallbackState.update()
700
703
  }
701
704
 
702
705
  // The bar takes the options of the new top at once, also without appearance events.
703
706
  override fun currentChildChanged() {
704
707
  updateBar()
705
708
  navigator?.shownScreensChanged(this@StackController)
709
+ BackCallbackState.update()
706
710
  }
707
711
 
708
712
  // Before the bar takes the new top's options: the row's picture or the bar's travel, and how it shows or hides.
@@ -36,6 +36,8 @@ import dev.apexnative.navigation.views.stack.topbar.search.SuggestionList
36
36
  class ScreenSearch(
37
37
  private val host: Host,
38
38
  private val screenId: String,
39
+ /** The sender of the JavaScript the screen was made for: silent once it went. */
40
+ private val emitter: EventEmitter,
39
41
  private val context: Context,
40
42
  private val loadIcon: (String) -> Drawable?,
41
43
  /** A scope the user picks goes into the screen's options, so it stays when they apply again. */
@@ -45,7 +47,6 @@ class ScreenSearch(
45
47
  /** The search changed what the bar shows (open or closed, its text): shown again if it is the shown one. */
46
48
  fun refresh(search: ScreenSearch)
47
49
  fun isShown(search: ScreenSearch): Boolean
48
- fun emitter(): EventEmitter?
49
50
  fun keyboard(view: View, show: Boolean)
50
51
  /** The stack's view: a search view goes over it. */
51
52
  fun stackView(): ViewGroup?
@@ -402,7 +403,7 @@ class ScreenSearch(
402
403
 
403
404
  // A pick sends searchSuggestionPicked with the suggestion's id; the text stays.
404
405
  private fun pickSuggestion(suggestionId: String) {
405
- host.emitter()?.emitSearchSuggestionPicked(screenId, suggestionId)
406
+ emitter.emitSearchSuggestionPicked(screenId, suggestionId)
406
407
  setSuggestions(emptyList())
407
408
  }
408
409
 
@@ -426,18 +427,15 @@ class ScreenSearch(
426
427
  private fun sendUpdate(
427
428
  focused: Boolean = isActive && (panel?.searchView?.editText ?: searchField.editText).hasFocus(),
428
429
  ) {
429
- val emitter = host.emitter() ?: return
430
430
  eventTargets().forEach { emitter.emitSearchBarUpdated(it, text, focused, scope()) }
431
431
  }
432
432
 
433
433
  private fun sendCancel() {
434
- val emitter = host.emitter() ?: return
435
434
  eventTargets().forEach { emitter.emitSearchBarCancelPressed(it) }
436
435
  sendUpdate()
437
436
  }
438
437
 
439
438
  private fun sendSubmit() {
440
- val emitter = host.emitter() ?: return
441
439
  eventTargets().forEach { emitter.emitSearchBarSubmitted(it, text, scope()) }
442
440
  }
443
441
 
@@ -12,7 +12,6 @@ import androidx.appcompat.content.res.AppCompatResources
12
12
  import androidx.coordinatorlayout.widget.CoordinatorLayout
13
13
  import androidx.core.view.WindowCompat
14
14
  import androidx.core.view.WindowInsetsCompat
15
- import com.facebook.react.ReactApplication
16
15
  import dev.apexnative.navigation.options.SearchBarOptions
17
16
  import dev.apexnative.navigation.options.params.ThemeColour
18
17
  import dev.apexnative.navigation.react.events.EventEmitter
@@ -137,9 +136,6 @@ class StackSearchPresenter(
137
136
  refresh()
138
137
  }
139
138
 
140
- override fun emitter(): EventEmitter? =
141
- (activity.application as? ReactApplication)?.reactHost?.currentReactContext?.let { EventEmitter(it) }
142
-
143
139
  override fun keyboard(view: View, show: Boolean) {
144
140
  val controller = WindowCompat.getInsetsController(activity.window, view)
145
141
  if (show) controller.show(WindowInsetsCompat.Type.ime()) else controller.hide(WindowInsetsCompat.Type.ime())
@@ -150,7 +146,8 @@ class StackSearchPresenter(
150
146
  // The search of a pair of screens is its primary's: its events name that screen, which keeps the scope in
151
147
  // its user layer, where a merge that sends the same options again doesn't take it away.
152
148
  val owner = (screen as? OptionsScreen)?.optionsScreen ?: screen
153
- ScreenSearch(this, owner.id, styleContext(), loadIcon) { scope ->
149
+ // Its events go to the JavaScript the screen was made for, and to no other.
150
+ ScreenSearch(this, owner.id, EventEmitter.of(owner.jsGeneration), styleContext(), loadIcon) { scope ->
154
151
  owner.changeChoices { it.pickScope(scope) }
155
152
  }
156
153
  }
@@ -5,7 +5,6 @@ import dev.apexnative.navigation.options.Options
5
5
  import dev.apexnative.navigation.options.TitleComponentOptions
6
6
  import dev.apexnative.navigation.options.TopBarBackgroundComponentOptions
7
7
  import dev.apexnative.navigation.options.params.Text
8
- import dev.apexnative.navigation.viewcontrollers.component.ScreenReadiness
9
8
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.IReactView
10
9
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
11
10
 
@@ -15,7 +14,8 @@ import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
15
14
  * same root, never made again; another replaces it, and the old one stops at once. A background with the same `name`
16
15
  * and `componentId` on several screens is one root, shared; it stops when no screen of the stack names it any more. The
17
16
  * roots go with their screen ([screenDestroyed]) or the stack ([destroyAll]). Each root renders at once, without
18
- * waiting for its layout: [makeRoot] marks its id so before its surface starts. Main thread only.
17
+ * waiting for its layout: [makeRoot] makes it so (`ReactView`'s `rendersAtOnce`), and each answers by its own render
18
+ * when several live roots show one id (`ScreenReadiness`). Main thread only.
19
19
  */
20
20
  internal class BarComponents(private val makeRoot: (componentId: String, name: String) -> IReactView) {
21
21
  /** A root of the bar: what it renders, and its content. */
@@ -151,18 +151,10 @@ internal class BarComponents(private val makeRoot: (componentId: String, name: S
151
151
  backgrounds.remove(key)?.let(::stop)
152
152
  }
153
153
 
154
- // Made first, counted after: the maker asks whether another root with the id lives.
155
- private fun make(key: Key): Root {
156
- val root = Root(key.name, key.componentId, makeRoot(key.componentId, key.name))
157
- LiveIds.add(key.componentId)
158
- return root
159
- }
154
+ private fun make(key: Key): Root = Root(key.name, key.componentId, makeRoot(key.componentId, key.name))
160
155
 
161
- // Stops a root once; what the readiness store kept of its id goes unless another live root holds the id.
162
- private fun stop(root: Root) {
163
- root.view.destroy()
164
- if (LiveIds.remove(root.componentId)) ScreenReadiness.remove(root.componentId)
165
- }
156
+ // Stops a root once; its content takes its mark of rendering at once with it.
157
+ private fun stop(root: Root) = root.view.destroy()
166
158
 
167
159
  private fun keyOf(name: Text, componentId: Text, id: Text): Key? {
168
160
  val componentName = name.get(null)?.takeIf { it.isNotEmpty() } ?: return null
@@ -170,42 +162,4 @@ internal class BarComponents(private val makeRoot: (componentId: String, name: S
170
162
  ?: return null
171
163
  return Key(componentName, rootId)
172
164
  }
173
-
174
- companion object {
175
- /** Whether a bar root with [id] is alive now, in any stack. */
176
- fun isLive(id: String): Boolean = LiveIds.has(id)
177
-
178
- /**
179
- * Another root that renders at once lives with [id] (a cell of the tabs' custom row): it counts as the bar's
180
- * roots do, so a root that stops clears the id's readiness only when it was the last one with it.
181
- */
182
- internal fun rootMade(id: String) = LiveIds.add(id)
183
-
184
- /** A root of [rootMade] stopped; true when no live root has [id] any more. */
185
- internal fun rootStopped(id: String): Boolean = LiveIds.remove(id)
186
-
187
- /** The JavaScript went (a reload): no id counts as held by a live root any more, as `ScreenReadiness.reset`. */
188
- fun reset() = LiveIds.clear()
189
- }
190
-
191
- // The ids of the bar roots alive now, in every stack: a root that stops clears its id's readiness only when it was
192
- // the last one with it.
193
- private object LiveIds {
194
- private val counts = HashMap<String, Int>()
195
-
196
- fun has(id: String): Boolean = (counts[id] ?: 0) > 0
197
-
198
- fun clear() = counts.clear()
199
-
200
- fun add(id: String) {
201
- counts[id] = (counts[id] ?: 0) + 1
202
- }
203
-
204
- // True when no root with the id is left.
205
- fun remove(id: String): Boolean {
206
- val count = (counts[id] ?: return true) - 1
207
- if (count <= 0) counts.remove(id) else counts[id] = count
208
- return count <= 0
209
- }
210
- }
211
165
  }
@@ -41,7 +41,6 @@ import dev.apexnative.navigation.utils.ImageLoader
41
41
  import dev.apexnative.navigation.utils.ImageLoadingListenerAdapter
42
42
  import dev.apexnative.navigation.utils.TestIds
43
43
  import dev.apexnative.navigation.utils.UiUtils
44
- import dev.apexnative.navigation.viewcontrollers.component.ScreenReadiness
45
44
  import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackState
46
45
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay
47
46
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.BarMotion
@@ -936,14 +935,10 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
936
935
  return resolved.component
937
936
  }
938
937
 
939
- private fun makeRoot(componentId: String, name: String): IReactView {
940
- // A component of the bar renders at once, without waiting for its layout, from before its surface starts.
941
- ScreenReadiness.setRendersAtOnce(componentId, true)
942
- // Another live root has this id (a component of the stack's options on two screens): what it rendered isn't
943
- // this root's, which answers by its own render.
944
- if (BarComponents.isLive(componentId)) ScreenReadiness.forgetRender(componentId)
945
- return ReactView(activity, componentId, name)
946
- }
938
+ // A component of the bar renders at once, without waiting for its layout. Another live root may show the same id
939
+ // (a component of the stack's options on two screens): each answers by the render of its own surface.
940
+ private fun makeRoot(componentId: String, name: String): IReactView =
941
+ ReactView(activity, componentId, name, rendersAtOnce = true)
947
942
 
948
943
  private fun loadIcon(uri: String): Drawable? {
949
944
  var loaded: Drawable? = null
@@ -22,9 +22,6 @@ open class CustomBottomTabItemView(context: Context) : FrameLayout(context) {
22
22
  private var state: TabCellState? = null
23
23
  private var destroyed = false
24
24
 
25
- /** Runs once as the cell stops: what was kept for its content's id goes. */
26
- internal var onDestroyed: (() -> Unit)? = null
27
-
28
25
  /** The content the cell shows, filling it, made with [state] as its first props. */
29
26
  internal fun show(view: IReactView, state: TabCellState) {
30
27
  content = view
@@ -68,8 +65,6 @@ open class CustomBottomTabItemView(context: Context) : FrameLayout(context) {
68
65
  didDisappear()
69
66
  destroyed = true
70
67
  content?.destroy()
71
- onDestroyed?.invoke()
72
- onDestroyed = null
73
68
  }
74
69
 
75
70
  /** The props of [state] as React Native takes them. */
@@ -17,34 +17,16 @@ const CIRCLE = 192;
17
17
  const LOGO_HEIGHT = /<dimen name="apex_navigation_splash_logo_height">([\d.]+)dp<\/dimen>/;
18
18
 
19
19
  /**
20
- * The native splash: made from its settings in package.json on both platforms, its logo whole on Android 12+; without
21
- * settings, a launch screen the navigation can't keep on screen until the first screen draws.
20
+ * The native splash: made from its settings in package.json on both platforms, its logo whole on Android 12+. Without
21
+ * settings there is nothing to check: the navigation keeps a copy of a storyboard and of `UILaunchScreen` alike.
22
22
  * @param {string} root
23
23
  * @param {Project} project
24
24
  * @returns {Check[]}
25
25
  */
26
26
  function splashChecks(root, project) {
27
- const plist = project.infoPlist && fs.readFileSync(project.infoPlist, 'utf8');
28
27
  const saved = readConfig(root).splash;
29
- if (!saved) {
30
- // UILaunchScreen alone: iOS shows it, but the navigation keeps only a storyboard while the first screen renders.
31
- if (
32
- plist?.includes('<key>UILaunchScreen</key>') &&
33
- !plist.includes('<key>UILaunchStoryboardName</key>')
34
- ) {
35
- return [
36
- {
37
- group: 'app',
38
- title: 'Launch screen',
39
- status: 'warn',
40
- detail:
41
- 'UILaunchScreen in Info.plist: an empty window shows between it and the first screen',
42
- fix: SPLASH,
43
- },
44
- ];
45
- }
46
- return [];
47
- }
28
+ if (!saved) return [];
29
+ const plist = project.infoPlist && fs.readFileSync(project.infoPlist, 'utf8');
48
30
  const resolved = resolveSettings(root, {}, saved);
49
31
  if ('errors' in resolved) {
50
32
  return [
@@ -88,7 +88,9 @@ async function splash({
88
88
  }
89
89
  const plist = project.infoPlist && fs.readFileSync(project.infoPlist, 'utf8');
90
90
  if (plist?.includes('<key>UILaunchScreen</key>')) {
91
- command.warn('Info.plist has UILaunchScreen too: remove it, so that iOS shows the storyboard.');
91
+ command.warn(
92
+ 'Info.plist has UILaunchScreen too: iOS shows it instead of the storyboard; remove it, so that iOS shows the storyboard.'
93
+ );
92
94
  }
93
95
  return command.apply(
94
96
  [
@@ -52,8 +52,8 @@ Compose shows with a close button at the leading edge of its top bar. The settin
52
52
 
53
53
  | Setting | What it does |
54
54
  | --- | --- |
55
- | `closeButton` | A close button on the first screen: the system one on iOS, an icon on Android. Needs the stack. |
56
- | `onDismiss` | Called once the modal is gone, whatever closed it: a swipe, a command, a new root. |
55
+ | `closeButton` | A close button on the first screen: the system one on iOS, an icon on Android. Needs the stack. A new first screen from `setStack` or `replace` in the modal's stack gets it too. |
56
+ | `onDismiss` | Called once the modal is gone, whatever closed it: a swipe, a command, a new root. `setStack` and `replace` in the modal's stack replace its first screen and call it too. |
57
57
  | `stack` | `false`: the screen alone, without a stack and its top bar. |
58
58
  | `style`, `sheet`, `popover`, `contentSize` | How it looks: see [How a modal looks](#styles). |
59
59
  | `transition`, `options` | The animation, and the screen's options for this show. |
@@ -311,7 +311,7 @@ Each file and why: [What the command writes](./splash-screen#files).
311
311
  - **The settings** go into `package.json`, under `apexNavigation.splash`. An option given replaces its saved value; to drop the dark logo or color, remove `darkLogo` or `darkBackground` there and run the command again.
312
312
  - **A logo whose corners leave the circle** of Android 12's splash icon, 192 dp, gets a warning: Android cuts them. Make `--logo-width` smaller, or give the logo room around it.
313
313
  - **A file it can't change** is listed as `manual`, with the change to make: an app without `Images.xcassets` next to `Info.plist`, a manifest whose `MainActivity` and `<application>` have no theme to go over.
314
- - **`Info.plist` with `UILaunchScreen`** as well gets a warning: remove that dictionary, so that iOS shows the storyboard.
314
+ - **`Info.plist` with `UILaunchScreen`** as well gets a warning: iOS shows that dictionary instead of the storyboard, so remove it.
315
315
  - **Its errors** in the JSON: `splash-settings` for a setting it can't take, `splash-image` for a logo it can't read or that doesn't fit (too tall at its width, a dark logo of other proportions), and `sharp-missing`, as for `icons`.
316
316
 
317
317
  `doctor` checks the splash in the App group:
@@ -320,7 +320,6 @@ Each file and why: [What the command writes](./splash-screen#files).
320
320
  | --- | --- |
321
321
  | Splash | The files of iOS or Android aren't made from the settings in `package.json`: changed by hand, made on one platform only, or by an older version of the command. Or the settings themselves are wrong, such as a logo that isn't there. |
322
322
  | Splash logo | The logo's corners leave Android 12's circle. |
323
- | Launch screen | Without settings: `Info.plist` has `UILaunchScreen` and no storyboard. iOS shows it at launch, but the navigation can only keep a storyboard on screen, so an empty window shows between it and the first screen. |
324
323
 
325
324
  Each warning names its fix, such as `npx apex-navigation splash`; `--fix` leaves these to you. What the user sees, and a splash of your own after the native one: [Splash screen](./splash-screen).
326
325
 
@@ -232,9 +232,9 @@ A signed-in user sees Login for a moment, then the app.
232
232
 
233
233
  (iOS) Between the launch screen and the first screen, the window is empty for a moment.
234
234
 
235
- **Why:** the launch screen is made with the `UILaunchScreen` dictionary of `Info.plist`, not a storyboard. The navigation keeps a copy of a storyboard on screen until the first root, and can't make one of the dictionary. `doctor` warns about it under Launch screen.
235
+ **Why:** the app has no launch screen the navigation can copy: `Info.plist` names no storyboard and has no `UILaunchScreen` dictionary (or `UILaunchScreens` default), and no legacy launch images.
236
236
 
237
- **Fix:** run `npx apex-navigation splash`, or name a storyboard in `UILaunchStoryboardName` and remove `UILaunchScreen`. More: [Make the native splash](../guides/splash-screen#command).
237
+ **Fix:** run `npx apex-navigation splash`, or name a storyboard in `UILaunchStoryboardName`. More: [Make the native splash](../guides/splash-screen#command).
238
238
 
239
239
  ### Android brings the app back without its pushed screens
240
240
 
@@ -106,6 +106,17 @@ export const MapScreen = defineScreen({
106
106
 
107
107
  The preview shows when back pops the screen with the default transition. It doesn't when back does something else first, or when the app takes it: a menu or a search that closes, a screen that holds back (`hardwareBackButton.popStackOnPress: false`, a [`BackHandler` listener](#back-handler), `usePreventLeave`), or a pop with an animation of its own (`animations.pop`). On Android 13 and older the screen pops when the swipe ends.
108
108
 
109
+ `animations.pop.preview: false` turns the preview off for a screen: the gesture shows nothing, and the screen pops when the user lets go, as on Android 13. It is read from the screen that goes, so the app's `options` turn it off everywhere:
110
+
111
+ ```ts
112
+ defineScreen({
113
+ component: Checkout,
114
+ options: { animations: { pop: { preview: false } } },
115
+ });
116
+ ```
117
+
118
+ iOS ignores it: the swipe back there is UIKit's.
119
+
109
120
  ### Back to the home screen
110
121
 
111
122
  (Android) On the first screen of the app, with nothing left to go back to, the navigation leaves the back to the system. The system then plays its predictive back animation: as the user swipes, the app shrinks and the home screen shows behind it, and the user can still change their mind halfway. The system decides this as the swipe starts, so the navigation takes the back only while it has something to do with it: a screen to pop, a modal, a menu or a search to close, a tab to go to (`backBehavior`), or a [`BackHandler` listener](#back-handler) of the app.
@@ -122,6 +133,17 @@ Without it, back on the first screen still leaves the app, at once. See [What se
122
133
 
123
134
  With the animation on, the system leaves the app itself: an `invokeDefaultOnBackPressed` of the app's `MainActivity` (such as `moveTaskToBack(true)`) isn't called on the first screen. Without it, and before Android 13, the back goes through the activity as before.
124
135
 
136
+ `hardwareBackButton.systemAnimation: false` keeps the back in the app on the first screen too, so the system plays no animation there: the gesture shows nothing, and on release the press goes to the [`BackHandler` listeners](#back-handler), then to the activity's own back, `invokeDefaultOnBackPressed` included. It is read from the screen back acts on; for the whole app, set it in the app's `options`:
137
+
138
+ ```ts
139
+ createNavigation({
140
+ // …
141
+ options: { hardwareBackButton: { systemAnimation: false } },
142
+ });
143
+ ```
144
+
145
+ iOS ignores it.
146
+
125
147
  ### Handling back yourself: BackHandler
126
148
 
127
149
  (Android) React Native's `BackHandler` works on every screen, as in any React Native app. A listener runs before the navigation's own back: returning `true` takes the press, `false` passes it on to the next listener, then to the navigation.
@@ -215,7 +215,7 @@ export {};
215
215
 
216
216
  | Group | What it checks |
217
217
  | --- | --- |
218
- | App | React Native and the New Architecture, the package, the files `setup` sets up, the entry point, React Native loaded twice, the status bar of the template, the generated code of native screens. Typed commands: `navigation-env.d.ts` there, pointing to the navigation, with its `context` line, and read by `tsconfig.json`. The splash screen made from its settings, with its logo inside the circle of Android's splash icon, and an iOS launch screen made with `UILaunchScreen` and no storyboard, which the navigation can't keep on screen. The app icon made from its settings, and an iOS icon set with no image, which the App Store refuses. |
218
+ | App | React Native and the New Architecture, the package, the files `setup` sets up, the entry point, React Native loaded twice, the status bar of the template, the generated code of native screens. Typed commands: `navigation-env.d.ts` there, pointing to the navigation, with its `context` line, and read by `tsconfig.json`. The splash screen made from its settings, with its logo inside the circle of Android's splash icon. The app icon made from its settings, and an iOS icon set with no image, which the App Store refuses. |
219
219
  | Navigation | From the app's code. Screens: no name given twice in `screens` and the groups, and no file of the screens' folder with a `defineScreen` or `nativeScreen` that no file imports, a screen made and never added. Links: each prefix of `linking` declared on both platforms, the declarations no prefix has, a main activity that isn't `singleTask`. Screen names, in a JavaScript app only: a name that `push`, `replace`, `showModal` or `showInColumn` gives and no screen has, with its file and line. |
220
220
  | (Android) | JDK 17 in use, the Android SDK, `minSdk`, how edge-to-edge works in the app. |
221
221
  | (iOS) | Xcode 26 or newer, CocoaPods, the pods of the navigation and whether they are out of date, the iOS version of the Podfile. |
package/ios/CORE.md CHANGED
@@ -87,10 +87,10 @@ dispatcher.
87
87
  `ANNStackAnimatingDelegate` give UIKit the `ANNTransitionAnimator`.
88
88
  7. **The end.** `stackWhenTransitionEnds:` fires from the transition coordinator and calls `endPush:`, which calls
89
89
  `+[ANNAppearanceDispatcher endTransitionHiding:showing:events:answer:value:]`. In this order: `ComponentDidDisappear`
90
- of Home, `ComponentDidAppear` of Profile, `ANN.NavigationStateChanged` (`+[ANNNavigationState updateNow]`), the
91
- transition's `events`, then `-[ANNCommandAnswer resolve:]`: the state again if it changed
92
- (`reportBeforeAnswerOf:`), the promise with Profile's id, then `ANN.CommandCompleted`. `finish:` frees the queue for
93
- the next operation.
90
+ of Home, `ComponentDidAppear` of Profile, `ANN.NavigationStateChanged` (`-[ANNCommandAnswer reportStateNow]`, the
91
+ answer's one state step), the transition's `events`, then `-[ANNCommandAnswer resolve:]`: the promise with
92
+ Profile's id, then `ANN.CommandCompleted`. A command answered without a transition's end (`setRoot` with layout
93
+ modals) sends its state from `resolve:`. `finish:` frees the queue for the next operation.
94
94
 
95
95
  Events go out through `ANNEventEmitter` on the main queue and reach JS through `ANNTurboEventEmitter`.
96
96
 
@@ -117,13 +117,13 @@ Each of these owns one rule. Call it; do not copy it.
117
117
 
118
118
  - `ANNCommandRunner`: The one path of every async command: generation, check, main queue, exception guard. Called by: `ANNTurboModule`.
119
119
  - `ANNLayoutReader`: The layout check, depth first, the same order and texts as Android. Module queue. Called by: The runner's check blocks.
120
- - `ANNCommandAnswer`: The answer: state, then promise, then `ANN.CommandCompleted`; once; silent for a JavaScript that went. Called by: Every coordinator and queue.
120
+ - `ANNCommandAnswer`: The answer: state (one step per answer, `reportStateNow`, asked by the end of its transition or by `resolve:`), then promise, then `ANN.CommandCompleted`; once; silent for a JavaScript that went. Called by: Every coordinator and queue.
121
121
  - `ANNNodeRegistry`: Every node by id, weakly. Filled by the factory, cleared at a reload. Called by: Commands, modules, `findViewController:`.
122
122
  - `ANNLayoutFactory`: The core code that names module classes (`ANNSplitViewController`, `ANNSideMenuViewController`, `ANNArrangementViewController`); `ANNSideMenuPresenter` is the one other file that does. One maker per layout type; registers each node. Called by: Every command that shows a layout.
123
123
  - `ANNOptionsCache`: The one invalidation point of resolved options. Called by: The setters of `ANNNodeHelper.options` and of its user layer (`setUserChoice:forKey:`), and `ANNDefaultOptions`, only.
124
124
  - `ANNNodeHelper resolveOptionsWithDefault`: What a node shows with: defaults, parents' own options (each with its user layer), the node's `resolveOptions` (own, its user layer, then the current child down). Cached, read-only. Called by: Presenters, coordinators.
125
125
  - `ANNAppearanceDispatcher`: The only sender of `ComponentWillAppear`, `ComponentDidAppear`, `ComponentDidDisappear`; the end-of-transition order; the events of a node that goes. Called by: Screens, queues, coordinators.
126
- - `ANNNavigationState`: The visible screen and the nearest tabs it is in (the last tabs met walking down); coalesced updates, holds, the state before an answer. Called by: The dispatcher, `ANNCommandAnswer`, `ANNRootCoordinator`.
126
+ - `ANNNavigationState`: The visible screen and the nearest tabs it is in (the last tabs met walking down); coalesced updates, holds, the state now. Called by: The dispatcher, `ANNCommandAnswer`, `ANNRootCoordinator`, `ANNModalCoordinator`, `ANNBottomTabsController`.
127
127
  - `ANNScreenReadiness`: The signals of a screen's safe area view (has one, laid out, content rendered), sticky, by screen id. Called by: `ANNSafeAreaViewComponentView`, `ANNReactView`.
128
128
  - `ANNRenderWait`: The one wait for the render of what a command shows: content, bar components, limits, the unknown-content rule. Called by: Root, stack queue, modals, overlays, `showInColumn`.
129
129
  - `ANNParking`: Where a view waits for its content unseen: the window, a stack's view, a preview's host view. Called by: Root, stack, modals, overlays, `ANNPreviewPush`.
@@ -27,8 +27,15 @@ NS_ASSUME_NONNULL_BEGIN
27
27
  /// Whether the JavaScript the command came from still runs: its work goes on only then.
28
28
  @property(nonatomic, readonly) BOOL isCurrent;
29
29
 
30
- /// Resolves the promise: first the state goes, then the promise, then `ANN.CommandCompleted` for a command that shows
31
- /// or dismisses.
30
+ /// The navigation state now, sent when it changed, once per answer: the end of the command's transition asks for it
31
+ /// after the `DidAppear` it sends and before the transition's own events (`ANNAppearanceDispatcher`); otherwise
32
+ /// `resolve:` asks for it before the promise. A later call sends nothing, and nothing goes for a JavaScript that went.
33
+ /// A failure of the step is logged and costs only the state.
34
+ - (void)reportStateNow;
35
+
36
+ /// Resolves the promise: first the state, when the command changes what shows (`ANNCommandContract`) and its
37
+ /// transition didn't send it already (`reportStateNow`), then the promise, then `ANN.CommandCompleted` for a command
38
+ /// that shows or dismisses.
32
39
  - (void)resolve:(nullable id)value;
33
40
 
34
41
  /// Rejects the promise with the text alone: no code of the navigation.
@@ -10,6 +10,7 @@
10
10
  @implementation ANNCommandAnswer {
11
11
  RCTPromiseResolveBlock _resolve;
12
12
  RCTPromiseRejectBlock _reject;
13
+ BOOL _stateReported;
13
14
  }
14
15
 
15
16
  - (instancetype)initWithCommandName:(NSString *)commandName
@@ -51,6 +52,27 @@
51
52
  return YES;
52
53
  }
53
54
 
55
+ - (void)reportStateNow {
56
+ ANNAssertMainQueue();
57
+ if (_stateReported) {
58
+ return;
59
+ }
60
+ _stateReported = YES;
61
+ // A step made for the JavaScript that went sends nothing, as its answer does.
62
+ if (!self.isCurrent) {
63
+ return;
64
+ }
65
+ // The command did its work: a failure of the state step costs only the state, never the events and the answer.
66
+ @try {
67
+ [ANNNavigationState updateNowFor:_commandName];
68
+ } @catch (NSException *exception) {
69
+ NSString *reason = exception.reason.length > 0 ? exception.reason : exception.name;
70
+ [ANNErrorHandler logFailureOf:_commandName
71
+ text:[ANNErrorTexts couldNot:@"report the navigation state"
72
+ reason:reason]];
73
+ }
74
+ }
75
+
54
76
  - (void)resolve:(id)value {
55
77
  if (![self takeAnswer]) {
56
78
  return;
@@ -59,18 +81,9 @@
59
81
  _resolve = nil;
60
82
  _reject = nil;
61
83
  // A command that may change what shows (`ANNCommandContract`): the state goes before the promise, so that
62
- // `getState()` after `await` has the result.
63
- BOOL changesWhatShows = ANNCommandReportsState(_commandName);
64
- if (changesWhatShows) {
65
- // The command did its work: a failure of the state step costs only the state, never the answer.
66
- @try {
67
- [ANNNavigationState reportBeforeAnswerOf:_commandName];
68
- } @catch (NSException *exception) {
69
- NSString *reason = exception.reason.length > 0 ? exception.reason : exception.name;
70
- [ANNErrorHandler logFailureOf:_commandName
71
- text:[ANNErrorTexts couldNot:@"report the navigation state"
72
- reason:reason]];
73
- }
84
+ // `getState()` after `await` has the result. Sent at the end of its transition already, it goes no more.
85
+ if (ANNCommandReportsState(_commandName)) {
86
+ [self reportStateNow];
74
87
  }
75
88
  [ANNErrorHandler resolve:resolve of:_commandName value:value];
76
89
  if (_commandId && ANNCommandSendsCompleted(_commandName)) {
@@ -58,11 +58,12 @@ NS_ASSUME_NONNULL_BEGIN
58
58
 
59
59
  /// A transition ended, in this order: first a screen a transition began inside a node of `shown` that left the window
60
60
  /// and isn't one of that node's shown screens gets its `DidDisappear`; then `DidDisappear` of every screen of `hidden`,
61
- /// `DidAppear` of what `shown` shows, the navigation state now when it changed, `events` (the transition's own events,
61
+ /// `DidAppear` of what `shown` shows, the navigation state now when it changed (the answer's one state step,
62
+ /// `-[ANNCommandAnswer reportStateNow]`; nothing for a JavaScript that went), `events` (the transition's own events,
62
63
  /// `ANN.ScreenPopped` and `ANN.ModalDismissed`, and what must follow them, like stopping the content that went), then
63
- /// `answer` resolves with `value` (its own state step finds nothing new, then the promise, then
64
- /// `ANN.CommandCompleted`). A user's action passes no answer. The callbacks of UIKit that come later find the phases
65
- /// set and send nothing.
64
+ /// `answer` resolves with `value` (the promise, then `ANN.CommandCompleted`; no second state). A user's action passes
65
+ /// no answer, and its state goes all the same. The callbacks of UIKit that come later find the phases set and send
66
+ /// nothing.
66
67
  + (void)endTransitionHiding:(NSArray<UIViewController *> *)hidden
67
68
  showing:(NSArray<UIViewController *> *)shown
68
69
  events:(nullable dispatch_block_t)events
@@ -305,7 +305,18 @@ static BOOL ANNIsInsideNode(UIViewController *screen, UIViewController *node) {
305
305
  for (UIViewController *node in shown) {
306
306
  [self nodeDidAppear:node];
307
307
  }
308
- [ANNNavigationState updateNowFor:answer.commandName];
308
+ // The state before the transition's events, once per answer: the answer's own step before its promise finds it
309
+ // sent. A user's transition has no answer; one whose answer went before its end (an interrupt) sends the state of
310
+ // its end itself, as Android does.
311
+ if (!answer) {
312
+ [ANNNavigationState updateNow];
313
+ } else if (answer.answered) {
314
+ if (answer.isCurrent) {
315
+ [ANNNavigationState updateNowFor:answer.commandName];
316
+ }
317
+ } else {
318
+ [answer reportStateNow];
319
+ }
309
320
  if (events) {
310
321
  events();
311
322
  }
@@ -23,17 +23,15 @@ NS_ASSUME_NONNULL_BEGIN
23
23
  + (void)holdUpdates;
24
24
  + (void)releaseUpdates;
25
25
 
26
- /// The state now, sent when it changed: at the end of a transition, at once after a tab chosen from code. A walk that
27
- /// throws logs one line, `[ApexNavigation] <commandName>: could not report the navigation state: <reason>` (as on
28
- /// Android; `transition` without a command), and sends nothing, so its caller always goes on.
26
+ /// The state now, sent when it changed. A command's answer asks for it once (`-[ANNCommandAnswer reportStateNow]`, at
27
+ /// the end of its transition or before its promise); the end of a user's transition and a tab chosen from code ask
28
+ /// `updateNow`. A walk that throws logs one line, `[ApexNavigation] <commandName>: could not report the navigation
29
+ /// state: <reason>` (as on Android; `transition` without a command), and sends nothing, so its caller always goes on.
29
30
  + (void)updateNowFor:(nullable NSString *)commandName;
30
31
 
31
32
  /// `updateNowFor:` without a command.
32
33
  + (void)updateNow;
33
34
 
34
- /// The command `commandName` is about to answer: when what shows changed, the new state goes now, before the promise.
35
- + (void)reportBeforeAnswerOf:(NSString *)commandName;
36
-
37
35
  /// Forgets the last state sent: the next one goes even if it is the same (a new bundle, `AppLaunched`).
38
36
  + (void)reset;
39
37
 
@@ -94,10 +94,6 @@ static BOOL ANNSameValue(id a, id b) {
94
94
  [ApexNavigation.eventEmitter sendNavigationStateChanged:screenId name:name tab:tab];
95
95
  }
96
96
 
97
- + (void)reportBeforeAnswerOf:(NSString *)commandName {
98
- [self updateNowFor:commandName];
99
- }
100
-
101
97
  + (void)reset {
102
98
  ANNAssertMainQueue();
103
99
  ANNHasLastState = NO;
@@ -7,9 +7,11 @@ NS_ASSUME_NONNULL_BEGIN
7
7
  /// either at once. The status bar follows the Info.plist keys `UIStatusBarStyle` and `UIStatusBarHidden`. Main queue.
8
8
  @interface ANNSplashViewController : UIViewController
9
9
 
10
- /// The copy of the launch screen: the initial view controller of `UILaunchStoryboardName` as a child (a storyboard
11
- /// that doesn't load: the first view of the nib of that name), else the legacy launch images, full screen. Nil when the
12
- /// app has none of these: the window then has no root view controller until the first root.
10
+ /// The copy of the launch screen. When iOS builds it from the Info.plist (`UILaunchScreen`, or the default of
11
+ /// `UILaunchScreens`), the same color, image and bars as plain views; else the initial view controller of
12
+ /// `UILaunchStoryboardName` as a child (a storyboard that doesn't load: the first view of the nib of that name), else
13
+ /// the legacy launch images, full screen. Nil when the app has none of these: the window then has no root view
14
+ /// controller until the first root.
13
15
  + (nullable instancetype)launchScreenCopyWithBounds:(CGRect)bounds;
14
16
 
15
17
  /// The blank screen after a reload: clear when the default options give the window a color (`window.backgroundColor`),