@apexnative/navigation 1.0.0-alpha.5 → 1.0.0-alpha.6

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 (182) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +1 -1
  3. package/android/CLAUDE.md +14 -12
  4. package/android/CORE.md +9 -6
  5. package/android/src/main/java/dev/apexnative/navigation/NavigationActivity.kt +36 -5
  6. package/android/src/main/java/dev/apexnative/navigation/options/BottomTabsOptions.kt +13 -8
  7. package/android/src/main/java/dev/apexnative/navigation/react/NavigationTurboModule.kt +3 -3
  8. package/android/src/main/java/dev/apexnative/navigation/react/ReloadCoordinator.kt +0 -2
  9. package/android/src/main/java/dev/apexnative/navigation/react/ScreenEnvironment.kt +177 -0
  10. package/android/src/main/java/dev/apexnative/navigation/react/ScreenRegions.kt +12 -49
  11. package/android/src/main/java/dev/apexnative/navigation/react/ScrollEvents.kt +49 -128
  12. package/android/src/main/java/dev/apexnative/navigation/safearea/SafeAreaView.kt +6 -6
  13. package/android/src/main/java/dev/apexnative/navigation/utils/ViewExtensions.kt +0 -13
  14. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/arrangement/ArrangementController.kt +9 -7
  15. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsCollapseBehavior.kt +5 -3
  16. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/BottomTabsController.kt +13 -10
  17. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/CustomTabRowGeometry.kt +22 -0
  18. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/TabsRoom.kt +0 -5
  19. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ComponentViewController.kt +7 -8
  20. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/component/ScreenSafeArea.kt +29 -11
  21. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/externalcomponent/ExternalComponentViewController.kt +2 -6
  22. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalCoordinator.kt +20 -2
  23. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalSheet.kt +119 -8
  24. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/ModalViews.kt +7 -0
  25. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/modal/SheetBack.kt +42 -0
  26. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/navigator/Navigator.kt +4 -0
  27. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/parent/ParentController.kt +3 -6
  28. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/PreparedFrame.kt +3 -3
  29. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackCommands.kt +2 -2
  30. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackController.kt +4 -0
  31. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/stack/StackViews.kt +3 -0
  32. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/viewcontroller/ScrollEventListener.kt +6 -9
  33. package/android/src/main/java/dev/apexnative/navigation/views/animations/AnimationLayer.kt +73 -0
  34. package/android/src/main/java/dev/apexnative/navigation/views/animations/ValueInterpolation.kt +14 -0
  35. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/BottomTabsLayout.kt +61 -2
  36. package/android/src/main/java/dev/apexnative/navigation/views/bottomtabs/CustomTabRow.kt +72 -10
  37. package/android/src/main/java/dev/apexnative/navigation/views/element/ElementTransitions.kt +9 -2
  38. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ElementPictures.kt +11 -0
  39. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/FlightGeometry.kt +318 -25
  40. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ImageMatrixAnimator.kt +18 -20
  41. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/PlainViewAnimator.kt +17 -25
  42. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ReactImageAnimator.kt +15 -22
  43. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/SharedAnimatorFactory.kt +12 -9
  44. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/TextChangeAnimator.kt +84 -35
  45. package/android/src/main/java/dev/apexnative/navigation/views/element/animators/ViewChangeAnimator.kt +49 -40
  46. package/docs-md/architecture/transitions.md +4 -2
  47. package/docs-md/containers/modals.md +2 -0
  48. package/docs-md/containers/overlays.md +1 -1
  49. package/docs-md/containers/side-menu.md +1 -1
  50. package/docs-md/containers/tabs.md +2 -0
  51. package/docs-md/flows/bottom-sheets.md +1 -1
  52. package/docs-md/fundamentals/starting-the-app.md +1 -1
  53. package/docs-md/help/glossary.md +2 -2
  54. package/docs-md/help/troubleshooting.md +21 -0
  55. package/docs-md/index.json +12 -0
  56. package/docs-md/migration/from-expo-router.md +1 -1
  57. package/docs-md/migration/from-react-native-navigation.md +1 -1
  58. package/docs-md/migration/from-react-navigation.md +2 -2
  59. package/docs-md/screen-ui/back-and-gestures.md +14 -0
  60. package/docs-md/screen-ui/bar-buttons.md +4 -4
  61. package/docs-md/screen-ui/search.md +1 -1
  62. package/docs-md/screen-ui/system-ui.md +16 -11
  63. package/docs-md/screen-ui/toolbar-and-fab.md +2 -2
  64. package/docs-md/screen-ui/top-bar.md +8 -4
  65. package/ios/Animations/ANNFrameDriver.mm +2 -0
  66. package/ios/Animations/Elements/ANNSharedElementFlight.mm +70 -4
  67. package/ios/Animations/Interpolators/ANNInterpolate.h +2 -30
  68. package/ios/Animations/Interpolators/ANNTransformParts.h +48 -0
  69. package/ios/Animations/Interpolators/ANNTransformParts.mm +507 -0
  70. package/ios/Animations/Interpolators/ANNViewTracks.h +6 -4
  71. package/ios/Animations/Interpolators/ANNViewTracks.mm +49 -9
  72. package/ios/CLAUDE.md +17 -15
  73. package/ios/CORE.md +24 -20
  74. package/ios/Contract/ANNCommandRunner.h +8 -7
  75. package/ios/Contract/ANNCommandRunner.mm +21 -27
  76. package/ios/Contract/ANNLayoutNode.h +3 -3
  77. package/ios/Contract/ANNLayoutReader.h +3 -3
  78. package/ios/Core/ANNJavaScriptCalls.h +11 -12
  79. package/ios/Core/ANNJavaScriptCalls.mm +7 -16
  80. package/ios/Core/ANNReloadCoordinator.mm +4 -8
  81. package/ios/Core/ApexNavigation+Internal.h +1 -1
  82. package/ios/Layouts/Arrangement/ANNArrangementState.h +2 -17
  83. package/ios/Layouts/Arrangement/ANNArrangementState.mm +0 -38
  84. package/ios/Layouts/Arrangement/ANNArrangementViewController.mm +4 -4
  85. package/ios/Layouts/Screen/ANNComponentViewController.h +2 -2
  86. package/ios/Layouts/Screen/ANNComponentViewController.mm +9 -22
  87. package/ios/Layouts/Screen/ANNPreviewInteraction.h +7 -4
  88. package/ios/Layouts/Screen/ANNPreviewInteraction.mm +72 -10
  89. package/ios/Layouts/Screen/ANNScreenEnvironment.h +63 -0
  90. package/ios/Layouts/Screen/ANNScreenEnvironment.mm +181 -0
  91. package/ios/Layouts/Screen/ANNScreenRegions.h +3 -18
  92. package/ios/Layouts/Screen/ANNScreenRegions.mm +0 -38
  93. package/ios/Layouts/Screen/ANNScreenSafeArea.h +4 -9
  94. package/ios/Layouts/Screen/ANNScreenSafeArea.mm +14 -16
  95. package/ios/Layouts/Stack/ANNPreviewPush.h +7 -6
  96. package/ios/Layouts/Stack/ANNPreviewPush.mm +57 -5
  97. package/ios/Layouts/Tabs/ANNBottomTabsAccessoryView.h +12 -1
  98. package/ios/Layouts/Tabs/ANNBottomTabsAccessoryView.mm +23 -0
  99. package/ios/Layouts/Tabs/ANNBottomTabsController.mm +2 -1
  100. package/ios/Layouts/Tabs/ANNBottomTabsCustomRow.mm +8 -3
  101. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.h +4 -0
  102. package/ios/Layouts/Tabs/ANNBottomTabsPresenter.mm +123 -36
  103. package/ios/Layouts/Tabs/ANNTabsDesign.h +6 -5
  104. package/ios/Layouts/Tabs/ANNTabsDesign.mm +9 -1
  105. package/ios/Layouts/Tabs/UITabBar+utils.h +5 -5
  106. package/ios/Layouts/Tabs/UITabBar+utils.mm +32 -13
  107. package/ios/Options/ANNBottomTabsOptions.h +2 -0
  108. package/ios/Options/ANNBottomTabsOptions.mm +1 -0
  109. package/ios/Options/ANNOptions.h +18 -0
  110. package/ios/Options/ANNOptions.mm +112 -1
  111. package/ios/Options/ANNOptionsReader.mm +110 -0
  112. package/ios/Options/Params/ANNParam.h +15 -0
  113. package/ios/Options/Params/ANNParam.mm +61 -0
  114. package/ios/Options/Params/Color.h +3 -0
  115. package/ios/Options/Params/Color.mm +21 -0
  116. package/ios/Options/Params/Image.h +4 -0
  117. package/ios/Options/Params/Image.mm +20 -0
  118. package/ios/React/ANNReactView.mm +6 -25
  119. package/ios/React/ANNSafeAreaViewComponentView.mm +26 -15
  120. package/ios/React/ANNSurfaceHostingView.h +6 -0
  121. package/ios/React/ANNSurfaceHostingView.mm +116 -0
  122. package/ios/TopBar/ANNBackButtonPresenter.mm +6 -5
  123. package/ios/TopBar/ANNBarAppearance.h +2 -1
  124. package/ios/TopBar/ANNBarAppearance.mm +111 -30
  125. package/ios/TopBar/ANNBarBackgroundComponent.mm +16 -0
  126. package/ios/TopBar/ANNBarButtonComponentView.h +4 -4
  127. package/ios/TopBar/ANNBarButtonComponentView.mm +1 -1
  128. package/ios/TopBar/ANNBarButtons.h +5 -3
  129. package/ios/TopBar/ANNBarButtons.mm +186 -17
  130. package/ios/TopBar/ANNLargeTitlePresenter.mm +24 -4
  131. package/ios/TopBar/ANNMainList.h +3 -0
  132. package/ios/TopBar/ANNMainList.mm +4 -0
  133. package/ios/TopBar/ANNScrollEdgeEffects.h +22 -6
  134. package/ios/TopBar/ANNScrollEdgeEffects.mm +123 -16
  135. package/ios/TopBar/ANNSearchPresenter.mm +2 -2
  136. package/ios/TopBar/ANNSearchResultsViewController.mm +5 -5
  137. package/ios/TopBar/ANNTopBarPresenter.h +3 -2
  138. package/ios/TopBar/ANNTopBarPresenter.mm +95 -22
  139. package/ios/TurboModules/ANNTurboModule.mm +20 -16
  140. package/lib/module/ARCHITECTURE.md +8 -4
  141. package/lib/module/api/hooks/useSafeAreaInsets.js +4 -3
  142. package/lib/module/api/hooks/useSafeAreaInsets.js.map +1 -1
  143. package/lib/module/api/options/OptionsChain.js +2 -2
  144. package/lib/module/engine/commands.js +21 -1
  145. package/lib/module/engine/commands.js.map +1 -1
  146. package/lib/module/engine/screens.js +29 -4
  147. package/lib/module/engine/screens.js.map +1 -1
  148. package/lib/typescript/api/app/createNavigation.d.ts +3 -3
  149. package/lib/typescript/api/hooks/useSafeAreaInsets.d.ts +4 -3
  150. package/lib/typescript/api/hooks/useSafeAreaInsets.d.ts.map +1 -1
  151. package/lib/typescript/api/options/OptionsChain.d.ts +2 -2
  152. package/lib/typescript/engine/commands.d.ts.map +1 -1
  153. package/lib/typescript/engine/screens.d.ts +20 -6
  154. package/lib/typescript/engine/screens.d.ts.map +1 -1
  155. package/lib/typescript/interfaces/options/animations.d.ts +12 -7
  156. package/lib/typescript/interfaces/options/animations.d.ts.map +1 -1
  157. package/lib/typescript/interfaces/options/bottomTabs.d.ts +18 -5
  158. package/lib/typescript/interfaces/options/bottomTabs.d.ts.map +1 -1
  159. package/lib/typescript/interfaces/options/buttons.d.ts +8 -8
  160. package/lib/typescript/interfaces/options/system.d.ts +8 -5
  161. package/lib/typescript/interfaces/options/system.d.ts.map +1 -1
  162. package/lib/typescript/interfaces/options/toolbar.d.ts +2 -2
  163. package/lib/typescript/interfaces/options/topBar.d.ts +32 -29
  164. package/lib/typescript/interfaces/options/topBar.d.ts.map +1 -1
  165. package/package.json +1 -1
  166. package/src/ARCHITECTURE.md +8 -4
  167. package/src/api/app/createNavigation.ts +3 -3
  168. package/src/api/hooks/useSafeAreaInsets.ts +4 -3
  169. package/src/api/options/OptionsChain.ts +2 -2
  170. package/src/engine/commands.ts +30 -6
  171. package/src/engine/screens.tsx +29 -8
  172. package/src/interfaces/options/animations.ts +12 -7
  173. package/src/interfaces/options/bottomTabs.ts +18 -5
  174. package/src/interfaces/options/buttons.ts +8 -8
  175. package/src/interfaces/options/system.ts +8 -5
  176. package/src/interfaces/options/toolbar.ts +2 -2
  177. package/src/interfaces/options/topBar.ts +32 -29
  178. package/android/src/main/java/dev/apexnative/navigation/react/ArrangementStates.kt +0 -42
  179. package/android/src/main/java/dev/apexnative/navigation/react/ScreenInsets.kt +0 -70
  180. package/android/src/main/java/dev/apexnative/navigation/viewcontrollers/bottomtabs/CustomTabRowPlacer.kt +0 -223
  181. package/ios/Layouts/Screen/ANNScreenInsets.h +0 -30
  182. package/ios/Layouts/Screen/ANNScreenInsets.mm +0 -70
