@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
@@ -261,6 +261,54 @@ internal class ModalCoordinator(
261
261
  }
262
262
  }
263
263
 
264
+ /**
265
+ * A modal of a `setRoot`'s layout made with the root, before its turn: it joins the modals layer and renders
266
+ * unseen while the root waits and comes in, held — it doesn't show, and commands and back don't count it — until
267
+ * [release]; [animated] as in [showInChain]. A failure is logged, and nothing is made (null).
268
+ */
269
+ fun hold(id: String, make: () -> ViewController<*>, animated: Boolean): ModalRecord? = try {
270
+ start(make, null, ModalRecord.Chain(animated) {}, held = true)
271
+ } catch (e: Throwable) {
272
+ val reason = ErrorTexts.reasonOf(e)
273
+ host.log("[ApexNavigation] setRoot: ${ErrorTexts.couldNot("show the screen '$id'", reason)}")
274
+ null
275
+ }
276
+
277
+ /**
278
+ * The turn of a [hold]'s modal in its `setRoot`'s chain: it shows as soon as it rendered, as [showInChain]'s
279
+ * would, with [done] once it showed or went; at once when it went meanwhile. The function returned stops it while
280
+ * it waits.
281
+ */
282
+ fun release(record: ModalRecord, done: () -> Unit): () -> Unit {
283
+ if (!record.held || record.state != State.Waiting) {
284
+ done()
285
+ return {}
286
+ }
287
+ record.chain = ModalRecord.Chain(record.chain?.animated ?: true, done)
288
+ record.held = false
289
+ guarded(record) {
290
+ host.backChanged()
291
+ // Rendered while held: the targets of its elements are in the layer under it only now.
292
+ if (record.isReady && !record.targetsAwaited && awaitsTargets(record)) {
293
+ record.isReady = false
294
+ awaitTargets(record)
295
+ } else {
296
+ presentReady()
297
+ }
298
+ }
299
+ return {
300
+ record.chain = null
301
+ if (record.state == State.Waiting) cancel(record, null, reports = false)
302
+ }
303
+ }
304
+
305
+ /** A [hold]'s modal whose `setRoot` ended before its turn: it goes without a word. */
306
+ fun drop(record: ModalRecord) {
307
+ if (!record.held) return
308
+ record.chain = null
309
+ if (record.state == State.Waiting) cancel(record, null, reports = false)
310
+ }
311
+
264
312
  /**
265
313
  * `dismissModal` after its shared steps: the record of [componentId], Android's rule of the last modal without a
266
314
  * root, [options] merged into the modal's visible screen, the animation of the command's screen with its parents
@@ -279,6 +327,7 @@ internal class ModalCoordinator(
279
327
  State.ClosedBelow -> return answer.resolve(record.rootId)
280
328
  else -> Unit
281
329
  }
330
+ if (record.held) return answer.reject(ErrorTexts.screenNotShown(componentId))
282
331
  if (isLastWithoutRoot(record)) return answer.reject(ErrorTexts.modalWithoutRoot(componentId))
283
332
  options?.let { mergeInto(record, it) }
284
333
  val from = node?.takeUnless { it.isDestroyed } ?: record.root
@@ -293,7 +342,7 @@ internal class ModalCoordinator(
293
342
  * before anything changes, with the id of the top modal's visible screen.
294
343
  */
