@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
@@ -15,6 +15,7 @@ import dev.apexnative.navigation.viewcontrollers.component.Readiness
15
15
  import dev.apexnative.navigation.viewcontrollers.component.RenderWait
16
16
  import dev.apexnative.navigation.viewcontrollers.parent.OptionsScreen
17
17
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.BarMotion
18
+ import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.BarTravel
18
19
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.BarVisibility
19
20
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.Custom
20
21
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.Elements
@@ -71,12 +72,20 @@ internal class StackQueue(
71
72
  /** The current child (the top of [shown]) changed: a transition started, or an operation ran at once. */
72
73
  fun currentChildChanged()
73
74
 
75
+ /** An operation was accepted: a back gesture's preview that shows ends, the stack as it was. */
76
+ fun operationAccepted() = Unit
77
+
74
78
  /**
75
79
  * The current child is about to change, right before [currentChildChanged]: [motion] is the default transition
76
80
  * the bar's row goes with, none when the change comes at once or with an animation of its own; [visibility] is
77
- * how the bar shows or hides when the new top wants it otherwise.
81
+ * how the bar shows or hides when the new top wants it otherwise; [travel]: the bar goes with the screens of an
82
+ * animation of its own without elements.
78
83
  */
79
- fun barTransition(motion: StackDisplay.BarMotion?, visibility: BarVisibility) = Unit
84
+ fun barTransition(
85
+ motion: StackDisplay.BarMotion?,
86
+ visibility: BarVisibility,
87
+ travel: StackDisplay.BarTravel? = null,
88
+ ) = Unit
80
89
 
81
90
  /**
82
91
  * The current child is about to change, right before [barTransition]: [move] is how the transition goes, which
@@ -140,6 +149,9 @@ internal class StackQueue(
140
149
  private var takenOver = false
141
150
  private var destroyed = false
142
151
 
152
+ /** [node]'s content is made and nothing waits for it to render as a lower screen. */
153
+ fun isRendered(node: Node): Boolean = node !in unrendered && node !in lowerWaits
154
+
143
155
  /** The stack as JavaScript sees it, bottom to top. */
144
156
  val entries: List<Node>
145
157
  get() = entryList
@@ -199,7 +211,9 @@ internal class StackQueue(
199
211
 
200
212
  /**
201
213
  * A cut to [target], an entry that becomes the top: every entry above it leaves. [answer] gets [value]; a back of
202
- * the user has none. [silent]: no animation and no appearance events (`popToRootOnBlur`).
214
+ * the user has none. [silent]: no animation and no appearance events (`popToRootOnBlur`). [gesture]: the release of
215
+ * a back gesture whose preview shows the pop already (`StackDisplay.finishBackGesture` ends it, the bar shows the
216
+ * target at once).
203
217
  */
204
218
  fun cut(
205
219
  target: Node,
@@ -208,6 +222,7 @@ internal class StackQueue(
208
222
  what: String,
209
223
  value: String?,
210
224
  silent: Boolean = false,
225
+ gesture: Boolean = false,
211
226
  ) {
212
227
  val index = entryList.indexOf(target)
213
228
  if (destroyed || index < 0) return refuse(answer, null, componentId, emptyList())
@@ -219,7 +234,7 @@ internal class StackQueue(
219
234
  (waiting as? ReplaceOp)?.let(::completeAtOnce)
220
235
  val showsAbove = target in shownList && shownList.last() !== target
221
236
  val op = if (showsAbove) {
222
- CutOp(seq, answer, componentId, what, target, value, silent)
237
+ CutOp(seq, answer, componentId, what, target, value, silent, gesture)
223
238
  } else {
224
239
  // A cut that moves nothing more: its answer at its turn.
225
240
  NoneOp(seq, answer, null, componentId, what, value, null)
@@ -341,6 +356,8 @@ internal class StackQueue(
341
356
  // The table.
342
357
 
343
358
  private fun enqueue(op: Op) {
359
+ // Anything but the release of a back gesture ends its preview first.
360
+ if (!(op is CutOp && op.gesture)) host.operationAccepted()
344
361
  queued.addLast(op)
345
362
  drain()
346
363
  }
@@ -450,7 +467,7 @@ internal class StackQueue(
450
467
  val elements = own && AnimationRules.hasElements(push)
451
468
  // The bar's row goes with the default transition only; with an animation of its own it changes at once. The tab
452
469
  // bar moves with its screens or by its own side.
453
- tellBars(animated, own, elements, push, TabBarMotion.Move.WithPush, BarMotion.Push)
470
+ tellBars(animated, own, elements, push, TabBarMotion.Move.WithPush, BarMotion.Push, BarTravel.Forward)
454
471
  host.currentChildChanged()
455
472
  finishTakenOver(ended)
456
473
  running += op
@@ -581,7 +598,9 @@ internal class StackQueue(
581
598
  val animated = !atOnce && !op.silent && display.canAnimate && !pop.enabled.isFalse
582
599
  val own = animated && AnimationRules.stackIsOwn(pop)
583
600
  val elements = own && AnimationRules.hasElements(pop)
584
- tellBars(animated, own, elements, pop, TabBarMotion.Move.WithPop, BarMotion.Pop)
601
+ // A back gesture's preview shows the target and its bar already: the bar stays as it is.
602
+ val gesture = op.gesture && animated && !own && leaving.size == 1 && display.hasBackPreview(top, target)
603
+ tellBars(animated, own, elements, pop, TabBarMotion.Move.WithPop, BarMotion.Pop, BarTravel.Back, gesture)
585
604
  host.currentChildChanged()
586
605
  finishTakenOver(ended)
587
606
  if (display.hasView) target.onViewBroughtToFront()
@@ -592,6 +611,17 @@ internal class StackQueue(
592
611
  // The screens between the top and the target leave without animation.
593
612
  leaving.drop(1).forEach(display::hide)
594
613
  if (!animated) return finishCut(op)
614
+ if (gesture) {
615
+ op.animating += top
616
+ op.animating += target
617
+ display.finishBackGesture(
618
+ top,
619
+ target,
620
+ { step(op) { screenDone(op, top) } },
621
+ { step(op) { screenDone(op, target) } },
622
+ )
623
+ return
624
+ }
595
625
  // Its own: no wait on a pop; the leaving screen starts from where it is.
596
626
  if (own) return animate(op, backMoves(top, target, pop, elements, moving))
597
627
  animate(op, listOf(Move(top, Motion.PopOut, true), Move(target, Motion.PopReveal, moving)))
@@ -657,7 +687,7 @@ internal class StackQueue(
657
687
  val elements = own && !op.asPush && AnimationRules.hasElements(played)
658
688
  val motion = if (op.asPush) BarMotion.Push else BarMotion.FadeThrough
659
689
  val system = if (op.asPush) TabBarMotion.Move.WithPush else TabBarMotion.Move.Slide
660
- tellBars(animated, own, elements, played, system, motion)
690
+ tellBars(animated, own, elements, played, system, motion, BarTravel.Forward)
661
691
  host.currentChildChanged()
662
692
  finishTakenOver(ended)
663
693
  op.owned += moved
@@ -1081,9 +1111,10 @@ internal class StackQueue(
1081
1111
  private fun awaitsTargets(animation: StackAnimationOptions, enabled: Boolean): Boolean = enabled &&
1082
1112
  display.canAnimate && !animation.waitForRender.isFalse && AnimationRules.hasElements(animation)
1083
1113
 
1084
- // The bars with a change of the current child. The row goes with the default transition only; the tab bar rides
1085
- // with the system's [system] move, slides with an animation of its own or a side of its own, and is where the new
1086
- // top wants it at once without animation; with elements every bar changes at once.
1114
+ // The bars with a change of the current child. The row goes with the default transition; with an animation of its
1115
+ // own the top bar goes with the screens ([travel]). The tab bar rides with the system's [system] move, slides with an
1116
+ // animation of its own or a side of its own, and is where the new top wants it at once without animation; with
1117
+ // elements every bar changes at once.
1087
1118
  private fun tellBars(
1088
1119
  animated: Boolean,
1089
1120
  own: Boolean,
@@ -1091,6 +1122,9 @@ internal class StackQueue(
1091
1122
  animation: StackAnimationOptions,
1092
1123
  system: TabBarMotion.Move,
1093
1124
  row: BarMotion,
1125
+ travel: BarTravel,
1126
+ // The top bar shows the new top already (a back gesture's preview): it changes at once, the tab bar still moves.
1127
+ barAtOnce: Boolean = false,
1094
1128
  ) {
1095
1129
  val tabs = animation.bottomTabs
1096
1130
  val tabsSide = AnimationRules.barSideGiven(tabs.enter) || AnimationRules.barSideGiven(tabs.exit)
@@ -1100,7 +1134,12 @@ internal class StackQueue(
1100
1134
  else -> system
1101
1135
  }
1102
1136
  host.tabBarTransition(move, tabs.takeIf { move == TabBarMotion.Move.Slide })
1103
- host.barTransition(if (animated && !own) row else null, barVisibility(animated && !elements, animation.topBar))
1137
+ val barMoves = animated && !barAtOnce
1138
+ host.barTransition(
1139
+ if (barMoves && !own) row else null,
1140
+ barVisibility(barMoves && !elements, animation.topBar),
1141
+ travel.takeIf { barMoves && own && !elements },
1142
+ )
1104
1143
  }
1105
1144
 
1106
1145
  private fun barVisibility(animated: Boolean, side: TopBarEnterExitAnimationOptions): BarVisibility =
@@ -1209,6 +1248,7 @@ internal class StackQueue(
1209
1248
  val target: Node,
1210
1249
  val value: String?,
1211
1250
  val silent: Boolean,
1251
+ val gesture: Boolean,
1212
1252
  ) : Op(seq, answer, null, componentId, what) {
1213
1253
  var leaving: List<Node> = emptyList()
1214
1254
  }
@@ -16,11 +16,15 @@ import dev.apexnative.navigation.views.animations.SystemTransition
16
16
  * system's transition between activities (`SystemTransition`: 96 dp along the axis in 450 ms on
17
17
  * `fast_out_extra_slow_in`, mirrored right to left), `setStackRoot` is Material's fade through (`FadeThrough`). What
18
18
  * leaves starts from where it is; what comes in on a push is hidden explicitly until it fades in, as a delay doesn't
19
- * apply an animator's first value. Main thread only.
19
+ * apply an animator's first value. The system's windows also extend their edge by the distance they move
20
+ * ([extendsStart], `StackLayout.extendEdge`). Main thread only.
20
21
  */
21
22
  internal object StackTransitions {
22
- /** The animator of [view] for [motion]; [fromCurrent] for a screen taken over from a transition still running. */
23
- fun animator(view: View, motion: Motion, fromCurrent: Boolean): Animator {
23
+ /**
24
+ * The animator of [view] for [motion]; [fromCurrent] for a screen taken over from a transition still running.
25
+ * [onSlide] runs at each frame of its slide.
26
+ */
27
+ fun animator(view: View, motion: Motion, fromCurrent: Boolean, onSlide: () -> Unit = {}): Animator {
24
28
  val distance = SystemTransition.DISTANCE_DP * view.resources.displayMetrics.density
25
29
  // Forward is to the end of the line: right to left, the screens move the other way. The stack's view inherits
26
30
  // the app's direction, which `layout.direction` sets on the activity's content (`AppDirection`).
@@ -30,24 +34,37 @@ internal object StackTransitions {
30
34
  view.translationX = forward
31
35
  view.alpha = 0f
32
36
  together(
33
- slide(view, 0f),
37
+ slide(view, 0f, onSlide),
34
38
  fade(view, 1f, SystemTransition.PUSH_FADE_DELAY),
35
39
  )
36
40
  }
37
- Motion.PushCover -> slide(view, -forward)
41
+ Motion.PushCover -> slide(view, -forward, onSlide)
38
42
  Motion.PopOut -> together(
39
- slide(view, forward),
43
+ slide(view, forward, onSlide),
40
44
  fade(view, 0f, SystemTransition.POP_FADE_DELAY),
41
45
  )
42
46
  Motion.PopReveal -> {
43
47
  if (!fromCurrent) view.translationX = -forward
44
- if (view.alpha < 1f) together(slide(view, 0f), fade(view, 1f, 0L)) else slide(view, 0f)
48
+ if (view.alpha < 1f) together(slide(view, 0f, onSlide), fade(view, 1f, 0L)) else slide(view, 0f, onSlide)
45
49
  }
46
50
  Motion.FadeIn -> FadeThrough.enter(view)
47
51
  Motion.FadeOut -> FadeThrough.exit(view)
48
52
  }
49
53
  }
50
54
 
55
+ /**
56
+ * Whether a screen moving by [motion] extends its start edge (the side it moves away from on a push in and a pop
57
+ * out), its end edge (a push cover, a pop reveal), or none (a fade through: null), as the system's windows do.
58
+ */
59
+ fun extendsStart(motion: Motion): Boolean? = when (motion) {
60
+ Motion.PushIn, Motion.PopOut -> true
61
+ Motion.PushCover, Motion.PopReveal -> false
62
+ Motion.FadeIn, Motion.FadeOut -> null
63
+ }
64
+
65
+ /** Whether [motion] animates the screen's alpha: it draws through a hardware layer meanwhile. */
66
+ fun fades(motion: Motion): Boolean = motion != Motion.PushCover && motion != Motion.PopReveal
67
+
51
68
  /**
52
69
  * The fade of a screen around elements: in from 0 to 1, out from where it is to 0, linearly in
53
70
  * [AnimationRules.SCREEN_FADE]; none for [Fade.None].
@@ -65,10 +82,11 @@ internal object StackTransitions {
65
82
  }
66
83
  }
67
84
 
68
- private fun slide(view: View, to: Float): Animator =
85
+ private fun slide(view: View, to: Float, onFrame: () -> Unit): Animator =
69
86
  ObjectAnimator.ofFloat(view, View.TRANSLATION_X, view.translationX, to).apply {
70
87
  duration = SystemTransition.DURATION
71
88
  interpolator = SystemTransition.CURVE
89
+ addUpdateListener { onFrame() }
72
90
  }
73
91
 
74
92
  private fun fade(view: View, to: Float, delay: Long): Animator =
@@ -9,6 +9,7 @@ import androidx.core.view.WindowInsetsCompat
9
9
  import androidx.lifecycle.Lifecycle
10
10
  import androidx.lifecycle.LifecycleOwner
11
11
  import dev.apexnative.navigation.options.LayoutAnimation
12
+ import dev.apexnative.navigation.utils.UiThread
12
13
  import dev.apexnative.navigation.utils.whenContentMounted
13
14
  import dev.apexnative.navigation.viewcontrollers.component.ComponentViewController
14
15
  import dev.apexnative.navigation.viewcontrollers.externalcomponent.ExternalComponentViewController
@@ -16,6 +17,7 @@ import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.Motion
16
17
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
17
18
  import dev.apexnative.navigation.views.animations.AnimationRules
18
19
  import dev.apexnative.navigation.views.animations.SideAnimator
20
+ import dev.apexnative.navigation.views.animations.SystemTransition
19
21
  import dev.apexnative.navigation.views.element.ElementTransition
20
22
  import dev.apexnative.navigation.views.element.TargetsWait
21
23
  import dev.apexnative.navigation.views.stack.StackLayout
@@ -29,6 +31,11 @@ import dev.apexnative.navigation.views.stack.StackLayout
29
31
  */
30
32
  internal class StackViews(private val stack: StackController) : StackDisplay {
31
33
  private val animators = HashMap<ViewController<*>, Animator>()
34
+ // The preview of the back gesture that runs or ends now, and its two screens.
35
+ private var preview: BackPreview? = null
36
+ private var previewScreens: Pair<ViewController<*>, ViewController<*>>? = null
37
+ // The ends of the two screens of a released gesture whose preview plays its end.
38
+ private var committing: Pair<() -> Unit, () -> Unit>? = null
32
39
  // What goes back when a screen's animator ends or stops: the elements its transition carries.
33
40
  private val cleanups = HashMap<ViewController<*>, () -> Unit>()
34
41
 
@@ -110,11 +117,16 @@ internal class StackViews(private val stack: StackController) : StackDisplay {
110
117
  override fun animate(screen: ViewController<*>, motion: Motion, fromCurrent: Boolean, done: () -> Unit) {
111
118
  stop(screen)
112
119
  val view = screen.peekView() ?: return done()
113
- val animator = StackTransitions.animator(view, motion, fromCurrent)
120
+ val layout = layout
121
+ val animator = StackTransitions.animator(view, motion, fromCurrent) { layout?.edgesMoved() }
122
+ cleanups[screen] = dress(view, motion, layout)
114
123
  animator.addListener(object : AnimatorListenerAdapter() {
115
124
  // Ended or cancelled by anything else than [stop]: the screen's part is done.
116
125
  override fun onAnimationEnd(animation: Animator) {
117
- if (animators[screen] === animator) animators.remove(screen)
126
+ if (animators[screen] === animator) {
127
+ animators.remove(screen)
128
+ cleanups.remove(screen)?.invoke()
129
+ }
118
130
  done()
119
131
  }
120
132
  })
@@ -122,6 +134,21 @@ internal class StackViews(private val stack: StackController) : StackDisplay {
122
134
  animator.start()
123
135
  }
124
136
 
137
+ // For the default transition [view] moves as the system's windows do: its edge extended by the distance it moves,
138
+ // and its fade drawn through a hardware layer, so the whole screen isn't drawn again at every frame of it. What
139
+ // undoes both.
140
+ private fun dress(view: View, motion: Motion, layout: StackLayout?): () -> Unit {
141
+ StackTransitions.extendsStart(motion)?.let { start ->
142
+ layout?.extendEdge(view, start, (SystemTransition.DISTANCE_DP * view.resources.displayMetrics.density).toInt())
143
+ }
144
+ val layered = StackTransitions.fades(motion) && view.layerType == View.LAYER_TYPE_NONE
145
+ if (layered) view.setLayerType(View.LAYER_TYPE_HARDWARE, null)
146
+ return {
147
+ layout?.dropEdge(view)
148
+ if (layered && view.layerType == View.LAYER_TYPE_HARDWARE) view.setLayerType(View.LAYER_TYPE_NONE, null)
149
+ }
150
+ }
151
+
125
152
  // The elements go into a layer over the screens, and back as the screen's animator ends or stops. A shared element
126
153
  // without a duration takes the screen's fade: its own single `alpha`, else 300 ms.
127
154
  override fun animateCustom(screen: ViewController<*>, custom: StackDisplay.Custom, done: () -> Unit) {
@@ -167,7 +194,83 @@ internal class StackViews(private val stack: StackController) : StackDisplay {
167
194
  done: () -> Unit,
168
195
  ): () -> Unit = TargetsWait.start(animation, from, to, waiting, done)
169
196
 
197
+ /**
198
+ * Starts the preview of a back gesture over [top], [target] under it (`BackPreview`): none without the stack's view
199
+ * or a window. [onReveal] shows [target] and its bar as the preview's picture is there.
200
+ */
201
+ fun startBackPreview(
202
+ top: ViewController<*>,
203
+ target: ViewController<*>,
204
+ fromLeft: Boolean,
205
+ touchY: Float,
206
+ onReveal: () -> Unit,
207
+ ): BackPreview? {
208
+ val bottomRoom = top.getBottomInset()
209
+ endPreview()
210
+ val layout = layout ?: return null
211
+ val window = stack.activity.window ?: return null
212
+ val topView = top.peekView() ?: return null
213
+ val targetView = target.peekView() ?: return null
214
+ val started = BackPreview(layout, topView, targetView, fromLeft, touchY, bottomRoom, onReveal)
215
+ preview = started
216
+ previewScreens = top to target
217
+ started.start(window)
218
+ return started
219
+ }
220
+
221
+ /** The preview of the back gesture went: cancelled and put back, or never shown. */
222
+ fun previewEnded(ended: BackPreview) {
223
+ if (preview !== ended) return
224
+ preview = null
225
+ previewScreens = null
226
+ }
227
+
228
+ override fun hasBackPreview(top: ViewController<*>, target: ViewController<*>): Boolean =
229
+ preview?.isReady == true && previewScreens == top to target
230
+
231
+ override fun finishBackGesture(
232
+ top: ViewController<*>,
233
+ target: ViewController<*>,
234
+ topDone: () -> Unit,
235
+ targetDone: () -> Unit,
236
+ ) {
237
+ val running = preview?.takeIf { hasBackPreview(top, target) } ?: run {
238
+ topDone()
239
+ targetDone()
240
+ return
241
+ }
242
+ committing = topDone to targetDone
243
+ running.commit {
244
+ committing = null
245
+ previewEnded(running)
246
+ topDone()
247
+ targetDone()
248
+ }
249
+ }
250
+
251
+ private fun endPreview() {
252
+ val ended = preview ?: return
253
+ preview = null
254
+ previewScreens = null
255
+ committing = null
256
+ ended.endAtOnce()
257
+ }
258
+
259
+ // A later transition takes one screen of a released gesture's preview: the preview ends where it is, the popped
260
+ // screen stays out of sight until the pop takes it away, and the other screen's part ends on the next turn.
261
+ private fun stopPreview(screen: ViewController<*>) {
262
+ val (top, target) = previewScreens ?: return
263
+ if (screen !== top && screen !== target) return
264
+ val dones = committing
265
+ endPreview()
266
+ if (dones == null) return
267
+ top.peekView()?.alpha = 0f
268
+ val other = if (screen === top) dones.second else dones.first
269
+ UiThread.post { other() }
270
+ }
271
+
170
272
  override fun stop(screen: ViewController<*>) {
273
+ stopPreview(screen)
171
274
  val animator = animators.remove(screen)
172
275
  if (animator != null) {
173
276
  animator.removeAllListeners()
@@ -49,7 +49,9 @@ import dev.apexnative.navigation.viewcontrollers.stack.search.StackSearchPresent
49
49
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.IReactView
50
50
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ScrollEventListener
51
51
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
52
+ import dev.apexnative.navigation.views.animations.AnimationRules
52
53
  import dev.apexnative.navigation.views.animations.SideAnimator
54
+ import dev.apexnative.navigation.views.animations.FadeThrough
53
55
  import dev.apexnative.navigation.views.animations.SystemTransition
54
56
  import dev.apexnative.navigation.views.component.ComponentLayout
55
57
  import dev.apexnative.navigation.views.stack.StackLayout
@@ -124,8 +126,13 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
124
126
  private var largeTitleShown = false
125
127
  private var followed: ScrollEventListener? = null
126
128
  private var rowTarget: RowTarget? = null
129
+ // The large title opening with a push from a screen without one; none at rest.
130
+ private var largeTitleOpening: ValueAnimator? = null
127
131
  // The picture of the part under the row in a leaving screen's overlay: the screen and the picture.
128
132
  private var underRow: Pair<ViewController<*>, Drawable>? = null
133
+ // The bar goes with the screens of a transition of the stack's own, and the screen that leaves with its picture.
134
+ private var travelling = false
135
+ private var travelLeaving: ViewController<*>? = null
129
136
 
130
137
  private enum class Part { Look, Title, Left, Right, Back, LargeTitle, Visible, TestId }
131
138
 
@@ -161,12 +168,16 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
161
168
  * The stack's current child changes to [incoming] ([motion]: the default transition that brings it, none at once):
162
169
  * the row's picture is taken before the bar takes the new screen's options. A push from a large title to a screen
163
170
  * without one makes the bar lower at once: the part under the row rides with the leaving screen as a picture in its
164
- * overlay, and the row's picture holds the row only. So does a pop back to a screen whose large title is collapsed.
171
+ * overlay, and the row's picture holds the row only. So does a pop or a fade through (`setStackRoot`) to a screen
172
+ * without a large title or with its large title collapsed: a picture as tall as the large title would hold the bar
173
+ * at its height to the end. With a transition of the stack's own ([travel]) the bar goes with the screens instead
174
+ * ([beginTravel]).
165
175
  */
166
176
  fun beginRowTransition(
167
177
  motion: BarMotion?,
168
178
  incoming: ViewController<*>?,
169
179
  visibility: StackDisplay.BarVisibility = StackDisplay.BarVisibility.AT_ONCE,
180
+ travel: StackDisplay.BarTravel? = null,
170
181
  ) {
171
182
  this.motion = motion
172
183
  this.visibilityChange = visibility
@@ -179,14 +190,68 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
179
190
  rowTarget?.withLargeTitle = !lowers
180
191
  if (lowers) pictureUnderRow(bar, leaving)
181
192
  rowTransition?.begin(motion)
193
+ endTravel()
194
+ if (travel != null && bar != null && incoming != null) beginTravel(bar, leaving, incoming, travel, visibility)
182
195
  }
183
196
 
184
197
  /** The transition that brought the current child ended: the row at rest, the picture under it gone. */
185
198
  fun endRowTransition() {
199
+ // A large title still opening is open now.
200
+ largeTitleOpening?.let {
201
+ largeTitleOpening = null
202
+ it.end()
203
+ }
186
204
  motion = null
187
205
  visibilityChange = StackDisplay.BarVisibility.AT_ONCE
188
206
  rowTransition?.end()
189
207
  dropPictureUnderRow()
208
+ endTravel()
209
+ }
210
+
211
+ /**
212
+ * The bar goes with the screens of a transition of the stack's own, as each screen's bar would: a picture of the
213
+ * bar as it is goes with [leaving], and the bar, which takes [incoming]'s options next, is drawn flat as [incoming]
214
+ * moves — under [leaving] on a pop ([StackDisplay.BarTravel.Back]) (`StackLayout.barTravels`). No picture when the
215
+ * bar doesn't show. Nothing travels when the new top hides the bar by a side of its own
216
+ * (`animations.*.topBar.exit`), which plays instead, or when the bar can't be drawn in software (a `HARDWARE`
217
+ * bitmap in it): the bar takes the options in its place.
218
+ */
219
+ private fun beginTravel(
220
+ bar: TopBar,
221
+ leaving: ViewController<*>?,
222
+ incoming: ViewController<*>,
223
+ direction: StackDisplay.BarTravel,
224
+ visibility: StackDisplay.BarVisibility,
225
+ ) {
226
+ val layout = layout ?: return
227
+ val screen = incoming.peekView()?.takeIf(layout::holds) ?: return
228
+ val leavingView = leaving?.peekView()?.takeIf(layout::holds)
229
+ // The new top hides the bar by a side of its own: the side plays on the bar in its place instead.
230
+ if (host.resolvedOf(incoming).topBar.visible.isFalse && AnimationRules.barSideGiven(visibility.side?.exit)) return
231
+ val shows = bar.visibility == View.VISIBLE && this.visibility?.isHidden != true
232
+ val picture = if (shows && leavingView != null) pictureOfBar(bar) ?: return else null
233
+ travelling = true
234
+ travelLeaving = leaving.takeIf { picture != null }
235
+ // Flat: with an elevation the renderer would draw the bar over every screen and out of its alpha layer.
236
+ bar.isFlat = true
237
+ layout.barTravels(screen, leavingView, picture, direction == StackDisplay.BarTravel.Back)
238
+ }
239
+
240
+ // The bar's picture where the bar is in the stack, its moves counted.
241
+ private fun pictureOfBar(bar: TopBar): Drawable? {
242
+ val bitmap = bar.picture() ?: return null
243
+ val left = (bar.left + bar.translationX).roundToInt()
244
+ val top = (bar.top + bar.translationY).roundToInt()
245
+ return BitmapDrawable(bar.resources, bitmap).apply { setBounds(left, top, left + bitmap.width, top + bitmap.height) }
246
+ }
247
+
248
+ // The bar's travel ends: the picture goes, the bar is drawn in its place again with its shadow.
249
+ private fun endTravel() {
250
+ if (!travelling) return
251
+ travelling = false
252
+ travelLeaving = null
253
+ bar?.isFlat = false
254
+ layout?.endBarTravel()
190
255
  }
191
256
 
192
257
  /**
@@ -205,8 +270,9 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
205
270
  keys.clear()
206
271
  }
207
272
  shownEntry = entry
208
- // The screen that left with the picture under the row shows again: the picture goes.
273
+ // The screen that left with the picture under the row, or of the bar, shows again: the picture goes.
209
274
  if (underRow?.first === entry) dropPictureUnderRow()
275
+ if (travelLeaving === entry) endTravel()
210
276
  val options = host.resolvedOf(entry)
211
277
  applyAll(bar, entry, screen, options, merge = false)
212
278
  }
@@ -334,7 +400,9 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
334
400
  /** The stack is destroyed: every component stops. */
335
401
  fun destroy() {
336
402
  rowTransition?.end()
403
+ endTravel()
337
404
  largeTitleFollower.stop()
405
+ stopLargeTitleOpening()
338
406
  collapse?.disableCollapse()
339
407
  components.destroyAll()
340
408
  cells.clear()
@@ -370,6 +438,9 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
370
438
  )
371
439
  }
372
440
 
441
+ /** Whether the bar shows [entry]: its parts are applied, so its place in the layout is [entry]'s. */
442
+ fun shows(entry: ViewController<*>): Boolean = entry === shownEntry
443
+
373
444
  /** The height of the search under the row of [entry] when it is the screen the bar shows; 0 otherwise. */
374
445
  fun searchHeightOf(entry: ViewController<*>): Int =
375
446
  if (entry === shownEntry) bar?.searchAreaHeight ?: 0 else 0
@@ -633,11 +704,13 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
633
704
  }
634
705
 
635
706
  // Whether [incoming] shows less of the bar under the row than the large title now: a push to a screen without a
636
- // large title, or a pop back to one whose large title is collapsed. The lower bar would cut the row's picture.
707
+ // large title, or a pop or a fade through to one without a large title or with its large title collapsed. The lower
708
+ // bar would cut the row's picture.
637
709
  private fun lowersBar(motion: BarMotion?, incoming: ViewController<*>, options: Options): Boolean = when (motion) {
638
710
  BarMotion.Push -> !largeTitleShownFor(incoming, options)
639
- BarMotion.Pop -> largeTitleShownFor(incoming, options) && collapseOf(incoming, options) > 0f
640
- else -> false
711
+ BarMotion.Pop, BarMotion.FadeThrough ->
712
+ !largeTitleShownFor(incoming, options) || collapseOf(incoming, options) > 0f
713
+ null -> false
641
714
  }
642
715
 
643
716
  // How far [entry]'s large title is collapsed as it shows: by the offset its list had last, unless it doesn't follow
@@ -675,6 +748,11 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
675
748
  large.fontSize.get(null), fontKey(large.font), large.collapseOnScroll.get(null), testId).joinToString()
676
749
  if (keys[Part.LargeTitle] == key) return
677
750
  keys[Part.LargeTitle] = key
751
+ // The default push or fade through from a screen without a large title opens it with the screens, on a bar that
752
+ // shows at rest: the bar's show and its hide on scroll move the same translation.
753
+ val opening = motion.takeIf { it == BarMotion.Push || it == BarMotion.FadeThrough }
754
+ val opens = shown && !largeTitleShown && bar.visibility == View.VISIBLE && bar.translationY == 0f
755
+ stopLargeTitleOpening()
678
756
  largeTitleShown = shown
679
757
  bar.setLargeTitleShown(shown)
680
758
  largeTitleFollower.stop()
@@ -692,9 +770,39 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
692
770
  // same frame: back to the screen, after light or dark changed, after a merge.
693
771
  if (large.collapseOnScroll.isFalse) scrollOf(entry)?.forgetOffset()
694
772
  largeTitleFollower.collapse(collapseOf(entry, options))
773
+ if (opens) opening?.let { openLargeTitle(bar, collapseOf(entry, options), it) }
695
774
  if (!large.collapseOnScroll.isFalse) scrollOf(entry)?.let(largeTitleFollower::follow)
696
775
  }
697
776
 
777
+ // The bar grows with the screens' [motion], on its curve and in its time, from the row's height to the large title
778
+ // as the screen's list left it: the large title comes out from under the row as it does when the list scrolls
779
+ // back. No frame shows the tall bar before the screens move.
780
+ private fun openLargeTitle(bar: TopBar, offset: Float, motion: BarMotion) {
781
+ val range = UiUtils.dpToPx(bar.context, LargeTitleView.HEIGHT)
782
+ val to = LargeTitleCollapse.shift(offset, range)
783
+ if (to >= range) return
784
+ bar.setLargeTitleCollapse(range.toInt())
785
+ largeTitleOpening = ValueAnimator.ofFloat(range, to).apply {
786
+ duration = if (motion == BarMotion.FadeThrough) FadeThrough.DURATION else SystemTransition.DURATION
787
+ interpolator = if (motion == BarMotion.FadeThrough) FadeThrough.CURVE else SystemTransition.CURVE
788
+ addUpdateListener { bar.setLargeTitleCollapse((it.animatedValue as Float).toInt()) }
789
+ addListener(object : AnimatorListenerAdapter() {
790
+ override fun onAnimationEnd(animation: Animator) {
791
+ if (largeTitleOpening === animation) largeTitleOpening = null
792
+ }
793
+ })
794
+ start()
795
+ }
796
+ }
797
+
798
+ // Stopped where it is: what applies next sets the large title.
799
+ private fun stopLargeTitleOpening() {
800
+ val opening = largeTitleOpening ?: return
801
+ largeTitleOpening = null
802
+ opening.removeAllUpdateListeners()
803
+ opening.cancel()
804
+ }
805
+
698
806
  // Shown or hidden: with the transition by its side, or by `animate` in a merge.
699
807
  private fun applyVisibility(options: Options, merge: Boolean) {
700
808
  val visible = !options.topBar.visible.isFalse
@@ -702,7 +810,10 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
702
810
  if (keys[Part.Visible] == key) return
703
811
  keys[Part.Visible] = key
704
812
  val change = visibilityChange
705
- val animated = !options.topBar.animate.isFalse && (merge || change.animated)
813
+ // While the bar goes with the screens it shows or hides at once, unless its side of its own plays.
814
+ val travels = !merge && travelling &&
815
+ !AnimationRules.barSideGiven(if (visible) change.side?.enter else change.side?.exit)
816
+ val animated = !options.topBar.animate.isFalse && (merge || change.animated) && !travels
706
817
  visibility?.apply(visible, animated, if (merge) null else change.side)
707
818
  }
708
819
 
@@ -960,8 +1071,11 @@ internal class TopBarPresenter(private val activity: Activity, private val host:
960
1071
  if (large) View.IMPORTANT_FOR_ACCESSIBILITY_NO else View.IMPORTANT_FOR_ACCESSIBILITY_YES
961
1072
  }
962
1073
 
1074
+ // The list scrolled while the large title opens: it follows the list from there.
963
1075
  override fun onScrollOffset(offsetY: Float) {
964
- if (largeTitleShown) collapse(offsetY)
1076
+ if (!largeTitleShown) return
1077
+ stopLargeTitleOpening()
1078
+ collapse(offsetY)
965
1079
  }
966
1080
 
967
1081
  override fun onScrollUp(nextTranslation: Float) = Unit
@@ -94,6 +94,19 @@ abstract class ViewController<V : ViewGroup>(
94
94
  open val isInSheet: Boolean
95
95
  get() = shownAsSheet || parent?.isInSheet == true
96
96
 
97
+ /**
98
+ * The node is the root of an overlay: the overlays set it before the node's view is made, so its screens are drawn
99
+ * under the status bar from their first layout (`StatusBarOptions.isHiddenOrDrawBehind`).
100
+ */
101
+ internal var shownAsOverlay = false
102
+
103
+ /**
104
+ * The node is in an overlay: it covers the whole window, and is drawn under the status bar by default. It holds from
105
+ * the start of the show, while `OverlayCoordinator.isInOverlay` answers only for the overlays it lists.
106
+ */
107
+ open val isInOverlay: Boolean
108
+ get() = shownAsOverlay || parent?.isInOverlay == true
109
+
97
110
  /**
98
111
  * The node is in a side of a side menu (its left or right menu, not its center): the side goes the whole height of
99
112
  * the window, and the node keeps no room for the status bar. The layout around answers for it
@@ -265,6 +278,12 @@ abstract class ViewController<V : ViewGroup>(
265
278
  /** Whether [handleBack] would go back. */
266
279
  open fun canHandleBack(): Boolean = false
267
280
 
281
+ /**
282
+ * The stack whose top [handleBack] would take away with the default pop, which a back gesture previews; none when
283
+ * back would do anything else (hold, close a menu or a pane, a pop of its own) or nothing.
284
+ */
285
+ internal open fun stackPoppedByBack(): StackController? = null
286
+
268
287
  /** Starts the node's content (a native screen's view, a React screen's surface). */
269
288
  open fun start() = Unit
270
289