@@ -158,6 +158,9 @@ internal class ModalCoordinator(
158
158
 
159
159
  /** The record's views lay out again (the activity resumed). */
160
160
  fun requestLayout(record: ModalRecord) = Unit
161
+
162
+ /** The record's sheet as back sees it; none before its show and for a modal that isn't a sheet. */
163
+ fun sheetBack(record: ModalRecord): SheetBack? = null
161
164
  }
162
165
 
163
166
  /**
@@ -397,11 +400,26 @@ internal class ModalCoordinator(
397
400
  }
398
401
 
399
402
  /**
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.
403
+ * The root of the modal that back goes to first; none without one. A back gesture previews a pop in it, or the
404
+ * modal's dismissal when it is a sheet ([backTargetSheet]).
402
405
  */
403
406
  fun backTargetRoot(): ViewController<*>? = backTarget()?.root
404
407
 
408
+ /**
409
+ * The sheet back would dismiss now, as [handleBack] would, and as a drag down of the user would: the top modal is
410
+ * a sheet that shows, its content can't go back (else back goes there, and a pop in it has its own preview),
411
+ * `modal.swipeToDismiss` isn't false, `hardwareBackButton.dismissModalOnPress` doesn't hold back, it isn't the only
412
+ * modal over no root, and its sheet would hide, not collapse, and isn't closing already ([SheetBack.hidesOnBack]).
413
+ * None otherwise. The back gesture previews its hide on Android 14+, and its release closes it as the user does.
414
+ */
415
+ fun backTargetSheet(): SheetBack? {
416
+ val record = backTarget()?.takeIf { it.isSheet && it.state == State.Shown } ?: return null
417
+ if (record.root.canHandleBack() || holdsBack(record) || isLastWithoutRoot(record)) return null
418
+ val options = record.root.resolveShownOptions(DefaultOptions.current)
419
+ if (options.modal.swipeToDismiss.isFalse) return null
420
+ return views.sheetBack(record)?.takeIf { it.hidesOnBack }
421
+ }
422
+
405
423
  /** The mirror of [handleBack], without acting; null without a modal to ask. */