295
344
  fun dismissAll(options: Options?, unreadablePath: String?, answer: CommandAnswer) {
296
- val members = records.filter { it.batch == null && it.state in BATCHED }
345
+ val members = records.filter { it.batch == null && !it.held && it.state in BATCHED }
297
346
  if (members.isEmpty()) {
298
347
  // Nothing else to take: the second one answers with the running batch (N6), else at once.
299
348
  val running = batches.lastOrNull()
@@ -347,6 +396,12 @@ internal class ModalCoordinator(
347
396
  return true
348
397
  }
349
398
 
399
+ /**
400
+ * The root of the modal that back goes to first; none without one. A back gesture previews only a pop in it: its
401
+ * dismissal has a preview of its own nowhere yet.
402
+ */
403
+ fun backTargetRoot(): ViewController<*>? = backTarget()?.root
404
+
350
405
  /** The mirror of [handleBack], without acting; null without a modal to ask. */
351
406
  fun canHandleBack(): Boolean? {
352
407
  val record = backTarget() ?: return null
@@ -422,6 +477,8 @@ internal class ModalCoordinator(
422
477
  }
423
478
  batches.clear()
424
479
  for (record in records.toList()) {
480
+ // A modal of the new root's layout, made with it, stays: it shows over the new root in its turn.
481
+ if (record.held) continue
425
482
  if (record.state == State.Waiting) {
426
483
  cancel(record, null, reports = false)
427
484
  continue
@@ -432,7 +489,7 @@ internal class ModalCoordinator(
432
489
  views.carry(record, old)
433
490
  carried += record
434
491
  }
435
- records.clear()
492
+ records.retainAll { it.held }
436
493
  host.backChanged()
437
494
  }
438
495
 
@@ -482,7 +539,12 @@ internal class ModalCoordinator(
482
539
 
483
540
  // The show (3, 4).
484
541
 
485
- private fun start(make: () -> ViewController<*>, answer: CommandAnswer?, chain: ModalRecord.Chain?): ModalRecord {
542
+ private fun start(
543
+ make: () -> ViewController<*>,
544
+ answer: CommandAnswer?,
545
+ chain: ModalRecord.Chain?,
546
+ held: Boolean = false,
547
+ ): ModalRecord {
486
548
  val root = make()
487
549
  val record: ModalRecord
488
550
  val options: Options
@@ -492,6 +554,7 @@ internal class ModalCoordinator(
492
554
  val sheet = ModalRecord.isSheet(options)
493
555
  root.shownAsSheet = sheet
494
556
  record = ModalRecord(root, sheet)
557
+ record.held = held
495
558
  } catch (e: Throwable) {
496
559
  root.destroy()
497
560
  throw e
@@ -555,7 +618,7 @@ internal class ModalCoordinator(
555
618
  private fun ready(record: ModalRecord) {
556
619
  if (record.state != State.Waiting) return
557
620
  record.wait = null
558
- if (!record.targetsAwaited && awaitsTargets(record)) return awaitTargets(record)
621
+ if (!record.targetsAwaited && !record.held && awaitsTargets(record)) return awaitTargets(record)
559
622
  record.cancelLimit()
560
623
  record.isReady = true
561
624
  presentReady()
@@ -607,7 +670,7 @@ internal class ModalCoordinator(
607
670
  if (taking) return
608
671
  for (record in records.toList()) {
609
672
  if (record.state != State.Waiting) continue
610
- if (!record.isReady) return
673
+ if (record.held || !record.isReady) return
611
674
  present(record)
612
675
  }
613
676
  }
@@ -1156,13 +1219,16 @@ internal class ModalCoordinator(
1156
1219
 
1157
1220
  // A record is the top when no record above it waits, comes in or shows; one that leaves doesn't count.
1158
1221
  private fun isTop(record: ModalRecord): Boolean =
1159
- records.drop(records.indexOf(record) + 1).none { it.effective in TOP_STATES }
1222
+ records.drop(records.indexOf(record) + 1).none(::countsAsTop)
1160
1223
 
1161
1224
  // The top record, as the commands see it; none.
1162
- private fun topRecord(): ModalRecord? = records.lastOrNull { it.effective in TOP_STATES }
1225
+ private fun topRecord(): ModalRecord? = records.lastOrNull(::countsAsTop)
1163
1226
 
1164
1227
  // Back goes to the top modal that waits, comes in or shows.
1165
- private fun backTarget(): ModalRecord? = records.lastOrNull { it.effective in TOP_STATES }
1228
+ private fun backTarget(): ModalRecord? = records.lastOrNull(::countsAsTop)
1229
+
1230
+ // A record that waits, comes in or shows; a held one doesn't count before its turn.
1231
+ private fun countsAsTop(record: ModalRecord): Boolean = !record.held && record.effective in TOP_STATES
1166
1232
 
1167
1233
  // `hardwareBackButton.dismissModalOnPress: false` of the modal's options now.
1168
1234
  private fun holdsBack(record: ModalRecord): Boolean =
@@ -17,7 +17,10 @@ import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
17
17
  internal class ModalRecord(
18
18
  /** The top node of the modal's layout. */
19
19
  val root: ViewController<*>,
20
- /** The modal shows as a Material sheet (`modal.sheet`), decided before its root's view is made. */
20
+ /**
21
+ * The modal shows as a Material sheet (`pageSheet`, `formSheet`, `popover`, or `modal.sheet` without a style),
22
+ * decided before its root's view is made.
23
+ */
21
24
  val isSheet: Boolean,
22
25
  ) : WaitingLayer {
23
26
  /** Where the modal is. */
@@ -78,6 +81,12 @@ internal class ModalRecord(
78
81
  /** Its wait ended: it shows once every older waiting modal showed or went (N1). */
79
82
  var isReady = false
80
83
 
84
+ /**
85
+ * A modal of a `setRoot`'s layout made with the root, before its turn: it renders unseen while the root waits and
86
+ * comes in, and neither shows nor counts for commands or back until its step of the chain lets it go.
87
+ */
88
+ var held = false
89
+
81
90
  /** The wait for its screens while it waits. */
82
91
  var wait: RenderWait? = null
83
92
 
@@ -160,14 +169,20 @@ internal class ModalRecord(
160
169
  /** `modalPresentationStyle` values that keep the layer under in place. */
161
170
  private val OVER_STYLES = setOf("overFullScreen", "overCurrentContext")
162
171
 
163
- /** The styles a sheet shows with: unset, `pageSheet`, `formSheet`. */
164
- private val SHEET_STYLES = setOf(null, "pageSheet", "formSheet")
172
+ /**
173
+ * The styles that always show as a sheet: the sheets of iOS, and the popover, which an iPhone shows as a sheet
174
+ * too.
175
+ */
176
+ private val SHEET_STYLES = setOf("pageSheet", "formSheet", "popover")
165
177
 
166
178
  /**
167
- * Whether a modal with [options] (resolved, with the defaults) shows as a sheet: any `modal.sheet` field set,
168
- * and the style unset, `pageSheet` or `formSheet`.
179
+ * Whether a modal with [options] (resolved, with the defaults) shows as a sheet: its style is `pageSheet`,
180
+ * `formSheet` or `popover`, or it has none and any `modal.sheet` field is set.
169
181
  */
170
- fun isSheet(options: Options): Boolean = options.modal.sheet.hasValue && styleOf(options) in SHEET_STYLES
182
+ fun isSheet(options: Options): Boolean {
183
+ val style = styleOf(options)
184
+ return style in SHEET_STYLES || (style == null && options.modal.sheet.hasValue)
185
+ }
171
186
 
172
187
  /**
173
188
  * Whether a modal covers the layer under it at the end of its show: full screen, unless it is a sheet or
@@ -29,11 +29,12 @@ import dev.apexnative.navigation.utils.dp
29
29
  import kotlin.math.roundToInt
30
30
 
31
31
  /**
32
- * A modal shown as a Material bottom sheet (`modal.sheet`): the modal's view in a container with a
33
- * [BottomSheetBehavior], in the modals' CoordinatorLayout, over a scrim. It stops at the detents of its options —
34
- * `medium` at half the height, `large` at the full height under the status bar, a number at that height in dp — and the
35
- * user drags it between them. A drag down past the smallest one closes it, unless `swipeToDismiss` is false: then the
36
- * sheet stays, and the try is told. The screen under it stays in place.
32
+ * A modal shown as a Material bottom sheet (`pageSheet`, `formSheet`, `popover`, or `modal.sheet` without a style): the
33
+ * modal's view in a container with a [BottomSheetBehavior], in the modals' CoordinatorLayout, over a scrim. It stops at
34
+ * the detents of its options — `medium` at half the height, `large` at the full height under the status bar, a number
35
+ * at that height in dp — and the user drags it between them. In a window wider than [MAX_WIDTH_DP] it stands in the
36
+ * middle at that width. A drag down past the smallest one closes it, unless `swipeToDismiss` is false: then the sheet
37
+ * stays, and the try is told. The screen under it stays in place.
37
38
  */
38
39
  internal class ModalSheet(
39
40
  private val layout: CoordinatorLayout,
@@ -96,11 +97,19 @@ internal class ModalSheet(
96
97
  outline.setRoundRect(0, 0, view.width, view.height + radius.roundToInt(), radius)
97
98
  }
98
99
  }
99
- // The sheet is under the status bar: its screens leave no room for it at their top.
100
+ // The sheet is under the status bar: its screens leave no room for it at their top. In the middle of a wide
101
+ // window it reaches no side, and keeps room only for the part of a side bar that is over it.
100
102
  ViewCompat.setOnApplyWindowInsetsListener(container) { _, insets ->
101
103
  val types = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
102
104
  val bars = insets.getInsets(types)
103
- WindowInsetsCompat.Builder(insets).setInsets(types, Insets.of(bars.left, 0, bars.right, bars.bottom)).build()
105
+ val laidOut = container.width > 0
106
+ val left = if (laidOut) (bars.left - container.left).coerceAtLeast(0) else bars.left
107
+ val right = if (laidOut) (bars.right - (layout.width - container.right)).coerceAtLeast(0) else bars.right
108
+ WindowInsetsCompat.Builder(insets).setInsets(types, Insets.of(left, 0, right, bars.bottom)).build()
109
+ }
110
+ // Its sides move with the window's width (a rotation, a resize): the room for the side bars follows.
111
+ container.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ ->
112
+ if (left != oldLeft || right != oldRight) container.post { ViewCompat.requestApplyInsets(container) }
104
113
  }
105
114
  container.addView(content, FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
106
115
  container.addView(grabber, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, Gravity.TOP or Gravity.CENTER_HORIZONTAL))
@@ -120,7 +129,12 @@ internal class ModalSheet(
120
129
  layout.addView(scrim, CoordinatorLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT))
121
130
  // As tall as its largest detent (maxHeight), not moved down by a margin: the behavior counts its states from
122
131
  // where the sheet is laid out.
123
- layout.addView(container, CoordinatorLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT).also { it.behavior = behavior })
132
+ // In a wide window it stands in the middle, as wide as a Material modal sheet at most.
133
+ behavior.maxWidth = MAX_WIDTH_DP.dp
134
+ val params = CoordinatorLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
135
+ params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
136
+ params.behavior = behavior
137
+ layout.addView(container, params)
124
138
  shown = onShown
125
139
  update(sheetOptions, swipeToDismiss, merge = null)
126
140
  selected = sheetOptions.selectedDetent?.takeIf { it in detents }
@@ -334,10 +348,13 @@ internal class ModalSheet(
334
348
  }
335
349
  }
336
350
 
337
- private companion object {
351
+ companion object {
352
+ /** The widest a sheet is, in dp: the Material modal bottom sheet's `maxWidth`. */
353
+ const val MAX_WIDTH_DP = 640
354
+
338
355
  /** The dim of a Material modal bottom sheet's scrim. */
339
- const val SCRIM_ALPHA = 0.32f
340
- const val GRAB_AREA_DP = 48
341
- const val ATTEMPT_DISTANCE_DP = 48
356
+ private const val SCRIM_ALPHA = 0.32f
357
+ private const val GRAB_AREA_DP = 48
358
+ private const val ATTEMPT_DISTANCE_DP = 48
342
359
  }
343
360
  }
@@ -4,9 +4,9 @@ import android.animation.Animator
4
4
  import android.animation.AnimatorListenerAdapter
5
5
  import android.animation.AnimatorSet
6
6
  import android.animation.ObjectAnimator
7
- import android.animation.PropertyValuesHolder
8
7
  import android.animation.TimeInterpolator
9
8
  import android.content.Context
9
+ import android.view.Gravity
10
10
  import android.view.View
11
11
  import android.view.ViewGroup
12
12
  import android.view.animation.LinearInterpolator
@@ -17,6 +17,7 @@ import androidx.core.view.WindowInsetsCompat
17
17
  import dev.apexnative.navigation.options.ModalAnimationOptions
18
18
  import dev.apexnative.navigation.options.Options
19
19
  import dev.apexnative.navigation.utils.TestIds
20
+ import dev.apexnative.navigation.utils.dp
20
21
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
21
22
  import dev.apexnative.navigation.views.animations.AnimationRules
22
23
  import dev.apexnative.navigation.views.animations.SideAnimator
@@ -62,9 +63,15 @@ internal class ModalViews(
62
63
  try {
63
64
  val view = record.root.view
64
65
  (view.parent as? ViewGroup)?.removeView(view)
65
- // A sheet's content waits at the sheet's full height, under the status bar.
66
+ // A sheet's content waits at the sheet's full height, under the status bar, and at its width.
66
67
  val params = CoordinatorLayout.LayoutParams(MATCH, MATCH)
67
- if (record.isSheet) params.topMargin = statusBarTop()
68
+ if (record.isSheet) {
69
+ params.topMargin = statusBarTop()
70
+ params.gravity = Gravity.CENTER_HORIZONTAL
71
+ // The modals layer is hidden while it has no modal: the window's content gives the width.
72
+ val maxWidth = ModalSheet.MAX_WIDTH_DP.dp
73
+ if (((modalsLayer.parent as? View)?.width ?: 0) > maxWidth) params.width = maxWidth
74
+ }
68
75
  container.addView(view, params)
69
76
  modalsLayer.addView(container, FrameLayout.LayoutParams(MATCH, MATCH))
70
77
  } catch (e: Throwable) {
@@ -100,14 +107,13 @@ internal class ModalViews(
100
107
  container.translationY = 0f
101
108
  return playOwn(record, parts, motion, showing = true, onEnd)
102
109
  }
110
+ // The system's slide over the window below: the modal comes up whole, without a fade.
103
111
  val distance = distanceOf(container)
104
- val animator = ObjectAnimator.ofPropertyValuesHolder(
105
- container,
106
- PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, distance, 0f),
107
- PropertyValuesHolder.ofFloat(View.ALPHA, 0f, 1f),
108
- )
109
- animator.interpolator = TimeInterpolator { ModalAnimations.decelerate(it) }
110
- return play(parts, animator, onEnd)
112
+ container.alpha = 1f
113
+ container.translationY = distance
114
+ val animator = ObjectAnimator.ofFloat(container, View.TRANSLATION_Y, distance, 0f)
115
+ animator.interpolator = TimeInterpolator { ModalAnimations.linearOutSlowIn(it) }
116
+ return play(parts, animator, ModalAnimations.SHOW_DURATION, onEnd)
111
117
  }
112
118
 
113
119
  // The root's view leaves the container and goes into the sheet's, in one turn; the sheet slides to its first
@@ -163,13 +169,11 @@ internal class ModalViews(
163
169
  if (!animated || !seen) return none(onEnd)
164
170
  if (motion != null) return playOwn(record, parts, motion, showing = false, onEnd)
165
171
  val container = parts.container
166
- val animator = ObjectAnimator.ofPropertyValuesHolder(
167
- container,
168
- PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, container.translationY, distanceOf(container)),
169
- PropertyValuesHolder.ofFloat(View.ALPHA, container.alpha, 0f),
172
+ val animator = ObjectAnimator.ofFloat(
173
+ container, View.TRANSLATION_Y, container.translationY, distanceOf(container),
170
174
  )
171
- animator.interpolator = TimeInterpolator { ModalAnimations.accelerateDecelerate(it) }
172
- return play(parts, animator, onEnd)
175
+ animator.interpolator = TimeInterpolator { ModalAnimations.accelerate(it) }
176
+ return play(parts, animator, ModalAnimations.DISMISS_DURATION, onEnd)
173
177
  }
174
178
 
175
179
  override fun awaitTargets(
@@ -355,8 +359,8 @@ internal class ModalViews(
355
359
  private fun viewOf(node: ViewController<*>): View? =
356
360
  parts.entries.firstOrNull { it.key.root === node }?.value?.container ?: node.peekView()
357
361
 
358
- private fun play(parts: Parts, animator: Animator, onEnd: () -> Unit): () -> Unit {
359
- animator.duration = ModalAnimations.DURATION
362
+ private fun play(parts: Parts, animator: Animator, duration: Long, onEnd: () -> Unit): () -> Unit {
363
+ animator.duration = duration
360
364
  animator.addListener(object : AnimatorListenerAdapter() {
361
365
  override fun onAnimationEnd(animation: Animator) {
362
366
  if (parts.animator === animation) parts.animator = null
@@ -41,6 +41,10 @@ internal object BackCallbackState {
41
41
  entries.removeAll { it.navigator.get().let { attached -> attached == null || attached === navigator } }
42
42
  }
43
43
 
44
+ /** JavaScript listens to back (`BackHandler`): it hears a press before the navigation does. */
45
+ val hasJsListeners: Boolean
46
+ get() = jsListeners
47
+
44
48
  /** `setBackListeners` of JavaScript; false again when its JavaScript goes. */
45
49
  fun setJsListeners(active: Boolean) {
46
50
  jsListeners = active
@@ -31,6 +31,7 @@ import dev.apexnative.navigation.viewcontrollers.component.ContentReadiness
31
31
  import dev.apexnative.navigation.viewcontrollers.component.ScreenReadiness
32
32
  import dev.apexnative.navigation.viewcontrollers.externalcomponent.ExternalComponentViewController
33
33
  import dev.apexnative.navigation.viewcontrollers.modal.ModalCoordinator
34
+ import dev.apexnative.navigation.viewcontrollers.modal.ModalRecord
34
35
  import dev.apexnative.navigation.viewcontrollers.modal.ModalViews
35
36
  import dev.apexnative.navigation.viewcontrollers.overlay.OverlayCoordinator
36
37
  import dev.apexnative.navigation.viewcontrollers.overlay.OverlayRecord
@@ -38,6 +39,7 @@ import dev.apexnative.navigation.viewcontrollers.overlay.OverlayViews
38
39
  import dev.apexnative.navigation.viewcontrollers.parent.ParentController
39
40
  import dev.apexnative.navigation.viewcontrollers.stack.PreparedPushes
40
41
  import dev.apexnative.navigation.viewcontrollers.stack.StackCommands
42
+ import dev.apexnative.navigation.viewcontrollers.stack.StackController
41
43
  import dev.apexnative.navigation.viewcontrollers.stack.topbar.BarComponents
42
44
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
43
45
  import dev.apexnative.navigation.views.animations.FadeThrough
@@ -153,6 +155,15 @@ class Navigator(
153
155
  fun handleBack(listener: CommandListener): Boolean =
154
156
  modals.handleBack(listener) ?: (root?.handleBack(listener) == true)
155
157
 
158
+ /**
159
+ * The stack whose top [handleBack] would take away with the default pop: in the top modal, else in the root; none
160
+ * when back would do anything else.
161
+ */
162
+ internal fun stackPoppedByBack(): StackController? {
163
+ val modalRoot = modals.backTargetRoot()
164
+ return if (modalRoot != null) modalRoot.stackPoppedByBack() else root?.stackPoppedByBack()
165
+ }
166
+
156
167
  /**
157
168
  * [layer] may wait to show from now on ([WaitingLayer]): the nodes in it ask it through [isInWaitingLayer] and
158
169
  * [shownScreensChanged]. The root's layer is there from the start.
@@ -264,9 +275,7 @@ class Navigator(
264
275
  if (!made) return answer.reject(ErrorTexts.notDoneYet("setRoot"))
265
276
  val steps = layout.modals.mapIndexed { index, node ->
266
277
  val last = index == layout.modals.lastIndex
267
- RootCoordinator.LayerStep { done ->
268
- modals.showInChain(node.id, { makeLayer(node, isOverlay = false) }, last, done)
269
- }
278
+ ModalLayerStep(node, last)
270
279
  } + layout.overlays.map { node ->
271
280
  RootCoordinator.LayerStep { done ->
272
281
  overlays.showInChain(node.id, { makeLayer(node, isOverlay = true) }, done)
@@ -275,6 +284,35 @@ class Navigator(
275
284
  roots.setRoot({ factory.create(layout.root) }, answer, steps)
276
285
  }
277
286
 
287
+ // A modal of a `setRoot`'s layout: made with the root and held until its turn, so it renders while the root waits
288
+ // and comes in; made in its turn instead while an id of it still shows (a modal of the old root with that id goes
289
+ // with the old root). A modal that couldn't be made is skipped in its turn.
290
+ private inner class ModalLayerStep(private val node: LayoutNode, private val animated: Boolean) :
291
+ RootCoordinator.LayerStep {
292
+ private var prepared = false
293
+ private var held: ModalRecord? = null
294
+
295
+ override fun prepare() {
296
+ if (LayoutChecks.firstShownId(listOf(node), ::isShownNow) { false } != null) return
297
+ prepared = true
298
+ held = modals.hold(node.id, { factory.create(node) }, animated)
299
+ }
300
+
301
+ override fun discard() {
302
+ held?.let(modals::drop)
303
+ held = null
304
+ }
305
+
306
+ override fun show(done: () -> Unit): () -> Unit {
307
+ val record = held
308
+ held = null
309
+ if (record != null) return modals.release(record, done)
310
+ if (!prepared) return modals.showInChain(node.id, { makeLayer(node, isOverlay = false) }, animated, done)
311
+ done()
312
+ return {}
313
+ }
314
+ }
315
+
278
316
  // A modal or an overlay of a `setRoot`'s layout, made as its turn comes: an id that shows by then fails it.
279
317
  private fun makeLayer(node: LayoutNode, isOverlay: Boolean): ViewController<*> {
280
318
  val shown = LayoutChecks.firstShownId(listOf(node), ::isShownNow) { isOverlay && isReplacedOverlay(node, it) }
@@ -36,7 +36,8 @@ import dev.apexnative.navigation.views.animations.AnimationRules
36
36
  * The modals over the old root ([ModalsOverRoot]): taken as the `setRoot` is accepted (commands don't find them), given
37
37
  * back when it ends without replacing the root, carried with the old root into its transition and destroyed with it,
38
38
  * without `ANN.ModalDismissed`. The modals and the overlays of the layout ([LayerStep]) show after the root, one by
39
- * one, before the answer: a newer `setRoot` stops them.
39
+ * one, before the answer: a newer `setRoot` stops them. Its modals are made with the root ([LayerStep.prepare]) and
40
+ * render unseen while it waits and comes in, so each shows in its turn without a wait of its own.
40
41
  *
41
42
  * The steps that come later (the wait's timers and readiness signals, the end of the transition, the end of a layer,
42
43
  * a stack of the waiting root that changed its top) run under no runner: each goes through one guard, and a failure
@@ -128,11 +129,20 @@ internal class RootCoordinator(
128
129
  }
129
130
 
130
131
  /**
131
- * A modal or an overlay of the layout of a `setRoot`, shown after its root: [show] makes it and shows it, and calls
132
- * its argument once it showed or went (a failure is logged and skipped); what it returns stops it while it waits —
133
- * a newer `setRoot`: it goes without a word.
132
+ * A modal or an overlay of the layout of a `setRoot`, shown after its root: [show] shows it (made then, unless
133
+ * [prepare] made it), and calls its argument once it showed or went (a failure is logged and skipped); what it
134
+ * returns stops it while it waits — a newer `setRoot`: it goes without a word.
134
135
  */
135
136
  fun interface LayerStep {
137
+ /**
138
+ * Right after the root is placed, before its wait: a step may make its layer now, so that it renders unseen
139
+ * while the root waits and comes in, and shows without a wait of its own in its turn.
140
+ */
141
+ fun prepare() = Unit
142
+
143
+ /** The `setRoot` ended before this step's turn: what [prepare] made goes without a word. */
144
+ fun discard() = Unit
145
+
136
146
  fun show(done: () -> Unit): () -> Unit
137
147
  }
138
148
 
@@ -221,6 +231,7 @@ internal class RootCoordinator(
221
231
  modals.rootAccepted()
222
232
  val waitForRender = animationsOf(root).enter.waitForRender
223
233
  this.pending = pending
234
+ layers.forEach { it.prepare() }
224
235
  firstRoot.rootSet(waitForRender)
225
236
  await(pending, waitForRender)
226
237
  }
@@ -367,6 +378,14 @@ internal class RootCoordinator(
367
378
  if (!done) pending.stopLayer = stop
368
379
  }
369
380
 
381
+ // The layers of [pending] not shown yet go: what they made ahead goes unseen, the others aren't made. Each on its
382
+ // own: one that fails doesn't keep the others.
383
+ private fun discardLayers(pending: Pending) {
384
+ val rest = pending.layers.drop(pending.nextLayer)
385
+ pending.nextLayer = pending.layers.size
386
+ rest.forEach { attempt { it.discard() } }
387
+ }
388
+
370
389
  // A new `setRoot` came while this one was on its way.
371
390
  private fun supersede() {
372
391
  val pending = pending ?: return
@@ -389,14 +408,15 @@ internal class RootCoordinator(
389
408
  pending.replaced = null
390
409
  modals.replacedRootGone()
391
410
  }
392
- // Its root is the current root: the modal or the overlay that waits now goes without a word, the others
393
- // aren't made; those shown stay over its root, and go with it when the new root replaces it.
411
+ // Its root is the current root: the modal or the overlay that waits now goes without a word, the others go
412
+ // unseen or aren't made; those shown stay over its root, and go with it when the new root replaces it.
394
413
  Phase.Layers -> {
395
414
  pending.stopLayer?.invoke()
396
415
  pending.stopLayer = null
397
416
  releaseState(pending)
398
417
  }
399
418
  }
419
+ discardLayers(pending)
400
420
  pending.answer.reject(ErrorTexts.setRootReplaced(pending.root.id))
401
421
  }
402
422
 
@@ -426,6 +446,7 @@ internal class RootCoordinator(
426
446
  pending.wait = null
427
447
  when (pending.phase) {
428
448
  Phase.Waiting -> {
449
+ discardLayers(pending)
429
450
  attempt { takeAway(pending.root) }
430
451
  attempt { modals.rootKept() }
431
452
  attempt { firstRoot.rootDestroyed() }
@@ -451,6 +472,7 @@ internal class RootCoordinator(
451
472
  pending.stopLayer = null
452
473
  }
453
474
  }
475
+ discardLayers(pending)
454
476
  // A root that is up takes the splash away, as at its show.
455
477
  if (pending.phase != Phase.Waiting) attempt { onRootShown() }
456
478
  attempt { releaseState(pending) }
@@ -482,6 +504,7 @@ internal class RootCoordinator(
482
504
  // The root was destroyed while it waited, by something other than a newer `setRoot` or the activity going.
483
505
  private fun rootGone(pending: Pending) {
484
506
  this.pending = null
507
+ discardLayers(pending)
485
508
  releaseState(pending)
486
509
  layer.remove(pending.root)
487
510
  modals.rootKept()
@@ -185,6 +185,8 @@ internal class OverlayCoordinator(
185
185
 
186
186
  private fun start(make: () -> ViewController<*>, answer: CommandAnswer?, chainDone: (() -> Unit)?): OverlayRecord {
187
187
  val root = make()
188
+ // Before the root's view is made: its screens are drawn under the status bar from their first layout.
189
+ root.shownAsOverlay = true
188
190
  val record = OverlayRecord(root)
189
191
  // A waiting layer before its views join the window: what is in it isn't on screen from its first layout.
190
192
  record.screensChanged = { screensChanged(record) }
@@ -31,9 +31,9 @@ internal object ContainerBackground {
31
31
  if (!colour.hasValue()) return null
32
32
  val color = colour.get(node.activity, Color.TRANSPARENT) ?: Color.TRANSPARENT
33
33
  // The screens' band rule (`ScreenSafeArea.backgroundBand`) for a layout, which keeps no room at its top: the
34
- // status bar's height, but none when it is hidden or the layout is drawn under it, in a side menu, a sheet or
35
- // a pair.
36
- val full = options.statusBar.isHiddenOrDrawBehind || node.isInSideMenu || node.isInSheet ||
34
+ // status bar's height, but none when it is hidden or the layout is drawn under it (in an overlay by default),
35
+ // in a side menu, a sheet or a pair.
36
+ val full = options.statusBar.isHiddenOrDrawBehind(node.isInOverlay) || node.isInSideMenu || node.isInSheet ||
37
37
  node.parentController is KeepsStatusBarRoom
38
38
  val band = if (full) 0 else SystemUiUtils.getStatusBarHeight(node.activity)
39
39
  if (band <= 0) return ColorDrawable(color)
@@ -8,6 +8,7 @@ import dev.apexnative.navigation.react.CommandListener
8
8
  import dev.apexnative.navigation.viewcontrollers.bottomtabs.TabBarMotion
9
9
  import dev.apexnative.navigation.viewcontrollers.child.ChildController
10
10
  import dev.apexnative.navigation.viewcontrollers.child.ChildControllersRegistry
11
+ import dev.apexnative.navigation.viewcontrollers.stack.StackController
11
12
  import dev.apexnative.navigation.viewcontrollers.stack.fab.FabPlacement
12
13
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
13
14
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
@@ -210,6 +211,8 @@ abstract class ParentController<V : ViewGroup>(
210
211
 
211
212
  override fun canHandleBack(): Boolean = getCurrentChild()?.canHandleBack() == true
212
213
 
214
+ override fun stackPoppedByBack(): StackController? = getCurrentChild()?.stackPoppedByBack()
215
+
213
216
  override fun destroy() {
214
217
  if (isDestroyed) return
215
218
  getChildControllers().forEach { it.destroy() }
@@ -16,6 +16,7 @@ import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
16
16
  import dev.apexnative.navigation.viewcontrollers.parent.ChildOffsetFromRest
17
17
  import dev.apexnative.navigation.viewcontrollers.parent.ParentController
18
18
  import dev.apexnative.navigation.viewcontrollers.sidemenu.SideMenuMotion.Side
19
+ import dev.apexnative.navigation.viewcontrollers.stack.StackController
19
20
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
20
21
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
21
22
  import dev.apexnative.navigation.views.sidemenu.SideMenuView
@@ -183,6 +184,9 @@ internal class SideMenuController(
183
184
 
184
185
  override fun canHandleBack(): Boolean = motion.canGoBack || center.canHandleBack()
185
186
 
187
+ // An open menu closes first: no preview of a pop then.
188
+ override fun stackPoppedByBack(): StackController? = if (motion.canGoBack) null else center.stackPoppedByBack()
189
+
186
190
  // A button's press addressed to the side menu goes to the center.
187
191
  override fun sendOnNavigationButtonPressed(buttonId: String) = center.sendOnNavigationButtonPressed(buttonId)
188
192
 
@@ -26,8 +26,8 @@ import dev.apexnative.navigation.viewcontrollers.navigator.Navigator
26
26
  import dev.apexnative.navigation.viewcontrollers.parent.ChildOffsetFromRest
27
27
  import dev.apexnative.navigation.viewcontrollers.parent.ParentController
28
28
  import dev.apexnative.navigation.viewcontrollers.parent.ReturnsScreenFromColumn
29
- import dev.apexnative.navigation.viewcontrollers.parent.ShowsColumns
30
29
  import dev.apexnative.navigation.viewcontrollers.parent.ShownChildren
30
+ import dev.apexnative.navigation.viewcontrollers.parent.ShowsColumns
31
31
  import dev.apexnative.navigation.viewcontrollers.stack.StackController
32
32
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
33
33
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
@@ -366,6 +366,19 @@ class SplitViewController(
366
366
  return can || super.canHandleBack()
367
367
  }
368
368
 
369
+ // As handleBack: the pane whose back goes first decides; an open sliding pane that would close previews nothing.
370
+ override fun stackPoppedByBack(): StackController? {
371
+ if (!isViewCreated) return super.stackPoppedByBack()
372
+ val layout = getView()
373
+ return when {
374
+ layout.isCompact -> compact?.stackPoppedByBack()
375
+ isSliding && !layout.panes.isOpen -> primary.stackPoppedByBack()
376
+ isSliding -> if (secondary.canHandleBack()) secondary.stackPoppedByBack() else null
377
+ secondary.canHandleBack() -> secondary.stackPoppedByBack()
378
+ else -> primary.stackPoppedByBack()
379
+ }
380
+ }
381
+
369
382
  /**
370
383
  * How far the pane of [child] is from where it rests: the secondary pane sliding over the primary one rests over
371
384
  * it, closed or on its way. Zero for the other children.