@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
@@ -0,0 +1,287 @@
1
+ package dev.apexnative.navigation.viewcontrollers.stack
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.ValueAnimator
6
+ import android.content.res.Configuration
7
+ import android.graphics.Bitmap
8
+ import android.graphics.Canvas
9
+ import android.graphics.Color
10
+ import android.graphics.Outline
11
+ import android.graphics.Rect
12
+ import android.os.Build
13
+ import android.os.Handler
14
+ import android.os.Looper
15
+ import android.view.PixelCopy
16
+ import android.view.View
17
+ import android.view.ViewGroup
18
+ import android.view.ViewOutlineProvider
19
+ import android.view.Window
20
+ import android.view.animation.DecelerateInterpolator
21
+ import android.view.animation.LinearInterpolator
22
+ import android.view.animation.PathInterpolator
23
+ import android.widget.ImageView
24
+ import androidx.coordinatorlayout.widget.CoordinatorLayout
25
+ import dev.apexnative.navigation.views.animations.SystemTransition
26
+ import dev.apexnative.navigation.views.stack.StackLayout
27
+
28
+ /**
29
+ * The preview of a pop while the user's back gesture runs (predictive back), as the system shows the activity under
30
+ * the one that closes: the screen on top with its bar, a picture taken as the gesture starts, shrinks toward the
31
+ * gesture's side with rounded corners and follows the finger up and down; the screen under it shows behind it with
32
+ * its own bar, smaller and dimmed. Released, the pop plays its end ([commit]); cancelled, everything goes back
33
+ * ([cancel]). The values are those of the system's cross-activity back animation (WMShell
34
+ * `DefaultCrossActivityBackAnimation` of Android 15 and 16, the smallest scale as Android 17 shows it), also on Android
35
+ * 14, whose own preview differs. Main thread only.
36
+ */
37
+ internal class BackPreview(
38
+ private val layout: StackLayout,
39
+ private val top: View,
40
+ private val target: View,
41
+ /** The gesture started at the left edge: the screen moves right; from the right edge it stays centered. */
42
+ private val fromLeft: Boolean,
43
+ private val startY: Float,
44
+ /**
45
+ * The room the tabs take at the bottom of the top screen: the card is the stack above them, rounded on all four
46
+ * corners, while the tab bar stays, as Gmail shows it.
47
+ */
48
+ private val bottomRoom: Int,
49
+ /** The picture is there: the stack shows the screen under the top, with its bar, before the preview draws. */
50
+ private val onReveal: () -> Unit,
51
+ ) {
52
+ private val density = layout.resources.displayMetrics.density
53
+ private val bar = layout.topBar
54
+ private val scrim = View(layout.context)
55
+ private var card: ImageView? = null
56
+ private var progress = 0f
57
+ private var touchY = startY
58
+ private var animator: Animator? = null
59
+ private var ended = false
60
+ private var topAlpha = 1f
61
+ // What puts the stack as it was after a cancel: once, as the preview ends.
62
+ private var onCancelled: (() -> Unit)? = null
63
+ // Forward is to the end of the line: right to left, the screens move the other way, as the stack's pop does.
64
+ private val forward = if (layout.layoutDirection == View.LAYOUT_DIRECTION_RTL) -1f else 1f
65
+
66
+ /** The picture of the top is there and the screen under it shows: the preview draws. */
67
+ val isReady: Boolean
68
+ get() = card != null && !ended
69
+
70
+ /**
71
+ * Takes the picture of the stack as it shows now, drawn from its views (what lies over the stack, an overlay or a
72
+ * tab bar drawn over it, stays out of it); with a hardware bitmap in it, copied from the window a frame later. The
73
+ * preview draws once it is there.
74
+ */
75
+ fun start(window: Window) {
76
+ val width = layout.width
77
+ val height = areaHeight
78
+ if (width <= 0 || height <= 0) return
79
+ val picture = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
80
+ try {
81
+ layout.draw(Canvas(picture))
82
+ show(picture)
83
+ return
84
+ } catch (e: IllegalArgumentException) {
85
+ picture.eraseColor(Color.TRANSPARENT)
86
+ }
87
+ val place = IntArray(2)
88
+ layout.getLocationInWindow(place)
89
+ val area = Rect(place[0], place[1], place[0] + width, place[1] + height)
90
+ PixelCopy.request(window, area, picture, { result ->
91
+ if (result == PixelCopy.SUCCESS && !ended) show(picture)
92
+ }, Handler(Looper.getMainLooper()))
93
+ }
94
+
95
+ /** The gesture moved: [value] is its progress, 0 … 1, and [y] the finger's height in the window. */
96
+ fun progress(value: Float, y: Float) {
97
+ progress = value
98
+ touchY = y
99
+ if (isReady) drawGesture(GESTURE_CURVE.getInterpolation(value))
100
+ }
101
+
102
+ /**
103
+ * The gesture was cancelled: the preview springs back, then [restore] puts the stack as it was; at once when the
104
+ * preview ends earlier (a new gesture, a command).
105
+ */
106
+ fun cancel(restore: () -> Unit) {
107
+ onCancelled = restore
108
+ if (!isReady) return endAtOnce()
109
+ val from = progress
110
+ animator?.cancel()
111
+ animator = ValueAnimator.ofFloat(from, 0f).apply {
112
+ duration = CANCEL_MILLIS
113
+ interpolator = DecelerateInterpolator()
114
+ addUpdateListener { drawGesture(GESTURE_CURVE.getInterpolation(it.animatedValue as Float)) }
115
+ addListener(object : AnimatorListenerAdapter() {
116
+ override fun onAnimationEnd(animation: Animator) {
117
+ if (animator === animation) endAtOnce()
118
+ }
119
+ })
120
+ start()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The gesture was released and the pop runs: the top goes back to its size while it moves away by 96 dp and fades
126
+ * in the first fifth, the screen under it grows to its place, the dim fades; [done] once at the end, after the
127
+ * preview took its views away.
128
+ */
129
+ fun commit(done: () -> Unit) {
130
+ val card = card ?: return done()
131
+ val distance = SystemTransition.DISTANCE_DP * density
132
+ val q = GESTURE_CURVE.getInterpolation(progress)
133
+ val scale = scaleAt(q)
134
+ val cardX = card.translationX
135
+ val cardY = card.translationY
136
+ val dim = scrim.alpha
137
+ animator?.cancel()
138
+ animator = ValueAnimator.ofFloat(0f, 1f).apply {
139
+ duration = SystemTransition.DURATION
140
+ interpolator = LinearInterpolator()
141
+ addUpdateListener {
142
+ val t = it.animatedValue as Float
143
+ val e = SystemTransition.CURVE.getInterpolation(t)
144
+ card.scaleX = lerp(scale, 1f, e)
145
+ card.scaleY = card.scaleX
146
+ card.translationX = lerp(cardX, distance * forward, e)
147
+ card.translationY = lerp(cardY, 0f, e)
148
+ card.alpha = (1f - 5f * t).coerceAtLeast(0f)
149
+ entering(lerp(scale, 1f, e), lerp(-distance * forward, 0f, e))
150
+ scrim.alpha = lerp(dim, 0f, t)
151
+ }
152
+ addListener(object : AnimatorListenerAdapter() {
153
+ override fun onAnimationEnd(animation: Animator) {
154
+ if (animator !== animation) return
155
+ endAtOnce()
156
+ done()
157
+ }
158
+ })
159
+ start()
160
+ }
161
+ }
162
+
163
+ /**
164
+ * The preview ends where it is: its views go, the screens and the bar take their own values again, and a cancel's
165
+ * restore runs.
166
+ */
167
+ fun endAtOnce() {
168
+ if (ended) return
169
+ ended = true
170
+ animator?.let {
171
+ animator = null
172
+ it.cancel()
173
+ }
174
+ card?.let { (it.parent as? ViewGroup)?.removeView(it) }
175
+ card = null
176
+ (scrim.parent as? ViewGroup)?.removeView(scrim)
177
+ top.alpha = topAlpha
178
+ entering(1f, 0f)
179
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
180
+ target.resetPivot()
181
+ bar.resetPivot()
182
+ }
183
+ onCancelled?.let {
184
+ onCancelled = null
185
+ it()
186
+ }
187
+ }
188
+
189
+ // The picture came: the screen under the top shows with its bar, dimmed, and the picture lies over everything in
190
+ // the top's place, cut to rounded corners.
191
+ private fun show(picture: Bitmap) {
192
+ onReveal()
193
+ val dark = layout.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
194
+ Configuration.UI_MODE_NIGHT_YES
195
+ scrim.setBackgroundColor(Color.BLACK)
196
+ scrim.alpha = if (dark) SCRIM_DARK else SCRIM_LIGHT
197
+ scrim.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
198
+ layout.addOverAll(scrim, fill())
199
+ val radius = CORNER_DP * density
200
+ val area = CoordinatorLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, areaHeight)
201
+ card = ImageView(layout.context).apply {
202
+ setImageBitmap(picture)
203
+ scaleType = ImageView.ScaleType.FIT_XY
204
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
205
+ outlineProvider = object : ViewOutlineProvider() {
206
+ override fun getOutline(view: View, outline: Outline) =
207
+ outline.setRoundRect(0, 0, view.width, view.height, radius)
208
+ }
209
+ clipToOutline = true
210
+ layout.addOverAll(this, area)
211
+ }
212
+ // Transparent, not invisible: a surface in it (a video, a map) stays, and so does the focus.
213
+ topAlpha = top.alpha
214
+ top.alpha = 0f
215
+ drawGesture(GESTURE_CURVE.getInterpolation(progress))
216
+ }
217
+
218
+ // The values of the gesture at [q], its progress on the system's curve.
219
+ private fun drawGesture(q: Float) {
220
+ val card = card ?: return
221
+ val width = layout.width.toFloat()
222
+ val height = areaHeight.toFloat()
223
+ val margin = MARGIN_DP * density
224
+ val scale = scaleAt(q)
225
+ card.scaleX = scale
226
+ card.scaleY = scale
227
+ // From the left edge the screen's right side ends 8 dp from the window's right edge; from the right edge it stays
228
+ // centered.
229
+ card.translationX = if (fromLeft) (width - width * scale) / 2f - margin * q else 0f
230
+ // Up and down with the finger, never closer than 8 dp to the window's edges.
231
+ val moved = touchY - startY
232
+ val ratio = FOLLOW_CURVE.getInterpolation((kotlin.math.abs(moved) / (height / 2f)).coerceAtMost(1f))
233
+ card.translationY = ((height - height * scale) / 2f - margin).coerceAtLeast(0f) * ratio *
234
+ Math.signum(moved)
235
+ entering(scale, -SystemTransition.DISTANCE_DP * density * forward)
236
+ }
237
+
238
+ // The screen under the top and its bar at [scale] around the stack's center, [x] along the line.
239
+ private fun entering(scale: Float, x: Float) {
240
+ for (view in listOf(target, bar)) {
241
+ view.pivotX = layout.width / 2f - view.left
242
+ view.pivotY = areaHeight / 2f - view.top
243
+ view.scaleX = scale
244
+ view.scaleY = scale
245
+ view.translationX = x
246
+ }
247
+ }
248
+
249
+ // The stack above the tabs' room: what the card shows and what the screens shrink around.
250
+ private val areaHeight: Int
251
+ get() = (layout.height - bottomRoom).coerceAtLeast(0)
252
+
253
+ private fun scaleAt(q: Float): Float = 1f - (1f - minScale()) * q
254
+
255
+ private fun fill() = CoordinatorLayout.LayoutParams(
256
+ ViewGroup.LayoutParams.MATCH_PARENT,
257
+ ViewGroup.LayoutParams.MATCH_PARENT,
258
+ )
259
+
260
+ private fun lerp(from: Float, to: Float, fraction: Float): Float = from + (to - from) * fraction
261
+
262
+ private companion object {
263
+ /** The system's curve of the back gesture's progress (`BackGestureInterpolator`). */
264
+ val GESTURE_CURVE = PathInterpolator(0.1f, 0.1f, 0f, 1f)
265
+
266
+ /** How the screen follows the finger up and down. */
267
+ val FOLLOW_CURVE = DecelerateInterpolator()
268
+
269
+ /** The room the shrunk screen keeps from the window's edges. */
270
+ const val MARGIN_DP = 8f
271
+
272
+ /** The corners of the shrunk screen, as Android 17 draws them on a Pixel (6.5 dp at its smallest). */
273
+ const val CORNER_DP = 7.5f
274
+
275
+ const val SCRIM_LIGHT = 0.2f
276
+ const val SCRIM_DARK = 0.8f
277
+
278
+ /** The cancel springs back about as fast as the system's (`BackProgressAnimator`'s spring, no bounce). */
279
+ const val CANCEL_MILLIS = 350L
280
+
281
+ /**
282
+ * The smallest scale of the screens: 0.9 in WMShell of Android 15 and 16 (and here on Android 14); Android 17
283
+ * shrinks the window further (0.86, measured on a Pixel 6).
284
+ */
285
+ fun minScale(): Float = if (android.os.Build.VERSION.SDK_INT >= 37) 0.86f else 0.9f
286
+ }
287
+ }
@@ -2,6 +2,7 @@ package dev.apexnative.navigation.viewcontrollers.stack
2
2
 
