@apexnative/navigation 1.0.0-alpha.1 → 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 (163) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +147 -51
  3. package/android/CLAUDE.md +31 -6
  4. package/android/CORE.md +7 -2
  5. package/android/RECIPES.md +4 -3
  6. package/android/src/main/java/dev/apexnative/navigation/NavigationActivity.kt +29 -1
  7. package/android/src/main/java/dev/apexnative/navigation/options/StatusBarOptions.kt +7 -3
  8. package/android/src/main/java/dev/apexnative/navigation/react/ArrangementStates.kt +5 -4
  9. package/android/src/main/java/dev/apexnative/navigation/react/ErrorTexts.kt +75 -60
  10. package/android/src/main/java/dev/apexnative/navigation/react/Hinge.kt +3 -2
  11. package/android/src/main/java/dev/apexnative/navigation/react/OptionsCommands.kt +1 -1
  12. package/android/src/main/java/dev/apexnative/navigation/react/ScreenInsets.kt +5 -4
  13. package/android/src/main/java/dev/apexnative/navigation/react/ScreenRegions.kt +7 -6
  14. package/android/src/main/java/dev/apexnative/navigation/react/events/AppearanceDispatcher.kt +3 -35
  15. package/android/src/main/java/dev/apexnative/navigation/react/events/EventContract.kt +226 -0
  16. package/android/src/main/java/dev/apexnative/navigation/react/events/EventEmitter.kt +101 -87
  17. package/android/src/main/java/dev/apexnative/navigation/react/events/ScreenAppearance.kt +0 -6
  18. package/android/src/main/java/dev/apexnative/navigation/utils/UiUtils.kt +3 -3
  19. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/arrangement/SecondaryScreenPresenter.kt +13 -5
  20. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +76 -13
  21. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/TabItems.kt +3 -3
  22. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ComponentPresenter.kt +28 -7
  23. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ScreenFrame.kt +1 -1
  24. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalAnimations.kt +22 -24
  25. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalCoordinator.kt +86 -16
  26. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalRecord.kt +21 -6
  27. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalSheet.kt +41 -23
  28. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalViews.kt +24 -20
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/SheetStops.kt +25 -0
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/BackCallbackState.kt +4 -0
  31. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +42 -4
  32. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/RootCoordinator.kt +29 -6
  33. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/overlay/OverlayCoordinator.kt +2 -0
  34. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ContainerBackground.kt +3 -3
  35. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ParentController.kt +3 -0
  36. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuController.kt +5 -3
  37. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuMotion.kt +4 -7
  38. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuPresenter.kt +16 -7
  39. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/sidemenu/SideMenuRules.kt +8 -1
  40. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/splitview/SplitViewController.kt +16 -2
  41. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/BackPreview.kt +287 -0
  42. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +148 -6
  43. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackDisplay.kt +24 -0
  44. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackQueue.kt +51 -11
  45. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackTransitions.kt +26 -8
  46. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackViews.kt +105 -2
  47. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/topbar/TopBarPresenter.kt +123 -8
  48. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/viewcontroller/ViewController.kt +19 -0
  49. package/android/src/main/java/dev/apexnative/navigation/views/animations/Curves.kt +29 -11
  50. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/BottomTabs.kt +5 -5
  51. package/android/src/main/java/dev/apexnative/navigation/views/element/ElementStep.kt +31 -0
  52. package/android/src/main/java/dev/apexnative/navigation/views/element/ElementTransitions.kt +85 -27
  53. package/android/src/main/java/dev/apexnative/navigation/views/element/TargetsWait.kt +5 -4
  54. package/android/src/main/java/dev/apexnative/navigation/views/element/TransitionTargets.kt +9 -6
  55. package/android/src/main/java/dev/apexnative/navigation/views/element/TransitionViews.kt +100 -0
  56. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ElementPictures.kt +45 -1
  57. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ImageMatrixAnimator.kt +3 -5
  58. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/PlainViewAnimator.kt +3 -5
  59. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ReactImageAnimator.kt +3 -5
  60. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/SharedAnimatorFactory.kt +2 -0
  61. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/TextChangeAnimator.kt +4 -6
  62. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ViewChangeAnimator.kt +4 -6
  63. package/android/src/main/java/dev/apexnative/navigation/views/stack/ScreenEdge.kt +15 -0
  64. package/android/src/main/java/dev/apexnative/navigation/views/stack/StackLayout.kt +137 -1
  65. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TitleBar.kt +8 -1
  66. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/TopBar.kt +26 -4
  67. package/android/src/main/java/dev/apexnative/navigation/views/stack/topbar/titlebar/ButtonBar.kt +4 -3
  68. package/cli/app.js +1 -1
  69. package/cli/project.js +6 -2
  70. package/cli/setup/index.js +13 -0
  71. package/cli/setup/steps/appDelegate.js +66 -11
  72. package/cli/setup/steps/sceneDelegate.js +108 -0
  73. package/docs-md/architecture/transitions.md +7 -1
  74. package/docs-md/containers/modals.md +4 -2
  75. package/docs-md/containers/overlays.md +3 -1
  76. package/docs-md/containers/side-menu.md +4 -2
  77. package/docs-md/containers/tabs.md +2 -0
  78. package/docs-md/flows/forms-in-a-modal.md +1 -1
  79. package/docs-md/guides/deep-links.md +15 -33
  80. package/docs-md/help/glossary.md +1 -1
  81. package/docs-md/index.json +8 -0
  82. package/docs-md/screen-ui/back-and-gestures.md +6 -0
  83. package/docs-md/screen-ui/system-ui.md +1 -1
  84. package/docs-md/start/installation.md +1 -0
  85. package/ios/Animations/ANNTransitionAnimator.mm +13 -11
  86. package/ios/Animations/ANNTransitionWait.mm +6 -7
  87. package/ios/Animations/Elements/ANNElementFinder.h +57 -0
  88. package/ios/Animations/Elements/ANNElementFinder.mm +174 -2
  89. package/ios/Animations/Elements/ANNElementTransition.h +7 -5
  90. package/ios/Animations/Elements/ANNElementTransition.mm +17 -19
  91. package/ios/Animations/Elements/ANNSharedElementFlight.h +13 -8
  92. package/ios/Animations/Elements/ANNSharedElementFlight.mm +59 -24
  93. package/ios/CLAUDE.md +10 -2
  94. package/ios/CORE.md +3 -2
  95. package/ios/Contract/ANNCommandAnswer.h +4 -3
  96. package/ios/Contract/ANNCommandAnswer.mm +12 -2
  97. package/ios/Contract/ANNCommandRunner.h +2 -2
  98. package/ios/Contract/ANNCommandRunner.mm +1 -1
  99. package/ios/Contract/ANNErrorTexts.h +6 -2
  100. package/ios/Contract/ANNErrorTexts.mm +18 -10
  101. package/ios/Core/ANNDeepLinkQueue.h +20 -1
  102. package/ios/Core/ANNDeepLinkQueue.mm +107 -12
  103. package/ios/Core/ANNErrorHandler.h +8 -3
  104. package/ios/Core/ANNErrorHandler.mm +17 -1
  105. package/ios/Core/ANNEventContract.h +246 -0
  106. package/ios/Core/ANNEventContract.mm +225 -0
  107. package/ios/Core/ANNEventEmitter.h +5 -1
  108. package/ios/Core/ANNEventEmitter.mm +117 -80
  109. package/ios/Core/ANNHinge.mm +6 -4
  110. package/ios/Core/ANNSceneDelegate.mm +25 -15
  111. package/ios/Core/ANNSystemUI.h +3 -2
  112. package/ios/Core/ANNSystemUI.mm +7 -0
  113. package/ios/Layouts/Arrangement/ANNArrangementViewController.mm +6 -4
  114. package/ios/Layouts/Modal/ANNModalCoordinator.mm +1 -1
  115. package/ios/Layouts/Modal/ANNModalPresentation.h +8 -5
  116. package/ios/Layouts/Modal/ANNModalPresentation.mm +12 -12
  117. package/ios/Layouts/Modal/ANNSheetPresenter.mm +83 -1
  118. package/ios/Layouts/Root/ANNRootCoordinator.mm +12 -3
  119. package/ios/Layouts/Screen/ANNComponentPresenter.h +11 -3
  120. package/ios/Layouts/Screen/ANNComponentPresenter.mm +34 -6
  121. package/ios/Layouts/Screen/ANNComponentViewController.mm +28 -7
  122. package/ios/Layouts/Screen/ANNNativeScreenContext.mm +7 -5
  123. package/ios/Layouts/Screen/ANNScreenInsets.mm +6 -4
  124. package/ios/Layouts/Screen/ANNScreenRegions.mm +9 -6
  125. package/ios/Layouts/SplitView/ANNSplitViewController.mm +4 -7
  126. package/ios/Options/ANNSheetOptions.h +4 -1
  127. package/ios/Public/ApexNavigation/ANNSceneDelegate.h +3 -0
  128. package/ios/RECIPES.md +2 -1
  129. package/ios/TurboModules/ANNTurboEventEmitter.mm +4 -35
  130. package/ios/TurboModules/ANNTurboModule.mm +5 -2
  131. package/lib/module/ARCHITECTURE.md +7 -4
  132. package/lib/module/api/app/createNavigation.js +2 -0
  133. package/lib/module/api/app/createNavigation.js.map +1 -1
  134. package/lib/module/api/hooks/useNavigation.js +8 -3
  135. package/lib/module/api/hooks/useNavigation.js.map +1 -1
  136. package/lib/module/api/linking/dispatchedLinks.js +23 -0
  137. package/lib/module/api/linking/dispatchedLinks.js.map +1 -0
  138. package/lib/module/interfaces/contract/errors.json +275 -0
  139. package/lib/typescript/api/app/createNavigation.d.ts.map +1 -1
  140. package/lib/typescript/api/hooks/useNavigation.d.ts.map +1 -1
  141. package/lib/typescript/api/linking/dispatchedLinks.d.ts +7 -0
  142. package/lib/typescript/api/linking/dispatchedLinks.d.ts.map +1 -0
  143. package/lib/typescript/interfaces/Events.d.ts +9 -0
  144. package/lib/typescript/interfaces/Events.d.ts.map +1 -1
  145. package/lib/typescript/interfaces/options/animations.d.ts +27 -18
  146. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  147. package/lib/typescript/interfaces/options/modals.d.ts +33 -24
  148. package/lib/typescript/interfaces/options/modals.d.ts.map +1 -1
  149. package/lib/typescript/interfaces/options/sideMenu.d.ts +7 -6
  150. package/lib/typescript/interfaces/options/sideMenu.d.ts.map +1 -1
  151. package/lib/typescript/interfaces/options/system.d.ts +10 -6
  152. package/lib/typescript/interfaces/options/system.d.ts.map +1 -1
  153. package/package.json +1 -1
  154. package/src/ARCHITECTURE.md +7 -4
  155. package/src/api/app/createNavigation.ts +2 -0
  156. package/src/api/hooks/useNavigation.ts +8 -3
  157. package/src/api/linking/dispatchedLinks.ts +18 -0
  158. package/src/interfaces/Events.ts +10 -0
  159. package/src/interfaces/contract/errors.json +275 -0
  160. package/src/interfaces/options/animations.ts +27 -18
  161. package/src/interfaces/options/modals.ts +33 -24
  162. package/src/interfaces/options/sideMenu.ts +7 -6
  163. package/src/interfaces/options/system.ts +10 -6
