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

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 (193) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/android/CLAUDE.md +17 -7
  3. package/android/CORE.md +8 -3
  4. package/android/RECIPES.md +9 -3
  5. package/android/src/main/java/dev/apexnative/navigation/NavigationApplication.kt +34 -13
  6. package/android/src/main/java/dev/apexnative/navigation/layout/LayoutChecks.kt +5 -1
  7. package/android/src/main/java/dev/apexnative/navigation/layout/LayoutNode.kt +6 -2
  8. package/android/src/main/java/dev/apexnative/navigation/nativescreen/NativeScreenContext.kt +2 -2
  9. package/android/src/main/java/dev/apexnative/navigation/options/ButtonMenuOptions.kt +2 -2
  10. package/android/src/main/java/dev/apexnative/navigation/options/ButtonOptions.kt +2 -2
  11. package/android/src/main/java/dev/apexnative/navigation/options/OptionsCache.kt +8 -4
  12. package/android/src/main/java/dev/apexnative/navigation/options/UserChoices.kt +159 -0
  13. package/android/src/main/java/dev/apexnative/navigation/react/CommandAnswer.kt +2 -24
  14. package/android/src/main/java/dev/apexnative/navigation/react/CommandContract.kt +59 -0
  15. package/android/src/main/java/dev/apexnative/navigation/react/Hinge.kt +27 -19
  16. package/android/src/main/java/dev/apexnative/navigation/react/NavigationTurboModule.kt +5 -4
  17. package/android/src/main/java/dev/apexnative/navigation/react/PendingCalls.kt +48 -0
  18. package/android/src/main/java/dev/apexnative/navigation/react/events/AppearanceDispatcher.kt +17 -7
  19. package/android/src/main/java/dev/apexnative/navigation/slots/SlotContainers.kt +10 -7
  20. package/android/src/main/java/dev/apexnative/navigation/slots/SlotView.kt +4 -3
  21. package/android/src/main/java/dev/apexnative/navigation/slots/SlotViews.kt +40 -0
  22. package/android/src/main/java/dev/apexnative/navigation/utils/ImageKey.kt +66 -0
  23. package/android/src/main/java/dev/apexnative/navigation/utils/ImageLoader.kt +7 -89
  24. package/android/src/main/java/dev/apexnative/navigation/utils/ImageRequests.kt +76 -0
  25. package/android/src/main/java/dev/apexnative/navigation/utils/OptionImages.kt +262 -0
  26. package/android/src/main/java/dev/apexnative/navigation/utils/ViewController.kt +3 -1
  27. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/AppliedTabCalls.kt +49 -0
  28. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +16 -6
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt +25 -24
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ContentReadiness.kt +1 -1
  31. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/RenderWait.kt +27 -7
  32. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalCoordinator.kt +5 -5
  33. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +4 -3
  34. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/RootCoordinator.kt +6 -3
  35. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/overlay/OverlayCoordinator.kt +8 -8
  36. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/Capabilities.kt +11 -4
  37. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuController.kt +1 -1
  38. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitPanes.kt +23 -0
  39. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitViewController.kt +58 -15
  40. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackCommands.kt +28 -18
  41. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +16 -7
  42. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackDisplay.kt +2 -1
  43. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackQueue.kt +85 -29
  44. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackViews.kt +2 -2
  45. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/fab/FabPresenter.kt +7 -5
  46. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/search/StackSearchPresenter.kt +3 -3
  47. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/MenuChecks.kt +13 -56
  48. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/TopBarPresenter.kt +10 -2
  49. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/viewcontroller/ViewController.kt +56 -21
  50. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/BottomTabs.kt +4 -2
  51. package/android/src/main/java/dev/apexnative/navigation/views/stack/fab/Fab.kt +14 -10
  52. package/cli/doctor/checks/app.js +61 -13
  53. package/cli/migrate/native.js +18 -0
  54. package/cli/setup/source.js +2 -1
  55. package/cli/setup/steps/mainApplication.js +134 -19
  56. package/docs-md/architecture/transitions.md +6 -7
  57. package/docs-md/help/glossary.md +1 -1
  58. package/docs-md/help/troubleshooting.md +20 -3
  59. package/docs-md/index.json +4 -0
  60. package/docs-md/migration/from-react-native-navigation.md +8 -2
  61. package/docs-md/screen-ui/back-and-gestures.md +2 -2
  62. package/docs-md/start/installation.md +3 -1
  63. package/ios/CLAUDE.md +17 -5
  64. package/ios/CORE.md +13 -8
  65. package/ios/Contract/ANNCommandAnswer.mm +5 -20
  66. package/ios/Contract/ANNCommandContract.h +15 -0
  67. package/ios/Contract/ANNCommandContract.mm +78 -0
  68. package/ios/Core/ANNAppearanceDispatcher.mm +1 -1
  69. package/ios/Core/ANNErrorHandler.h +1 -1
  70. package/ios/Core/ANNErrorHandler.mm +3 -1
  71. package/ios/Core/ANNEventEmitter.h +1 -1
  72. package/ios/Core/ANNEventEmitter.mm +2 -2
  73. package/ios/Core/ANNJavaScriptCalls.h +34 -0
  74. package/ios/Core/ANNJavaScriptCalls.mm +61 -0
  75. package/ios/Core/ANNNavigationState.h +7 -3
  76. package/ios/Core/ANNNavigationState.mm +10 -5
  77. package/ios/Core/ANNNodeHelper.h +32 -12
  78. package/ios/Core/ANNNodeHelper.mm +100 -3
  79. package/ios/Core/ANNRenderWait.h +15 -1
  80. package/ios/Core/ANNRenderWait.mm +119 -17
  81. package/ios/Core/UIViewController+LayoutProtocol.h +9 -2
  82. package/ios/Core/UIViewController+LayoutProtocol.mm +8 -0
  83. package/ios/Layouts/Modal/ANNModalCoordinator.h +1 -1
  84. package/ios/Layouts/Modal/ANNModalCoordinator.mm +55 -14
  85. package/ios/Layouts/Screen/ANNComponentViewController.h +8 -0
  86. package/ios/Layouts/Screen/ANNComponentViewController.mm +32 -5
  87. package/ios/Layouts/Screen/ANNScreenPlace.h +41 -0
  88. package/ios/Layouts/Screen/ANNScreenPlace.mm +175 -0
  89. package/ios/Layouts/SplitView/ANNSplitViewCommands.h +3 -2
  90. package/ios/Layouts/SplitView/ANNSplitViewCommands.mm +105 -39
  91. package/ios/Layouts/SplitView/ANNSplitViewController.mm +4 -2
  92. package/ios/Layouts/Stack/ANNStackCommands.mm +1 -0
  93. package/ios/Layouts/Stack/ANNStackController.mm +26 -3
  94. package/ios/Layouts/Stack/ANNStackQueue.h +3 -1
  95. package/ios/Layouts/Stack/ANNStackQueue.mm +56 -43
  96. package/ios/Layouts/Stack/UINavigationController+ANNCommands.h +4 -2
  97. package/ios/Layouts/Stack/UINavigationController+ANNCommands.mm +2 -3
  98. package/ios/Layouts/Tabs/ANNBottomTabItemBuilder.h +4 -0
  99. package/ios/Layouts/Tabs/ANNBottomTabItemBuilder.mm +11 -0
  100. package/ios/Layouts/Tabs/ANNBottomTabsController.mm +13 -5
  101. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.h +2 -2
  102. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.mm +109 -7
  103. package/ios/Options/ANNOptionsCache.h +7 -6
  104. package/ios/Options/ANNSearchBarOptions.h +4 -3
  105. package/ios/Options/ANNUserChoice.h +57 -0
  106. package/ios/Options/ANNUserChoice.mm +44 -0
  107. package/ios/Options/Params/ANNImageSource.h +61 -0
  108. package/ios/Options/Params/ANNImageSource.mm +101 -0
  109. package/ios/Options/Params/ANNOptionImages.h +37 -0
  110. package/ios/Options/Params/ANNOptionImages.mm +284 -0
  111. package/ios/Options/Params/Image.h +11 -2
  112. package/ios/Options/Params/Image.mm +15 -2
  113. package/ios/Options/Params/ImageParser.h +5 -5
  114. package/ios/Options/Params/ImageParser.mm +28 -46
  115. package/ios/Public/ApexNavigation/ApexNavigation.h +1 -1
  116. package/ios/RECIPES.md +11 -5
  117. package/ios/React/Slots/ANNSlotComponentView.mm +24 -17
  118. package/ios/React/Slots/ANNSlotContainers.h +14 -6
  119. package/ios/React/Slots/ANNSlotContainers.mm +75 -2
  120. package/ios/TopBar/ANNBarButtons.h +9 -5
  121. package/ios/TopBar/ANNBarButtons.mm +211 -63
  122. package/ios/TopBar/ANNIconButtonImage.h +1 -0
  123. package/ios/TopBar/ANNIconButtonImage.mm +34 -1
  124. package/ios/TopBar/ANNLargeTitlePresenter.h +13 -0
  125. package/ios/TopBar/ANNLargeTitlePresenter.mm +26 -3
  126. package/ios/TopBar/ANNSearchPresenter.mm +170 -19
  127. package/ios/TopBar/ANNTopBarPresenter.h +5 -0
  128. package/ios/TopBar/ANNTopBarPresenter.mm +60 -11
  129. package/ios/TurboModules/ANNTurboModule.mm +39 -21
  130. package/lib/module/ARCHITECTURE.md +18 -18
  131. package/lib/module/adapters/NativeANNTurboModule.js.map +1 -1
  132. package/lib/module/api/navigation.js.map +1 -1
  133. package/lib/module/engine/commands.js +9 -7
  134. package/lib/module/engine/commands.js.map +1 -1
  135. package/lib/module/engine/layout.js +13 -9
  136. package/lib/module/engine/layout.js.map +1 -1
  137. package/lib/module/engine/options.js +44 -11
  138. package/lib/module/engine/options.js.map +1 -1
  139. package/lib/module/engine/places.js +44 -7
  140. package/lib/module/engine/places.js.map +1 -1
  141. package/lib/module/engine/props.js +30 -1
  142. package/lib/module/engine/props.js.map +1 -1
  143. package/lib/module/engine/rememberedOptions.js +1 -1
  144. package/lib/module/engine/rememberedOptions.js.map +1 -1
  145. package/lib/module/engine/screens.js +22 -12
  146. package/lib/module/engine/screens.js.map +1 -1
  147. package/lib/module/interfaces/contract/commands.json +101 -0
  148. package/lib/typescript/adapters/NativeANNTurboModule.d.ts +5 -2
  149. package/lib/typescript/adapters/NativeANNTurboModule.d.ts.map +1 -1
  150. package/lib/typescript/api/events/navigationEvents.d.ts +1 -1
  151. package/lib/typescript/api/events/navigationEvents.d.ts.map +1 -1
  152. package/lib/typescript/api/hooks/useNavigation.d.ts +1 -1
  153. package/lib/typescript/api/hooks/useNavigation.d.ts.map +1 -1
  154. package/lib/typescript/api/navigation.d.ts +4 -1
  155. package/lib/typescript/api/navigation.d.ts.map +1 -1
  156. package/lib/typescript/engine/commands.d.ts.map +1 -1
  157. package/lib/typescript/engine/options.d.ts +5 -2
  158. package/lib/typescript/engine/options.d.ts.map +1 -1
  159. package/lib/typescript/engine/places.d.ts +5 -2
  160. package/lib/typescript/engine/places.d.ts.map +1 -1
  161. package/lib/typescript/engine/props.d.ts +8 -1
  162. package/lib/typescript/engine/props.d.ts.map +1 -1
  163. package/lib/typescript/engine/rememberedOptions.d.ts.map +1 -1
  164. package/lib/typescript/engine/screens.d.ts.map +1 -1
  165. package/lib/typescript/interfaces/ComponentEvents.d.ts +2 -2
  166. package/lib/typescript/interfaces/ComponentEvents.d.ts.map +1 -1
  167. package/lib/typescript/interfaces/Events.d.ts +19 -10
  168. package/lib/typescript/interfaces/Events.d.ts.map +1 -1
  169. package/lib/typescript/interfaces/options/animations.d.ts +5 -2
  170. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  171. package/lib/typescript/interfaces/options/buttons.d.ts +13 -5
  172. package/lib/typescript/interfaces/options/buttons.d.ts.map +1 -1
  173. package/lib/typescript/interfaces/options/topBar.d.ts +22 -9
  174. package/lib/typescript/interfaces/options/topBar.d.ts.map +1 -1
  175. package/package.json +1 -1
  176. package/src/ARCHITECTURE.md +18 -18
  177. package/src/adapters/NativeANNTurboModule.ts +5 -2
  178. package/src/api/events/navigationEvents.ts +1 -1
  179. package/src/api/hooks/useNavigation.ts +1 -1
  180. package/src/api/navigation.ts +4 -1
  181. package/src/engine/commands.ts +19 -7
  182. package/src/engine/layout.ts +16 -9
  183. package/src/engine/options.ts +39 -11
  184. package/src/engine/places.ts +56 -7
  185. package/src/engine/props.ts +30 -1
  186. package/src/engine/rememberedOptions.ts +4 -1
  187. package/src/engine/screens.tsx +23 -13
  188. package/src/interfaces/ComponentEvents.ts +2 -2
  189. package/src/interfaces/Events.ts +19 -10
  190. package/src/interfaces/contract/commands.json +101 -0
  191. package/src/interfaces/options/animations.ts +5 -2
  192. package/src/interfaces/options/buttons.ts +13 -5
  193. package/src/interfaces/options/topBar.ts +22 -9