3
3
  import android.app.Activity
4
4
  import android.util.Log
5
+ import android.view.View
5
6
  import dev.apexnative.navigation.NavigationActivity
6
7
  import dev.apexnative.navigation.options.AnimationsOptions
7
8
  import dev.apexnative.navigation.options.BottomTabsEnterExitAnimationOptions
@@ -35,6 +36,7 @@ import dev.apexnative.navigation.viewcontrollers.stack.topbar.TopBarPresenter
35
36
  import dev.apexnative.navigation.viewcontrollers.stack.topbar.TopBarRoom
36
37
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.Presenter
37
38
  import dev.apexnative.navigation.viewcontrollers.viewcontroller.ViewController
39
+ import dev.apexnative.navigation.views.animations.AnimationRules
38
40
  import dev.apexnative.navigation.views.component.ComponentLayout
39
41
  import dev.apexnative.navigation.views.stack.StackLayout
40
42
  import dev.apexnative.navigation.views.stack.topbar.LargeTitleView
@@ -257,6 +259,136 @@ class StackController(
257
259
  */
258
260
  override fun canHandleBack(): Boolean = topBar.isSearchOpen() || queue.entries.size >= 2
259
261
 
262
+ /**
263
+ * The default pop of the top is what back does now and it can be previewed: two entries or more, nothing running
264
+ * or waiting, no open search, the top doesn't hold back and pops with the default transition, the screen under it
265
+ * has its view, and the stack shows.
266
+ */
267
+ override fun stackPoppedByBack(): StackController? {
268
+ if (topBar.isSearchOpen() || !queue.isAtRest) return null
269
+ val entries = queue.entries
270
+ val top = queue.currentChild ?: return null
271
+ val target = entries.getOrNull(entries.size - 2) ?: return null
272
+ if (top !== entries.last() || target !in queue.shown || target.peekView() == null) return null
273
+ if (!queue.isRendered(target) || backGesture != null) return null
274
+ val options = resolvedOf(top)
275
+ val pop = options.animations.pop
276
+ if (options.hardwareBackButton.popStackOnPress.isFalse || pop.enabled.isFalse || AnimationRules.stackIsOwn(pop)) {
277
+ return null
278
+ }
279
+ val display = queue.display
280
+ return this.takeIf { display.isOnScreen && display.canAnimate }
281
+ }
282
+
283
+ /**
284
+ * Starts the preview of the pop a back gesture would make ([stackPoppedByBack] said yes): the top shrinks toward
285
+ * the gesture's side ([fromLeft]) and follows the finger from [touchY]; the screen under it shows with its bar.
286
+ * None when it can't be shown.
287
+ */
288
+ internal fun startBackGesture(fromLeft: Boolean, touchY: Float): BackGesture? {
289
+ val views = queue.display as? StackViews ?: return null
290
+ val top = queue.currentChild ?: return null
291
+ val target = queue.entries.getOrNull(queue.entries.size - 2) ?: return null
292
+ val gesture = BackGesture(views, top, target)
293
+ val preview = views.startBackPreview(top, target, fromLeft, touchY, gesture::reveal) ?: return null
294
+ gesture.preview = preview
295
+ backGesture = gesture
296
+ return gesture
297
+ }
298
+
299
+ // The back gesture over this stack whose preview hasn't ended yet; none.
300
+ private var backGesture: BackGesture? = null
301
+
302
+ /**
303
+ * A back gesture over the top of this stack ([startBackGesture]): its progress, its cancel, and its release, which
304
+ * pops the top through the queue with the preview's end. A command accepted meanwhile, or the stack's destroy,
305
+ * ends the preview at once ([abort]).
306
+ */
307
+ internal inner class BackGesture(
308
+ private val views: StackViews,
309
+ private val top: ViewController<*>,
310
+ private val target: ViewController<*>,
311
+ ) {
312
+ lateinit var preview: BackPreview
313
+ // How the screen under the top was before the preview showed it: in the container and seen, its alpha and
314
+ // accessibility.
315
+ private var targetWasPlaced = false
316
+ private var targetAlpha = 1f
317
+ private var targetAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
318
+ private var revealed = false
319
+ private var aborted = false
320
+
321
+ /** The gesture moved: [progress] 0 … 1, the finger at [touchY]. */
322
+ fun progress(progress: Float, touchY: Float) {
323
+ if (!aborted) preview.progress(progress, touchY)
324
+ }
325
+
326
+ /** Cancelled: the preview springs back, then the screen under the top and the bar are as they were. */
327
+ fun cancel() {
328
+ if (!aborted) preview.cancel(::restore)
329
+ }
330
+
331
+ /**
332
+ * Released: the top pops, the preview playing its end; the default pop when its picture isn't there yet. False
333
+ * when the stack changed since the gesture started (a command ran, the stack went): back goes the usual way.
334
+ */
335
+ fun commit(): Boolean {
336
+ if (aborted || isDestroyed) return false
337
+ if (!preview.isReady) {
338
+ abort()
339
+ queue.cut(target, null, id, "go back in the stack '$id'", null)
340
+ return true
341
+ }
342
+ if (backGesture === this) backGesture = null
343
+ queue.cut(target, null, id, "go back in the stack '$id'", null, gesture = true)
344
+ return true
345
+ }
346
+
347
+ /** The stack changed or goes: the preview ends at once and the stack is as it was. */
348
+ fun abort() {
349
+ if (aborted) return
350
+ aborted = true
351
+ preview.endAtOnce()
352
+ restore()
353
+ }
354
+
355
+ // The preview's picture is there: the screen under the top shows under it, and the bar shows that screen.
356
+ internal fun reveal() {
357
+ val view = target.peekView() ?: return
358
+ targetWasPlaced = view.parent === peekView() && view.visibility == View.VISIBLE
359
+ targetAlpha = view.alpha
360
+ targetAccessibility = view.importantForAccessibility
361
+ revealed = true
362
+ views.reveal(target, top)
363
+ view.alpha = 1f
364
+ topBar.apply(target)
365
+ }
366
+
367
+ private fun restore() {
368
+ views.previewEnded(preview)
369
+ if (backGesture === this) backGesture = null
370
+ if (!revealed || isDestroyed) return
371
+ revealed = false
372
+ if (target in queue.shown && target !== queue.currentChild) {
373
+ val view = target.peekView()
374
+ if (targetWasPlaced && view != null) {
375
+ view.alpha = targetAlpha
376
+ view.importantForAccessibility = targetAccessibility
377
+ } else {
378
+ views.detach(target)
379
+ }
380
+ }
381
+ // The bar shows the top again, its components appeared again.
382
+ val shown = queue.currentChild
383
+ topBar.apply(shown)
384
+ shown?.let {
385
+ topBar.screenWillAppear(optionsScreen(it))
386
+ topBar.screenDidAppear(optionsScreen(it))
387
+ }
388
+ BackCallbackState.update()
389
+ }
390
+ }
391
+
260
392
  /**
261
393
  * The back arrow of the bar was pressed: the screen the user sees holds it (`topBar.backButton.popStackOnPress:
262
394
  * false`: `ANN.NavigationButtonPressed` with `topBar.backButton.id` or `ApexNavigation.back`); on the first screen
@@ -460,8 +592,9 @@ class StackController(
460
592
 
461
593
  /**
462
594
  * The bar's bottom in the window while [child] is under it and it shows, from the stack's place in the window: for
463
- * the screen on top where the bar is laid out (a move of the bar doesn't count); for another child (a prepared one)
464
- * as its own options set the bar.
595
+ * the screen on top that the bar shows, where the bar is laid out (a move of the bar doesn't count); for another
596
+ * child (a prepared one) and for a top the bar hasn't shown yet (in a tab not selected yet, which measures though
597
+ * invisible) as its own options set the bar, so its content doesn't move as the bar takes them.
465
598
  */
466
599
  override fun headerBottomOf(child: ViewController<*>): Int? {
467
600
  if (!isUnderBar(child)) return super.headerBottomOf(child)
@@ -471,7 +604,7 @@ class StackController(
471
604
  val layout = peekView() ?: return null
472
605
  // The stack's place at rest, as the screen's own: a modal sliding in or a sheet dragged doesn't move it.
473
606
  val top = RestPlace.topInWindow(layout)
474
- if (entry === queue.currentChild) return topBar.barBottomInStack()?.let { top + it }
607
+ if (entry === queue.currentChild && topBar.shows(entry)) return topBar.barBottomInStack()?.let { top + it }
475
608
  val bar = topBar.barOf(entry, options)
476
609
  val large = if (largeTitleShown(child)) UiUtils.dpToPx(activity, LargeTitleView.HEIGHT).toInt() else 0
477
610
  return top + bar.margin + bar.strip + bar.height + large
@@ -506,6 +639,7 @@ class StackController(
506
639
 
507
640
  override fun destroy() {
508
641
  if (isDestroyed) return
642
+ backGesture?.abort()
509
643
  queue.destroy()
510
644
  preparedPushes()?.discardFor(this)
511
645
  fab.destroy()
@@ -552,15 +686,23 @@ class StackController(
552
686
  (node as? OptionsScreen)?.optionsScreen ?: node
553
687
 
554
688
  private inner class QueueHost : StackQueue.Host {
689
+ override fun operationAccepted() {
690
+ backGesture?.abort()
691
+ }
692
+
555
693
  // The bar takes the options of the new top at once, also without appearance events.
556
694
  override fun currentChildChanged() {
557
695
  updateBar()
558
696
  navigator?.shownScreensChanged(this@StackController)
559
697
  }
560
698
 
561
- // Before the bar takes the new top's options: the row's picture, and how it shows or hides.
562
- override fun barTransition(motion: StackDisplay.BarMotion?, visibility: StackDisplay.BarVisibility) {
563
- if (peekView() != null) topBar.beginRowTransition(motion, queue.currentChild, visibility)
699
+ // Before the bar takes the new top's options: the row's picture or the bar's travel, and how it shows or hides.
700
+ override fun barTransition(
701
+ motion: StackDisplay.BarMotion?,
702
+ visibility: StackDisplay.BarVisibility,
703
+ travel: StackDisplay.BarTravel?,
704
+ ) {
705
+ if (peekView() != null) topBar.beginRowTransition(motion, queue.currentChild, visibility, travel)
564
706
  }
565
707
 
566
708
  // The tab bar of the tabs around goes with the transition; a takeover doesn't bring it to rest.
@@ -38,6 +38,12 @@ internal interface StackDisplay {
38
38
  */
39
39
  enum class BarMotion { Push, Pop, FadeThrough }
40
40
 
41
+ /**
42
+ * How the bar goes with a transition of the stack's own (`content`): with its screens. [Forward]: the screen that
43
+ * comes lies over the one that goes (a push, `setStackRoot`); [Back]: the one that goes lies over it (a pop).
44
+ */
45
+ enum class BarTravel { Forward, Back }
46
+
41
47
  /**
42
48
  * How the bar shows or hides when the new top's `topBar.visible` differs: at once (no animation, or a transition
43
49
  * with elements), or [animated] by the transition's [side] (`animations.push.topBar`, the leaving screen's
@@ -156,6 +162,24 @@ internal interface StackDisplay {
156
162
  done: () -> Unit,
157
163
  ): () -> Unit
158
164
 
165
+ /** The preview of a back gesture over [top], [target] under it, shows ([finishBackGesture] can end it). */
166
+ fun hasBackPreview(top: ViewController<*>, target: ViewController<*>): Boolean = false
167
+
168
+ /**
169
+ * The pop of a back gesture whose preview shows ([hasBackPreview]), released: the preview plays the end of [top]
170
+ * going and [target] coming. [topDone] and [targetDone] once each, as [animate]'s; [stop] of one screen ends the
171
+ * preview where it is and its other screen's part.
172
+ */
173
+ fun finishBackGesture(
174
+ top: ViewController<*>,
175
+ target: ViewController<*>,
176
+ topDone: () -> Unit,
177
+ targetDone: () -> Unit,
178
+ ) {
179
+ topDone()
180
+ targetDone()
181
+ }
182
+
159
183
  /** Stops the animator of [screen] where it is, without its end: a later transition takes the screen over. */
160
184
  fun stop(screen: ViewController<*>)
161
185