406
424
  fun canHandleBack(): Boolean? {
407
425
  val record = backTarget() ?: return null
@@ -15,6 +15,7 @@ import android.view.ViewOutlineProvider
15
15
  import android.view.ViewTreeObserver
16
16
  import android.widget.FrameLayout
17
17
  import android.widget.ScrollView
18
+ import androidx.activity.BackEventCompat
18
19
  import androidx.coordinatorlayout.widget.CoordinatorLayout
19
20
  import androidx.core.graphics.Insets
20
21
  import androidx.core.view.ViewCompat
@@ -34,13 +35,14 @@ import kotlin.math.roundToInt
34
35
  * the detents of its options — `medium` at half the height, `large` at the full height under the status bar, a number
35
36
  * 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
37
  * 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.
38
+ * stays, and the try is told. The screen under it stays in place. On Android 14+ the back gesture previews its hide
39
+ * ([SheetBack]): the Material behavior shrinks it with the progress and slides it down at the release.
38
40
  */
39
41
  internal class ModalSheet(
40
42
  private val layout: CoordinatorLayout,
41
43
  private val content: View,
42
44
  private val events: Events,
43
- ) {
45
+ ) : SheetBack {
44
46
  interface Events {
45
47
  /** The user dragged the sheet to another detent. */
46
48
  fun onDetentChanged(detent: SheetDetent)
@@ -66,6 +68,28 @@ internal class ModalSheet(
66
68
  private var dragged = false
67
69
  private var shown: (() -> Unit)? = null
68
70
  private var closing: (() -> Unit)? = null
71
+ // The back gesture whose preview runs now; none.
72
+ private var backPreview: BackPreview? = null
73
+ // Released: the behavior slides the sheet down from where the preview left it, and hides it at the end.
74
+ private var backFinishing = false
75
+ // The window's observer the scrim follows the preview's frames through, from its start until the sheet is back
76
+ // in place or gone; none otherwise.
77
+ private var backFramesObserver: ViewTreeObserver? = null
78
+
79
+ // The behavior tells no slide while back's preview scales the sheet and its release slides it down (they move it by
80
+ // its transform, not its offset): the scrim follows what is drawn, frame by frame, until the sheet stands again.
81
+ private val backFrames = ViewTreeObserver.OnPreDrawListener {
82
+ updateScrim()
83
+ if (backPreview == null && !backFinishing && container.scaleY == 1f && container.translationY == 0f) {
84
+ stopBackFrames()
85
+ }
86
+ true
87
+ }
88
+
89
+ private val sheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
90
+ override fun onStateChanged(sheet: View, newState: Int) = onState(newState)
91
+ override fun onSlide(sheet: View, slideOffset: Float) = updateScrim()
92
+ }
69
93
 
70
94
  // A list inside the sheet scrolls with it, as the Material behavior expects: up at half height the sheet goes up
71
95
  // first, down at the top of the list the sheet comes down. The behavior follows views that scroll nested.
@@ -89,6 +113,13 @@ internal class ModalSheet(
89
113
  }
90
114
  })
91
115
  scrim.setOnTouchListener { _, event -> taps.onTouchEvent(event) }
116
+ // The sheet keeps a touch that lands on it and no view in it takes (its bar, an empty part), as Material's
117
+ // BottomSheetDialog does: else the touch falls to the scrim under it, and a tap there closes a dimmed sheet;
118
+ // past an undimmed scrim it reaches the behavior, which takes the sheet into its drag state at the touch's
119
+ // down, before any move. The back gesture starts with such a touch, and a sheet in the drag state has no
120
+ // preview. A drag that starts there still moves the sheet: the behavior takes it past the touch slop, as from
121
+ // the views in it.
122
+ container.setOnTouchListener { _, _ -> true }
92
123
  container.clipToOutline = true
93
124
  container.outlineProvider = object : ViewOutlineProvider() {
94
125
  // The bottom corners are under the screen's bottom: only the top ones round.
@@ -116,10 +147,7 @@ internal class ModalSheet(
116
147
  behavior.isHideable = true
117
148
  behavior.state = BottomSheetBehavior.STATE_HIDDEN
118
149
  behavior.halfExpandedRatio = 0.5f
119
- behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
120
- override fun onStateChanged(sheet: View, newState: Int) = onState(newState)
121
- override fun onSlide(sheet: View, slideOffset: Float) = updateScrim()
122
- })
150
+ behavior.addBottomSheetCallback(sheetCallback)
123
151
  container.viewTreeObserver.addOnGlobalLayoutListener(scrollViews)
124
152
  }
