@apexnative/navigation 1.0.0-alpha.2 → 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 (226) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/android/CLAUDE.md +38 -9
  3. package/android/CORE.md +11 -4
  4. package/android/RECIPES.md +9 -3
  5. package/android/src/main/java/dev/apexnative/navigation/NavigationActivity.kt +29 -1
  6. package/android/src/main/java/dev/apexnative/navigation/NavigationApplication.kt +34 -13
  7. package/android/src/main/java/dev/apexnative/navigation/layout/LayoutChecks.kt +5 -1
  8. package/android/src/main/java/dev/apexnative/navigation/layout/LayoutNode.kt +6 -2
  9. package/android/src/main/java/dev/apexnative/navigation/nativescreen/NativeScreenContext.kt +2 -2
  10. package/android/src/main/java/dev/apexnative/navigation/options/ButtonMenuOptions.kt +2 -2
  11. package/android/src/main/java/dev/apexnative/navigation/options/ButtonOptions.kt +2 -2
  12. package/android/src/main/java/dev/apexnative/navigation/options/OptionsCache.kt +8 -4
  13. package/android/src/main/java/dev/apexnative/navigation/options/StatusBarOptions.kt +7 -3
  14. package/android/src/main/java/dev/apexnative/navigation/options/UserChoices.kt +159 -0
  15. package/android/src/main/java/dev/apexnative/navigation/react/CommandAnswer.kt +2 -24
  16. package/android/src/main/java/dev/apexnative/navigation/react/CommandContract.kt +59 -0
  17. package/android/src/main/java/dev/apexnative/navigation/react/Hinge.kt +27 -19
  18. package/android/src/main/java/dev/apexnative/navigation/react/NavigationTurboModule.kt +5 -4
  19. package/android/src/main/java/dev/apexnative/navigation/react/PendingCalls.kt +48 -0
  20. package/android/src/main/java/dev/apexnative/navigation/react/events/AppearanceDispatcher.kt +17 -7
  21. package/android/src/main/java/dev/apexnative/navigation/slots/SlotContainers.kt +10 -7
  22. package/android/src/main/java/dev/apexnative/navigation/slots/SlotView.kt +4 -3
  23. package/android/src/main/java/dev/apexnative/navigation/slots/SlotViews.kt +40 -0
  24. package/android/src/main/java/dev/apexnative/navigation/utils/ImageKey.kt +66 -0
  25. package/android/src/main/java/dev/apexnative/navigation/utils/ImageLoader.kt +7 -89
  26. package/android/src/main/java/dev/apexnative/navigation/utils/ImageRequests.kt +76 -0
  27. package/android/src/main/java/dev/apexnative/navigation/utils/OptionImages.kt +262 -0
  28. package/android/src/main/java/dev/apexnative/navigation/utils/ViewController.kt +3 -1
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/arrangement/SecondaryScreenPresenter.kt +13 -5
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/AppliedTabCalls.kt +49 -0
  31. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +92 -19
  32. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsPresenter.kt +25 -24
  33. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ComponentPresenter.kt +28 -7
  34. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ContentReadiness.kt +1 -1
  35. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/RenderWait.kt +27 -7
  36. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ScreenFrame.kt +1 -1
  37. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalAnimations.kt +22 -24
  38. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalCoordinator.kt +79 -13
  39. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalRecord.kt +21 -6
  40. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalSheet.kt +29 -12
  41. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalViews.kt +22 -18
  42. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/BackCallbackState.kt +4 -0
  43. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +45 -6
  44. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/RootCoordinator.kt +35 -9
  45. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/overlay/OverlayCoordinator.kt +10 -8
  46. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/Capabilities.kt +11 -4
  47. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ContainerBackground.kt +3 -3
  48. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ParentController.kt +3 -0
  49. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuController.kt +5 -1
  50. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitPanes.kt +23 -0
  51. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitViewController.kt +72 -16
  52. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/BackPreview.kt +287 -0
  53. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackCommands.kt +28 -18
  54. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +164 -13
  55. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackDisplay.kt +26 -1
  56. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackQueue.kt +136 -40
  57. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackTransitions.kt +26 -8
  58. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackViews.kt +107 -4
  59. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/fab/FabPresenter.kt +7 -5
  60. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/search/StackSearchPresenter.kt +3 -3
  61. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/MenuChecks.kt +13 -56
  62. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/TopBarPresenter.kt +131 -9
  63. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/viewcontroller/ViewController.kt +75 -21
  64. package/android/src/main/java/dev/apexnative/navigation/views/animations/Curves.kt +29 -11
  65. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/BottomTabs.kt +4 -2
  66. package/android/src/main/java/dev/apexnative/navigation/views/stack/ScreenEdge.kt +15 -0
  67. package/android/src/main/java/dev/apexnative/navigation/views/stack/StackLayout.kt +137 -1
  68. package/android/src/main/java/dev/apexnative/navigation/views/stack/fab/Fab.kt +14 -10
  69. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TitleBar.kt +8 -1
  70. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TopBar.kt +26 -4
  71. package/cli/doctor/checks/app.js +61 -13
  72. package/cli/migrate/native.js +18 -0
  73. package/cli/setup/source.js +2 -1
  74. package/cli/setup/steps/mainApplication.js +134 -19
  75. package/docs-md/architecture/transitions.md +12 -7
  76. package/docs-md/containers/modals.md +4 -2
  77. package/docs-md/containers/overlays.md +3 -1
  78. package/docs-md/containers/side-menu.md +4 -2
  79. package/docs-md/containers/tabs.md +2 -0
  80. package/docs-md/flows/forms-in-a-modal.md +1 -1
  81. package/docs-md/help/glossary.md +2 -2
  82. package/docs-md/help/troubleshooting.md +20 -3
  83. package/docs-md/index.json +12 -0
  84. package/docs-md/migration/from-react-native-navigation.md +8 -2
  85. package/docs-md/screen-ui/back-and-gestures.md +8 -2
  86. package/docs-md/screen-ui/system-ui.md +1 -1
  87. package/docs-md/start/installation.md +3 -1
  88. package/ios/CLAUDE.md +17 -5
  89. package/ios/CORE.md +13 -8
  90. package/ios/Contract/ANNCommandAnswer.mm +5 -20
  91. package/ios/Contract/ANNCommandContract.h +15 -0
  92. package/ios/Contract/ANNCommandContract.mm +78 -0
  93. package/ios/Core/ANNAppearanceDispatcher.mm +1 -1
  94. package/ios/Core/ANNErrorHandler.h +1 -1
  95. package/ios/Core/ANNErrorHandler.mm +3 -1
  96. package/ios/Core/ANNEventEmitter.h +1 -1
  97. package/ios/Core/ANNEventEmitter.mm +2 -2
  98. package/ios/Core/ANNJavaScriptCalls.h +34 -0
  99. package/ios/Core/ANNJavaScriptCalls.mm +61 -0
  100. package/ios/Core/ANNNavigationState.h +7 -3
  101. package/ios/Core/ANNNavigationState.mm +10 -5
  102. package/ios/Core/ANNNodeHelper.h +32 -12
  103. package/ios/Core/ANNNodeHelper.mm +100 -3
  104. package/ios/Core/ANNRenderWait.h +15 -1
  105. package/ios/Core/ANNRenderWait.mm +119 -17
  106. package/ios/Core/UIViewController+LayoutProtocol.h +9 -2
  107. package/ios/Core/UIViewController+LayoutProtocol.mm +8 -0
  108. package/ios/Layouts/Modal/ANNModalCoordinator.h +1 -1
  109. package/ios/Layouts/Modal/ANNModalCoordinator.mm +55 -14
  110. package/ios/Layouts/Screen/ANNComponentViewController.h +8 -0
  111. package/ios/Layouts/Screen/ANNComponentViewController.mm +32 -5
  112. package/ios/Layouts/Screen/ANNScreenPlace.h +41 -0
  113. package/ios/Layouts/Screen/ANNScreenPlace.mm +175 -0
  114. package/ios/Layouts/SplitView/ANNSplitViewCommands.h +3 -2
  115. package/ios/Layouts/SplitView/ANNSplitViewCommands.mm +105 -39
  116. package/ios/Layouts/SplitView/ANNSplitViewController.mm +4 -2
  117. package/ios/Layouts/Stack/ANNStackCommands.mm +1 -0
  118. package/ios/Layouts/Stack/ANNStackController.mm +26 -3
  119. package/ios/Layouts/Stack/ANNStackQueue.h +3 -1
  120. package/ios/Layouts/Stack/ANNStackQueue.mm +56 -43
  121. package/ios/Layouts/Stack/UINavigationController+ANNCommands.h +4 -2
  122. package/ios/Layouts/Stack/UINavigationController+ANNCommands.mm +2 -3
  123. package/ios/Layouts/Tabs/ANNBottomTabItemBuilder.h +4 -0
  124. package/ios/Layouts/Tabs/ANNBottomTabItemBuilder.mm +11 -0
  125. package/ios/Layouts/Tabs/ANNBottomTabsController.mm +13 -5
  126. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.h +2 -2
  127. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.mm +109 -7
  128. package/ios/Options/ANNOptionsCache.h +7 -6
  129. package/ios/Options/ANNSearchBarOptions.h +4 -3
  130. package/ios/Options/ANNUserChoice.h +57 -0
  131. package/ios/Options/ANNUserChoice.mm +44 -0
  132. package/ios/Options/Params/ANNImageSource.h +61 -0
  133. package/ios/Options/Params/ANNImageSource.mm +101 -0
  134. package/ios/Options/Params/ANNOptionImages.h +37 -0
  135. package/ios/Options/Params/ANNOptionImages.mm +284 -0
  136. package/ios/Options/Params/Image.h +11 -2
  137. package/ios/Options/Params/Image.mm +15 -2
  138. package/ios/Options/Params/ImageParser.h +5 -5
  139. package/ios/Options/Params/ImageParser.mm +28 -46
  140. package/ios/Public/ApexNavigation/ApexNavigation.h +1 -1
  141. package/ios/RECIPES.md +11 -5
  142. package/ios/React/Slots/ANNSlotComponentView.mm +24 -17
  143. package/ios/React/Slots/ANNSlotContainers.h +14 -6
  144. package/ios/React/Slots/ANNSlotContainers.mm +75 -2
  145. package/ios/TopBar/ANNBarButtons.h +9 -5
  146. package/ios/TopBar/ANNBarButtons.mm +211 -63
  147. package/ios/TopBar/ANNIconButtonImage.h +1 -0
  148. package/ios/TopBar/ANNIconButtonImage.mm +34 -1
  149. package/ios/TopBar/ANNLargeTitlePresenter.h +13 -0
  150. package/ios/TopBar/ANNLargeTitlePresenter.mm +26 -3
  151. package/ios/TopBar/ANNSearchPresenter.mm +170 -19
  152. package/ios/TopBar/ANNTopBarPresenter.h +5 -0
  153. package/ios/TopBar/ANNTopBarPresenter.mm +60 -11
  154. package/ios/TurboModules/ANNTurboModule.mm +39 -21
  155. package/lib/module/ARCHITECTURE.md +18 -18
  156. package/lib/module/adapters/NativeANNTurboModule.js.map +1 -1
  157. package/lib/module/api/hooks/useNavigation.js +8 -3
  158. package/lib/module/api/hooks/useNavigation.js.map +1 -1
  159. package/lib/module/api/navigation.js.map +1 -1
  160. package/lib/module/engine/commands.js +9 -7
  161. package/lib/module/engine/commands.js.map +1 -1
  162. package/lib/module/engine/layout.js +13 -9
  163. package/lib/module/engine/layout.js.map +1 -1
  164. package/lib/module/engine/options.js +44 -11
  165. package/lib/module/engine/options.js.map +1 -1
  166. package/lib/module/engine/places.js +44 -7
  167. package/lib/module/engine/places.js.map +1 -1
  168. package/lib/module/engine/props.js +30 -1
  169. package/lib/module/engine/props.js.map +1 -1
  170. package/lib/module/engine/rememberedOptions.js +1 -1
  171. package/lib/module/engine/rememberedOptions.js.map +1 -1
  172. package/lib/module/engine/screens.js +22 -12
  173. package/lib/module/engine/screens.js.map +1 -1
  174. package/lib/module/interfaces/contract/commands.json +101 -0
  175. package/lib/typescript/adapters/NativeANNTurboModule.d.ts +5 -2
  176. package/lib/typescript/adapters/NativeANNTurboModule.d.ts.map +1 -1
  177. package/lib/typescript/api/events/navigationEvents.d.ts +1 -1
  178. package/lib/typescript/api/events/navigationEvents.d.ts.map +1 -1
  179. package/lib/typescript/api/hooks/useNavigation.d.ts +1 -1
  180. package/lib/typescript/api/hooks/useNavigation.d.ts.map +1 -1
  181. package/lib/typescript/api/navigation.d.ts +4 -1
  182. package/lib/typescript/api/navigation.d.ts.map +1 -1
  183. package/lib/typescript/engine/commands.d.ts.map +1 -1
  184. package/lib/typescript/engine/options.d.ts +5 -2
  185. package/lib/typescript/engine/options.d.ts.map +1 -1
  186. package/lib/typescript/engine/places.d.ts +5 -2
  187. package/lib/typescript/engine/places.d.ts.map +1 -1
  188. package/lib/typescript/engine/props.d.ts +8 -1
  189. package/lib/typescript/engine/props.d.ts.map +1 -1
  190. package/lib/typescript/engine/rememberedOptions.d.ts.map +1 -1
  191. package/lib/typescript/engine/screens.d.ts.map +1 -1
  192. package/lib/typescript/interfaces/ComponentEvents.d.ts +2 -2
  193. package/lib/typescript/interfaces/ComponentEvents.d.ts.map +1 -1
  194. package/lib/typescript/interfaces/Events.d.ts +19 -10
  195. package/lib/typescript/interfaces/Events.d.ts.map +1 -1
  196. package/lib/typescript/interfaces/options/animations.d.ts +15 -7
  197. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  198. package/lib/typescript/interfaces/options/buttons.d.ts +13 -5
  199. package/lib/typescript/interfaces/options/buttons.d.ts.map +1 -1
  200. package/lib/typescript/interfaces/options/modals.d.ts +18 -13
  201. package/lib/typescript/interfaces/options/modals.d.ts.map +1 -1
  202. package/lib/typescript/interfaces/options/system.d.ts +6 -3
  203. package/lib/typescript/interfaces/options/system.d.ts.map +1 -1
  204. package/lib/typescript/interfaces/options/topBar.d.ts +22 -9
  205. package/lib/typescript/interfaces/options/topBar.d.ts.map +1 -1
  206. package/package.json +1 -1
  207. package/src/ARCHITECTURE.md +18 -18
  208. package/src/adapters/NativeANNTurboModule.ts +5 -2
  209. package/src/api/events/navigationEvents.ts +1 -1
  210. package/src/api/hooks/useNavigation.ts +9 -4
  211. package/src/api/navigation.ts +4 -1
  212. package/src/engine/commands.ts +19 -7
  213. package/src/engine/layout.ts +16 -9
  214. package/src/engine/options.ts +39 -11
  215. package/src/engine/places.ts +56 -7
  216. package/src/engine/props.ts +30 -1
  217. package/src/engine/rememberedOptions.ts +4 -1
  218. package/src/engine/screens.tsx +23 -13
  219. package/src/interfaces/ComponentEvents.ts +2 -2
  220. package/src/interfaces/Events.ts +19 -10
  221. package/src/interfaces/contract/commands.json +101 -0
  222. package/src/interfaces/options/animations.ts +15 -7
  223. package/src/interfaces/options/buttons.ts +13 -5
  224. package/src/interfaces/options/modals.ts +18 -13
  225. package/src/interfaces/options/system.ts +6 -3
  226. package/src/interfaces/options/topBar.ts +22 -9
