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

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 (59) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/android/CLAUDE.md +21 -2
  3. package/android/CORE.md +3 -1
  4. package/android/src/main/java/dev/apexnative/navigation/NavigationActivity.kt +29 -1
  5. package/android/src/main/java/dev/apexnative/navigation/options/StatusBarOptions.kt +7 -3
  6. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/arrangement/SecondaryScreenPresenter.kt +13 -5
  7. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +76 -13
  8. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ComponentPresenter.kt +28 -7
  9. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ScreenFrame.kt +1 -1
  10. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalAnimations.kt +22 -24
  11. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalCoordinator.kt +74 -8
  12. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalRecord.kt +21 -6
  13. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalSheet.kt +29 -12
  14. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalViews.kt +22 -18
  15. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/BackCallbackState.kt +4 -0
  16. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +41 -3
  17. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/RootCoordinator.kt +29 -6
  18. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/overlay/OverlayCoordinator.kt +2 -0
  19. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ContainerBackground.kt +3 -3
  20. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ParentController.kt +3 -0
  21. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuController.kt +4 -0
  22. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitViewController.kt +14 -1
  23. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/BackPreview.kt +287 -0
  24. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +148 -6
  25. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackDisplay.kt +24 -0
  26. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackQueue.kt +51 -11
  27. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackTransitions.kt +26 -8
  28. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackViews.kt +105 -2
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/TopBarPresenter.kt +121 -7
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/viewcontroller/ViewController.kt +19 -0
  31. package/android/src/main/java/dev/apexnative/navigation/views/animations/Curves.kt +29 -11
  32. package/android/src/main/java/dev/apexnative/navigation/views/stack/ScreenEdge.kt +15 -0
  33. package/android/src/main/java/dev/apexnative/navigation/views/stack/StackLayout.kt +137 -1
  34. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TitleBar.kt +8 -1
  35. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TopBar.kt +26 -4
  36. package/docs-md/architecture/transitions.md +6 -0
  37. package/docs-md/containers/modals.md +4 -2
  38. package/docs-md/containers/overlays.md +3 -1
  39. package/docs-md/containers/side-menu.md +4 -2
  40. package/docs-md/containers/tabs.md +2 -0
  41. package/docs-md/flows/forms-in-a-modal.md +1 -1
  42. package/docs-md/help/glossary.md +1 -1
  43. package/docs-md/index.json +8 -0
  44. package/docs-md/screen-ui/back-and-gestures.md +6 -0
  45. package/docs-md/screen-ui/system-ui.md +1 -1
  46. package/lib/module/api/hooks/useNavigation.js +8 -3
  47. package/lib/module/api/hooks/useNavigation.js.map +1 -1
  48. package/lib/typescript/api/hooks/useNavigation.d.ts.map +1 -1
  49. package/lib/typescript/interfaces/options/animations.d.ts +10 -5
  50. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  51. package/lib/typescript/interfaces/options/modals.d.ts +18 -13
  52. package/lib/typescript/interfaces/options/modals.d.ts.map +1 -1
  53. package/lib/typescript/interfaces/options/system.d.ts +6 -3
  54. package/lib/typescript/interfaces/options/system.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/api/hooks/useNavigation.ts +8 -3
  57. package/src/interfaces/options/animations.ts +10 -5
  58. package/src/interfaces/options/modals.ts +18 -13
  59. package/src/interfaces/options/system.ts +6 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-alpha.3](https://github.com/apexnative/navigation/compare/v1.0.0-alpha.2...v1.0.0-alpha.3) (2026-10-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **android:** system stack transitions, an opaque default screen background and predictive back ([#26](https://github.com/apexnative/navigation/issues/26)) ([9f28969](https://github.com/apexnative/navigation/commit/9f28969a30a8902c1512cd2a24c3ddf5439f4642))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **android:** center the back button and the bar buttons in the row ([#35](https://github.com/apexnative/navigation/issues/35)) ([c908971](https://github.com/apexnative/navigation/commit/c9089716ea4e3b3f4bcdc42b16e57134018d76c0))
14
+ * **android:** fade through on a tab switch, a new tab's first frame in place ([#29](https://github.com/apexnative/navigation/issues/29)) ([60d69b6](https://github.com/apexnative/navigation/commit/60d69b618a97c8f7da974fbdf6f427461960d672))
15
+ * **android:** let an overlay cover the status bar ([#27](https://github.com/apexnative/navigation/issues/27)) ([2632194](https://github.com/apexnative/navigation/commit/26321945cf0c457780b3eb7bba1f3d988dad4d96))
16
+ * **android:** match each modal presentation style to its Android equivalent ([#31](https://github.com/apexnative/navigation/issues/31)) ([54ab265](https://github.com/apexnative/navigation/commit/54ab2651f3139e869a2d99a43465b33bfb171414))
17
+ * **android:** move the top bar with its screen in transitions of their own ([#34](https://github.com/apexnative/navigation/issues/34)) ([4d12ee6](https://github.com/apexnative/navigation/commit/4d12ee6d6ec26a04e529bdef8b8111d20e5116aa))
18
+ * close the side menu as a pick in it changes the center ([#28](https://github.com/apexnative/navigation/issues/28)) ([c4b3e81](https://github.com/apexnative/navigation/commit/c4b3e81ba44d2edb6d93d3ddc906eca165d46e29))
19
+
20
+
21
+ ### Performance Improvements
22
+
23
+ * **android:** render the modals of a setRoot's layout together with its root ([#32](https://github.com/apexnative/navigation/issues/32)) ([cf3e1d9](https://github.com/apexnative/navigation/commit/cf3e1d98ff7c5d2a05aba6485203e6fe35a3423d))
24
+
3
25
  ## [1.0.0-alpha.2](https://github.com/apexnative/navigation/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2026-10-22)
4
26
 
5
27
 
package/android/CLAUDE.md CHANGED
@@ -73,8 +73,10 @@ top-level folder. Main thread only, unless a line says otherwise.
73
73
  `ScreenFrame.kt` (bars' room, window insets, keyboard), `ScreenSafeArea.kt`, `ComponentPresenter.kt` (background),
74
74
  `ScreenContentFactory.kt`, `ScreenFocus.kt`; `react/ReactView.kt`; `views/component/ComponentLayout.kt`.
75
75
  - Stack (queue, commands, back): `viewcontrollers/stack/StackQueue.kt`, `StackCommands.kt`, `StackController.kt`,
76
- `StackViews.kt`, `StackTransitions.kt`, `PreparedPushes.kt`; `views/stack/StackLayout.kt`; back:
77
- `NavigationActivity.kt`, `viewcontrollers/navigator/BackCallbackState.kt`.
76
+ `StackViews.kt`, `StackTransitions.kt`, `PreparedPushes.kt`; `views/stack/StackLayout.kt`, `ScreenEdge.kt` (the edge
77
+ a moving screen extends); back: `NavigationActivity.kt`, `viewcontrollers/navigator/BackCallbackState.kt`; the
78
+ preview of a pop during the back gesture (Android 14+): `StackController.BackGesture`, `BackPreview.kt`,
79
+ `stackPoppedByBack` of the layouts.
78
80
  - Stack FAB: `viewcontrollers/stack/fab/FabPresenter.kt`, `FabPlan.kt`, `FabPlacement.kt`; `views/stack/fab/`.
79
81
  - Top bar (title, buttons, menus, large title): `viewcontrollers/stack/topbar/TopBarPresenter.kt`, `TopBarRoom.kt`,
80
82
  `TopBarVisibility.kt`, `BarComponents.kt`, `ButtonPlaces.kt`, `LargeTitleCollapse.kt`, `BarRowTransition.kt`;
@@ -167,6 +169,9 @@ top-level folder. Main thread only, unless a line says otherwise.
167
169
  `OverlayCoordinator.guarded`), which rejects the open answer and ends the record, or an exception ends the app and
168
170
  the command never answers.
169
171
  - A node makes its view in `createView`, never in its constructor.
172
+ - Whether a node keeps the status bar's room (its margin, its background band) goes through
173
+ `StatusBarOptions.isHiddenOrDrawBehind(node.isInOverlay)`: an overlay covers the whole window, and a rule that reads
174
+ `drawBehind` alone puts a light band over an overlay's dimmed backdrop.
170
175
  - Only `SystemUiWindow` writes to the window. A presenter that changes a bar option calls `SystemUiOwner.invalidate`.
171
176
  - The core names a module container only in `LayoutFactory`; elsewhere use a capability or `visibleChildren()`.
172
177
  - A hidden stack keeps a `mergeOptions` per entry; applying it for the wrong entry paints the wrong screen's bar.
@@ -177,6 +182,20 @@ top-level folder. Main thread only, unless a line says otherwise.
177
182
  offset that `ScrollEventListener.lastOffset` keeps, never to 0, or it comes back expanded over a scrolled list.
178
183
  - A view's place in the window while a modal slides or a sheet is dragged comes from layout (`RestPlace`), never from
179
184
  `getLocationInWindow`, which counts the translations of the animation.
185
+ - The bar measures to its children: a picture laid into it for a transition that is taller than the bar the incoming
186
+ screen gives holds the bar at that height to the end (the row's picture of a pop from a large title). What doesn't
187
+ stay in the bar rides with the leaving screen (`TopBarPresenter.pictureUnderRow`).
188
+ - A `Toolbar` centers its navigation button and its menu in `min(minimumHeight, height)` (`buttonGravity` is top):
189
+ the row's toolbars take the row's height as their minimum (`TitleBar`); 0 pins the buttons to the row's top.
190
+ - A screen's overlay is clipped to the screen, and a screen without a large title is laid out under the bar (its `top`
191
+ is the bar's height): a picture of the bar in its overlay doesn't show. The bar's picture in a transition of the
192
+ stack's own is drawn by the stack's view with the screen's transform (`StackLayout.barTravels`).
193
+ - A React screen without `layout.backgroundColor` is opaque with the theme's `android:colorBackground`
194
+ (`ComponentPresenter`): a push keeps the covered screen under the new one until it ends, and a transparent screen
195
+ shows it. Only an explicit `'transparent'` / `null` (overlays, see-through modals) lets it through.
196
+ - The back gesture's preview shows the screen under the top outside any operation of the queue: it may only reveal
197
+ it and put it back, and its release goes through `StackQueue.cut(gesture = true)`. A command that comes meanwhile
198
+ stops it through `StackDisplay.stop`, like any transition it takes over.
180
199
  - Without edge to edge the window resizes for the keyboard by the manifest's soft input mode, so
181
200
  `layout.adjustResize: false` goes through the system UI owner's keyboard group (`SystemUiWindow.setKeyboardCovers`),
182
201
  not through the screen's padding.
package/android/CORE.md CHANGED
@@ -91,7 +91,9 @@ The events go from the main thread, synchronously, through `EventEmitter.current
91
91
  current root in the root layer, transparent (`RootLayerViews.place`), so its React roots start while the old root keeps
92
92
  the touches. It waits (`RenderWait`, by `animations.setRoot.enter.waitForRender`), then fades through or plays its own
93
93
  animation, the old root is destroyed with its `ComponentDidDisappear`, `NativeScreenReleased` and `SlotDestroyed`, and
94
- the end steps and answer are the same. The layout's modals and overlays show after it, one by one, before the answer.
94
+ the end steps and answer are the same. The layout's modals and overlays show after it, one by one, before the answer;
95
+ its modals are made with the root and render unseen in the modals layer, held (`ModalCoordinator.hold`), so each
96
+ shows in its turn without a wait of its own.
95
97
  The last `setRoot` wins: an older one is rejected (`ErrorTexts.setRootReplaced`) or finished at once.
96
98
  - **`showModal`** (`Navigator.showModal`, `ModalCoordinator.show`). One `ModalRecord` per modal; it joins the modals
97
99
  layer unseen (`ModalViews`), waits with its own `RenderWait`, then slides in (or opens as a `ModalSheet`). It shows
@@ -11,6 +11,7 @@ import android.view.View
11
11
  import android.view.ViewGroup
12
12
  import android.view.ViewTreeObserver
13
13
  import android.widget.EditText
14
+ import androidx.activity.BackEventCompat
14
15
  import androidx.activity.OnBackPressedCallback
15
16
  import androidx.activity.SystemBarStyle
16
17
  import androidx.activity.enableEdgeToEdge as enableActivityEdgeToEdge
@@ -37,6 +38,7 @@ import dev.apexnative.navigation.utils.MaterialTheme
37
38
  import dev.apexnative.navigation.viewcontrollers.navigator.BackCallbackState
38
39
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
39
40
  import dev.apexnative.navigation.viewcontrollers.navigator.Navigators
41
+ import dev.apexnative.navigation.viewcontrollers.stack.StackController
40
42
 
41
43
  /**
42
44
  * The activity of an app with the navigation: its content holds, bottom to top, the copy of the splash and the layers
@@ -67,10 +69,36 @@ open class NavigationActivity : AppCompatActivity(), DefaultHardwareBackBtnHandl
67
69
  private val reloadBroadcast = ReloadBroadcast(this) { reloadFromDeveloperTools() }
68
70
 
69
71
  // Enabled while back has something to do in the app (BackCallbackState); otherwise the system does its own back.
72
+ // From Android 14, where the app hears the progress of the back gesture, a gesture that would pop a stack with the
73
+ // default transition previews the pop as the system previews back between activities (`StackController
74
+ // .BackGesture`); JavaScript's listeners hear back first, so with them there is no preview.
70
75
  private val backCallback = object : OnBackPressedCallback(false) {
71
- override fun handleOnBackPressed() = passBack()
76
+ override fun handleOnBackStarted(backEvent: BackEventCompat) {
77
+ backGesture = null
78
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || BackCallbackState.hasJsListeners) return
79
+ val stack = navigator?.stackPoppedByBack() ?: return
80
+ backGesture = stack.startBackGesture(backEvent.swipeEdge == BackEventCompat.EDGE_LEFT, backEvent.touchY)
81
+ }
82
+
83
+ override fun handleOnBackProgressed(backEvent: BackEventCompat) {
84
+ backGesture?.progress(backEvent.progress, backEvent.touchY)
85
+ }
86
+
87
+ override fun handleOnBackCancelled() {
88
+ backGesture?.cancel()
89
+ backGesture = null
90
+ }
91
+
92
+ override fun handleOnBackPressed() {
93
+ val gesture = backGesture
94
+ backGesture = null
95
+ if (gesture?.commit() == true) BackCallbackState.update() else passBack()
96
+ }
72
97
  }
73
98
 
99
+ // The back gesture that previews a pop now; none.
100
+ private var backGesture: StackController.BackGesture? = null
101
+
74
102
  // Before each frame of the window: what back does may have changed, and the system decides at the start of a
75
103
  // gesture whether the app takes it.
76
104
  private val backBeforeFrame = ViewTreeObserver.OnPreDrawListener {
@@ -22,9 +22,13 @@ class StatusBarOptions {
22
22
  @JvmField var hideWithTopBar: Bool = NullBool()
23
23
  @JvmField var blur: Bool = NullBool()
24
24
 
25
- /** The status bar is hidden, or the screen is drawn under it: the screen keeps no room for it. */
26
- val isHiddenOrDrawBehind: Boolean
27
- get() = visible.isFalse || drawBehind.isTrue
25
+ /**
26
+ * The status bar is hidden, or the screen is drawn under it: the screen keeps no room for it. Without `drawBehind`, a
27
+ * screen or a layout in an overlay ([inOverlay]) is drawn under it: an overlay covers the whole window, its dimmed
28
+ * backdrop the status bar too.
29
+ */
30
+ fun isHiddenOrDrawBehind(inOverlay: Boolean): Boolean =
31
+ visible.isFalse || if (drawBehind.hasValue()) drawBehind.isTrue else inOverlay
28
32
 
29
33
  fun hasValue(): Boolean = visible.hasValue() || style.hasValue() || backgroundColor.hasValue() ||
30
34
  drawBehind.hasValue() || translucent.hasValue() || animate.hasValue() || hideWithTopBar.hasValue() ||
@@ -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
  }
@@ -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,8 @@ 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
71
77
  // The bar goes with a stack's transition: the options of the new top don't move it themselves.
72
78
  private var riding = false
73
79
  private var restAttached = false
@@ -150,8 +156,11 @@ class BottomTabsController(
150
156
  /** The view of [tab] joins, or is there already: [visible], or invisible but laid out. */
151
157
  fun attach(tab: ViewController<*>, visible: Boolean)
152
158
 
153
- /** The view of [shown] becomes visible and that of [hidden] invisible: the switch, at once. */
154
- fun switch(shown: ViewController<*>, hidden: ViewController<*>)
159
+ /**
160
+ * The view of [shown] becomes visible and that of [hidden] invisible: at once, or with Material's fade through
161
+ * when [animated]; then [onEnd]. The function returned ends a switch still running at once, [onEnd] included.
162
+ */
163
+ fun switch(shown: ViewController<*>, hidden: ViewController<*>, animated: Boolean, onEnd: () -> Unit): () -> Unit
155
164
  }
156
165
 
157
166
  /** The views of the tabs; none before the tabs' view is made. */
@@ -166,9 +175,43 @@ class BottomTabsController(
166
175
  tab.view.visibility = if (visible) View.VISIBLE else View.INVISIBLE
167
176
  }
168
177
 
169
- override fun switch(shown: ViewController<*>, hidden: ViewController<*>) {
170
- shown.view.visibility = View.VISIBLE
171
- hidden.peekView()?.visibility = View.INVISIBLE
178
+ override fun switch(
179
+ shown: ViewController<*>,
180
+ hidden: ViewController<*>,
181
+ animated: Boolean,
182
+ onEnd: () -> Unit,
183
+ ): () -> Unit {
184
+ val coming = shown.view
185
+ coming.visibility = View.VISIBLE
186
+ val going = hidden.peekView()
187
+ if (!animated || going == null) {
188
+ going?.visibility = View.INVISIBLE
189
+ onEnd()
190
+ return {}
191
+ }
192
+ val exit = FadeThrough.exit(going)
193
+ // Faded out, the tab that goes takes no more touches and no accessibility focus.
194
+ exit.addListener(object : AnimatorListenerAdapter() {
195
+ override fun onAnimationEnd(animation: Animator) {
196
+ going.visibility = View.INVISIBLE
197
+ }
198
+ })
199
+ val set = AnimatorSet()
200
+ set.playTogether(exit, FadeThrough.enter(coming))
201
+ set.addListener(object : AnimatorListenerAdapter() {
202
+ override fun onAnimationEnd(animation: Animator) {
203
+ set.removeAllListeners()
204
+ // The tab that went keeps its look for its next selection; only its visibility hides it.
205
+ going.visibility = View.INVISIBLE
206
+ going.alpha = 1f
207
+ coming.alpha = 1f
208
+ coming.scaleX = 1f
209
+ coming.scaleY = 1f
210
+ onEnd()
211
+ }
212
+ })
213
+ set.start()
214
+ return set::end
172
215
  }
173
216
  }
174
217
 
@@ -302,10 +345,9 @@ class BottomTabsController(
302
345
  }
303
346
 
304
347
  /**
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.
348
+ * Selects the tab at [index]: the bar shows it at once; on screen the tab it left fades through to it (Material's
349
+ * fade through), off screen one container becomes invisible and the other visible at once; then [switched]. The
350
+ * history of back follows.
309
351
  */
310
352
  private fun select(index: Int, how: How) {
311
353
  if (index == selectedIndex || index !in tabs.indices || isDestroyed) return
@@ -328,10 +370,27 @@ class BottomTabsController(
328
370
  // A layer that waits for the tabs waits for the tab they show now.
329
371
  navigator?.shownScreensChanged(this)
330
372
  if (onScreen) AppearanceDispatcher.beginTransition(listOf(shown))
331
- views?.switch(shown, left)
373
+ if (views == null) return switched(index, previous, how, onScreen, hasViews = false)
374
+ endSwitch = views.switch(shown, left, animated = onScreen) {
375
+ endSwitch = null
376
+ if (!isDestroyed) switched(index, previous, how, onScreen, hasViews = true)
377
+ }
378
+ }
379
+
380
+ /**
381
+ * The switch to the tab at [index] from [previous] ended: what went disappears, what came appears — unless the tabs
382
+ * stopped showing during the fade (a modal over them): it appears when they show again; then for a press
383
+ * `ANN.BottomTabSelected` right after the new screen's DidAppear and the left tab's `popToRootOnBlur`, with the state
384
+ * as one update after them; from code and by back, `popToRootOnBlur` and the state at once. [onScreen]: the switch
385
+ * started on screen.
386
+ */
387
+ private fun switched(index: Int, previous: Int, how: How, onScreen: Boolean, hasViews: Boolean) {
388
+ val left = tabs[previous]
389
+ val shown = tabs[index]
332
390
  if (onScreen) {
333
- AppearanceDispatcher.endTransition(listOf(left), listOf(shown), null, null, null, reportsState = false)
334
- } else if (views != null) {
391
+ val showing = if (isOnScreen) listOf(shown) else emptyList()
392
+ AppearanceDispatcher.endTransition(listOf(left), showing, null, null, null, reportsState = false)
393
+ } else if (hasViews) {
335
394
  // Not on screen: the bar takes the new tab's options as they are, without appearance events.
336
395
  val options = resolveShownOptions(DefaultOptions.current)
337
396
  bar.applyBar(options.bottomTabs, directionOf(options))
@@ -358,8 +417,10 @@ class BottomTabsController(
358
417
  if (screen == null) finish() else AppearanceDispatcher.afterDidAppear(screen, finish)
359
418
  }
360
419
 
361
- // A press's `ANN.BottomTabSelected` still waiting goes before anything newer on these tabs.
420
+ // A switch still running ends at once and a press's `ANN.BottomTabSelected` still waiting goes, before anything
421
+ // newer on these tabs.
362
422
  private fun flushPendingSelected() {
423
+ endSwitch?.invoke()
363
424
  pendingSelected?.invoke()
364
425
  }
365
426
 
@@ -621,6 +682,8 @@ class BottomTabsController(
621
682
  if (isDestroyed) return
622
683
  pendingSelected = null
623
684
  super.destroy()
685
+ // A switch still running ends with its views at rest, without events: the tabs are gone.
686
+ endSwitch?.invoke()
624
687
  peekView()?.let { layout ->
625
688
  layout.bar.destroyCustomItemViews()
626
689
  layout.row?.let(CustomTabRowPlacer::forget)
@@ -6,6 +6,7 @@ import android.graphics.drawable.ColorDrawable
6
6
  import android.graphics.drawable.Drawable
7
7
  import android.graphics.drawable.LayerDrawable
8
8
  import android.view.View
9
+ import com.google.android.material.color.MaterialColors
9
10
  import dev.apexnative.navigation.options.DefaultOptions
10
11
  import dev.apexnative.navigation.options.Options
11
12
  import dev.apexnative.navigation.options.params.ThemeColour
@@ -15,9 +16,9 @@ import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
15
16
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
16
17
 
17
18
  /**
18
- * The presenter of a screen, React or native: its background — the screen's view takes `layout.backgroundColor`, kept
19
- * out of the status bar's height where the status bar shows over it, and the React content takes
20
- * `layout.componentBackgroundColor` over it —, its room for the bars, and its safe area again on `mergeOptions`
19
+ * The presenter of a screen, React or native: its background — the screen's view takes `layout.backgroundColor` (a
20
+ * React screen without it the theme's background), kept out of the status bar's height where the status bar shows over
21
+ * it, and the React content takes `layout.componentBackgroundColor` over it —, its room for the bars, and its safe area again on `mergeOptions`
21
22
  * (`mergeOptions` step 4). It applies as the screen appears, on `mergeOptions` of what it covers, and again with new
22
23
  * default options or after a light/dark switch from the resolved options it is given, which it never writes. Each
23
24
  * appearance tells the activity's system UI owner, which follows the screen on top. Main thread only.
@@ -49,8 +50,8 @@ open class ComponentPresenter(activity: Activity) : Presenter(activity, DefaultO
49
50
  applyBackground(controller, options)
50
51
 
51
52
  /**
52
- * The colors of [screen] from its resolved [options]: `layout.backgroundColor` on the screen's view, none without
53
- * it (the window shows); `layout.componentBackgroundColor` on its React content.
53
+ * The colors of [screen] from its resolved [options]: `layout.backgroundColor` on the screen's view, without it the
54
+ * theme's background for a React screen ([defaultColorOf]); `layout.componentBackgroundColor` on its React content.
54
55
  */
55
56
  fun applyBackground(screen: ViewController<*>, options: Options) {
56
57
  val view = screen.peekView() ?: return
@@ -59,11 +60,11 @@ open class ComponentPresenter(activity: Activity) : Presenter(activity, DefaultO
59
60
  }
60
61
 
61
62
  private fun screenBackground(screen: ViewController<*>, options: Options): Drawable? {
62
- val color = colorOf(options.layout.backgroundColor) ?: return null
63
+ val color = colorOf(options.layout.backgroundColor) ?: defaultColorOf(screen) ?: return null
63
64
  val frame = frameOf(screen) ?: return ColorDrawable(color)
64
65
  val band = ScreenSafeArea.backgroundBand(
65
66
  statusBarHeight = frame.statusBarHeight,
66
- hiddenOrDrawBehind = options.statusBar.isHiddenOrDrawBehind,
67
+ hiddenOrDrawBehind = options.statusBar.isHiddenOrDrawBehind(screen.isInOverlay),
67
68
  inSideMenu = screen.isInSideMenu,
68
69
  inSheet = screen.isInSheet,
69
70
  inPair = frame.isInPair,
@@ -79,6 +80,15 @@ open class ComponentPresenter(activity: Activity) : Presenter(activity, DefaultO
79
80
  private fun colorOf(colour: ThemeColour): Int? =
80
81
  if (colour.hasValue()) colour.get(activity, Color.TRANSPARENT) ?: Color.TRANSPARENT else null
81
82
 
83
+ // A React screen is opaque without a color of its own, as iOS gives it the system background: the screen under it
84
+ // never shows through, also while a push slides it aside. The app theme's `android:colorBackground`, read now, so a
85
+ // light/dark switch takes the new mode's. A screen of a sheet shows the sheet's surface; a native screen paints its
86
+ // own view.
87
+ private fun defaultColorOf(screen: ViewController<*>): Int? {
88
+ if (screen !is ComponentViewController || screen.isInSheet) return null
89
+ return themeBackgroundOf(activity)
90
+ }
91
+
82
92
  private fun frameOf(screen: ViewController<*>): ScreenFrame? = when (screen) {
83
93
  is ComponentViewController -> screen.frame
84
94
  is ExternalComponentViewController -> screen.frame
@@ -87,4 +97,15 @@ open class ComponentPresenter(activity: Activity) : Presenter(activity, DefaultO
87
97
 
88
98
  // The view under the React content: the React root, or the container of a slot screen. A native screen has none.
89
99
  private fun contentOf(screen: ViewController<*>): View? = (screen as? ComponentViewController)?.content?.asView()
100
+
101
+ internal companion object {
102
+ /**
103
+ * The background of a React screen without `layout.backgroundColor`: `android:colorBackground` of [activity]'s
104
+ * theme now. None for an activity without a theme (the unit tests' fakes).
105
+ */
106
+ fun themeBackgroundOf(activity: Activity): Int? {
107
+ if (activity.theme == null) return null
108
+ return MaterialColors.getColor(activity, android.R.attr.colorBackground, Color.WHITE)
109
+ }
110
+ }
90
111
  }
@@ -143,7 +143,7 @@ internal class ScreenFrame(
143
143
  val parent = node.parentController
144
144
  val statusBar = ScreenSafeArea.statusBarRoom(
145
145
  statusBarHeight = statusBarHeight,
146
- hiddenOrDrawBehind = options.statusBar.isHiddenOrDrawBehind,
146
+ hiddenOrDrawBehind = options.statusBar.isHiddenOrDrawBehind(node.isInOverlay),
147
147
  inSheet = node.isInSheet,
148
148
  inSideMenu = node.isInSideMenu,
149
149
  keptAround = parent?.keepsStatusBarRoomOf(node) == true,
@@ -1,40 +1,38 @@
1
1
  package dev.apexnative.navigation.viewcontrollers.modal
2
2
 
3
- import kotlin.math.PI
4
- import kotlin.math.cos
3
+ import dev.apexnative.navigation.views.animations.Curves
5
4
 
6
5
  /**
7
- * The default animations of a modal on Android, as values: it comes up from the bottom of the window by the window's
8
- * height (in a split screen or a window, the window's, not the display's) and fades in, [DURATION] with a deceleration
9
- * at the end; it goes down and fades out in the same time, accelerating and decelerating (the system's cosine, not the
10
- * option's `accelerateDecelerate`). A sheet moves only by the Material sheet's own motion. Pure arithmetic.
6
+ * The default animations of a modal over the whole screen on Android, as values: the system's motion of a window that
7
+ * opens over the one below it (`activity_translucent_open_enter` and `activity_translucent_close_exit` of the
8
+ * platform, which Android 16 plays for a translucent activity). The modal comes up from the bottom of the window by the
9
+ * window's height (in a split screen or a window, the window's, not the display's) in [SHOW_DURATION], slowing down at
10
+ * the end; it goes down in [DISMISS_DURATION], speeding up. It doesn't fade: the screen below is seen only around it,
11
+ * as under a translucent activity. A sheet moves only by the Material sheet's own motion. Pure arithmetic.
11
12
  */
12
13
  internal object ModalAnimations {
13
- /** How long the default show and the default dismissal last (ms). */
14
- const val DURATION = 300L
14
+ /** How long the default show lasts (ms): `activity_translucent_open_enter`. */
15
+ const val SHOW_DURATION = 300L
16
+
17
+ /** How long the default dismissal lasts (ms): `activity_translucent_close_exit`. */
18
+ const val DISMISS_DURATION = 250L
19
+
20
+ // `linear_out_slow_in` of the platform: the path (0, 0) (0, 0) (0.2, 1) (1, 1).
21
+ private val linearOutSlowIn = Curves.CubicBezier(0.0, 0.0, 0.2, 1.0)
15
22
 
16
23
  /** How far a modal comes up and goes down: the window's height ([windowHeight]), or the display's while it is 0. */
17
24
  fun distance(windowHeight: Int, displayHeight: Int): Float =
18
25
  (if (windowHeight > 0) windowHeight else displayHeight).toFloat()
19
26
 
20
- /** The curve of the show: a deceleration, 1 − (1 − p)². */
21
- fun decelerate(progress: Float): Float = 1f - (1f - progress) * (1f - progress)
27
+ /** The curve of the show: the platform's `linear_out_slow_in`, fast at first and slow at the end. */
28
+ fun linearOutSlowIn(progress: Float): Float = linearOutSlowIn.getInterpolation(progress)
22
29
 
23
- /** The curve of the dismissal: an acceleration then a deceleration, cos((p + 1)π) / 2 + 0.5. */
24
- fun accelerateDecelerate(progress: Float): Float = (cos((progress + 1) * PI) / 2 + 0.5).toFloat()
30
+ /** The curve of the dismissal: the platform's `accelerate_quad`, p². */
31
+ fun accelerate(progress: Float): Float = progress.coerceIn(0f, 1f).let { it * it }
25
32
 
26
- /** Where a modal is at [progress] (0 … 1, in time) of its show over [distance]: its translation and its alpha. */
27
- fun show(progress: Float, distance: Float): Frame {
28
- val value = decelerate(progress)
29
- return Frame(translationY = distance * (1f - value), alpha = value)
30
- }
33
+ /** Where a modal is at [progress] (0 … 1, in time) of its show over [distance]: its translation. */
34
+ fun show(progress: Float, distance: Float): Float = distance * (1f - linearOutSlowIn(progress))
31
35
 
32
36
  /** Where a modal is at [progress] of its dismissal over [distance], from where it rests. */
33
- fun dismiss(progress: Float, distance: Float): Frame {
34
- val value = accelerateDecelerate(progress)
35
- return Frame(translationY = distance * value, alpha = 1f - value)
36
- }
37
-
38
- /** A modal's place in its animation. */
39
- data class Frame(val translationY: Float, val alpha: Float)
37
+ fun dismiss(progress: Float, distance: Float): Float = distance * accelerate(progress)
40
38
  }