@@ -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
  }
@@ -147,8 +147,11 @@ internal class ModalCoordinator(
147
147
  */
148
148
  fun carry(record: ModalRecord, oldRoot: ViewController<*>?)
149
149
 
150
- /** New [options] (resolved) of the record: its `testID`s, its sheet's `update` ([move]: to its selection). */
151
- fun update(record: ModalRecord, options: Options, move: Boolean)
150
+ /**
151
+ * New [options] (resolved) of the record: its `testID`s, its sheet's `update`; [merge] is the `mergeOptions`
152
+ * that brought them, which alone may move the sheet, or null when the shown screen changed.
153
+ */
154
+ fun update(record: ModalRecord, options: Options, merge: Options?)
152
155
 
153
156
  /** The colors of the record's own views again (a light/dark switch). */
154
157
  fun repaint(record: ModalRecord) = Unit
@@ -258,6 +261,54 @@ internal class ModalCoordinator(
258
261
  }
259
262
  }
260
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
+
261
312
  /**
262
313
  * `dismissModal` after its shared steps: the record of [componentId], Android's rule of the last modal without a
263
314
  * root, [options] merged into the modal's visible screen, the animation of the command's screen with its parents
@@ -276,6 +327,7 @@ internal class ModalCoordinator(
276
327
  State.ClosedBelow -> return answer.resolve(record.rootId)
277
328
  else -> Unit
278
329
  }
330
+ if (record.held) return answer.reject(ErrorTexts.screenNotShown(componentId))
279
331
  if (isLastWithoutRoot(record)) return answer.reject(ErrorTexts.modalWithoutRoot(componentId))
280
332
  options?.let { mergeInto(record, it) }
281
333
  val from = node?.takeUnless { it.isDestroyed } ?: record.root
@@ -290,7 +342,7 @@ internal class ModalCoordinator(
290
342
  * before anything changes, with the id of the top modal's visible screen.
291
343
  */