125
153
 
@@ -154,10 +182,12 @@ internal class ModalSheet(
154
182
  behavior.dragAnywhere = sheetOptions.expandsOnScroll.get(true)
155
183
  container.invalidateOutline()
156
184
  configure()
157
- if (merge == null || closing != null) return
185
+ if (merge == null || closing != null || backFinishing) return
158
186
  val settled = detentOf(behavior.state)
159
187
  if (settled != null) behavior.isHideable = swipeToDismiss && settled == detents.first()
160
188
  val target = SheetStops.target(settled, detents, merge.selectedDetent, sheetOptions.selectedDetent)
189
+ // A merge that moves the sheet or keeps it from going down ends a preview of back on it.
190
+ if (target != null || !behavior.isHideable) stopBackPreview()
161
191
  if (target != null) {
162
192
  selected = target
163
193
  behavior.state = stateOf(target)
@@ -172,12 +202,19 @@ internal class ModalSheet(
172
202
  finishClosing()
173
203
  return
174
204
  }
205
+ // Released by back, it goes down already: it is closed at the end of that slide.
206
+ if (backFinishing) return
207
+ stopBackPreview()
175
208
  behavior.isHideable = true
176
209
  behavior.state = BottomSheetBehavior.STATE_HIDDEN
177
210
  }
178
211
 
179
212
  /** Takes the sheet's views away; the modal's view is the caller's. */
180
213
  fun remove() {
214
+ // A slide down of back's release that ends after this tells nothing: the modal went already.
215
+ behavior.removeBottomSheetCallback(sheetCallback)
216
+ stopBackPreview()
217
+ stopBackFrames()
181
218
  container.viewTreeObserver.removeOnGlobalLayoutListener(scrollViews)
182
219
  container.removeView(content)
183
220
  layout.removeView(container)
@@ -191,6 +228,69 @@ internal class ModalSheet(
191
228
  TestIds.apply(scrim, testId?.let { "$it.scrim" })
192
229
  }
193
230
 
231
+ override val hidesOnBack: Boolean
232
+ get() = closing == null && !backFinishing && behavior.isHideable && container.isLaidOut &&
233
+ detentOf(behavior.state) != null
234
+
235
+ override fun startBackPreview(event: BackEventCompat): SheetBack.Gesture? {
236
+ if (!hidesOnBack || backPreview != null) return null
237
+ val preview = BackPreview()
238
+ backPreview = preview
239
+ startBackFrames()
240
+ behavior.startBackProgress(event)
241
+ return preview
242
+ }
243
+
244
+ // A command closed or moved the sheet, or it goes, while back's preview runs: the sheet grows back where it stands
245
+ // (the behavior's cancel), and the gesture's release does the usual back.
246
+ private fun stopBackPreview() {
247
+ if (backPreview == null) return
248
+ backPreview = null
249
+ behavior.cancelBackProgress()
250
+ }
251
+
252
+ private fun startBackFrames() {
253
+ if (backFramesObserver != null) return
254
+ backFramesObserver = container.viewTreeObserver.also { it.addOnPreDrawListener(backFrames) }
255
+ }
256
+
257
+ // The listener leaves the observer it joined: a container out of the window has another one.
258
+ private fun stopBackFrames() {
259
+ val observer = backFramesObserver ?: return
260
+ backFramesObserver = null
261
+ if (observer.isAlive) observer.removeOnPreDrawListener(backFrames)
262
+ }
263
+
264
+ /** The back gesture over this sheet: the Material behavior previews its hide (`MaterialBackHandler`). */
265
+ private inner class BackPreview : SheetBack.Gesture {
266
+ override val sheet: SheetBack
267
+ get() = this@ModalSheet
268
+
269
+ override fun progress(event: BackEventCompat) {
270
+ if (backPreview !== this) return
271
+ behavior.updateBackProgress(event)
272
+ updateScrim()
273
+ }
274
+
275
+ override fun cancel() {
276
+ if (backPreview === this) stopBackPreview()
277
+ }
278
+
279
+ // The behavior slides the sheet down from the preview and sets its hidden state at the end: `onState` closes
280
+ // it there as a drag down does (`onClosedByUser`), so the modal goes through the user's path once.
281
+ override fun commit(): Boolean {
282
+ if (backPreview !== this) return false
283
+ backPreview = null
284
+ if (!hidesOnBack) {
285
+ behavior.cancelBackProgress()
286
+ return false
287
+ }
288
+ backFinishing = true
289
+ behavior.handleBackInvoked()
290
+ return true
291
+ }
292
+ }
293
+
194
294
  private fun closeByUser() {
195
295
  if (!swipeToDismiss) {
196
296
  events.onCloseAttempted()
@@ -243,6 +343,7 @@ internal class ModalSheet(
243
343
  when (state) {
244
344
  BottomSheetBehavior.STATE_DRAGGING -> dragged = true
245
345
  BottomSheetBehavior.STATE_HIDDEN -> {
346
+ backFinishing = false
246
347
  when {
247
348
  closing != null -> finishClosing()
248
349
  else -> {
@@ -280,13 +381,23 @@ internal class ModalSheet(
280
381
  // The screen under the sheet dims as the sheet goes up past `largestUndimmedDetent` (from the bottom without
281
382
  // one), to the dim of a Material modal sheet at the next detent; while it isn't dimmed, it takes touches.
282
383
  private fun updateScrim() {
283
- val height = layout.height - container.top
384
+ // Hidden at the end of back's slide, it still stands at its detent until the next layout.
385
+ if (behavior.state == BottomSheetBehavior.STATE_HIDDEN) return setDim(0f)
386
+ val height = layout.height - sheetTop()
284
387
  val undimmed = options.largestUndimmedDetent?.takeIf { it in detents }
285
388
  val from = undimmed?.let { heightOf(it) } ?: 0
286
389
  val to = detents.firstOrNull { heightOf(it) > from }?.let { heightOf(it) } ?: return run { setDim(0f) }
287
390
  setDim(((height - from).toFloat() / (to - from)).coerceIn(0f, 1f))
288
391
  }
289
392
 
393
+ // The top of the sheet as drawn: its offset, and while back's preview moves it, its slide and its scale toward
394
+ // the bottom (the behavior's pivot).
395
+ private fun sheetTop(): Int {
396
+ if (backFramesObserver == null) return container.top
397
+ val scaled = container.pivotY * (1f - container.scaleY)
398
+ return (container.top + container.translationY + scaled).roundToInt()
399
+ }
400
+
290
401
  private fun setDim(fraction: Float) {
291
402
  scrim.alpha = fraction * SCRIM_ALPHA
292
403
  }
@@ -19,6 +19,7 @@ import dev.apexnative.navigation.options.Options
19
19
  import dev.apexnative.navigation.utils.TestIds
20
20
  import dev.apexnative.navigation.utils.dp
21
21
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
22
+ import dev.apexnative.navigation.views.animations.AnimationLayer
22
23
  import dev.apexnative.navigation.views.animations.AnimationRules
23
24
  import dev.apexnative.navigation.views.animations.SideAnimator
24
25
  import dev.apexnative.navigation.views.element.ElementTransition
@@ -263,6 +264,8 @@ internal class ModalViews(
263
264
  parts[record]?.container?.requestLayout()
264
265
  }
265
266
 
267
+ override fun sheetBack(record: ModalRecord): SheetBack? = parts[record]?.sheet
268
+
266
269
  // `modal.testID` on the modal's view; a sheet's on the sheet's container, `modal.sheet.testID` first, and its
267
270
  // grabber and scrim after it.
268
271
  private fun applyTestIds(record: ModalRecord, parts: Parts, options: Options) {
@@ -293,7 +296,11 @@ internal class ModalViews(
293
296
  val under = motion.underSide?.let { side -> underView?.let { SideAnimator.build(it, side, underBase) } }
294
297
  if (under != null && underView != null) {
295
298
  if (showing) moved[underView] = record else moved.remove(underView)
299
+ AnimationLayer.during(underView, under)
296
300
  }
301
+ // A fade of the modal or of the layer under it draws it through a hardware layer, given back at its end and at
302
+ // any stop (the set's cancel cancels it).
303
+ for (animator in listOfNotNull(side, fade)) AnimationLayer.during(container, animator)
297
304
  val fadeLength = motion.side?.takeIf(AnimationRules::isSingleAlpha)?.let(AnimationRules::lengthOf)
298
305
  val elements = motion.elements?.let { animation ->
299
306
  val content = modalsLayer.parent as? ViewGroup ?: return@let null
@@ -0,0 +1,42 @@
1
+ package dev.apexnative.navigation.viewcontrollers.modal
2
+
3
+ import androidx.activity.BackEventCompat
4
+
5
+ /**
6
+ * A modal's sheet as back sees it ([ModalSheet]): whether a release of back would hide it, and the preview of that
7
+ * hide during the back gesture (Android 14+). [ModalCoordinator.backTargetSheet] gives the one back would dismiss now.
8
+ * Main thread only.
9
+ */
10
+ internal interface SheetBack {
11
+ /**
12
+ * Back's release would hide the sheet, as a drag down does: it stands at a detent from which it goes down (its
13
+ * smallest, while it lets the user close it), laid out, and it isn't closing already. False at a larger detent:
14
+ * the Material behavior would only collapse it there.
15
+ */
16
+ val hidesOnBack: Boolean
17
+
18
+ /**
19
+ * Starts the preview of back on the sheet with the gesture's first [event]: the sheet shrinks a little toward the
20
+ * bottom with the progress, the scrim following it. None when [hidesOnBack] doesn't hold or a preview runs.
21
+ */
22
+ fun startBackPreview(event: BackEventCompat): Gesture?
23
+
24
+ /** A back gesture over the sheet ([startBackPreview]). */
25
+ interface Gesture {
26
+ /** The sheet the gesture previews. */
27
+ val sheet: SheetBack
28
+
29
+ /** The gesture moved. */
30
+ fun progress(event: BackEventCompat)
31
+
32
+ /** Cancelled, or back went elsewhere: the sheet grows back to where it stood, the scrim with it. */
33
+ fun cancel()
34
+
35
+ /**
36
+ * Released: the sheet slides down from where the preview left it and goes as one the user dragged down (its
37
+ * events, its dismissal). False when the preview stopped since it started (a command closed or moved the
38
+ * sheet): back goes the usual way.
39
+ */
40
+ fun commit(): Boolean
41
+ }
42
+ }
@@ -33,6 +33,7 @@ import dev.apexnative.navigation.viewcontrollers.externalcomponent.ExternalCompo
33
33
  import dev.apexnative.navigation.viewcontrollers.modal.ModalCoordinator
34
34
  import dev.apexnative.navigation.viewcontrollers.modal.ModalRecord
35
35
  import dev.apexnative.navigation.viewcontrollers.modal.ModalViews
36
+ import dev.apexnative.navigation.viewcontrollers.modal.SheetBack
36
37
  import dev.apexnative.navigation.viewcontrollers.overlay.OverlayCoordinator
37
38
  import dev.apexnative.navigation.viewcontrollers.overlay.OverlayRecord
38
39
  import dev.apexnative.navigation.viewcontrollers.overlay.OverlayViews
@@ -177,6 +178,9 @@ class Navigator(
177
178
  return if (modalRoot != null) modalRoot.stackPoppedByBack() else root?.stackPoppedByBack()
178
179
  }
179
180
 
181
+ /** The sheet [handleBack] would dismiss, as a drag down of the user would; none when back would do anything else. */
182
+ internal fun sheetDismissedByBack(): SheetBack? = modals.backTargetSheet()
183
+
180
184
  /**
181
185
  * [layer] may wait to show from now on ([WaitingLayer]): the nodes in it ask it through [isInWaitingLayer] and
182
186
  * [shownScreensChanged]. The root's layer is there from the start.
@@ -129,12 +129,9 @@ abstract class ParentController<V : ViewGroup>(
129
129
  open fun leavesTopEdgeFree(child: ViewController<*>): Boolean =
130
130
  parentController?.leavesTopEdgeFree(this) ?: false
131
131
 
132
- /**
133
- * Whether the safe area of [child] leaves its bottom edge free by default: the tabs show with
134
- * `bottomTabs.hideOnScroll` of [screen], the screen that asks.
135
- */
136
- open fun leavesBottomEdgeFree(child: ViewController<*>, screen: ViewController<*> = child): Boolean =
137
- parentController?.leavesBottomEdgeFree(this, screen) ?: false
132
+ /** Whether a large title shows over [child]: its stack shows the bar with one. No by default. */
133
+ open fun showsLargeTitleOver(child: ViewController<*>): Boolean =
134
+ parentController?.showsLargeTitleOver(this) ?: false
138
135
 
139
136
  /**
140
137
  * The tab bar at rest under [child]: where a stack's FAB keeps clear of it, by the options of [screen], the stack's
@@ -2,18 +2,18 @@ package dev.apexnative.navigation.viewcontrollers.stack
2
2
 
3
3
  import android.view.View
4
4
  import com.facebook.react.uimanager.PixelUtil.pxToDp
5
- import dev.apexnative.navigation.react.ScreenInsets
5
+ import dev.apexnative.navigation.react.ScreenEnvironment
6
6
 
7
7
  /**
8
8
  * Where a screen prepared for a push (`preparePush`) shows, in dp: the size of the container its slot fills and the
9
9
  * padding of its safe area view, as the push lays it out. JavaScript renders the screen with them before the push, and
10
10
  * the slot and the safe area view start their states with them.
11
11
  */
12
- internal data class PreparedFrame(val width: Float, val height: Float, val padding: ScreenInsets.Insets) {
12
+ internal data class PreparedFrame(val width: Float, val height: Float, val padding: ScreenEnvironment.Insets) {
13
13
  companion object {
14
14
  /** The size of [container] as its slot measures it (`SlotView`), so the slot finds it equal. */
15
15
  @JvmStatic
16
- fun of(container: View, padding: ScreenInsets.Insets) =
16
+ fun of(container: View, padding: ScreenEnvironment.Insets) =
17
17
  PreparedFrame(container.width.toFloat().pxToDp(), container.height.toFloat().pxToDp(), padding)
18
18
  }
19
19
  }
@@ -11,7 +11,7 @@ import dev.apexnative.navigation.options.DefaultOptions
11
11
  import dev.apexnative.navigation.react.CommandAnswer
12
12
  import dev.apexnative.navigation.react.CommandListener
13
13
  import dev.apexnative.navigation.react.ErrorTexts
14
- import dev.apexnative.navigation.react.ScreenInsets
14
+ import dev.apexnative.navigation.react.ScreenEnvironment
15
15
  import dev.apexnative.navigation.slots.SlotScreenView
16
16
  import dev.apexnative.navigation.utils.NavigationLog
17
17
  import dev.apexnative.navigation.viewcontrollers.child.ChildControllersRegistry
@@ -318,7 +318,7 @@ internal class StackCommands(
318
318
  layoutNow(stackView, screenView)
319
319
  node.updateSafeArea()
320
320
  val content = node.content?.asView()
321
- val padding = ScreenInsets.padding(node.id)
321
+ val padding = ScreenEnvironment.padding(node.id)
322
322
  val noInsets = content == null || ViewCompat.getRootWindowInsets(content) == null
323
323
  if (noInsets || padding == null || content.width <= 0 || content.height <= 0) {
324
324
  prepared.discard(node.id)
@@ -594,6 +594,10 @@ class StackController(
594
594
  override fun leavesTopEdgeFree(child: ViewController<*>): Boolean =
595
595
  TopBarRoom.leavesTopEdgeFree(resolvedOf(child), largeTitleShown(child)) || super.leavesTopEdgeFree(child)
596
596
 
597
+ /** A large title shows over [child] in this stack's bar. */
598
+ override fun showsLargeTitleOver(child: ViewController<*>): Boolean =
599
+ largeTitleShown(child) || super.showsLargeTitleOver(child)
600
+
597
601
  /** A screen under another doesn't compute its safe area again; the prepared and waiting do. */
598
602
  override fun isTopOfStack(child: ViewController<*>): Boolean =
599
603
  child === queue.currentChild || child === queue.waitingScreen || isPrepared(child)
@@ -15,6 +15,7 @@ import dev.apexnative.navigation.viewcontrollers.component.ComponentViewControll
15
15
  import dev.apexnative.navigation.viewcontrollers.externalcomponent.ExternalComponentViewController
16
16
  import dev.apexnative.navigation.viewcontrollers.stack.StackDisplay.Motion
17
17
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
18
+ import dev.apexnative.navigation.views.animations.AnimationLayer
18
19
  import dev.apexnative.navigation.views.animations.AnimationRules
19
20
  import dev.apexnative.navigation.views.animations.SideAnimator
20
21
  import dev.apexnative.navigation.views.animations.SystemTransition
@@ -157,6 +158,8 @@ internal class StackViews(private val stack: StackController) : StackDisplay {
157
158
  custom.alpha?.let { view.alpha = it }
158
159
  val own = custom.side?.let { SideAnimator.build(view, it, SideAnimator.Base(alpha = custom.alpha)) }
159
160
  val fade = if (own == null) StackTransitions.fade(view, custom.fade) else null
161
+ // A fade of the whole screen draws it through a hardware layer, as the default transition does ([dress]).
162
+ for (animator in listOfNotNull(own, fade)) AnimationLayer.during(view, animator)
160
163
  val elements = custom.elements?.let { elements ->
161
164
  val layout = layout ?: return@let null
162
165
  val fadeLength = custom.side?.takeIf(AnimationRules::isSingleAlpha)?.let(AnimationRules::lengthOf)
@@ -13,8 +13,8 @@ import java.lang.ref.WeakReference
13
13
  * tag) is in the screen, found once per tag —, not the horizontal ones, nor those of a modal, an overlay or another
14
14
  * screen; a slot screen's lists are in its container too. While a list is dragged, each follower gets the drag as a
15
15
  * translation to follow; when the drag ends, the way to settle; at every scroll event, the drags' and the inertia's,
16
- * the offset. It listens to React Native's events only while it has followers; without React Native's event dispatcher
17
- * (around a reload) it waits and listens once there is one. Released with the screen. Main thread only.
16
+ * the offset. It listens to React Native's scroll views ([ScrollEvents]) only while it has followers and its screen's
17
+ * view. Released with the screen. Main thread only.
18
18
  */
19
19
  class ScrollEventListener() {
20
20
  /** Follows the content while it is dragged. */
@@ -105,15 +105,12 @@ class ScrollEventListener() {
105
105
  }
106
106
 
107
107
  /**
108
- * Listens to React Native's scroll events when followers wait for them and it doesn't yet: after a reload there may
109
- * be no event dispatcher at first: then it is asked again once React Native has one, and as the screen joins a
110
- * window.
108
+ * Listens to React Native's scroll views when followers wait for them and it doesn't yet, once it has the screen's
109
+ * view to tell its own lists by; asked again as the screen joins a window.
111
110
  */
112
111
  internal fun attachIfNeeded() {
113
- if (released || stopListening != null || registrations.isEmpty()) return
114
- val context = scope?.get()?.context ?: return
115
- stopListening = ScrollEvents.listen(context, ::onScrollEvent)
116
- if (stopListening == null) ScrollEvents.waitForDispatcher(this)
112
+ if (released || stopListening != null || registrations.isEmpty() || scope?.get() == null) return
113
+ stopListening = ScrollEvents.listen(::onScrollEvent)
117
114
  }
118
115
 
119
116
  /** The screen is destroyed: no follower hears it any more. */
@@ -0,0 +1,73 @@
1
+ package dev.apexnative.navigation.views.animations
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.AnimatorSet
6
+ import android.animation.ObjectAnimator
7
+ import android.view.View
8
+
9
+ /**
10
+ * A hardware layer on a view for the time of an animator that fades it. A view whose `alpha` is below 1 and whose
11
+ * rendering overlaps (any `ViewGroup` by default) is drawn into an offscreen buffer at every frame; through a layer it
12
+ * is drawn once and the GPU only composites it. The layer is taken as the animator starts and only from
13
+ * `LAYER_TYPE_NONE` (a layer someone else set stays theirs), and given back as it ends or is cancelled. A pure
14
+ * translation or scale needs none: RenderThread draws it. The stack's default transition has its own
15
+ * (`StackViews.dress`). Main thread only.
16
+ */
17
+ internal object AnimationLayer {
18
+ private const val ALPHA = "alpha"
19
+
20
+ /**
21
+ * [view] draws through a hardware layer while [animator] plays, when [animator] changes the `alpha` of [view] and
22
+ * the view's rendering overlaps; [animator] itself, for chaining.
23
+ */
24
+ fun during(view: View, animator: Animator): Animator {
25
+ if (!changesAlpha(animator, view)) return animator
26
+ val hold = Hold({ view.layerType }, { view.setLayerType(it, null) })
27
+ animator.addListener(object : AnimatorListenerAdapter() {
28
+ override fun onAnimationStart(animation: Animator) {
29
+ hold.take(changesAlpha = true, overlapping = view.hasOverlappingRendering())
30
+ }
31
+
32
+ override fun onAnimationCancel(animation: Animator) = hold.release()
33
+
34
+ override fun onAnimationEnd(animation: Animator) = hold.release()
35
+ })
36
+ return animator
37
+ }
38
+
39
+ /** Whether an animation takes a layer: it fades, its view's rendering overlaps, and the view has no layer. */
40
+ fun takes(changesAlpha: Boolean, overlapping: Boolean, layerType: Int): Boolean =
41
+ changesAlpha && overlapping && layerType == View.LAYER_TYPE_NONE
42
+
43
+ /**
44
+ * The layer one animation took on one view, through [layerType] and [setLayerType]: taken at most once at a time,
45
+ * given back once, and only while it is still the hardware layer it set.
46
+ */
47
+ class Hold(private val layerType: () -> Int, private val setLayerType: (Int) -> Unit) {
48
+ /** Whether the animation holds the view's layer now. */
49
+ var isTaken = false
50
+ private set
51
+
52
+ /** The animation starts: the layer, when [takes] says so. */
53
+ fun take(changesAlpha: Boolean, overlapping: Boolean) {
54
+ if (isTaken || !takes(changesAlpha, overlapping, layerType())) return
55
+ setLayerType(View.LAYER_TYPE_HARDWARE)
56
+ isTaken = true
57
+ }
58
+
59
+ /** The animation ended or stopped: the layer goes, unless someone changed it meanwhile. */
60
+ fun release() {
61
+ if (!isTaken) return
62
+ isTaken = false
63
+ if (layerType() == View.LAYER_TYPE_HARDWARE) setLayerType(View.LAYER_TYPE_NONE)
64
+ }
65
+ }
66
+
67
+ // Whether [animator], or an animator in it, writes the alpha of [view].
68
+ private fun changesAlpha(animator: Animator, view: View): Boolean = when (animator) {
69
+ is AnimatorSet -> animator.childAnimations.any { changesAlpha(it, view) }
70
+ is ObjectAnimator -> animator.target === view && animator.values.orEmpty().any { it.propertyName == ALPHA }
71
+ else -> false
72
+ }
73
+ }
@@ -18,6 +18,18 @@ internal object ValueInterpolation {
18
18
  else -> from + fraction * (to - from)
19
19
  }
20
20
 
21
+ /**
22
+ * An angle in degrees at [fraction] of the way along the shorter arc, as iOS turns a shared element: 0 → 270 turns
23
+ * back by 90, 170 → −170 turns on by 20; half a turn goes on. At the end of the time exactly [to].
24
+ */
25
+ fun angle(from: Float, to: Float, fraction: Float, time: Float = fraction): Float {
26
+ if (time <= 0f) return from
27
+ if (time >= 1f) return to
28
+ val forward = ((to - from) % FULL_TURN + FULL_TURN) % FULL_TURN
29
+ val turn = if (forward > HALF_TURN) forward - FULL_TURN else forward
30
+ return from + fraction * turn
31
+ }
32
+
21
33
  /**
22
34
  * A color (an sRGB color int, as React Native keeps a background; none is transparent) at [fraction] of the way
23
35
  * from [from] to [to]: L, a and b in Lab (D65), the alpha linear, the result rounded; at the end of the time
@@ -90,4 +102,6 @@ internal object ValueInterpolation {
90
102
  private const val WHITE_Z = 108.883
91
103
  private const val EPSILON = 0.008856
92
104
  private const val KAPPA = 903.3
105
+ private const val FULL_TURN = 360f
106
+ private const val HALF_TURN = 180f
93
107
  }