@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
@@ -31,7 +31,12 @@ class SlotScreenView(
31
31
  private val whenMounted = mutableListOf<Runnable>()
32
32
  private val whenRendered = mutableListOf<Runnable>()
33
33
  private val mountedHandlers = LinkedHashMap<String, Runnable>()
34
- private var readinessListener: ScreenReadiness.Listener? = null
34
+ private var listening = false
35
+
36
+ // The safe area view of a slot renders in the app's one React tree: every signal of the id is the screen's.
37
+ private val readiness = object : ScreenReadiness.Listener {
38
+ override fun onContentRendered() = checkReadiness()
39
+ }
35
40
  private var mounted = false
36
41
  private var readyTold = false
37
42
  private var destroyed = false
@@ -105,14 +110,9 @@ class SlotScreenView(
105
110
  if (readyTold) return listener.run()
106
111
  whenRendered += listener
107
112
  if (isReady()) return checkReadiness()
108
- if (readinessListener != null) return
109
- val readiness = object : ScreenReadiness.Listener {
110
- override fun onLaidOut() = Unit
111
-
112
- override fun onContentRendered() = checkReadiness()
113
- }
114
- readinessListener = readiness
115
- ScreenReadiness.setListener(componentId, readiness)
113
+ if (listening) return
114
+ listening = true
115
+ ScreenReadiness.addListener(componentId, this, readiness)
116
116
  }
117
117
 
118
118
  override fun whenSlotContentMounted(listener: Runnable) {
@@ -151,8 +151,8 @@ class SlotScreenView(
151
151
  }
152
152
 
153
153
  private fun stopListening() {
154
- val readiness = readinessListener ?: return
155
- readinessListener = null
156
- ScreenReadiness.removeListener(componentId, readiness)
154
+ if (!listening) return
155
+ listening = false
156
+ ScreenReadiness.removeListener(componentId, this)
157
157
  }
158
158
  }
@@ -10,6 +10,7 @@ import dev.apexnative.navigation.NavigationActivity
10
10
  import dev.apexnative.navigation.options.DefaultOptions
11
11
  import dev.apexnative.navigation.options.Options
12
12
  import dev.apexnative.navigation.react.ArrangementStates
13
+ import dev.apexnative.navigation.react.events.EventEmitter
13
14
  import dev.apexnative.navigation.utils.SystemUiUtils
14
15
  import dev.apexnative.navigation.utils.WindowFolds
15
16
  import dev.apexnative.navigation.viewcontrollers.child.ChildController
@@ -146,11 +147,11 @@ class ArrangementController internal constructor(
146
147
  val single = plan.layout == ArrangementPlan.Layout.SINGLE
147
148
  if (primary is ComponentViewController) {
148
149
  val state = ArrangementStates.State(PRIMARY, layout, front = overlay, hidden = false)
149
- ArrangementStates.set(activity, primary.id, state, notify)
150
+ ArrangementStates.set(primary.id, state, notify, EventEmitter.of(primary.jsGeneration))
150
151
  }
151
152
  if (secondary is ComponentViewController) {
152
153
  val state = ArrangementStates.State(SECONDARY, layout, front = false, hidden = single)
153
- ArrangementStates.set(activity, secondary.id, state, notify)
154
+ ArrangementStates.set(secondary.id, state, notify, EventEmitter.of(secondary.jsGeneration))
154
155
  }
155
156
  }
156
157
 
@@ -26,14 +26,12 @@ import dev.apexnative.navigation.utils.scrollToTop
26
26
  import dev.apexnative.navigation.viewcontrollers.child.ChildControllersRegistry
27
27
  import dev.apexnative.navigation.viewcontrollers.component.ComponentViewController
28
28
  import dev.apexnative.navigation.viewcontrollers.component.RestPlace
29
- import dev.apexnative.navigation.viewcontrollers.component.ScreenReadiness
30
29
  import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackState
31
30
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
32
31
  import dev.apexnative.navigation.viewcontrollers.parent.ContainerBackground
33
32
  import dev.apexnative.navigation.viewcontrollers.parent.ParentController
34
33
  import dev.apexnative.navigation.viewcontrollers.stack.StackController
35
34
  import dev.apexnative.navigation.viewcontrollers.stack.fab.FabPlacement
36
- import dev.apexnative.navigation.viewcontrollers.stack.topbar.BarComponents
37
35
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
38
36
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
39
37
  import dev.apexnative.navigation.views.animations.FadeThrough
@@ -379,6 +377,9 @@ class BottomTabsController(
379
377
  // A layer that waits for the tabs waits for the tab they show now.
380
378
  navigator?.shownScreensChanged(this)
381
379
  if (onScreen) AppearanceDispatcher.beginTransition(listOf(shown))
380
+ // From code and by back the state goes at once, as on iOS: `selectTab` resolves on it, and the command after
381
+ // it must act in this tab, not in the one still fading out.
382
+ if (how != How.Press) AppearanceDispatcher.reportState()
382
383
  if (views == null) return switched(index, previous, how, onScreen, hasViews = false)
383
384
  endSwitch = views.switch(shown, left, animated = onScreen) {
384
385
  endSwitch = null
@@ -390,8 +391,8 @@ class BottomTabsController(
390
391
  * The switch to the tab at [index] from [previous] ended: what went disappears, what came appears — unless the tabs
391
392
  * stopped showing during the fade (a modal over them): it appears when they show again; then for a press
392
393
  * `ANN.BottomTabSelected` right after the new screen's DidAppear and the left tab's `popToRootOnBlur`, with the state
393
- * as one update after them; from code and by back, `popToRootOnBlur` and the state at once. [onScreen]: the switch
394
- * started on screen.
394
+ * as one update after them; from code and by back, `popToRootOnBlur` and the state, which went at the start too
395
+ * ([select]). [onScreen]: the switch started on screen.
395
396
  */
396
397
  private fun switched(index: Int, previous: Int, how: How, onScreen: Boolean, hasViews: Boolean) {
397
398
  val left = tabs[previous]
@@ -660,15 +661,12 @@ class BottomTabsController(
660
661
  private fun makeCell(index: Int): CustomBottomTabItemView {
661
662
  val component = itemOptions(index).component
662
663
  val componentId = component.componentId.get(null) ?: "$id.$index"
663
- ScreenReadiness.setRendersAtOnce(componentId, true)
664
- // Another live root has the id (the same layout set again before the old one went): what it rendered isn't
665
- // this one's; the id's readiness goes only with its last root (as the bar's roots).
666
- if (BarComponents.isLive(componentId)) ScreenReadiness.forgetRender(componentId)
667
- BarComponents.rootMade(componentId)
668
664
  val cell = CustomBottomTabItemView(activity)
669
665
  val state = cellState(index)
670
- cell.show(ReactView(activity, componentId, component.name.get(""), cell.bundleOf(state)), state)
671
- cell.onDestroyed = { if (BarComponents.rootStopped(componentId)) ScreenReadiness.remove(componentId) }
666
+ // Another live root may have the id (the same layout set again before the old one went): each answers by the
667
+ // render of its own surface, and the id's readiness goes with its last root.
668
+ val root = ReactView(activity, componentId, component.name.get(""), cell.bundleOf(state), rendersAtOnce = true)
669
+ cell.show(root, state)
672
670
  return cell
673
671
  }
674
672
 
@@ -14,6 +14,7 @@ import dev.apexnative.navigation.react.ScreenInsets
14
14
  import dev.apexnative.navigation.react.ScreenRegions
15
15
  import dev.apexnative.navigation.react.events.AppearanceDispatcher
16
16
  import dev.apexnative.navigation.react.events.ComponentType
17
+ import dev.apexnative.navigation.react.events.EventEmitter
17
18
  import dev.apexnative.navigation.slots.SlotScreenView
18
19
  import dev.apexnative.navigation.system.SystemUiOwner
19
20
  import dev.apexnative.navigation.viewcontrollers.child.ChildController
@@ -177,8 +178,11 @@ class ComponentViewController(
177
178
  topFree = parent?.leavesTopEdgeFree(this) == true,
178
179
  bottomFree = parent?.leavesBottomEdgeFree(this) == true,
179
180
  )
180
- ScreenInsets.update(activity, id, insets, ScreenSafeArea.padding(insets, edges))
181
- ScreenRegions.update(activity, id, Rect(place.left, place.top, place.right, place.bottom), windowInsets)
181
+ // The events of the screen go to the JavaScript it was made for, and to no other.
182
+ val emitter = EventEmitter.of(jsGeneration)
183
+ ScreenInsets.update(id, insets, ScreenSafeArea.padding(insets, edges), emitter)
184
+ val area = Rect(place.left, place.top, place.right, place.bottom)
185
+ ScreenRegions.update(activity, id, area, windowInsets, emitter)
182
186
  }
183
187
 
184
188
  // A layout asked for on the content or on a view around it: it isn't in its place yet.
@@ -5,7 +5,7 @@ import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
5
5
  /**
6
6
  * Whether a screen's content rendered, which a command waits for before the screen comes in: a new root (`setRoot`),
7
7
  * a push (unless `waitForRender: false`), the new top of a `setStackRoot`. A wait never listens to the readiness store
8
- * itself: the content keeps the one listener of its id and tells every waiter. Main thread only.
8
+ * itself: the content keeps its own listener of its id and tells every waiter. Main thread only.
9
9
  */
10
10
  internal interface Readiness {
11
11
  /** The content of [screen] rendered; a screen without content to wait for is ready. */
@@ -1,93 +1,128 @@
1
1
  package dev.apexnative.navigation.viewcontrollers.component
2
2
 
3
3
  /**
4
- * The readiness of each screen by its id: the safe area view of a screen (`ANNSafeAreaView`) tells it here that it is
5
- * laid out in its place and that the screen's content rendered into it, and asks whether the screen waits for that at
6
- * all. The screen's container listens by the screen's id: no view walks up or down the views to find the other. Main
7
- * thread only.
4
+ * The readiness of each screen by its id: the safe area view of a screen (`ANNSafeAreaView`) tells it here that it
5
+ * has one, that it is laid out in its place and that the screen's content rendered into it, and asks whether the
6
+ * screen waits for that at all. The content of a screen or of a component listens by the id: no view walks up or down
7
+ * the views to find the other. Main thread only.
8
+ *
9
+ * As on iOS (`ANNScreenReadiness`), what a safe area view said is kept: a listener that comes later hears it at once.
10
+ * Several listeners may wait on one id (the roots of a bar component that several screens show with one id), each
11
+ * under a key of its own, which removes it. Each signal carries its source, the React surface of the safe area view
12
+ * that gave it, and a listener hears only the sources it [accepts][Listener.accepts]: React Native never gives a
13
+ * surface's id twice in a process, so the late signal of another root with the same id (a root of the JavaScript that
14
+ * went, a destroyed screen, another live root of a bar component) never makes a new root ready.
8
15
  */
9
16
  object ScreenReadiness {
10
- /** What a screen's container hears from the safe area view of its screen. */
17
+ /** The source of a signal that names none: a listener that accepts every source hears it. */
18
+ const val NO_SOURCE = -1
19
+
20
+ /** What the content of a screen or of a component hears from the safe area view of its id. */
11
21
  interface Listener {
22
+ /**
23
+ * Whether a signal of [source] (the id of the React surface of the safe area view that gave it) is this
24
+ * listener's: by default every one is.
25
+ */
26
+ fun accepts(source: Int): Boolean = true
27
+
12
28
  /** The safe area view is laid out in its place, with a size. */
13
- fun onLaidOut()
29
+ fun onLaidOut() = Unit
14
30
 
15
31
  /** The screen rendered its content into its safe area view, also when it renders nothing (`null`). */
16
32
  fun onContentRendered()
17
33
  }
18
34
 
19
- private val atOnce = HashSet<String>()
20
- private val wrapped = HashSet<String>()
21
- private val laidOut = HashSet<String>()
22
- private val rendered = HashSet<String>()
23
- private val listeners = HashMap<String, Listener>()
35
+ // What the safe area views of one id said: that one exists, and the sources that are laid out and rendered.
36
+ private class Entry {
37
+ var wrapped = false
38
+ val laidOut = HashSet<Int>()
39
+ val rendered = HashSet<Int>()
40
+ }
41
+
42
+ private val entries = HashMap<String, Entry>()
43
+ private val listeners = HashMap<String, LinkedHashMap<Any, Listener>>()
44
+ // The roots that render at once, by id: an id renders at once while one of them lives.
45
+ private val atOnce = HashMap<String, MutableSet<Any>>()
24
46
 
25
47
  /**
26
48
  * Whether the screen [screenId] waits for its safe area view to be laid out before its content renders: every
27
- * screen does, as on iOS, unless the core marked it as one that renders at once ([setRendersAtOnce]) — a React
28
- * component of a bar or a tab item, whose size comes from its content.
49
+ * screen does, as on iOS, unless a root of the id renders at once ([setRendersAtOnce]) — a React component of a
50
+ * bar or a tab item, whose size comes from its content.
29
51
  */
30
- fun waitsForReadiness(screenId: String): Boolean = screenId !in atOnce
52
+ fun waitsForReadiness(screenId: String): Boolean = atOnce[screenId].isNullOrEmpty()
31
53
 
32
- /** The core marks a React root it made as one that renders at once (a component of a bar, a tab item), or not. */
33
- fun setRendersAtOnce(screenId: String, rendersAtOnce: Boolean) {
34
- if (rendersAtOnce) atOnce += screenId else atOnce -= screenId
54
+ /**
55
+ * The root [key] of [screenId] renders at once (a component of a bar, a tab item), or stopped. The id renders at
56
+ * once while a root marks it; when the last one stops, what was said of the id goes too, so a later root with it
57
+ * starts anew.
58
+ */
59
+ fun setRendersAtOnce(screenId: String, key: Any, rendersAtOnce: Boolean) {
60
+ if (rendersAtOnce) {
61
+ atOnce.getOrPut(screenId, ::HashSet).add(key)
62
+ return
63
+ }
64
+ val keys = atOnce[screenId] ?: return
65
+ if (!keys.remove(key) || keys.isNotEmpty()) return
66
+ atOnce.remove(screenId)
67
+ entries.remove(screenId)
35
68
  }
36
69
 
37
70
  /** The screen [screenId] has a safe area view: it is rendered only once that view says so. */
38
71
  fun wrapped(screenId: String) {
39
- wrapped += screenId
72
+ entryOf(screenId).wrapped = true
40
73
  }
41
74
 
42
- /** The safe area view of [screenId] is laid out in its place, with a size. */
43
- fun laidOut(screenId: String) {
44
- if (laidOut.add(screenId)) listeners[screenId]?.onLaidOut()
75
+ /** The safe area view of [screenId], in the React surface [source], is laid out in its place, with a size. */
76
+ fun laidOut(screenId: String, source: Int = NO_SOURCE) {
77
+ val entry = entryOf(screenId)
78
+ entry.wrapped = true
79
+ if (entry.laidOut.add(source)) tell(screenId, source, Listener::onLaidOut)
45
80
  }
46
81
 
47
- /** The screen [screenId] rendered its content into its safe area view. */
48
- fun contentRendered(screenId: String) {
49
- if (rendered.add(screenId)) listeners[screenId]?.onContentRendered()
82
+ /** The screen [screenId] rendered its content into its safe area view, in the React surface [source]. */
83
+ fun contentRendered(screenId: String, source: Int = NO_SOURCE) {
84
+ val entry = entryOf(screenId)
85
+ entry.wrapped = true
86
+ if (entry.rendered.add(source)) tell(screenId, source, Listener::onContentRendered)
50
87
  }
51
88
 
52
89
  /**
53
- * Whether the screen [screenId] rendered its content into its safe area view; null when no safe area view told
54
- * anything of it (a screen without one, of the old API: its container decides by its own content).
90
+ * Whether the screen [screenId] rendered its content into its safe area view, in a source [listener] accepts (any
91
+ * source without one); null when no safe area view told anything of the id (a screen without one, of the old API:
92
+ * its content decides by its own children).
55
93
  */
56
- fun isContentRendered(screenId: String): Boolean? = when (screenId) {
57
- in rendered -> true
58
- in laidOut, in wrapped -> false
59
- else -> null
60
- }
61
-
62
- /** [listener] hears the signals of [screenId] from now on; null stops it. */
63
- fun setListener(screenId: String, listener: Listener?) {
64
- if (listener == null) listeners.remove(screenId) else listeners[screenId] = listener
94
+ fun isContentRendered(screenId: String, listener: Listener? = null): Boolean? {
95
+ val entry = entries[screenId] ?: return null
96
+ return entry.rendered.any { listener?.accepts(it) ?: true }
65
97
  }
66
98
 
67
99
  /**
68
- * [listener] stops hearing [screenId], if it is the one that listens: a later screen with the same id (a `setRoot`
69
- * makes the new root before the old one goes) keeps its own.
100
+ * [listener] hears the signals of [screenId] under [key] (its owner) until [removeListener] with the key: a key
101
+ * has one listener per id, the other keys keep theirs. What the safe area views said before reaches it at once, in
102
+ * order, for the sources it accepts.
70
103
  */
71
- fun removeListener(screenId: String, listener: Listener) {
72
- if (listeners[screenId] === listener) listeners.remove(screenId)
104
+ fun addListener(screenId: String, key: Any, listener: Listener) {
105
+ listeners.getOrPut(screenId, ::LinkedHashMap)[key] = listener
106
+ val entry = entries[screenId] ?: return
107
+ if (entry.laidOut.any(listener::accepts) && isListening(screenId, key, listener)) listener.onLaidOut()
108
+ if (entry.rendered.any(listener::accepts) && isListening(screenId, key, listener)) listener.onContentRendered()
73
109
  }
74
110
 
75
- /**
76
- * A new root takes [screenId] while another with it lives (a component of a bar on two screens): what the other
77
- * rendered isn't the new one's, which waits for its own render. Its mark of rendering at once stays.
78
- */
79
- fun forgetRender(screenId: String) {
80
- laidOut -= screenId
81
- rendered -= screenId
111
+ /** The listener of [key] stops hearing [screenId]; those of other keys (a later root with the id) keep theirs. */
112
+ fun removeListener(screenId: String, key: Any) {
113
+ val keys = listeners[screenId] ?: return
114
+ keys.remove(key)
115
+ if (keys.isEmpty()) listeners.remove(screenId)
82
116
  }
83
117
 
84
- /** The screen is destroyed: what was kept of it goes, so a later screen with the same id starts anew. */
118
+ /**
119
+ * The screen [screenId] owns the id from now on (made), or went while no other node has it (destroyed): what its
120
+ * safe area views said and the marks of rendering at once go, so a later screen with the id starts anew. The
121
+ * listeners stay with their keys, which remove them.
122
+ */
85
123
  fun remove(screenId: String) {
86
- atOnce -= screenId
87
- wrapped -= screenId
88
- laidOut -= screenId
89
- rendered -= screenId
90
- listeners -= screenId
124
+ entries.remove(screenId)
125
+ atOnce.remove(screenId)
91
126
  }
92
127
 
93
128
  /**
@@ -95,10 +130,22 @@ object ScreenReadiness {
95
130
  * "rendered" kept for a screen of the old one would make its new screen with the same id ready at once.
96
131
  */
97
132
  fun reset() {
98
- atOnce.clear()
99
- wrapped.clear()
100
- laidOut.clear()
101
- rendered.clear()
133
+ entries.clear()
102
134
  listeners.clear()
135
+ atOnce.clear()
136
+ }
137
+
138
+ private fun entryOf(screenId: String): Entry = entries.getOrPut(screenId, ::Entry)
139
+
140
+ private fun isListening(screenId: String, key: Any, listener: Listener): Boolean =
141
+ listeners[screenId]?.get(key) === listener
142
+
143
+ // Each listener of the id that accepts the source, as they are now: one that a listener before it removed hears
144
+ // nothing.
145
+ private fun tell(screenId: String, source: Int, signal: (Listener) -> Unit) {
146
+ val now = listeners[screenId]?.entries?.toList() ?: return
147
+ for ((key, listener) in now) {
148
+ if (isListening(screenId, key, listener) && listener.accepts(source)) signal(listener)
149
+ }
103
150
  }
104
151
  }
@@ -0,0 +1,50 @@
1
+ package dev.apexnative.navigation.viewcontrollers.navigator
2
+
3
+ import android.util.Log
4
+ import dev.apexnative.navigation.utils.NavigationLog
5
+
6
+ /**
7
+ * The check of [BackCallbackState]'s triggers in debug builds (`NavigationActivity` makes it only when
8
+ * `BuildConfig.DEBUG`): it compares an activity's back callback with what back does now
9
+ * ([BackCallbackState.mismatchOf]) and writes a mismatch to the log once per state, with the screen on top. It never
10
+ * changes the callback, so a missing trigger behaves as in a release build. A [sample] before a frame runs at most
11
+ * every [SAMPLE_MILLIS] and reports a mismatch only when the sample before saw the same one: a change whose trigger
12
+ * comes at the end of its motion (a pane that slides, a menu dragged open) isn't reported while it moves. A back event
13
+ * ([atBack]) reports at once. Main thread only.
14
+ */
15
+ internal class BackCallbackCheck(
16
+ private val log: (String) -> Unit = { Log.w(NavigationLog.TAG, it) },
17
+ ) {
18
+ private var lastSample: Long? = null
19
+ private var previous: String? = null
20
+ private val logged = HashSet<String>()
21
+
22
+ /** Before a frame of the activity of [navigator], at [now] (`SystemClock.uptimeMillis`). */
23
+ fun sample(navigator: Navigator, now: Long) {
24
+ val last = lastSample
25
+ if (last != null && now - last < SAMPLE_MILLIS) return
26
+ lastSample = now
27
+ val mismatch = BackCallbackState.mismatchOf(navigator)
28
+ if (mismatch != null && mismatch == previous) report(mismatch)
29
+ previous = mismatch
30
+ }
31
+
32
+ /** A back event reached the callback of the activity of [navigator], before it acts. */
33
+ fun atBack(navigator: Navigator) {
34
+ BackCallbackState.mismatchOf(navigator)?.let(::report)
35
+ }
36
+
37
+ // Once per state, and not without end.
38
+ private fun report(mismatch: String) {
39
+ if (logged.size >= MAX_LOGGED || !logged.add(mismatch)) return
40
+ log("[ApexNavigation] $mismatch: a change of what back does came without its trigger")
41
+ }
42
+
43
+ companion object {
44
+ /** The least time between two samples. */
45
+ const val SAMPLE_MILLIS = 500L
46
+
47
+ // The states logged at most: a debug check, not a second log.
48
+ private const val MAX_LOGGED = 32
49
+ }
50
+ }
@@ -2,21 +2,38 @@ package dev.apexnative.navigation.viewcontrollers.navigator
2
2
 
3
3
  import android.util.Log
4
4
  import androidx.activity.OnBackPressedCallback
5
+ import dev.apexnative.navigation.react.events.AppearanceDispatcher
5
6
  import dev.apexnative.navigation.utils.NavigationLog
6
7
  import java.lang.ref.WeakReference
7
8
 
8
9
  /**
9
10
  * Keeps the back callback of each navigation activity enabled only while back has something to do in the app: its
10
- * navigation can go back ([Navigator.canHandleBack]), or JavaScript listens to it (`BackHandler`). Otherwise the
11
- * callback is disabled and the system does its own back. The system plays its back animation (to the home screen, to
12
- * another task) only when no callback of the app is enabled as the gesture starts, and it decides that before the app
13
- * hears of the gesture.
11
+ * navigation can go back ([Navigator.canHandleBack]), the screen back acts on keeps it from the system
12
+ * (`hardwareBackButton.systemAnimation: false`, [Navigator.keepsBackFromSystem]), or JavaScript listens to it
13
+ * (`BackHandler`). Otherwise the callback is disabled and the system does its own back. The system plays its back
14
+ * animation (to the home screen, to another task) only when no callback of the app is enabled as the gesture starts,
15
+ * and it decides that before the app hears of the gesture.
14
16
  *
15
17
  * A stale "enabled" only loses that animation; a stale "disabled" would leave the app where back has to go back. So
16
- * the value is computed again whenever what back does can have changed: before each frame of the activity's window,
17
- * when a command is done, when JavaScript reports its listeners, and when a side menu, the panes of a split view or a
18
- * search open or close (the first two after their last frame). Each activity has its own navigator and callback; the
19
- * listeners of JavaScript count for all of them. Main thread only.
18
+ * the value is computed again on every event that can change what back does, never by walking the layouts before
19
+ * each frame:
20
+ * - the end of each command's main-thread task, `setDefaultOptions` and `mergeOptions` too (`CommandRunner`);
21
+ * - a stack's operation accepted, its current child changed, and the operation ended or rejected
22
+ * (`StackController`'s queue host), which covers a push or a `setStackRoot` that waited for its screen, a prepared
23
+ * push, back of the user and the back arrow;
24
+ * - a layout that changed the screens it shows (`Navigator.shownScreensChanged`: the tabs, a side menu, a split view,
25
+ * a pair) and the end of a tab's switch;
26
+ * - every change of the modals (`ModalCoordinator.Host.backChanged`): a show, a dismissal by a command, by back or by
27
+ * the user (a sheet dragged down, its scrim), a failure, a `setRoot` that takes them;
28
+ * - a root that changed or showed, and the layers that went (a reload: `Navigator.destroy`);
29
+ * - a side menu, the panes of a split view or a search that opened or closed (the first two after their last frame),
30
+ * and a split view's width that changed;
31
+ * - the release and the cancel of a back gesture, and each back press;
32
+ * - JavaScript's listeners ([setJsListeners]), and the activity resumed or focused, as a safety net.
33
+ *
34
+ * Overlays don't count: back never goes to them. Each activity has its own navigator and callback; the listeners of
35
+ * JavaScript count for all of them. Debug builds check the triggers ([mismatchOf], `BackCallbackCheck`). Main thread
36
+ * only.
20
37
  */
21
38
  internal object BackCallbackState {
22
39
  private class Entry(navigator: Navigator, callback: OnBackPressedCallback) {
@@ -56,22 +73,36 @@ internal object BackCallbackState {
56
73
  for (entry in entries.toList()) update(entry)
57
74
  }
58
75
 
59
- /** The callback of the activity of [navigator]: before its frames and at its back presses. */
76
+ /** The callback of the activity of [navigator]: after a change in its navigation. */
60
77
  fun update(navigator: Navigator) {
61
78
  entries.firstOrNull { it.navigator.get() === navigator }?.let(::update)
62
79
  }
63
80
 
81
+ /**
82
+ * Debug builds only: a callback of [navigator]'s activity that differs from what back does now — a change without
83
+ * its trigger —, described with the screen on top; null when it is right or not attached. It changes nothing.
84
+ */
85
+ fun mismatchOf(navigator: Navigator): String? {
86
+ val callback = entries.firstOrNull { it.navigator.get() === navigator }?.callback?.get() ?: return null
87
+ val expected = jsListeners || takesBack(navigator)
88
+ if (callback.isEnabled == expected) return null
89
+ val top = navigator.visibleLayout?.let { AppearanceDispatcher.visibleNode(it).id } ?: "none"
90
+ val state = if (callback.isEnabled) "enabled" else "disabled"
91
+ val back = if (expected) "back stays in the app" else "back leaves the app"
92
+ return "the back callback is $state where $back (on top: '$top')"
93
+ }
94
+
64
95
  // Enables or disables the callback when what back does changed.
65
96
  private fun update(entry: Entry) {
66
97
  val callback = entry.callback.get() ?: return
67
- val enabled = jsListeners || canHandleBack(entry.navigator.get())
98
+ val enabled = jsListeners || takesBack(entry.navigator.get())
68
99
  if (callback.isEnabled != enabled) callback.isEnabled = enabled
69
100
  }
70
101
 
71
- // A layout that fails to answer leaves back to the navigation, as before: this runs before each frame, and an
72
- // exception here would end the app.
73
- private fun canHandleBack(navigator: Navigator?): Boolean = try {
74
- navigator?.canHandleBack() == true
102
+ // A layout that fails to answer leaves back to the navigation, as before: this runs from the triggers of layouts
103
+ // and animators, and an exception here would end the app.
104
+ private fun takesBack(navigator: Navigator?): Boolean = try {
105
+ navigator != null && (navigator.canHandleBack() || navigator.keepsBackFromSystem())
75
106
  } catch (e: RuntimeException) {
76
107
  if (!failureLogged) {
77
108
  failureLogged = true
@@ -40,7 +40,6 @@ import dev.apexnative.navigation.viewcontrollers.parent.ParentController
40
40
  import dev.apexnative.navigation.viewcontrollers.stack.PreparedPushes
41
41
  import dev.apexnative.navigation.viewcontrollers.stack.StackCommands
42
42
  import dev.apexnative.navigation.viewcontrollers.stack.StackController
43
- import dev.apexnative.navigation.viewcontrollers.stack.topbar.BarComponents
44
43
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
45
44
  import dev.apexnative.navigation.views.animations.FadeThrough
46
45
  import dev.apexnative.navigation.views.animations.SideAnimator
@@ -105,7 +104,10 @@ class Navigator(
105
104
  readiness = ContentReadiness,
106
105
  scheduler = scheduler,
107
106
  firstRoot = firstRootDraw,
108
- onRootChanged = { root = it },
107
+ onRootChanged = {
108
+ root = it
109
+ BackCallbackState.update(this)
110
+ },
109
111
  onRootShown = {
110
112
  onRootShown?.invoke()
111
113
  BackCallbackState.update(this)
@@ -142,10 +144,21 @@ class Navigator(
142
144
 
143
145
  /**
144
146
  * Whether back has something to do in the navigation: the mirror of [handleBack], without acting — the top modal
145
- * first, then the root. The back callback asks it before each frame (`BackCallbackState`).
147
+ * first, then the root. The back callback asks it whenever what back does can have changed (`BackCallbackState`).
146
148
  */
147
149
  fun canHandleBack(): Boolean = modals.canHandleBack() ?: (root?.canHandleBack() == true)
148
150
 
151
+ /**
152
+ * Whether back stays in the app also when the navigation has nothing to do: the layout back acts on — the top
153
+ * modal's, else the root — shows a screen with `hardwareBackButton.systemAnimation: false`. The system then doesn't
154
+ * play its animation of going home, and the press does the activity's own back ([handleBack] is false). The back
155
+ * callback asks it with [canHandleBack] (`BackCallbackState`).
156
+ */
157
+ fun keepsBackFromSystem(): Boolean {
158
+ val layout = modals.backTargetRoot() ?: root ?: return false
159
+ return layout.resolveShownOptions(DefaultOptions.current).hardwareBackButton.systemAnimation.isFalse
160
+ }
161
+
149
162
  /**
150
163
  * The navigation's step of back, after JavaScript's listeners didn't take it: the top modal first (its content,
151
164
  * else it closes or holds), else the root does the first thing it can (a stack's top leaves or holds, an open side
@@ -187,12 +200,14 @@ class Navigator(
187
200
  * A layout of [node] (a stack, the tabs, a side menu, a split view, a pair) changed the screens it shows: when it
188
201
  * is in a layer that still waits, the layer waits for the screens it shows now, and no longer for one that went
189
202
  * ([WaitingLayer.shownScreensChanged]); so does the operation of its stack that waits for [node] as its entry (a
190
- * pushed pair); a modal's sheet takes the hold of the screen it shows now.
203
+ * pushed pair); a modal's sheet takes the hold of the screen it shows now; what back does is computed again (the
204
+ * tab, the pane or the menu back goes to, and the options of the screen it shows).
191
205
  */
192
206
  internal fun shownScreensChanged(node: ViewController<*>) {
193
207
  waitingLayerOf(node)?.shownScreensChanged()
194
208
  node.findParentStack()?.queue?.shownScreensChanged(node)
195
209
  modals.visibleScreenChanged(node)
210
+ BackCallbackState.update(this)
196
211
  }
197
212
 
198
213
  // The waiting layer [node] is in, up through the layouts around it; none.
@@ -435,7 +450,8 @@ class Navigator(
435
450
  destroyLayers(answerPending = false)
436
451
  // Nothing the old screens told of their readiness holds for the screens of the new JavaScript.
437
452
  ScreenReadiness.reset()
438
- BarComponents.reset()
453
+ // Without layers back leaves the app until the new JavaScript sets its root.
454
+ BackCallbackState.update(this)
439
455
  }
440
456
 
441
457
  /**
@@ -488,8 +488,8 @@ internal class RootCoordinator(
488
488
  }
489
489
 
490
490
  // The navigation state goes once, when the root is in place and its layers have shown, right before the answer:
491
- // what the old root's destroy, the root's appearance and each layer ask for meanwhile waits (StateHold). Taken once
492
- // per `setRoot`, before the old root goes.
491
+ // what the old root's destroy, the root's appearance and each layer ask for meanwhile waits (the hold of
492
+ // NavigationState). Taken once per `setRoot`, before the old root goes.
493
493
  private fun holdState(pending: Pending) {
494
494
  if (pending.holdsState) return
495
495
  pending.holdsState = true
@@ -153,6 +153,9 @@ class SplitViewController(
153
153
 
154
154
  private fun onWidthChanged(layout: SplitViewLayout) {
155
155
  primary.getView().visibility = if (primaryHidden && layout.isWide == true) View.GONE else View.VISIBLE
156
+ // Compact, side by side or one over the other decides what back does from this measure on: the callback only
157
+ // takes its value here, nothing moves; the panes' end below computes it again.
158
+ BackCallbackState.update()
156
159
  // Where a later `compactTopColumn` puts the pane over the other at this switch: true open, false closed.
157
160
  val settle = if (measured && isSliding) laterTopColumn?.let { it == SECONDARY } else null
158
161
  if (!measured) {