@@ -0,0 +1,76 @@
1
+ package dev.apexnative.navigation.utils
2
+
3
+ import android.graphics.drawable.Drawable
4
+
5
+ /** Where the images of the options come from, for one context: [OptionImages.source], or a fake in the tests. */
6
+ internal interface ImageSource {
7
+ /** The key of [uri] in the context's configuration now; for a `file:` uri it reads the file's version. */
8
+ fun key(uri: String): ImageKey
9
+
10
+ /** A copy of the image of [key] when the cache has it, none otherwise; never loads. */
11
+ fun cached(key: ImageKey): Drawable?
12
+
13
+ /** A copy of the image of [key], from the cache or loaded on the caller's thread; none on a failure. */
14
+ fun now(key: ImageKey): Drawable?
15
+
16
+ /** Whether [uri] can load off the main thread: a file or a drawable of the app. */
17
+ fun loadsLater(uri: String): Boolean
18
+
19
+ /** Loads [key] off the main thread and gives a copy of it, or none on a failure, to [done] on the main thread. */
20
+ fun later(key: ImageKey, done: (Drawable?) -> Unit)
21
+ }
22
+
23
+ /**
24
+ * The images that the places of a view asked for (an item's icon, a button's icon), by place [P]: what the cache has
25
+ * comes at once; what it doesn't, when the place can show it later, loads off the main thread and comes when ready;
26
+ * everything else loads at once. The last request of a place wins: a load that arrives after the place asked again,
27
+ * or after [forget] or [clear], is dropped. A load that fails gives the place no image, as an option without one:
28
+ * the image it showed before doesn't stay. A request builds the key of its image once (a file's version is read once).
29
+ * Main thread only.
30
+ */
31
+ internal class ImageRequests<P : Any>(private val source: ImageSource) {
32
+ // The open load of each place: its number, which a later request of the place replaces.
33
+ private val waiting = HashMap<P, Long>()
34
+ private var last = 0L
35
+
36
+ /** The key of [uri] now, for a caller that compares it before it asks ([request] with the key). */
37
+ fun key(uri: String): ImageKey = source.key(uri)
38
+
39
+ /** [request] for the image of [uri], its key built here. */
40
+ fun request(place: P, uri: String, mayWait: Boolean, show: (Drawable?) -> Unit) =
41
+ request(place, source.key(uri), mayWait, show)
42
+
43
+ /**
44
+ * Gives [show] the image of [key] for [place]: now when the cache has it, or when the place can't wait for it
45
+ * ([mayWait] false: the first frame needs it) or the uri can't load off the main thread (Metro's address, a content
46
+ * uri); otherwise later, unless [place] asks again first. Null when it doesn't load: the place shows none.
47
+ */
48
+ fun request(place: P, key: ImageKey, mayWait: Boolean, show: (Drawable?) -> Unit) {
49
+ waiting.remove(place)
50
+ source.cached(key)?.let {
51
+ show(it)
52
+ return
53
+ }
54
+ if (!mayWait || !source.loadsLater(key.uri)) {
55
+ show(source.now(key))
56
+ return
57
+ }
58
+ val number = ++last
59
+ waiting[place] = number
60
+ source.later(key) { image ->
61
+ if (waiting[place] != number) return@later
62
+ waiting.remove(place)
63
+ show(image)
64
+ }
65
+ }
66
+
67
+ /** The place shows no image of the options now: a load it asked for earlier is dropped. */
68
+ fun forget(place: P) {
69
+ waiting.remove(place)
70
+ }
71
+
72
+ /** The view goes: every open load is dropped. */
73
+ fun clear() {
74
+ waiting.clear()
75
+ }
76
+ }
@@ -0,0 +1,262 @@
1
+ package dev.apexnative.navigation.utils
2
+
3
+ import android.content.ComponentCallbacks2
4
+ import android.content.Context
5
+ import android.content.res.Configuration
6
+ import android.content.res.Resources
7
+ import android.graphics.BitmapFactory
8
+ import android.graphics.drawable.BitmapDrawable
9
+ import android.graphics.drawable.Drawable
10
+ import android.net.Uri
11
+ import android.os.StrictMode
12
+ import android.util.LruCache
13
+ import androidx.core.content.ContextCompat
14
+ import com.facebook.react.views.imagehelper.ResourceDrawableIdHelper
15
+ import java.io.FileInputStream
16
+ import java.io.FileNotFoundException
17
+ import java.io.IOException
18
+ import java.io.InputStream
19
+ import java.net.URL
20
+ import java.util.Collections
21
+ import java.util.concurrent.ExecutorService
22
+ import java.util.concurrent.Executors
23
+
24
+ /**
25
+ * The images and icons of the options, every one of them (bar buttons and their menus, the back button, search
26
+ * suggestions, tab icons, the FAB): one cache for the app, by [ImageKey], sized by the bytes of its images and let go
27
+ * when the system is low on memory with the app in the background ([clearsOnTrim]), not each time the app's UI hides;
28
+ * each caller gets its own mutable copy. The images don't depend on the JavaScript, so a reload keeps them.
29
+ *
30
+ * A `file:` address is a file; anything else first a drawable of the app by name (as React Native's images by resource
31
+ * name: release builds' assets, `{ android }` icons). Those two load on the caller's thread ([load]) or on a
32
+ * background thread of their own ([loadLater]). Not found while the app is in development: the address is read as a
33
+ * stream on the main thread, Metro's asset over the network (network on the main thread allowed for that read only)
34
+ * or through the content resolver. A remote address that is not Metro's asset is never loaded. A failure (a missing
35
+ * file or drawable, data that doesn't decode, an image too large for the memory) never ends the app: no image, and one
36
+ * warning per address in the log (and LogBox in development).
37
+ *
38
+ * A theme attribute inside a drawable of the app is resolved against the theme of the context that loaded it first.
39
+ * Main thread, unless a member says otherwise.
40
+ */
41
+ internal object OptionImages {
42
+ private val cache = object : LruCache<ImageKey, Entry>(maxBytes()) {
43
+ override fun sizeOf(key: ImageKey, value: Entry) = value.bytes
44
+ }
45
+
46
+ // The addresses already named in a warning: one each.
47
+ private val warned: MutableSet<String> = Collections.synchronizedSet(HashSet())
48
+
49
+ // The loads off the main thread under way, with who waits for each.
50
+ private val loading = HashMap<ImageKey, MutableList<Waiter>>()
51
+
52
+ private val background: ExecutorService by lazy {
53
+ Executors.newSingleThreadExecutor { task -> Thread(task, THREAD_NAME).apply { isDaemon = true } }
54
+ }
55
+
56
+ @Volatile
57
+ private var listening = false
58
+
59
+ /** The cache and the loads for [context]: the source of an [ImageRequests]. */
60
+ fun source(context: Context): ImageSource = object : ImageSource {
61
+ override fun key(uri: String) = ImageKey.of(context, uri)
62
+
63
+ override fun cached(key: ImageKey) = cached(context, key)
64
+
65
+ override fun now(key: ImageKey) = load(context, key)
66
+
67
+ override fun loadsLater(uri: String) = loadsLater(context, uri)
68
+
69
+ override fun later(key: ImageKey, done: (Drawable?) -> Unit) = loadLater(context, key, done)
70
+ }
71
+
72
+ /** A copy of the image of [key] when the cache has it. Any thread. */
73
+ fun cached(context: Context, key: ImageKey): Drawable? = cache.get(key)?.copy(context)
74
+
75
+ /** A copy of the image of [uri], from the cache or loaded on the caller's thread; none on a failure. */
76
+ fun load(context: Context, uri: String): Drawable? = load(context, ImageKey.of(context, uri))
77
+
78
+ /**
79
+ * A copy of the image of [key] (built in the configuration of [context]), from the cache or loaded on the caller's
80
+ * thread; none on a failure.
81
+ */
82
+ fun load(context: Context, key: ImageKey): Drawable? {
83
+ listen(context)
84
+ cache.get(key)?.let { return it.copy(context) }
85
+ val loaded = decodeOrWarn(context, key.uri) ?: return null
86
+ return keep(key, loaded)?.copy(context) ?: loaded
87
+ }
88
+
89
+ /** Whether [uri] loads off the main thread: a file, or a drawable the app has by that name. Any thread. */
90
+ fun loadsLater(context: Context, uri: String): Boolean =
91
+ uri.startsWith(FILE_SCHEME) || ResourceDrawableIdHelper.getResourceDrawableId(context, uri) > 0
92
+
93
+ /**
94
+ * Loads [key] (built in the configuration of [context]) on the background thread and gives [done] a copy of it, or
95
+ * none on a failure, on the main thread; one load for all who ask for the same image meanwhile. Only for what
96
+ * [loadsLater].
97
+ */
98
+ fun loadLater(context: Context, key: ImageKey, done: (Drawable?) -> Unit) {
99
+ listen(context)
100
+ val waiters = loading[key]
101
+ if (waiters != null) {
102
+ waiters.add(Waiter(context, done))
103
+ return
104
+ }
105
+ loading[key] = mutableListOf(Waiter(context, done))
106
+ background.execute {
107
+ val loaded = decodeOrWarn(context, key.uri)
108
+ val entry = loaded?.let { keep(key, it) }
109
+ UiThread.post { deliver(key, loaded, entry) }
110
+ }
111
+ }
112
+
113
+ /** Lets every image of the cache go. Any thread. */
114
+ fun clear() {
115
+ cache.evictAll()
116
+ }
117
+
118
+ /**
119
+ * Whether the cache lets its images go at a trim of [level]: when the system is low on memory with the app in the
120
+ * background (`TRIM_MEMORY_BACKGROUND` and above), not at `TRIM_MEMORY_UI_HIDDEN`, which comes each time the app
121
+ * goes to the background: its icons are there when it comes back.
122
+ */
123
+ fun clearsOnTrim(level: Int): Boolean = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
124
+
125
+ // Each waiter gets a copy of its own; an image the cache can't keep (no constant state) goes to the first only,
126
+ // the others load it again.
127
+ private fun deliver(key: ImageKey, loaded: Drawable?, entry: Entry?) {
128
+ val waiters = loading.remove(key) ?: return
129
+ waiters.forEachIndexed { index, waiter ->
130
+ val image = when {
131
+ loaded == null -> null
132
+ entry != null -> entry.copy(waiter.context)
133
+ index == 0 -> loaded
134
+ else -> load(waiter.context, key)
135
+ }
136
+ waiter.done(image)
137
+ }
138
+ }
139
+
140
+ // Into the cache, when the image can be copied; the entry it keeps. Any thread.
141
+ private fun keep(key: ImageKey, image: Drawable): Entry? {
142
+ val state = image.constantState ?: return null
143
+ return Entry(state, bytesOf(image)).also { cache.put(key, it) }
144
+ }
145
+
146
+ private fun bytesOf(image: Drawable): Int {
147
+ if (image is BitmapDrawable) image.bitmap?.let { return it.allocationByteCount }
148
+ // A drawable that draws itself (a vector) keeps a picture of its own size when it is drawn.
149
+ return image.intrinsicWidth.coerceAtLeast(1) * image.intrinsicHeight.coerceAtLeast(1) * BYTES_PER_PIXEL
150
+ }
151
+
152
+ // The image of [uri], or none and one warning per address. Any thread.
153
+ private fun decodeOrWarn(context: Context, uri: String): Drawable? = try {
154
+ decode(context, uri)
155
+ } catch (e: Exception) {
156
+ warnOnce(uri, (e as? LoadFailure)?.why ?: ": ${e.message ?: e.javaClass.simpleName}")
157
+ null
158
+ } catch (e: OutOfMemoryError) {
159
+ warnOnce(uri, ": it is too large to decode")
160
+ null
161
+ }
162
+
163
+ private fun warnOnce(uri: String, why: String) {
164
+ if (warned.add(uri)) DevelopmentLog.warn("Could not load image $uri$why")
165
+ }
166
+
167
+ private fun decode(context: Context, uri: String): Drawable {
168
+ if (uri.startsWith(FILE_SCHEME)) return decodeStream(context) { FileInputStream(Uri.parse(uri).path) }
169
+ resourceDrawable(context, uri)?.let { return it }
170
+ if (!DevelopmentLog.isDevelopment) throw LoadFailure("")
171
+ val address = Uri.parse(uri)
172
+ return when (address.scheme) {
173
+ "http", "https" -> {
174
+ if (!isMetroAsset(address)) throw LoadFailure(": remote images are not supported in options")
175
+ withNetworkOnMainThread { decodeStream(context) { URL(uri).openStream() } }
176
+ }
177
+ else -> decodeStream(context) {
178
+ context.contentResolver.openInputStream(address) ?: throw FileNotFoundException(uri)
179
+ }
180
+ }
181
+ }
182
+
183
+ // A drawable of the app by name; none when the app has none by that name.
184
+ private fun resourceDrawable(context: Context, name: String): Drawable? {
185
+ val id = ResourceDrawableIdHelper.getResourceDrawableId(context, name)
186
+ if (id <= 0) return null
187
+ return try {
188
+ ContextCompat.getDrawable(context, id)
189
+ } catch (e: Resources.NotFoundException) {
190
+ null
191
+ }
192
+ }
193
+
194
+ // The image of the stream [open] gives, closed after the read, also on an error; one it can't decode fails, and so
195
+ // does one too large for the memory.
196
+ private fun decodeStream(context: Context, open: () -> InputStream): Drawable {
197
+ val decoded = try {
198
+ open().use { BitmapFactory.decodeStream(it) }
199
+ } catch (e: OutOfMemoryError) {
200
+ throw LoadFailure(": it is too large to decode")
201
+ }
202
+ val bitmap = decoded ?: throw LoadFailure(": it can't be decoded")
203
+ return BitmapDrawable(context.resources, bitmap)
204
+ }
205
+
206
+ // Metro's asset: an address of the server the JavaScript bundle came from, under `/assets/`.
207
+ private fun isMetroAsset(address: Uri): Boolean {
208
+ val bundle = DevelopmentLog.bundleUrl?.let(Uri::parse) ?: return false
209
+ return address.host == bundle.host && address.port == bundle.port &&
210
+ address.path.orEmpty().startsWith(ASSETS_PATH)
211
+ }
212
+
213
+ // Reading from the network on the main thread for [read] only; the thread's policy comes back, also on an error.
214
+ private fun <T> withNetworkOnMainThread(read: () -> T): T {
215
+ val policy = StrictMode.getThreadPolicy()
216
+ StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder(policy).permitNetwork().build())
217
+ try {
218
+ return read()
219
+ } finally {
220
+ StrictMode.setThreadPolicy(policy)
221
+ }
222
+ }
223
+
224
+ // The cache lets its images go when the system is low on memory ([clearsOnTrim]): they load again when asked.
225
+ private fun listen(context: Context) {
226
+ if (listening) return
227
+ synchronized(this) {
228
+ if (listening) return
229
+ listening = true
230
+ }
231
+ context.applicationContext.registerComponentCallbacks(object : ComponentCallbacks2 {
232
+ override fun onTrimMemory(level: Int) {
233
+ if (clearsOnTrim(level)) clear()
234
+ }
235
+
236
+ override fun onConfigurationChanged(newConfig: Configuration) = Unit
237
+
238
+ @Deprecated("Android 14 deprecates it; onTrimMemory comes with it")
239
+ override fun onLowMemory() = clear()
240
+ })
241
+ }
242
+
243
+ // A sixteenth of the app's heap, at most 16 MB: the options' icons are small.
244
+ private fun maxBytes(): Int = (Runtime.getRuntime().maxMemory() / HEAP_PART).coerceAtMost(MAX_BYTES).toInt()
245
+
246
+ // An image the cache keeps: what makes its copies, and its size.
247
+ private class Entry(val state: Drawable.ConstantState, val bytes: Int) {
248
+ fun copy(context: Context): Drawable = state.newDrawable(context.resources).mutate()
249
+ }
250
+
251
+ private class Waiter(val context: Context, val done: (Drawable?) -> Unit)
252
+
253
+ // A failure the loader names itself: `Could not load image <source>`, then [why].
254
+ private class LoadFailure(val why: String) : IOException("Could not load image$why")
255
+
256
+ private const val FILE_SCHEME = "file:"
257
+ private const val ASSETS_PATH = "/assets/"
258
+ private const val THREAD_NAME = "ApexNavigation images"
259
+ private const val BYTES_PER_PIXEL = 4
260
+ private const val HEAP_PART = 16L
261
+ private const val MAX_BYTES = 16L * 1024 * 1024
262
+ }
@@ -59,7 +59,9 @@ private fun ViewController<*>.hostIsShown(): Boolean {
59
59
  * Runs [mounted] as the content of this screen's slot mounts into its container, and [start] on the pre-draw of that
60
60
  * frame: after React Native applied the whole mount, before the frame is drawn. At once if the content has mounted,
61
61
  * after [DRAW_WAIT_LIMIT] if it never does; while [waiting] holds. A screen prepared for its push was rendered at its
62
- * size and insets before: in that frame it is whole.
62
+ * size and insets before, so it is whole from that frame on; its push doesn't start in that frame: [start] hands it to
63
+ * the stack's queue, which starts the transition on a later turn, behind the calls JavaScript made before
64
+ * (`PendingCalls`), so the `mergeOptions` of the mount's commit has applied.
63
65
  */
64
66
  fun ViewController<*>.whenContentMounted(waiting: () -> Boolean, mounted: () -> Unit, start: () -> Unit) {
65
67
  var done = false
@@ -2,24 +2,25 @@ package dev.apexnative.navigation.viewcontrollers.arrangement
2
2
 
3
3
  import android.app.Activity
4
4
  import dev.apexnative.navigation.options.Options
5
+ import dev.apexnative.navigation.viewcontrollers.component.ComponentPresenter
5
6
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
6
7
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
7
8
 
8
9
  /**
9
10
  * The presenter of the secondary screen of a pair ([ArrangementController]). The status bar, the navigation bar and
10
- * the orientation are the primary's, as the top bar is: the secondary applies only its background, and brought to the
11
- * front it gives them back to the primary.
11
+ * the orientation are the primary's, as the top bar is: the secondary applies only its background (the theme's
12
+ * without `layout.backgroundColor`, as any React screen), and brought to the front it gives them back to the primary.
12
13
  */
13
14
  internal class SecondaryScreenPresenter(activity: Activity, defaultOptions: Options) :
14
15
  Presenter(activity, defaultOptions) {
15
16
 
16
17
  override fun applyOptions(view: ViewController<*>, options: Options) =
17
- applyBackgroundColor(view, options.copy().withDefaultOptions(defaultOptions))
18
+ applyBackground(view, options.copy().withDefaultOptions(defaultOptions))
18
19
 
19
20
  override fun mergeOptions(viewController: ViewController<*>, options: Options) {
20
21
  if (!options.layout.backgroundColor.hasValue()) return
21
22
  val merged = viewController.resolveCurrentOptions().copy().mergeWith(options)
22
- applyBackgroundColor(viewController, merged.withDefaultOptions(defaultOptions))
23
+ applyBackground(viewController, merged.withDefaultOptions(defaultOptions))
23
24
  }
24
25
 
25
26
  override fun onViewBroughtToFront(viewController: ViewController<*>, options: Options) {
@@ -27,5 +28,12 @@ internal class SecondaryScreenPresenter(activity: Activity, defaultOptions: Opti
27
28
  }
28
29
 
29
30
  override fun onConfigurationChanged(controller: ViewController<*>, options: Options) =
30
- applyBackgroundColor(controller, options.copy().withDefaultOptions(defaultOptions))
31
+ applyBackground(controller, options.copy().withDefaultOptions(defaultOptions))
32
+
33
+ // `layout.backgroundColor`, else the theme's background (`ComponentPresenter.themeBackgroundOf`).
34
+ private fun applyBackground(view: ViewController<*>, options: Options) {
35
+ if (options.layout.backgroundColor.hasValue()) return applyBackgroundColor(view, options)
36
+ val color = ComponentPresenter.themeBackgroundOf(activity) ?: return
37
+ view.peekView()?.setBackgroundColor(color)
38
+ }
31
39
  }
@@ -0,0 +1,49 @@
1
+ package dev.apexnative.navigation.viewcontrollers.bottomtabs
2
+
3
+ import dev.apexnative.navigation.utils.ImageKey
4
+
5
+ /**
6
+ * The last call of each kind for each item of the bar, by which [BottomTabsPresenter.run] skips a call equal to it: an
7
+ * icon isn't loaded and set again as a screen appears. The badge and the font always go (their defaults are read at
8
+ * each set). An icon whose load gave no image goes again ([failed]); a `file:` icon compares by its [ImageKey] (the
9
+ * file's modification time and size, read by [fileKey]), so a file written again at the same path shows. Main thread.
10
+ */
11
+ internal class AppliedTabCalls(private val fileKey: (String) -> ImageKey) {
12
+ /** A call that runs: [isChange], the item had a call of its kind before; [image], the key of a `file:` icon. */
13
+ class Next(val isChange: Boolean, val image: ImageKey?)
14
+
15
+ private class Applied(val call: TabItems.Call, val image: ImageKey?, var failed: Boolean = false)
16
+
17
+ private val last = HashMap<Pair<Int, Class<*>>, Applied>()
18
+
19
+ /** How [call] runs, and it is the last of its kind for its item from now on; none when it is skipped. */
20
+ fun next(call: TabItems.Call): Next? {
21
+ val key = call.index to call.javaClass
22
+ val before = last[key]
23
+ val image = uriOf(call)?.takeIf { it.startsWith(FILE_SCHEME) }?.let(fileKey)
24
+ val always = call is TabItems.Call.Badge || call is TabItems.Call.Font
25
+ if (!always && before != null && before.call == call && !before.failed && before.image == image) return null
26
+ last[key] = Applied(call, image)
27
+ return Next(isChange = before != null, image = image)
28
+ }
29
+
30
+ /** The icon of [call] didn't load: the same call goes again. */
31
+ fun failed(call: TabItems.Call) {
32
+ last[call.index to call.javaClass]?.takeIf { it.call == call }?.failed = true
33
+ }
34
+
35
+ /** The bar goes: every call goes again. */
36
+ fun clear() {
37
+ last.clear()
38
+ }
39
+
40
+ private fun uriOf(call: TabItems.Call): String? = when (call) {
41
+ is TabItems.Call.Icon -> call.uri
42
+ is TabItems.Call.SelectedIcon -> call.uri
43
+ else -> null
44
+ }
45
+
46
+ private companion object {
47
+ const val FILE_SCHEME = "file:"
48
+ }
49
+ }
@@ -1,5 +1,8 @@
1
1
  package dev.apexnative.navigation.viewcontrollers.bottomtabs
2
2
 
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.AnimatorSet
3
6
  import android.app.Activity
4
7
  import android.graphics.Color
5
8
  import android.util.Log
@@ -33,6 +36,7 @@ import dev.apexnative.navigation.viewcontrollers.stack.fab.FabPlacement
33
36
  import dev.apexnative.navigation.viewcontrollers.stack.topbar.BarComponents
34
37
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
35
38
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
39
+ import dev.apexnative.navigation.views.animations.FadeThrough
36
40
  import dev.apexnative.navigation.views.bottomtabs.BottomTabItem
37
41
  import dev.apexnative.navigation.views.bottomtabs.BottomTabsLayout
38
42
  import dev.apexnative.navigation.views.bottomtabs.CustomBottomTabItemView
@@ -68,6 +72,12 @@ class BottomTabsController(
68
72
  private var cells: List<CustomBottomTabItemView> = emptyList()
69
73
  // `ANN.BottomTabSelected` of a press that waits for the new tab's screen to appear.
70
74
  private var pendingSelected: (() -> Unit)? = null
75
+ // Ends the fade through of a switch still running, with its events.
76
+ private var endSwitch: (() -> Unit)? = null
77
+
78
+ // The made tabs that didn't show at the last repaint (new default options, a light/dark switch): each is repainted
79
+ // as it is selected, not while hidden.
80
+ private val staleTabs = mutableSetOf<ViewController<*>>()
71
81
  // The bar goes with a stack's transition: the options of the new top don't move it themselves.
72
82
  private var riding = false
73
83
  private var restAttached = false
@@ -150,8 +160,11 @@ class BottomTabsController(
150
160
  /** The view of [tab] joins, or is there already: [visible], or invisible but laid out. */
151
161
  fun attach(tab: ViewController<*>, visible: Boolean)
152
162
 
153
- /** The view of [shown] becomes visible and that of [hidden] invisible: the switch, at once. */
154
- fun switch(shown: ViewController<*>, hidden: ViewController<*>)
163
+ /**
164
+ * The view of [shown] becomes visible and that of [hidden] invisible: at once, or with Material's fade through
165
+ * when [animated]; then [onEnd]. The function returned ends a switch still running at once, [onEnd] included.
166
+ */
167
+ fun switch(shown: ViewController<*>, hidden: ViewController<*>, animated: Boolean, onEnd: () -> Unit): () -> Unit
155
168
  }
156
169
 
157
170
  /** The views of the tabs; none before the tabs' view is made. */
@@ -166,9 +179,43 @@ class BottomTabsController(
166
179
  tab.view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
167
180
  }
168
181
 
169
- override fun switch(shown: ViewController<*>, hidden: ViewController<*>) {
170
- shown.view.visibility = View.VISIBLE
171
- hidden.peekView()?.visibility = View.INVISIBLE
182
+ override fun switch(
183
+ shown: ViewController<*>,
184
+ hidden: ViewController<*>,
185
+ animated: Boolean,
186
+ onEnd: () -> Unit,
187
+ ): () -> Unit {
188
+ val coming = shown.view
189
+ coming.visibility = View.VISIBLE
190
+ val going = hidden.peekView()
191
+ if (!animated || going == null) {
192
+ going?.visibility = View.INVISIBLE
193
+ onEnd()
194
+ return {}
195
+ }
196
+ val exit = FadeThrough.exit(going)
197
+ // Faded out, the tab that goes takes no more touches and no accessibility focus.
198
+ exit.addListener(object : AnimatorListenerAdapter() {
199
+ override fun onAnimationEnd(animation: Animator) {
200
+ going.visibility = View.INVISIBLE
201
+ }
202
+ })
203
+ val set = AnimatorSet()
204
+ set.playTogether(exit, FadeThrough.enter(coming))
205
+ set.addListener(object : AnimatorListenerAdapter() {
206
+ override fun onAnimationEnd(animation: Animator) {
207
+ set.removeAllListeners()
208
+ // The tab that went keeps its look for its next selection; only its visibility hides it.
209
+ going.visibility = View.INVISIBLE
210
+ going.alpha = 1f
211
+ coming.alpha = 1f
212
+ coming.scaleX = 1f
213
+ coming.scaleY = 1f
214
+ onEnd()
215
+ }
216
+ })
217
+ set.start()
218
+ return set::end
172
219
  }
173
220
  }
174
221
 
@@ -204,7 +251,7 @@ class BottomTabsController(
204
251
  // `mergeOptions` with the tabs' id: their own options, then what they show, then the layouts around without
205
252
  // `bottomTabs`; the one-shot options act once, also while the view isn't shown.
206
253
  override fun mergeOptions(options: Options) {
207
- this.options = this.options.copy().mergeWith(options)
254
+ mergeOwnOptions(options)
208
255
  applyMerge(options, null)
209
256
  performOnParentController { it.mergeChildOptions(withoutBottomTabs(options), this) }
210
257
  }
@@ -268,8 +315,10 @@ class BottomTabsController(
268
315
  private enum class How { Press, Code, Back }
269
316
 
270
317
  /**
271
- * A tap on the tab at [index], on the bar or on its cell of the custom row: its `ANN.BottomTabPressed` first, then
272
- * the selection, the repeat, or nothing, as its resolved options say.
318
+ * A tap on the tab at [index], on the bar or on its cell of the custom row, the one path of both: a switch still
319
+ * running ends first, with its `ANN.BottomTabSelected`; then this press's `ANN.BottomTabPressed`, then the
320
+ * selection (`ANN.BottomTabSelected` after the new screen's DidAppear), the repeat (the pop's own events), or
321
+ * nothing, as its resolved options say. The same order as `-[ANNBottomTabsController pressTabAtIndex:source:]`.
273
322
  */
274
323
  internal fun onTabPressed(index: Int) {
275
324
  if (isDestroyed || index !in tabs.indices) return
@@ -302,10 +351,9 @@ class BottomTabsController(
302
351
  }
303
352
 
304
353
  /**
305
- * Selects the tab at [index]: what it shows appears, the tab it left disappears — at once, one container invisible
306
- * and the other visible —, then for a press `ANN.BottomTabSelected` right after the new screen's DidAppear and the
307
- * left tab's `popToRootOnBlur`, with the state as one update after them; from code and by back, `popToRootOnBlur`
308
- * and the state at once. The history of back follows.
354
+ * Selects the tab at [index]: the bar shows it at once; on screen the tab it left fades through to it (Material's
355
+ * fade through), off screen one container becomes invisible and the other visible at once; then [switched]. The
356
+ * history of back follows.
309
357
  */
310
358
  private fun select(index: Int, how: How) {
311
359
  if (index == selectedIndex || index !in tabs.indices || isDestroyed) return
@@ -321,6 +369,9 @@ class BottomTabsController(
321
369
  }
322
370
  selectedIndex = index
323
371
  attach(index)
372
+ // A tab hidden at a repaint takes it now, before its screens appear: its bar's parts, its search's and its
373
+ // FAB's colors follow the theme and the defaults only through a repaint.
374
+ if (staleTabs.remove(shown)) Navigator.repaintLayer(shown)
324
375
  peekView()?.bar?.let {
325
376
  it.setCurrentItem(index, false)
326
377
  it.onCustomItemViewSelectionChanged(index)
@@ -328,10 +379,27 @@ class BottomTabsController(
328
379
  // A layer that waits for the tabs waits for the tab they show now.
329
380
  navigator?.shownScreensChanged(this)
330
381
  if (onScreen) AppearanceDispatcher.beginTransition(listOf(shown))
331
- views?.switch(shown, left)
382
+ if (views == null) return switched(index, previous, how, onScreen, hasViews = false)
383
+ endSwitch = views.switch(shown, left, animated = onScreen) {
384
+ endSwitch = null
385
+ if (!isDestroyed) switched(index, previous, how, onScreen, hasViews = true)
386
+ }
387
+ }
388
+
389
+ /**
390
+ * The switch to the tab at [index] from [previous] ended: what went disappears, what came appears — unless the tabs
391
+ * stopped showing during the fade (a modal over them): it appears when they show again; then for a press
392
+ * `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.
395
+ */
396
+ private fun switched(index: Int, previous: Int, how: How, onScreen: Boolean, hasViews: Boolean) {
397
+ val left = tabs[previous]
398
+ val shown = tabs[index]
332
399
  if (onScreen) {
333
- AppearanceDispatcher.endTransition(listOf(left), listOf(shown), null, null, null, reportsState = false)
334
- } else if (views != null) {
400
+ val showing = if (isOnScreen) listOf(shown) else emptyList()
401
+ AppearanceDispatcher.endTransition(listOf(left), showing, null, null, null, reportsState = false)
402
+ } else if (hasViews) {
335
403
  // Not on screen: the bar takes the new tab's options as they are, without appearance events.
336
404
  val options = resolveShownOptions(DefaultOptions.current)
337
405
  bar.applyBar(options.bottomTabs, directionOf(options))
@@ -358,8 +426,10 @@ class BottomTabsController(
358
426
  if (screen == null) finish() else AppearanceDispatcher.afterDidAppear(screen, finish)
359
427
  }
360
428
 
361
- // A press's `ANN.BottomTabSelected` still waiting goes before anything newer on these tabs.
429
+ // A switch still running ends at once and a press's `ANN.BottomTabSelected` still waiting goes, before anything
430
+ // newer on these tabs.
362
431
  private fun flushPendingSelected() {
432
+ endSwitch?.invoke()
363
433
  pendingSelected?.invoke()
364
434
  }
365
435
 
@@ -531,8 +601,8 @@ class BottomTabsController(
531
601
  }
532
602
  }
533
603
 
534
- // The repaint: the bar, the items of every tab, the row, then the made tabs that don't show, which the pass over
535
- // what shows doesn't reach.
604
+ // The repaint: the bar, the items of every tab, the row. Only what shows is repainted (the pass over what shows
605
+ // reaches the selected tab); a made tab that doesn't show is repainted when it is selected ([staleTabs]).
536
606
  internal fun repaint(options: Options) {
537
607
  if (isDestroyed) return
538
608
  ContainerBackground.apply(this, options)
@@ -542,7 +612,7 @@ class BottomTabsController(
542
612
  if (usesRow) layout.bar.customRowOptions = options.bottomTabs.customRow.copy()
543
613
  }
544
614
  cells.forEachIndexed { index, cell -> cell.update(cellState(index)) }
545
- tabs.forEach { if (it !== currentChild && it.isViewCreated) Navigator.repaintLayer(it) }
615
+ tabs.forEach { if (it !== currentChild && it.isViewCreated) staleTabs += it }
546
616
  }
547
617
 
548
618
  // The custom row: the cells first into the bar's external host, then the row holds them; its look follows the tabs'
@@ -620,7 +690,10 @@ class BottomTabsController(
620
690
  override fun destroy() {
621
691
  if (isDestroyed) return
622
692
  pendingSelected = null
693
+ staleTabs.clear()
623
694
  super.destroy()
695
+ // A switch still running ends with its views at rest, without events: the tabs are gone.
696
+ endSwitch?.invoke()
624
697
  peekView()?.let { layout ->
625
698
  layout.bar.destroyCustomItemViews()
626
699
  layout.row?.let(CustomTabRowPlacer::forget)