@@ -288,8 +288,8 @@ internal class StackViews(private val stack: StackController) : StackDisplay {
288
288
  screen.whenContentMounted(
289
289
  waiting = waiting,
290
290
  mounted = {
291
- // The frame of the mount: the screen shows transparent and lays out; its transition starts on the
292
- // pre-draw of that frame.
291
+ // The frame of the mount: the screen shows transparent and lays out. The pre-draw of that frame hands
292
+ // the start to the queue, whose transition begins behind the calls JavaScript made before.
293
293
  screen.peekView()?.let {
294
294
  it.alpha = 0f
295
295
  it.visibility = View.VISIBLE
@@ -172,19 +172,20 @@ internal class FabPresenter(
172
172
  alignStart = options.alignHorizontally.get("right") == "left"
173
173
  val source = source
174
174
  fab?.let { fab ->
175
- applyButton(fab, options)
175
+ applyButton(fab, options, mayWait = fab.drawable != null || options.visible.isFalse)
176
176
  TestIds.apply(fab, options.testID.takeIf { it.hasValue() }?.get())
177
177
  val id = shownId.orEmpty()
178
178
  fab.setOnClickListener { press(source, id) }
179
179
  }
180
180
  menu?.let { menu ->
181
- applyButton(menu.main, options)
181
+ applyButton(menu.main, options, mayWait = menu.main.drawable != null || options.visible.isFalse)
182
182
  TestIds.apply(menu.main, options.testID.takeIf { it.hasValue() }?.get())
183
183
  menu.scrimColor = options.scrimColor.get(activity, null)
184
184
  menu.setActions(options.actions.orEmpty().map { action ->
185
185
  val actionId = action.id.get("")
186
186
  Fab(activity, actionId).also { button ->
187
- applyButton(button, action)
187
+ // The actions show only while the menu is open.
188
+ applyButton(button, action, mayWait = !menu.isOpen)
188
189
  TestIds.apply(button, action.testID.takeIf { it.hasValue() }?.get())
189
190
  button.setOnClickListener {
190
191
  press(source, actionId)
@@ -205,9 +206,10 @@ internal class FabPresenter(
205
206
  place()
206
207
  }
207
208
 
208
- private fun applyButton(button: Fab, options: FabOptions) {
209
+ // [mayWait]: the button can take its icon when it is ready (it shows one, or it stays hidden), see [Fab.applyIcon].
210
+ private fun applyButton(button: Fab, options: FabOptions, mayWait: Boolean) {
209
211
  val icon = options.icon.takeIf { it.hasValue() }?.get()?.ifEmpty { null }
210
- if (icon != null) button.applyIcon(icon, options.iconColor)
212
+ if (icon != null) button.applyIcon(icon, options.iconColor, mayWait)
211
213
  applyColors(button, options)
212
214
  button.setMini(options.size.get("regular") == "mini")
213
215
  }
@@ -14,7 +14,6 @@ import androidx.core.view.WindowCompat
14
14
  import androidx.core.view.WindowInsetsCompat
15
15
  import com.facebook.react.ReactApplication
16
16
  import dev.apexnative.navigation.options.SearchBarOptions
17
- import dev.apexnative.navigation.options.params.Text
18
17
  import dev.apexnative.navigation.options.params.ThemeColour
19
18
  import dev.apexnative.navigation.react.events.EventEmitter
20
19
  import dev.apexnative.navigation.utils.dp
@@ -148,10 +147,11 @@ class StackSearchPresenter(
148
147
 
149
148
  private fun searchOf(screen: ViewController<*>): ScreenSearch =
150
149
  searches.getOrPut(screen.view) {
151
- // The search of a pair of screens is its primary's: its events name that screen, which keeps the scope.
150
+ // The search of a pair of screens is its primary's: its events name that screen, which keeps the scope in
151
+ // its user layer, where a merge that sends the same options again doesn't take it away.
152
152
  val owner = (screen as? OptionsScreen)?.optionsScreen ?: screen
153
153
  ScreenSearch(this, owner.id, styleContext(), loadIcon) { scope ->
154
- owner.changeOptions { it.topBar.searchBar.selectedScope = Text(scope) }
154
+ owner.changeChoices { it.pickScope(scope) }
155
155
  }
156
156
  }
157
157
 
@@ -1,16 +1,14 @@
1
1
  package dev.apexnative.navigation.viewcontrollers.stack.topbar
2
2
 
3
- import dev.apexnative.navigation.options.ButtonMenuOptions
4
- import dev.apexnative.navigation.options.ButtonOptions
5
3
  import dev.apexnative.navigation.options.Options
6
- import dev.apexnative.navigation.options.copyMenu
4
+ import dev.apexnative.navigation.options.UserChoices
7
5
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
8
6
 
9
7
  /**
10
- * The check of a single-selection menu the user picked: written through [ViewController.changeOptions] into the
11
- * screen's own menu, never into a menu other screens share (the stack's, the defaults'). A menu that came from
12
- * elsewhere is copied into the screen's own options first — for a button, its whole side, as lists of buttons replace
13
- * whole. The check stays with the screen until new options give the menu. Main thread only.
8
+ * The check of a single-selection menu the user picked: written into the screen's user layer
9
+ * ([ViewController.changeChoices]), never into its own options nor into a menu other screens share (the stack's, the
10
+ * defaults'). It goes over the menu as the screen's options resolve, by the button's id and the item's id, and stays
11
+ * until the app changes that menu's checks or takes the item away. Main thread only.
14
12
  */
15
13
  internal object MenuChecks {
16
14
  /**
@@ -19,54 +17,13 @@ internal object MenuChecks {
19
17
  */
20
18
  fun write(screen: ViewController<*>, resolved: Options, buttonId: String?, itemId: String) {
21
19
  val topBar = resolved.topBar
22
- if (buttonId == null) {
23
- val menu = topBar.overflowMenu ?: return
24
- if (!checks(menu, itemId)) return
25
- screen.changeOptions { own ->
26
- val copy = (own.topBar.overflowMenu ?: menu).copyMenu()
27
- check(copy, itemId)
28
- own.topBar.overflowMenu = copy
29
- }
30
- return
31
- }
32
- val left = topBar.buttons.left?.any { it.id.get(null) == buttonId } == true
33
- val list = (if (left) topBar.buttons.left else topBar.buttons.right) ?: return
34
- val menu = list.firstOrNull { it.id.get(null) == buttonId }?.menu ?: return
35
- if (!checks(menu, itemId)) return
36
- screen.changeOptions { own ->
37
- val ownList = (if (left) own.topBar.buttons.left else own.topBar.buttons.right)
38
- ?: list.map(ButtonOptions::copyWithMenu)
39
- val button = ownList.firstOrNull { it.id.get(null) == buttonId } ?: return@changeOptions
40
- val copy = (button.menu ?: menu).copyMenu()
41
- check(copy, itemId)
42
- button.menu = copy
43
- if (left) own.topBar.buttons.left = ownList else own.topBar.buttons.right = ownList
44
- }
45
- }
46
-
47
- /** Whether picking [itemId] moves a check: its level has `singleSelection`. */
48
- fun checks(menu: ButtonMenuOptions, itemId: String): Boolean = levelOf(menu.items, menu.singleSelection, itemId) == true
49
-
50
- /** Moves the check of [itemId]'s level to it, in [menu] itself. */
51
- fun check(menu: ButtonMenuOptions, itemId: String) {
52
- mark(menu.items, menu.singleSelection, itemId)
53
- }
54
-
55
- // Whether the level holding [itemId] has single selection; null when [itemId] isn't in [elements].
56
- private fun levelOf(elements: List<ButtonMenuOptions.Element>, single: Boolean, itemId: String): Boolean? {
57
- if (elements.any { it is ButtonMenuOptions.Item && it.id == itemId }) return single
58
- for (element in elements) {
59
- if (element !is ButtonMenuOptions.Submenu) continue
60
- levelOf(element.items, element.singleSelection, itemId)?.let { return it }
61
- }
62
- return null
63
- }
64
-
65
- private fun mark(elements: List<ButtonMenuOptions.Element>, single: Boolean, itemId: String): Boolean {
66
- if (elements.any { it is ButtonMenuOptions.Item && it.id == itemId }) {
67
- if (single) elements.forEach { if (it is ButtonMenuOptions.Item) it.checked = it.id == itemId }
68
- return true
69
- }
70
- return elements.any { it is ButtonMenuOptions.Submenu && mark(it.items, it.singleSelection, itemId) }
20
+ val menu = if (buttonId == null) {
21
+ topBar.overflowMenu
22
+ } else {
23
+ (topBar.buttons.left.orEmpty() + topBar.buttons.right.orEmpty()).firstOrNull { it.id.get(null) == buttonId }
24
+ ?.menu
25
+ } ?: return
26
+ if (!UserChoices.checks(menu, itemId)) return
27
+ screen.changeChoices { it.check(buttonId, menu, itemId) }
71
28
  }
72
29
  }
@@ -1008,8 +1008,16 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
1008
1008
  button.priority.get(null),
1009
1009
  ).joinToString("|")
1010
1010
 
1011
- private fun menuKey(menu: ButtonMenuOptions): String =
1012
- menu.items.joinToString("|") { "${it.title}:${it.icon}:${it.testID}" } + menu.testID
1011
+ // What the overflow menu shows, its submenus and checks with it: a check the app moves, or one of the user layer
1012
+ // that leaves, fills the menu again.
1013
+ private fun menuKey(menu: ButtonMenuOptions): String = elementsKey(menu.items) + menu.testID
1014
+
1015
+ private fun elementsKey(elements: List<ButtonMenuOptions.Element>): String = elements.joinToString("|") {
1016
+ "${it.title}:${it.icon}:${it.testID}" + when (it) {
1017
+ is ButtonMenuOptions.Item -> ":${it.checked}:${it.disabled}"
1018
+ is ButtonMenuOptions.Submenu -> "[${elementsKey(it.items)}]"
1019
+ }
1020
+ }
1013
1021
 
1014
1022
  private fun fontKey(font: FontOptions): String =
1015
1023
  listOf(font.fontFamily, font.fontStyle, font.fontWeight).joinToString { it.get(null).toString() }
@@ -6,6 +6,7 @@ import android.view.ViewGroup
6
6
  import dev.apexnative.navigation.options.DefaultOptions
7
7
  import dev.apexnative.navigation.options.Options
8
8
  import dev.apexnative.navigation.options.OptionsCache
9
+ import dev.apexnative.navigation.options.UserChoices
9
10
  import dev.apexnative.navigation.options.params.Bool
10
11
  import dev.apexnative.navigation.options.params.NullBool
11
12
  import dev.apexnative.navigation.react.CommandListener
@@ -30,11 +31,11 @@ abstract class ViewController<V : ViewGroup>(
30
31
  var initialOptions: Options,
31
32
  ) {
32
33
  /**
33
- * The node's own options: those it was made with, with every `mergeOptions` and every user's choice over them.
34
- * Writing them is one of the two places the options change ([OptionsCache]): the setter takes away the one-shot
35
- * options, keeps the object (a stripped copy when it has a one-shot option: the caller's object never changes), and
36
- * makes every resolved result stale. Don't change the object in place: [changeOptions] or [mergeOptions] write a
37
- * changed copy.
34
+ * The node's own options: those it was made with, with every `mergeOptions` over them; what the user chose in the
35
+ * native UI is not here but in [choices]. Writing them is one of the places the options change ([OptionsCache]):
36
+ * the setter takes away the one-shot options, keeps the object (a stripped copy when it has a one-shot option: the
37
+ * caller's object never changes), and makes every resolved result stale. Don't change the object in place:
38
+ * [changeOptions] or [mergeOptions] write a changed copy.
38
39
  */
39
40
  var options: Options = initialOptions.copy().also(Options::clearOneShotOptions)
40
41
  set(value) {
@@ -42,6 +43,10 @@ abstract class ViewController<V : ViewGroup>(
42
43
  OptionsCache.optionsChanged()
43
44
  }
44
45
 
46
+ // The user layer: what the user chose in the native UI (a menu's check, a search scope), over the node's own
47
+ // options as they resolve ([changeChoices]).
48
+ private val choices = UserChoices()
49
+
45
50
  /**
46
51
  * The JavaScript the node was made for (`ReloadCoordinator`): the events of the node and its screens reach only
47
52
  * that one.
@@ -156,9 +161,9 @@ abstract class ViewController<V : ViewGroup>(
156
161
  }
157
162
 
158
163
  /**
159
- * The node's options over those of the layouts around it, without the default options: the parents' resolution,
160
- * then the node's own. A new object at each call, which the caller may change. For a layout of nodes this is not
161
- * what it shows with: that is [resolveShownOptions].
164
+ * The node's options over those of the layouts around it, without the default options and without the user layer
165
+ * ([changeChoices]): the parents' resolution, then the node's own. A new object at each call, which the caller may
166
+ * change. For a layout of nodes this is not what it shows with: that is [resolveShownOptions].
162
167
  */
163
168
  open fun resolveCurrentOptions(): Options {
164
169
  val resolved = parent?.resolveChildOptions(this) ?: Options()
@@ -166,21 +171,21 @@ abstract class ViewController<V : ViewGroup>(
166
171
  }
167
172
 
168
173
  /**
169
- * The node's options over those of the layouts around it and [defaultOptions]: what it shows with. With the app's
170
- * default options ([DefaultOptions.current]) the result is kept and given again while no options changed
171
- * ([OptionsCache]) and the layouts around the node and its current children are the same: it is read-only, whoever
172
- * changes it copies it first. Main thread only.
174
+ * The node's options over those of the layouts around it and [defaultOptions], with the user layer over them
175
+ * ([changeChoices]): what it shows with. With the app's default options ([DefaultOptions.current]) the result is
176
+ * kept and given again while no options changed ([OptionsCache]) and the layouts around the node and its current
177
+ * children are the same: it is read-only, whoever changes it copies it first. Main thread only.
173
178
  *
174
179
  * For a layout of nodes (a stack, tabs, a side menu) it is the layout's own options over those around it, without
175
180
  * any child's: not what the layout shows with, which [resolveShownOptions] gives.
176
181
  */
177
182
  open fun resolveCurrentOptions(defaultOptions: Options): Options {
178
- if (defaultOptions !== DefaultOptions.current) return resolveCurrentOptions().withDefaultOptions(defaultOptions)
183
+ if (defaultOptions !== DefaultOptions.current) return withChoices(defaultOptions)
179
184
  val generation = OptionsCache.generation
180
185
  val along = resolvedAlong()
181
186
  val kept = resolved
182
187
  if (kept != null && resolvedGeneration == generation && sameNodes(along, resolvedAlong)) return kept
183
- val result = resolveCurrentOptions().withDefaultOptions(defaultOptions)
188
+ val result = withChoices(defaultOptions)
184
189
  resolved = result
185
190
  resolvedGeneration = generation
186
191
  resolvedAlong = along.map(::WeakReference)
@@ -196,6 +201,10 @@ abstract class ViewController<V : ViewGroup>(
196
201
  */
197
202
  open fun resolveShownOptions(defaultOptions: Options): Options = resolveCurrentOptions(defaultOptions)
198
203
 
204
+ // The resolution in its order: the defaults, the parents, the node's own options, then the user layer over them.
205
+ private fun withChoices(defaultOptions: Options): Options =
206
+ choices.applyTo(resolveCurrentOptions().withDefaultOptions(defaultOptions))
207
+
199
208
  // The layouts around this node, nearest first, then its current children down the visible chain.
200
209
  private fun resolvedAlong(): List<ViewController<*>> {
201
210
  val nodes = ArrayList<ViewController<*>>()
@@ -216,9 +225,9 @@ abstract class ViewController<V : ViewGroup>(
216
225
  nodes.size == kept.size && nodes.indices.all { nodes[it] === kept[it].get() }
217
226
 
218
227
  /**
219
- * Changes the node's own options: [change] gets a copy, which is stored ([options]). A presenter or a module that
220
- * writes a user's choice into the options (a menu's checkmark, a search scope, tokens, a toggled button) does it
221
- * this way; a button menu keeps its own shared `checked` (`ButtonMenuOptions`).
228
+ * Changes the node's own options: [change] gets a copy, which is stored ([options]). It is for the library's own
229
+ * bookkeeping of a merge (a side color written into the node's buttons, the back button's visibility); what the
230
+ * user chose goes through [changeChoices].
222
231
  */
223
232
  fun changeOptions(change: (Options) -> Unit) {
224
233
  val copy = options.copy()
@@ -226,14 +235,40 @@ abstract class ViewController<V : ViewGroup>(
226
235
  options = copy
227
236
  }
228
237
 
238
+ /**
239
+ * Changes the node's user layer: what the user chose in the native UI (a single-selection menu's check, a search
240
+ * scope). It goes over the node's own options as they resolve, by the ids of the buttons, items and scopes, and
241
+ * stays through an unrelated `mergeOptions`, the same values sent again and `setDefaultOptions`; a merge that
242
+ * changes the chosen field takes it away ([mergeOwnOptions]). Every resolved result is stale after it
243
+ * ([OptionsCache]).
244
+ */
245
+ internal fun changeChoices(change: (UserChoices) -> Unit) {
246
+ change(choices)
247
+ OptionsCache.optionsChanged()
248
+ }
249
+
250
+ /**
251
+ * [delta] of a `mergeOptions` goes over the node's own options ([options]); a choice of the user layer whose field
252
+ * the merge changes, or whose button, item or scope it took away, leaves, so the app's value shows
253
+ * (`UserChoices.yieldTo`). Every `mergeOptions` into a node writes its own options this way.
254
+ */
255
+ protected fun mergeOwnOptions(delta: Options) {
256
+ val own = options
257
+ val before = if (choices.isEmpty) null else resolveCurrentOptions().withDefaultOptions(DefaultOptions.current)
258
+ options = own.copy().mergeWith(delta)
259
+ before ?: return
260
+ val after = resolveCurrentOptions().withDefaultOptions(DefaultOptions.current)
261
+ if (choices.yieldTo(delta, own, before, after)) OptionsCache.optionsChanged()
262
+ }
263
+
229
264
  /**
230
265
  * `mergeOptions` with the node's id (`mergeOptions`): [options] go over its own and stay there (one-shot options
231
- * not kept); what shows applies them ([applyMergedOptions], when the view shows; otherwise as it appears); the
232
- * layouts around it apply their part (`ParentController.mergeChildOptions`, each passes it on). [options] still
233
- * carry the one-shot options to them. Containers that hand the merge to a child override it.
266
+ * not kept, [mergeOwnOptions]); what shows applies them ([applyMergedOptions], when the view shows; otherwise as it
267
+ * appears); the layouts around it apply their part (`ParentController.mergeChildOptions`, each passes it on).
268
+ * [options] still carry the one-shot options to them. Containers that hand the merge to a child override it.
234
269
  */
235
270
  open fun mergeOptions(options: Options) {
236
- this.options = this.options.copy().mergeWith(options)
271
+ mergeOwnOptions(options)
237
272
  if (isViewShown) applyMergedOptions(options)
238
273
  performOnParentController { it.mergeChildOptions(options, this) }
239
274
  }
@@ -228,13 +228,15 @@ class BottomTabs(context: Context) : BottomNavigationView(MaterialTheme.context(
228
228
  applyLongPressesAgain()
229
229
  }
230
230
 
231
- fun setIcon(index: Int, icon: Drawable) {
231
+ /** Null: the item has no icon. */
232
+ fun setIcon(index: Int, icon: Drawable?) {
232
233
  val item = items.getOrNull(index) ?: return
233
234
  if (item.icon == icon) return
234
235
  updateIcon(index, item.copy(icon = icon))
235
236
  }
236
237
 
237
- fun setSelectedIcon(index: Int, icon: Drawable) {
238
+ /** Null: the item shows its icon when selected too. */
239
+ fun setSelectedIcon(index: Int, icon: Drawable?) {
238
240
  val item = items.getOrNull(index) ?: return
239
241
  if (item.selectedIcon == icon) return
240
242
  updateIcon(index, item.copy(selectedIcon = icon))
@@ -4,12 +4,11 @@ import android.content.Context
4
4
  import android.content.res.ColorStateList
5
5
  import android.content.res.Configuration
6
6
  import android.graphics.Color
7
- import android.graphics.drawable.Drawable
8
7
  import com.google.android.material.floatingactionbutton.FloatingActionButton
9
8
  import dev.apexnative.navigation.options.params.ThemeColour
10
- import dev.apexnative.navigation.utils.ImageLoader
11
- import dev.apexnative.navigation.utils.ImageLoadingListenerAdapter
9
+ import dev.apexnative.navigation.utils.ImageRequests
12
10
  import dev.apexnative.navigation.utils.MaterialTheme
11
+ import dev.apexnative.navigation.utils.OptionImages
13
12
  import dev.apexnative.navigation.utils.UiUtils
14
13
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ScrollEventListener
15
14
  import androidx.appcompat.R as AppCompatR
@@ -29,6 +28,7 @@ class Fab(context: Context, val fabId: String) : FloatingActionButton(MaterialTh
29
28
  // The icon's color of the options: a container color merged later kept the theme's icon color instead.
30
29
  private var iconColor: Int? = null
31
30
  private var optionRippleColor: Int? = null
31
+ private val icons = ImageRequests<Unit>(OptionImages.source(this.context))
32
32
 
33
33
  init {
34
34
  // Material's theme overlay of the button is a copy of the theme, made once: it follows a light/dark switch too.
@@ -36,14 +36,18 @@ class Fab(context: Context, val fabId: String) : FloatingActionButton(MaterialTh
36
36
  MaterialTheme.followOverlay(this.context)
37
37
  }
38
38
 
39
- fun applyIcon(icon: String, color: ThemeColour) {
39
+ /**
40
+ * The icon of the options, with its color. [mayWait]: the button can show it when it is ready (it shows an icon
41
+ * already, it is hidden with `visible: false`, or it is an action of a closed menu), so an icon the cache doesn't
42
+ * have loads off the main thread; the last icon asked for wins. Otherwise it loads at once. An icon that doesn't
43
+ * load leaves the button without an icon.
44
+ */
45
+ fun applyIcon(icon: String, color: ThemeColour, mayWait: Boolean) {
40
46
  if (color.hasValue()) iconColor = color.get()
41
- ImageLoader().loadIcon(context, icon, object : ImageLoadingListenerAdapter() {
42
- override fun onComplete(drawable: Drawable) {
43
- setImageDrawable(drawable)
44
- iconColor?.let { imageTintList = ColorStateList.valueOf(it) }
45
- }
46
- })
47
+ icons.request(Unit, icon, mayWait) { drawable ->
48
+ setImageDrawable(drawable)
49
+ iconColor?.let { imageTintList = ColorStateList.valueOf(it) }
50
+ }
47
51
  }
48
52
 
49
53
  /** iconColor alone, without a new icon: it did nothing in a merge. */
@@ -11,8 +11,10 @@ const {
11
11
  reactNativeVersion,
12
12
  usesExpo,
13
13
  } = require('../../app');
14
+ const { mainActivity } = require('../../links/native');
14
15
  const { findProject } = require('../../project');
15
16
  const { codeOf } = require('../../setup/source');
17
+ const { loadsReactNativeItself } = require('../../setup/steps/mainApplication');
16
18
  const { planCodegen } = require('../../codegen');
17
19
  const { readConfig } = require('../../config');
18
20
  const { planProject } = require('../../setup');
@@ -27,6 +29,16 @@ const { splashChecks } = require('./splash');
27
29
  const SETUP = { label: 'npx apex-navigation setup', run: /** @type {const} */ ('setup') };
28
30
  const GENERATED = ['ApexNavigation.generated.swift', 'ApexNavigationGenerated.kt'];
29
31
  const CODEGEN = { label: 'npx apex-navigation codegen', run: /** @type {const} */ ('codegen') };
32
+ // The changes the main activity must handle itself; the template's value has them all.
33
+ const CONFIG_CHANGES = [
34
+ 'orientation',
35
+ 'screenSize',
36
+ 'smallestScreenSize',
37
+ 'screenLayout',
38
+ 'uiMode',
39
+ ];
40
+ const TEMPLATE_CONFIG_CHANGES =
41
+ 'keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode';
30
42
 
31
43
  /**
32
44
  * React Native, the navigation, and the files setup sets up.
@@ -122,28 +134,64 @@ function appChecks({ root, run }) {
122
134
  });
123
135
  }
124
136
 
137
+ const configChanges = configChangesCheck(project.manifest);
138
+ if (configChanges) checks.push(configChanges);
139
+
125
140
  checks.push(...splashChecks(root, project), ...iconChecks(root, project));
126
141
 
127
- // A second load of React Native crashes the app on start: the most common slip of a setup by hand.
142
+ // NavigationApplication leaves the load of React Native to the template's call: without it the app closes on start,
143
+ // and so it does with a second load of the app's own next to it.
128
144
  const application = project.mainApplication && fs.readFileSync(project.mainApplication, 'utf8');
129
- if (
130
- application &&
131
- /:\s*NavigationApplication\(\)/.test(application) &&
132
- /\bloadReactNative\s*\(/.test(codeOf(application))
133
- ) {
134
- checks.push({
135
- group: 'app',
136
- title: 'React Native loads twice',
137
- status: 'error',
138
- detail: 'MainApplication.kt calls loadReactNative(this), NavigationApplication loads it too',
139
- fix: { label: 'Remove loadReactNative(this) from MainApplication.kt' },
140
- });
145
+ if (application && /:\s*NavigationApplication\(/.test(application)) {
146
+ const loads = /\bloadReactNative\s*\(/.test(codeOf(application));
147
+ if (!loads) {
148
+ checks.push({
149
+ group: 'app',
150
+ title: 'React Native not loaded',
151
+ status: 'error',
152
+ detail: "MainApplication.kt doesn't call loadReactNative(this) in onCreate",
153
+ fix: SETUP,
154
+ });
155
+ } else if (loadsReactNativeItself(application)) {
156
+ checks.push({
157
+ group: 'app',
158
+ title: 'React Native loads twice',
159
+ status: 'error',
160
+ detail:
161
+ 'MainApplication.kt loads React Native itself (SoLoader.init or the New Architecture entry point) next to loadReactNative(this)',
162
+ fix: SETUP,
163
+ });
164
+ }
141
165
  }
142
166
  const generated = generatedCheck(root, run);
143
167
  if (generated) checks.push(generated);
144
168
  return checks;
145
169
  }
146
170
 
171
+ /**
172
+ * Whether the main activity handles the changes of its window itself, as the React Native template has it. Without
173
+ * one of them Android recreates the activity on a rotation, a resize, a fold or a light/dark switch: every screen goes
174
+ * with it and the navigation starts again from its root. Nothing without a manifest or a main activity.
175
+ * @param {string | undefined} manifest
176
+ * @returns {Check | undefined}
177
+ */
178
+ function configChangesCheck(manifest) {
179
+ if (!manifest || !fs.existsSync(manifest)) return undefined;
180
+ const activity = mainActivity(fs.readFileSync(manifest, 'utf8'));
181
+ if (!activity) return undefined;
182
+ const value = /android:configChanges\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(activity.text);
183
+ const declared = (value?.[1] ?? value?.[2] ?? '').split('|').map((change) => change.trim());
184
+ const missing = CONFIG_CHANGES.filter((change) => !declared.includes(change));
185
+ if (missing.length === 0) return undefined;
186
+ return {
187
+ group: 'app',
188
+ title: 'Config changes',
189
+ status: 'warn',
190
+ detail: `the main activity doesn't handle ${missing.join(', ')}: Android recreates it, and every screen with it`,
191
+ fix: { label: `Set android:configChanges="${TEMPLATE_CONFIG_CHANGES}" on the main activity` },
192
+ };
193
+ }
194
+
147
195
  /**
148
196
  * One line for a few files: set up, or which need setup or a change by hand.
149
197
  * @param {string} title
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const { withReactNativeLoaded } = require('../setup/steps/mainApplication');
6
7
 
7
8
  /** @typedef {import('../changes').FileChange} FileChange */
8
9
  /** @typedef {import('./report').ReportItem} ReportItem */
@@ -158,6 +159,23 @@ function planNative(project) {
158
159
  const before = fs.readFileSync(file, 'utf8');
159
160
  let next = before;
160
161
  for (const [pattern, replacement] of rewrite.rules) next = next.replace(pattern, replacement);
162
+ // react-native-navigation loaded React Native itself; the navigation's application leaves it to the template's
163
+ // loadReactNative(this), which such an app doesn't have.
164
+ if (file === project.mainApplication && /:\s*NavigationApplication\(/.test(next)) {
165
+ const loaded = withReactNativeLoaded(next);
166
+ if (loaded !== undefined) next = loaded;
167
+ else {
168
+ report.push({
169
+ file: path.relative(root, file),
170
+ line: 1,
171
+ step: 'native',
172
+ before: 'onCreate',
173
+ after: 'super.onCreate() then loadReactNative(this), and no load of its own',
174
+ what: 'React Native loads through loadReactNative(this) in onCreate: add it by hand',
175
+ docs: `${DOCS}#native`,
176
+ });
177
+ }
178
+ }
161
179
  if (next !== before) {
162
180
  changes.push({
163
181
  name: path.basename(file),
@@ -35,7 +35,8 @@ function removeUnusedImports(text, names) {
35
35
  for (const name of names) {
36
36
  const line = new RegExp(`^import\\s+${escape(name)}[ \\t]*\\r?\\n`, 'm');
37
37
  const simple = name.slice(name.lastIndexOf('.') + 1);
38
- const code = result.replace(/^import\s.*$/gm, '');
38
+ // A name in a comment is no use of the import.
39
+ const code = codeOf(result).replace(/^import\s.*$/gm, '');
39
40
  if (line.test(result) && !new RegExp(`\\b${escape(simple)}\\b`).test(code))
40
41
  result = result.replace(line, '');
41
42
  }