react-native-screens 4.25.0 → 4.26.0-nightly-20260512-e6b354434

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 (83) hide show
  1. package/android/src/main/java/com/swmansion/rnscreens/Screen.kt +5 -115
  2. package/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +25 -3
  3. package/android/src/main/java/com/swmansion/rnscreens/ScreenStackViewManager.kt +0 -14
  4. package/android/src/main/java/com/swmansion/rnscreens/ScreenViewManager.kt +0 -5
  5. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt +364 -0
  6. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt +8 -157
  7. package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt +16 -0
  8. package/common/cpp/react/renderer/components/rnscreens/RNSScreenStackHeaderConfigState.h +3 -5
  9. package/ios/RNSEnums.h +6 -6
  10. package/ios/RNSScreen.h +8 -17
  11. package/ios/RNSScreen.mm +83 -141
  12. package/ios/RNSScreenContainer.mm +11 -13
  13. package/ios/RNSScreenStack.h +5 -9
  14. package/ios/RNSScreenStack.mm +20 -69
  15. package/ios/RNSScreenStackHeaderConfig.mm +27 -30
  16. package/ios/RNSScreenStackHeaderSubview.mm +14 -15
  17. package/ios/RNSSearchBar.mm +11 -12
  18. package/ios/conversion/RNSConversions-SplitView.mm +16 -0
  19. package/ios/conversion/RNSConversions-Tabs.mm +4 -5
  20. package/ios/conversion/RNSConversions.h +4 -3
  21. package/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm +6 -8
  22. package/ios/gamma/split/RNSSplitAppearanceApplicator.swift +1 -0
  23. package/ios/gamma/split/RNSSplitHostComponentView.h +1 -0
  24. package/ios/gamma/split/RNSSplitHostComponentView.mm +16 -14
  25. package/ios/gamma/split/RNSSplitScreenShadowStateProxy.mm +3 -4
  26. package/ios/gamma/stack/host/RNSStackHostComponentView.mm +10 -12
  27. package/ios/safe-area/RNSSafeAreaViewComponentView.mm +3 -4
  28. package/ios/tabs/RCTConvert+RNSTabs.mm +58 -63
  29. package/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm +2 -3
  30. package/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryShadowStateProxy.mm +3 -4
  31. package/ios/tabs/host/RNSTabsHostComponentViewManager.mm +3 -4
  32. package/ios/tabs/screen/RNSTabsScreenComponentView.h +4 -5
  33. package/ios/tabs/screen/RNSTabsScreenViewController.mm +5 -6
  34. package/lib/commonjs/components/Screen.js +1 -4
  35. package/lib/commonjs/components/Screen.js.map +1 -1
  36. package/lib/commonjs/components/ScreenStack.js +0 -8
  37. package/lib/commonjs/components/ScreenStack.js.map +1 -1
  38. package/lib/commonjs/fabric/ScreenNativeComponent.js.map +1 -1
  39. package/lib/commonjs/fabric/ScreenStackNativeComponent.js.map +1 -1
  40. package/lib/commonjs/fabric/gamma/split/SplitHostNativeComponent.js.map +1 -1
  41. package/lib/commonjs/flags.js +13 -25
  42. package/lib/commonjs/flags.js.map +1 -1
  43. package/lib/module/components/Screen.js +1 -4
  44. package/lib/module/components/Screen.js.map +1 -1
  45. package/lib/module/components/ScreenStack.js +0 -8
  46. package/lib/module/components/ScreenStack.js.map +1 -1
  47. package/lib/module/fabric/ScreenNativeComponent.js.map +1 -1
  48. package/lib/module/fabric/ScreenStackNativeComponent.js.map +1 -1
  49. package/lib/module/fabric/gamma/split/SplitHostNativeComponent.js.map +1 -1
  50. package/lib/module/flags.js +13 -25
  51. package/lib/module/flags.js.map +1 -1
  52. package/lib/typescript/components/Screen.d.ts.map +1 -1
  53. package/lib/typescript/components/ScreenStack.d.ts.map +1 -1
  54. package/lib/typescript/components/gamma/split/SplitHost.types.d.ts +15 -1
  55. package/lib/typescript/components/gamma/split/SplitHost.types.d.ts.map +1 -1
  56. package/lib/typescript/fabric/ModalScreenNativeComponent.d.ts +1 -1
  57. package/lib/typescript/fabric/ModalScreenNativeComponent.d.ts.map +1 -1
  58. package/lib/typescript/fabric/ScreenNativeComponent.d.ts +1 -2
  59. package/lib/typescript/fabric/ScreenNativeComponent.d.ts.map +1 -1
  60. package/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts +1 -1
  61. package/lib/typescript/fabric/ScreenStackHeaderConfigNativeComponent.d.ts.map +1 -1
  62. package/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts +1 -1
  63. package/lib/typescript/fabric/ScreenStackHeaderSubviewNativeComponent.d.ts.map +1 -1
  64. package/lib/typescript/fabric/ScreenStackNativeComponent.d.ts +0 -2
  65. package/lib/typescript/fabric/ScreenStackNativeComponent.d.ts.map +1 -1
  66. package/lib/typescript/fabric/gamma/split/SplitHostNativeComponent.d.ts +2 -0
  67. package/lib/typescript/fabric/gamma/split/SplitHostNativeComponent.d.ts.map +1 -1
  68. package/lib/typescript/flags.d.ts +3 -0
  69. package/lib/typescript/flags.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/components/Screen.tsx +0 -11
  72. package/src/components/ScreenStack.tsx +0 -13
  73. package/src/components/gamma/split/SplitHost.types.ts +16 -1
  74. package/src/fabric/ModalScreenNativeComponent.ts +1 -1
  75. package/src/fabric/ScreenNativeComponent.ts +1 -2
  76. package/src/fabric/ScreenStackHeaderConfigNativeComponent.ts +1 -1
  77. package/src/fabric/ScreenStackHeaderSubviewNativeComponent.ts +1 -1
  78. package/src/fabric/ScreenStackNativeComponent.ts +0 -3
  79. package/src/fabric/gamma/split/SplitHostNativeComponent.ts +3 -0
  80. package/src/flags.ts +12 -39
  81. package/ios/helpers/RNSViewInteractionAware.h +0 -9
  82. package/ios/helpers/RNSViewInteractionManager.h +0 -25
  83. package/ios/helpers/RNSViewInteractionManager.mm +0 -64