292
344
  fun dismissAll(options: Options?, unreadablePath: String?, answer: CommandAnswer) {
293
- 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 }
294
346
  if (members.isEmpty()) {
295
347
  // Nothing else to take: the second one answers with the running batch (N6), else at once.
296
348
  val running = batches.lastOrNull()
@@ -344,6 +396,12 @@ internal class ModalCoordinator(
344
396
  return true
345
397
  }
346
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
+
347
405
  /** The mirror of [handleBack], without acting; null without a modal to ask. */
348
406
  fun canHandleBack(): Boolean? {
349
407
  val record = backTarget() ?: return null
@@ -353,13 +411,14 @@ internal class ModalCoordinator(
353
411
  // Options.
354
412
 
355
413
  /**
356
- * A `mergeOptions` went into [node] (`mergeOptions` step 5): when it is in a modal, the modal's sheet takes its
357
- * options again (it re-reads the resolved ones at any merge) and its `testID`s apply again.
414
+ * A `mergeOptions` of [merge] went into [node] (`mergeOptions` step 5): when it is in a modal, the modal's sheet
415
+ * takes its resolved options again, moving only to a `selectedDetent` that [merge] names, and its `testID`s apply
416
+ * again.
358
417
  */
359
- fun optionsMergedInto(node: ViewController<*>) {
418
+ fun optionsMergedInto(node: ViewController<*>, merge: Options) {
360
419
  val record = recordOf(node) ?: return
361
420
  if (record.root.isDestroyed) return
362
- views.update(record, record.root.resolveShownOptions(DefaultOptions.current), move = true)
421
+ views.update(record, record.root.resolveShownOptions(DefaultOptions.current), merge)
363
422
  }
364
423
 
365
424
  /**
@@ -369,7 +428,7 @@ internal class ModalCoordinator(
369
428
  fun visibleScreenChanged(node: ViewController<*>) {
370
429
  val record = recordOf(node) ?: return
371
430
  if (record.state == State.Waiting || record.root.isDestroyed) return
372
- views.update(record, record.root.resolveShownOptions(DefaultOptions.current), move = false)
431
+ views.update(record, record.root.resolveShownOptions(DefaultOptions.current), merge = null)
373
432
  }
374
433
 
375
434
  /** The colors of the modals' own views again, after a light/dark switch. */
@@ -418,6 +477,8 @@ internal class ModalCoordinator(
418
477
  }
419
478
  batches.clear()
420
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
421
482
  if (record.state == State.Waiting) {
422
483
  cancel(record, null, reports = false)
423
484
  continue
@@ -428,7 +489,7 @@ internal class ModalCoordinator(
428
489
  views.carry(record, old)
429
490
  carried += record
430
491
  }
431
- records.clear()
492
+ records.retainAll { it.held }
432
493
  host.backChanged()
433
494
  }
434
495
 
@@ -478,7 +539,12 @@ internal class ModalCoordinator(
478
539
 
479
540
  // The show (3, 4).
480
541
 
481
- 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 {
482
548
  val root = make()
483
549
  val record: ModalRecord
484
550
  val options: Options
@@ -488,6 +554,7 @@ internal class ModalCoordinator(
488
554
  val sheet = ModalRecord.isSheet(options)
489
555
  root.shownAsSheet = sheet
490
556
  record = ModalRecord(root, sheet)
557
+ record.held = held
491
558
  } catch (e: Throwable) {
492
559
  root.destroy()
493
560
  throw e
@@ -551,7 +618,7 @@ internal class ModalCoordinator(
551
618
  private fun ready(record: ModalRecord) {
552
619
  if (record.state != State.Waiting) return
553
620
  record.wait = null
554
- if (!record.targetsAwaited && awaitsTargets(record)) return awaitTargets(record)
621
+ if (!record.targetsAwaited && !record.held && awaitsTargets(record)) return awaitTargets(record)
555
622
  record.cancelLimit()
556
623
  record.isReady = true
557
624
  presentReady()
@@ -603,7 +670,7 @@ internal class ModalCoordinator(
603
670
  if (taking) return
604
671
  for (record in records.toList()) {
605
672
  if (record.state != State.Waiting) continue
606
- if (!record.isReady) return
673
+ if (record.held || !record.isReady) return
607
674
  present(record)
608
675
  }
609
676
  }
@@ -1152,13 +1219,16 @@ internal class ModalCoordinator(
1152
1219
 
1153
1220
  // A record is the top when no record above it waits, comes in or shows; one that leaves doesn't count.
1154
1221
  private fun isTop(record: ModalRecord): Boolean =
1155
- records.drop(records.indexOf(record) + 1).none { it.effective in TOP_STATES }
1222
+ records.drop(records.indexOf(record) + 1).none(::countsAsTop)
1156
1223
 
1157
1224
  // The top record, as the commands see it; none.
1158
- private fun topRecord(): ModalRecord? = records.lastOrNull { it.effective in TOP_STATES }
1225
+ private fun topRecord(): ModalRecord? = records.lastOrNull(::countsAsTop)
1159
1226
 
1160
1227
  // Back goes to the top modal that waits, comes in or shows.
1161
- 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
1162
1232
 
1163
1233
  // `hardwareBackButton.dismissModalOnPress: false` of the modal's options now.
1164
1234
  private fun holdsBack(record: ModalRecord): Boolean =
@@ -1230,7 +1300,7 @@ internal class ModalCoordinator(
1230
1300
  val screen = AppearanceDispatcher.visibleNode(record.root)
1231
1301
  if (screen.isDestroyed) return
1232
1302
  screen.mergeOptions(options)
1233
- optionsMergedInto(screen)
1303
+ optionsMergedInto(screen, options)
1234
1304
  }
1235
1305
 
1236
1306
  private inline fun whileTaking(work: () -> Unit) {
@@ -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,33 +129,39 @@ 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
- update(sheetOptions, swipeToDismiss, move = false)
139
+ update(sheetOptions, swipeToDismiss, merge = null)
140
+ selected = sheetOptions.selectedDetent?.takeIf { it in detents }
126
141
  // Laid out hidden first, so it slides up from the bottom.
127
142
  container.post { if (closing == null) behavior.state = stateOf(selected ?: detents.first()) }
128
143
  }
129
144
 
130
- /** New options from mergeOptions: the detents, the look, and the selected detent, which the sheet moves to. */
131
- fun update(sheetOptions: SheetOptions, swipeToDismiss: Boolean, move: Boolean = true) {
132
- val previous = selected
145
+ /**
146
+ * New resolved options: the detents and the look. The sheet moves only as [SheetStops] says, after a `mergeOptions`
147
+ * of [merge]; without one (the shown screen changed, the show) it stays where it is.
148
+ */
149
+ fun update(sheetOptions: SheetOptions, swipeToDismiss: Boolean, merge: SheetOptions?) {
133
150
  options = sheetOptions
134
151
  this.swipeToDismiss = swipeToDismiss
135
152
  detents = sheetOptions.detents?.takeIf { it.isNotEmpty() }?.sortedBy { heightOf(it) } ?: listOf(SheetDetent.Large)
136
- selected = sheetOptions.selectedDetent?.takeIf { it in detents }
137
153
  grabber.visibility = if (sheetOptions.grabber.isTrue) View.VISIBLE else View.GONE
138
154
  behavior.dragAnywhere = sheetOptions.expandsOnScroll.get(true)
139
155
  container.invalidateOutline()
140
156
  configure()
141
- if (!move || closing != null) return
157
+ if (merge == null || closing != null) return
142
158
  val settled = detentOf(behavior.state)
143
159
  if (settled != null) behavior.isHideable = swipeToDismiss && settled == detents.first()
144
- val target = when {
145
- selected != null && selected != previous -> selected
146
- settled != null && settled !in detents -> selected ?: detents.first()
147
- else -> null
160
+ val target = SheetStops.target(settled, detents, merge.selectedDetent, sheetOptions.selectedDetent)
161
+ if (target != null) {
162
+ selected = target
163
+ behavior.state = stateOf(target)
148
164
  }
149
- if (target != null) behavior.state = stateOf(target)
150
165
  updateScrim()
151
166
  }
152
167
 
@@ -333,10 +348,13 @@ internal class ModalSheet(
333
348
  }
334
349
  }
335
350
 
336
- 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
+
337
355
  /** The dim of a Material modal bottom sheet's scrim. */
338
- const val SCRIM_ALPHA = 0.32f
339
- const val GRAB_AREA_DP = 48
340
- 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
341
359
  }
342
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(
@@ -245,10 +249,10 @@ internal class ModalViews(
245
249
  hideEmptyLayer()
246
250
  }
247
251
 
248
- override fun update(record: ModalRecord, options: Options, move: Boolean) {
252
+ override fun update(record: ModalRecord, options: Options, merge: Options?) {
249
253
  val parts = parts[record] ?: return
250
254
  applyTestIds(record, parts, options)
251
- parts.sheet?.update(options.modal.sheet, swipeToDismissOf(options), move)
255
+ parts.sheet?.update(options.modal.sheet, swipeToDismissOf(options), merge?.modal?.sheet)
252
256
  }
253
257
 
254
258
  override fun repaint(record: ModalRecord) {
@@ -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
@@ -0,0 +1,25 @@
1
+ package dev.apexnative.navigation.viewcontrollers.modal
2
+
3
+ import dev.apexnative.navigation.options.SheetDetent
4
+
5
+ /**
6
+ * Where a shown sheet goes at new options, as on iOS: the sheet keeps the stop it stands at (the user's last drag
7
+ * included) until a `selectedDetent` arrives in a `mergeOptions`, or until its stop is no longer among the detents.
8
+ */
9
+ internal object SheetStops {
10
+ /**
11
+ * The stop to move to, or null to stay: [arrived] (the `selectedDetent` the merge names) when it is among
12
+ * [detents]; else, when [settled] (the stop the sheet rests at) is no longer one of them, [selected] (the resolved
13
+ * `selectedDetent`) when it is, or the lowest.
14
+ */
15
+ fun target(
16
+ settled: SheetDetent?,
17
+ detents: List<SheetDetent>,
18
+ arrived: SheetDetent?,
19
+ selected: SheetDetent?,
20
+ ): SheetDetent? = when {
21
+ arrived != null && arrived in detents -> arrived
22
+ settled != null && settled !in detents -> selected?.takeIf { it in detents } ?: detents.first()
23
+ else -> null
24
+ }
25
+ }
@@ -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