@@ -26,9 +26,11 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
26
26
  import com.google.android.material.shape.CornerFamily
27
27
  import com.google.android.material.shape.MaterialShapeDrawable
28
28
  import com.google.android.material.shape.ShapeAppearanceModel
29
+ import com.swmansion.rnscreens.bottomsheet.SheetAnimationCoordinator
29
30
  import com.swmansion.rnscreens.bottomsheet.SheetDetents
30
31
  import com.swmansion.rnscreens.bottomsheet.fitToContentsSheetHeight
31
32
  import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents
33
+ import com.swmansion.rnscreens.bottomsheet.resolveClampedHeight
32
34
  import com.swmansion.rnscreens.bottomsheet.updateMetrics
33
35
  import com.swmansion.rnscreens.bottomsheet.useSingleDetent
34
36
  import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation
@@ -50,6 +52,8 @@ class Screen(
50
52
  val sheetBehavior: BottomSheetBehavior<Screen>?
51
53
  get() = (layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<Screen>
52
54
 
55
+ internal val sheetAnimationCoordinator: SheetAnimationCoordinator by lazy { SheetAnimationCoordinator(this) }
56
+
53
57
  val reactEventDispatcher: EventDispatcher?
54
58
  get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
55
59
 
@@ -155,7 +159,7 @@ class Screen(
155
159
  if (isInitial) {
156
160
  setupInitialSheetContentHeight(sheetBehavior, height)
157
161
  } else if (sheetDefaultResizeAnimationEnabled) {
158
- updateSheetContentHeightWithAnimation(sheetBehavior, oldHeight, height)
162
+ sheetAnimationCoordinator.updateSheetContentHeightWithAnimation(sheetBehavior, oldHeight, height)
159
163
  } else {
160
164
  updateSheetContentHeightWithoutAnimation(sheetBehavior, height)
161
165
  }
@@ -171,104 +175,6 @@ class Screen(
171
175
  updateState(width, height, headerHeight)
172
176
  }
173
177
 
174
- /**
175
- * This should be used only with sheet in `fitToContents` mode.
176
- */
177
- private fun updateSheetContentHeightWithAnimation(
178
- behavior: BottomSheetBehavior<Screen>,
179
- oldHeight: Int,
180
- newHeight: Int,
181
- ) {
182
- val currentTranslationY = this.translationY
183
-
184
- /*
185
- * WHY OVERFLOW MATTERS:
186
- * BottomSheetBehavior has a physical limit (maxHeight) defined by the parent container.
187
- * If the new content height exceeds this limit (by its size or keyboard offset), simply
188
- * animating translationY back to 'currentTranslationY' would attempt to render the sheet
189
- * larger than the screen.
190
- *
191
- * We need to have constraint height inside the container's bounds.
192
- * By including this overflow to our animation, we ensure the sheet stops
193
- * expanding exactly at the maxHeight, preventing from being pushed
194
- * off-screen or causing layout synchronization issues with the CoordinatorLayout.
195
- */
196
- val clampedOldHeight = resolveClampedHeight(oldHeight, currentTranslationY)
197
- val clampedNewHeight = resolveClampedHeight(newHeight, currentTranslationY)
198
- val visibleDelta = (clampedNewHeight - clampedOldHeight).toFloat()
199
-
200
- if (visibleDelta == 0f) return
201
-
202
- val isContentExpanding = visibleDelta > 0
203
-
204
- if (isContentExpanding) {
205
- /*
206
- * Expanding content animation:
207
- *
208
- * Before animation, we're updating the SheetBehavior - the maximum height is the new
209
- * content height, then we're forcing a layout pass. This ensures the view calculates
210
- * with its new bounds when the animation starts.
211
- *
212
- * In the animation, we're translating the Screen back to it's (newly calculated) origin
213
- * position, providing an impression that FormSheet expands. It already has the final size,
214
- * but some content is not yet visible on the screen.
215
- *
216
- * After animation, we just need to send a notification that ShadowTree state should be updated,
217
- * as the positioning of pressables has changed due to the Y translation manipulation.
218
- */
219
- this.translationY += visibleDelta
220
- this
221
- .animate()
222
- .translationY(currentTranslationY)
223
- .withStartAction {
224
- behavior.updateMetrics(clampedNewHeight)
225
- layout(this.left, this.bottom - clampedNewHeight, this.right, this.bottom)
226
- }.withEndAction {
227
- // Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
228
- // internal offsets with the new maxHeight. This prevents the sheet from snapping back
229
- // to its old position when the user starts a gesture.
230
- parent.requestLayout()
231
- onSheetYTranslationChanged()
232
- }.start()
233
- } else {
234
- /*
235
- * Shrinking content animation:
236
- *
237
- * Before the animation, our Screen translationY is 0 - because its actual layout and visual position are equal.
238
- *
239
- * Before the animation, I'm updating sheet metrics to the target value - it won't update until the next layout pass,
240
- * which is controlled by end action. This is done deliberately, to allow catching the case when quick combination
241
- * of shrink & expand animation is detected.
242
- *
243
- * In the animation, we're translating the Screen down by the calculated height delta to the position (which will
244
- * be new absolute 0 for the Screen, after ending the transition), providing an impression that FormSheet shrinks.
245
- * FormSheet's size remains unchanged during the whole animation, therefore there is no view clipping.
246
- *
247
- * After animation, we can update the layout: the maximum FormSheet height is updated and we're forcing
248
- * another layout pass. Additionally, since the actual layout and the target position are equal,
249
- * we can reset translationY to 0.
250
- *
251
- * After animation, we need to send a notification that ShadowTree state should be updated,
252
- * as the FormSheet size has changed and the positioning of pressables has changed due to the Y translation manipulation.
253
- */
254
- val targetTranslationY = currentTranslationY - visibleDelta
255
- this
256
- .animate()
257
- .translationY(targetTranslationY)
258
- .withStartAction {
259
- behavior.updateMetrics(clampedNewHeight)
260
- }.withEndAction {
261
- layout(this.left, this.bottom - clampedNewHeight, this.right, this.bottom)
262
- this.translationY = currentTranslationY
263
- // Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
264
- // internal offsets with the new maxHeight. This prevents the sheet from snapping back
265
- // to its old position when the user starts a gesture.
266
- parent.requestLayout()
267
- onSheetYTranslationChanged()
268
- }.start()
269
- }
270
- }
271
-
272
178
  private fun updateSheetContentHeightWithoutAnimation(
273
179
  behavior: BottomSheetBehavior<Screen>,
274
180
  height: Int,
@@ -299,22 +205,6 @@ class Screen(
299
205
  requestLayout()
300
206
  }
301
207
 
302
- private fun resolveClampedHeight(
303
- targetHeight: Int,
304
- currentTranslationY: Float,
305
- ): Int {
306
- val maxAvailableVerticalSpace =
307
- this.fragment
308
- ?.asScreenStackFragment()
309
- ?.sheetDelegate
310
- ?.tryResolveMaxFormSheetHeight() ?: return targetHeight
311
-
312
- // Please note that currentTranslationY is rather < 0 here.
313
- // The translation is included in constraining the available space, because the FormSheet can have some offset, e.g. to
314
- // avoid the keyboard.
315
- return targetHeight.coerceAtMost((maxAvailableVerticalSpace + currentTranslationY).toInt())
316
- }
317
-
318
208
  fun registerLayoutCallbackForWrapper(wrapper: ScreenContentWrapper) {
319
209
  wrapper.delegate = this
320
210
  }
@@ -37,6 +37,7 @@ import com.swmansion.rnscreens.events.ScreenDismissedEvent
37
37
  import com.swmansion.rnscreens.ext.recycle
38
38
  import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
39
39
  import com.swmansion.rnscreens.utils.DeviceUtils
40
+ import com.swmansion.rnscreens.utils.RNSLog
40
41
  import com.swmansion.rnscreens.utils.resolveBackgroundColor
41
42
  import kotlin.math.max
42
43
 
@@ -128,9 +129,14 @@ class ScreenStackFragment :
128
129
 
129
130
  override fun setToolbarTranslucent(translucent: Boolean) {
130
131
  if (isToolbarTranslucent != translucent) {
131
- val params = screen.layoutParams
132
- (params as CoordinatorLayout.LayoutParams).behavior =
133
- if (translucent) null else ScrollingViewBehavior()
132
+ // FormSheet is using BottomSheetBehavior which shouldn't be overwritten.
133
+ if (!screen.usesFormSheetPresentation()) {
134
+ val params = screen.layoutParams
135
+ (params as CoordinatorLayout.LayoutParams).behavior =
136
+ if (translucent) null else ScrollingViewBehavior()
137
+ } else {
138
+ RNSLog.w(TAG, "Skipping behavior update: Cannot override BottomSheetBehavior on a FormSheet.")
139
+ }
134
140
  isToolbarTranslucent = translucent
135
141
  }
136
142
  }
@@ -270,6 +276,14 @@ class ScreenStackFragment :
270
276
  object : WindowInsetsAnimationCompat.Callback(
271
277
  WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
272
278
  ) {
279
+ override fun onPrepare(animation: WindowInsetsAnimationCompat) {
280
+ super.onPrepare(animation)
281
+
282
+ if ((animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
283
+ sheetDelegate.notifyKeyboardAnimationStart()
284
+ }
285
+ }
286
+
273
287
  // Replace InsetsAnimationCallback created by BottomSheetBehavior
274
288
  // to avoid interfering with custom animations.
275
289
  // See: https://github.com/software-mansion/react-native-screens/pull/2909
@@ -288,6 +302,10 @@ class ScreenStackFragment :
288
302
  override fun onEnd(animation: WindowInsetsAnimationCompat) {
289
303
  super.onEnd(animation)
290
304
 
305
+ if ((animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
306
+ sheetDelegate.notifyKeyboardAnimationEnd()
307
+ }
308
+
291
309
  screen.onSheetYTranslationChanged()
292
310
  }
293
311
  }
@@ -583,4 +601,8 @@ class ScreenStackFragment :
583
601
  }
584
602
  return bottomSheetWindowInsetListenerChain!!
585
603
  }
604
+
605
+ companion object {
606
+ private const val TAG = "ScreenStackFragment"
607
+ }
586
608
  }
@@ -68,20 +68,6 @@ class ScreenStackViewManager :
68
68
  StackFinishTransitioningEvent.EVENT_NAME to mutableMapOf("registrationName" to "onFinishTransitioning"),
69
69
  )
70
70
 
71
- // iosPreventReattachmentOfDismissedScreens is not available on Android,
72
- // however we must override the setter
73
- override fun setIosPreventReattachmentOfDismissedScreens(
74
- view: ScreenStack?,
75
- value: Boolean,
76
- ) = Unit
77
-
78
- // iosPreventReattachmentOfDismissedModals is not available on Android,
79
- // however we must override the setter
80
- override fun setIosPreventReattachmentOfDismissedModals(
81
- view: ScreenStack?,
82
- value: Boolean,
83
- ) = Unit
84
-
85
71
  // nativeContainerBackgroundColor is iOS-only because the native view hierarchy
86
72
  // differs between platforms. On Android, ScreenStack is used directly as the
87
73
  // container, so `style.backgroundColor` achieves the same effect.
@@ -323,11 +323,6 @@ open class ScreenViewManager :
323
323
  value: Boolean,
324
324
  ) = Unit
325
325
 
326
- override fun setIos26AllowInteractionsDuringTransition(
327
- view: Screen?,
328
- value: Boolean,
329
- ) = Unit
330
-
331
326
  // END mark: iOS-only
332
327
 
333
328
  override fun setAndroidResetScreenShadowStateOnOrientationChangeEnabled(
@@ -0,0 +1,364 @@
1
+ package com.swmansion.rnscreens.bottomsheet
2
+
3
+ import android.animation.Animator
4
+ import android.animation.AnimatorListenerAdapter
5
+ import android.animation.AnimatorSet
6
+ import android.animation.ValueAnimator
7
+ import androidx.coordinatorlayout.widget.CoordinatorLayout
8
+ import androidx.core.view.WindowInsetsCompat
9
+ import com.google.android.material.bottomsheet.BottomSheetBehavior
10
+ import com.swmansion.rnscreens.Screen
11
+ import com.swmansion.rnscreens.ScreenStackFragment
12
+ import com.swmansion.rnscreens.events.ScreenAnimationDelegate
13
+ import com.swmansion.rnscreens.events.ScreenEventEmitter
14
+ import com.swmansion.rnscreens.ext.asScreenStackFragment
15
+ import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
16
+ import java.lang.ref.WeakReference
17
+
18
+ internal class SheetAnimationCoordinator(
19
+ screen: Screen,
20
+ ) {
21
+ private val screenRef = WeakReference(screen)
22
+ private val screen: Screen get() =
23
+ checkNotNull(screenRef.get()) {
24
+ "[RNScreens] Screen has been destroyed and shouldn't be the subject of any animations"
25
+ }
26
+ private var activeKeyboardAnimationsCount: Int = 0
27
+ private val isKeyboardAnimationInProgress: Boolean
28
+ get() = activeKeyboardAnimationsCount > 0
29
+ private var isSheetAnimationInProgress: Boolean = false
30
+ private var currentContentAnimator: ValueAnimator? = null
31
+
32
+ private var lastKeyboardBottomOffset: Int = 0
33
+
34
+ internal fun createSheetEnterAnimator(sheetAnimationContext: SheetDelegate.SheetAnimationContext): Animator {
35
+ val animatorSet = AnimatorSet()
36
+
37
+ val dimmingDelegate = sheetAnimationContext.dimmingDelegate
38
+ val screenStackFragment = sheetAnimationContext.fragment
39
+
40
+ val alphaAnimator = createDimmingViewAlphaAnimator(0f, dimmingDelegate.maxAlpha, dimmingDelegate)
41
+ val slideAnimator = createSheetSlideInAnimator()
42
+
43
+ animatorSet
44
+ .play(slideAnimator)
45
+ .takeIf {
46
+ dimmingDelegate.willDimForDetentIndex(screen, screen.sheetInitialDetentIndex)
47
+ }?.with(alphaAnimator)
48
+
49
+ attachCommonListeners(animatorSet, isEnter = true, screenStackFragment)
50
+
51
+ return animatorSet
52
+ }
53
+
54
+ internal fun createSheetExitAnimator(sheetAnimationContext: SheetDelegate.SheetAnimationContext): Animator {
55
+ val animatorSet = AnimatorSet()
56
+
57
+ val coordinatorLayout = sheetAnimationContext.coordinatorLayout
58
+ val dimmingDelegate = sheetAnimationContext.dimmingDelegate
59
+ val screenStackFragment = sheetAnimationContext.fragment
60
+
61
+ val alphaAnimator =
62
+ createDimmingViewAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f, dimmingDelegate)
63
+ val slideAnimator = createSheetSlideOutAnimator(coordinatorLayout)
64
+
65
+ animatorSet.play(alphaAnimator).with(slideAnimator)
66
+
67
+ attachCommonListeners(animatorSet, isEnter = false, screenStackFragment)
68
+
69
+ return animatorSet
70
+ }
71
+
72
+ /**
73
+ * This should be used only with sheet in `fitToContents` mode.
74
+ *
75
+ * If an entry/exit animation is already in progress, we silently update the
76
+ * behavior metrics and view layout without starting a competing translationY
77
+ * animation - this is the fix for the race condition between the slide-in
78
+ * ValueAnimator and externally triggered layout pass (e.g. applying padding from SAV insets).
79
+ */
80
+ internal fun updateSheetContentHeightWithAnimation(
81
+ behavior: BottomSheetBehavior<Screen>,
82
+ oldHeight: Int,
83
+ newHeight: Int,
84
+ ) {
85
+ val currentTranslationY = screen.translationY
86
+
87
+ /*
88
+ * WHY OVERFLOW MATTERS:
89
+ * BottomSheetBehavior has a physical limit (maxHeight) defined by the parent container.
90
+ * If the new content height exceeds this limit (by its size or keyboard offset), simply
91
+ * animating translationY back to 'currentTranslationY' would attempt to render the sheet
92
+ * larger than the screen.
93
+ *
94
+ * We need to constrain the height within the container's bounds.
95
+ * By including this overflow to our animation, we ensure the sheet stops
96
+ * expanding exactly at the maxHeight, preventing from being pushed
97
+ * off-screen or causing layout synchronization issues with the CoordinatorLayout.
98
+ */
99
+ val clampedOldHeight = screen.resolveClampedHeight(oldHeight, currentTranslationY)
100
+ val clampedNewHeight = screen.resolveClampedHeight(newHeight, currentTranslationY)
101
+
102
+ // If an entry/exit animation or a keyboard animation is in progress - it owns
103
+ // translationY writes. Then when the content size is changing, we silently
104
+ // update behavior metrics and re-layout so the ongoing slide animation
105
+ // lands at the correct final geometry, without firing a competing animation.
106
+ if (isSheetAnimationInProgress || isKeyboardAnimationInProgress) {
107
+ behavior.updateMetrics(clampedNewHeight)
108
+ screen.layoutBottomSheetAtHeight(clampedNewHeight)
109
+ screen.finalizeBottomSheetLayoutUpdates()
110
+ return
111
+ }
112
+
113
+ val visibleDelta = (clampedNewHeight - clampedOldHeight).toFloat()
114
+ if (visibleDelta == 0f) return
115
+
116
+ if (visibleDelta > 0) {
117
+ animateContentExpanding(behavior, clampedNewHeight, currentTranslationY, visibleDelta)
118
+ } else {
119
+ animateContentShrinking(behavior, clampedNewHeight, currentTranslationY, visibleDelta)
120
+ }
121
+ }
122
+
123
+ /*
124
+ * Expanding content animation:
125
+ *
126
+ * Before animation, we're updating the SheetBehavior - the maximum height is the new
127
+ * content height, then we're forcing a layout pass. This ensures the view calculates
128
+ * with its new bounds when the animation starts.
129
+ *
130
+ * In the animation, we're translating the Screen back to its (newly calculated) origin
131
+ * position, providing an impression that FormSheet expands. It already has the final size,
132
+ * but some content is not yet visible on the screen.
133
+ *
134
+ * After animation, we just need to send a notification that ShadowTree state should be updated,
135
+ * as the positioning of pressables has changed due to the Y translation manipulation.
136
+ */
137
+ private fun animateContentExpanding(
138
+ behavior: BottomSheetBehavior<Screen>,
139
+ clampedNewHeight: Int,
140
+ currentTranslationY: Float,
141
+ visibleDelta: Float,
142
+ ) {
143
+ screen.translationY += visibleDelta
144
+ cancelCurrentContentAnimation()
145
+ currentContentAnimator =
146
+ ValueAnimator.ofFloat(screen.translationY, currentTranslationY).apply {
147
+ addListener(
148
+ object : AnimatorListenerAdapter() {
149
+ override fun onAnimationStart(animation: Animator) {
150
+ behavior.updateMetrics(clampedNewHeight)
151
+ screen.layoutBottomSheetAtHeight(clampedNewHeight)
152
+ }
153
+
154
+ override fun onAnimationEnd(animation: Animator) {
155
+ currentContentAnimator = null
156
+ screen.finalizeBottomSheetLayoutUpdates()
157
+ }
158
+ },
159
+ )
160
+ addUpdateListener { screen.translationY = it.animatedValue as Float }
161
+ start()
162
+ }
163
+ }
164
+
165
+ /*
166
+ * Shrinking content animation:
167
+ *
168
+ * Before the animation, our Screen translationY is 0 - because its actual layout and visual position are equal.
169
+ *
170
+ * Before the animation, I'm updating sheet metrics to the target value - it won't update until the next layout pass,
171
+ * which is controlled by end action. This is done deliberately, to allow catching the case when quick combination
172
+ * of shrink & expand animation is detected.
173
+ *
174
+ * In the animation, we're translating the Screen down by the calculated height delta to the position (which will
175
+ * be new absolute 0 for the Screen, after ending the transition), providing an impression that FormSheet shrinks.
176
+ * FormSheet's size remains unchanged during the whole animation, therefore there is no view clipping.
177
+ *
178
+ * After animation, we can update the layout: the maximum FormSheet height is updated and we're forcing
179
+ * another layout pass. Additionally, since the actual layout and the target position are equal,
180
+ * we can reset translationY to 0.
181
+ *
182
+ * After animation, we need to send a notification that ShadowTree state should be updated,
183
+ * as the FormSheet size has changed and the positioning of pressables has changed due to the Y translation manipulation.
184
+ */
185
+ private fun animateContentShrinking(
186
+ behavior: BottomSheetBehavior<Screen>,
187
+ clampedNewHeight: Int,
188
+ currentTranslationY: Float,
189
+ visibleDelta: Float,
190
+ ) {
191
+ val targetTranslationY = currentTranslationY - visibleDelta
192
+ cancelCurrentContentAnimation()
193
+ currentContentAnimator =
194
+ ValueAnimator.ofFloat(currentTranslationY, targetTranslationY).apply {
195
+ addListener(
196
+ object : AnimatorListenerAdapter() {
197
+ override fun onAnimationStart(animation: Animator) {
198
+ behavior.updateMetrics(clampedNewHeight)
199
+ }
200
+
201
+ override fun onAnimationEnd(animation: Animator) {
202
+ currentContentAnimator = null
203
+ screen.layoutBottomSheetAtHeight(clampedNewHeight)
204
+ screen.translationY = currentTranslationY
205
+ screen.finalizeBottomSheetLayoutUpdates()
206
+ }
207
+ },
208
+ )
209
+ addUpdateListener { screen.translationY = it.animatedValue as Float }
210
+ start()
211
+ }
212
+ }
213
+
214
+ internal fun notifyKeyboardAnimationStart() {
215
+ activeKeyboardAnimationsCount++
216
+ }
217
+
218
+ internal fun notifyKeyboardAnimationEnd() {
219
+ activeKeyboardAnimationsCount = maxOf(0, activeKeyboardAnimationsCount - 1)
220
+ }
221
+
222
+ internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) {
223
+ lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
224
+ // Prioritize enter/exit animations over direct keyboard inset reactions.
225
+ // We store the latest keyboard offset in `lastKeyboardBottomOffset`
226
+ // so that it can always be respected when applying translations in `updateSheetTranslationY`.
227
+ //
228
+ // This approach allows screen translation to be triggered from two sources, but without messing them together:
229
+ // - During enter/exit animations, while accounting for the keyboard height.
230
+ // - While interacting with a TextInput inside the bottom sheet, to handle keyboard show/hide events.
231
+ if (!isSheetAnimationInProgress) {
232
+ updateSheetTranslationY(0f)
233
+ }
234
+ }
235
+
236
+ // This function calculates the Y offset to which the FormSheet should animate
237
+ // when appearing (entering) or disappearing (exiting) with the on-screen keyboard (IME) present.
238
+ // Its purpose is to ensure the FormSheet does not exceed the top edge of the screen.
239
+ // It tries to display the FormSheet fully above the keyboard when there's enough space.
240
+ // Otherwise, it shifts the sheet as high as possible, even if it means part of its content
241
+ // will remain hidden behind the keyboard.
242
+ private fun computeSheetOffsetYWithIMEPresent(keyboardHeight: Int): Int {
243
+ val containerHeight =
244
+ screen.fragment
245
+ ?.asScreenStackFragment()
246
+ ?.sheetDelegate
247
+ ?.tryResolveMaxFormSheetHeight()
248
+ check(containerHeight != null) {
249
+ "[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
250
+ }
251
+
252
+ if (screen.isSheetFitToContents()) {
253
+ val contentHeight = screen.contentWrapper?.height ?: 0
254
+ val offsetFromTop = maxOf(containerHeight - contentHeight, 0)
255
+ // If the content is higher than the Screen, offsetFromTop becomes negative.
256
+ // In such cases, we return 0 because a negative translation would shift the Screen
257
+ // to the bottom, which is not intended.
258
+ return minOf(offsetFromTop, keyboardHeight)
259
+ }
260
+
261
+ val detents = screen.sheetDetents
262
+
263
+ val detentValue = detents.highest().coerceIn(0.0, 1.0)
264
+ val sheetHeight = (detentValue * containerHeight).toInt()
265
+ val offsetFromTop = containerHeight - sheetHeight
266
+
267
+ return minOf(offsetFromTop, keyboardHeight)
268
+ }
269
+
270
+ private fun updateSheetTranslationY(baseTranslationY: Float) {
271
+ val keyboardCorrection = lastKeyboardBottomOffset
272
+ val bottomOffset = computeSheetOffsetYWithIMEPresent(keyboardCorrection).toFloat()
273
+
274
+ screen.translationY = baseTranslationY - bottomOffset
275
+ }
276
+
277
+ private fun createSheetSlideInAnimator(): ValueAnimator {
278
+ val startValueCallback = { _: Number? -> screen.height.toFloat() }
279
+ val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })
280
+
281
+ return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
282
+ addUpdateListener { updateSheetTranslationY(it.animatedValue as Float) }
283
+ }
284
+ }
285
+
286
+ private fun createSheetSlideOutAnimator(coordinatorLayout: CoordinatorLayout): ValueAnimator {
287
+ val endValue = (coordinatorLayout.bottom - screen.top - screen.translationY)
288
+
289
+ return ValueAnimator.ofFloat(0f, endValue).apply {
290
+ addUpdateListener {
291
+ updateSheetTranslationY(it.animatedValue as Float)
292
+ }
293
+ }
294
+ }
295
+
296
+ private fun createDimmingViewAlphaAnimator(
297
+ from: Float,
298
+ to: Float,
299
+ dimmingDelegate: DimmingViewManager,
300
+ ): ValueAnimator =
301
+ ValueAnimator.ofFloat(from, to).apply {
302
+ addUpdateListener { animator ->
303
+ (animator.animatedValue as? Float)?.let {
304
+ dimmingDelegate.dimmingView.alpha = it
305
+ }
306
+ }
307
+ }
308
+
309
+ private fun cancelCurrentContentAnimation() {
310
+ currentContentAnimator?.removeAllListeners()
311
+ currentContentAnimator?.removeAllUpdateListeners()
312
+ currentContentAnimator?.cancel()
313
+ currentContentAnimator = null
314
+ }
315
+
316
+ private fun Screen.layoutBottomSheetAtHeight(height: Int) = layout(left, bottom - height, right, bottom)
317
+
318
+ private fun Screen.finalizeBottomSheetLayoutUpdates() {
319
+ // Force a layout pass on the CoordinatorLayout to synchronize BottomSheetBehavior's
320
+ // internal offsets with the new maxHeight. This prevents the sheet from snapping back
321
+ // to its old position when the user starts a gesture.
322
+ parent.requestLayout()
323
+
324
+ // Notify that ShadowTree state should be updated, as the positioning of pressables
325
+ // has changed due to the Y translation manipulation.
326
+ onSheetYTranslationChanged()
327
+ }
328
+
329
+ private fun attachCommonListeners(
330
+ animatorSet: AnimatorSet,
331
+ isEnter: Boolean,
332
+ screenStackFragment: ScreenStackFragment,
333
+ ) {
334
+ animatorSet.addListener(
335
+ ScreenAnimationDelegate(
336
+ screenStackFragment,
337
+ ScreenEventEmitter(screen),
338
+ if (isEnter) {
339
+ ScreenAnimationDelegate.AnimationType.ENTER
340
+ } else {
341
+ ScreenAnimationDelegate.AnimationType.EXIT
342
+ },
343
+ ),
344
+ )
345
+
346
+ animatorSet.addListener(
347
+ object : AnimatorListenerAdapter() {
348
+ override fun onAnimationStart(animation: Animator) {
349
+ isSheetAnimationInProgress = true
350
+ }
351
+
352
+ override fun onAnimationCancel(animation: Animator) {
353
+ isSheetAnimationInProgress = false
354
+ }
355
+
356
+ override fun onAnimationEnd(animation: Animator) {
357
+ isSheetAnimationInProgress = false
358
+
359
+ screen.onSheetYTranslationChanged()
360
+ }
361
+ },
362
+ )
363
+ }
364
+ }