react-native-screens 4.25.0 → 4.25.1

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.
@@ -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
  }
@@ -270,6 +270,14 @@ class ScreenStackFragment :
270
270
  object : WindowInsetsAnimationCompat.Callback(
271
271
  WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
272
272
  ) {
273
+ override fun onPrepare(animation: WindowInsetsAnimationCompat) {
274
+ super.onPrepare(animation)
275
+
276
+ if ((animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
277
+ sheetDelegate.notifyKeyboardAnimationStart()
278
+ }
279
+ }
280
+
273
281
  // Replace InsetsAnimationCallback created by BottomSheetBehavior
274
282
  // to avoid interfering with custom animations.
275
283
  // See: https://github.com/software-mansion/react-native-screens/pull/2909
@@ -288,6 +296,10 @@ class ScreenStackFragment :
288
296
  override fun onEnd(animation: WindowInsetsAnimationCompat) {
289
297
  super.onEnd(animation)
290
298
 
299
+ if ((animation.typeMask and WindowInsetsCompat.Type.ime()) != 0) {
300
+ sheetDelegate.notifyKeyboardAnimationEnd()
301
+ }
302
+
291
303
  screen.onSheetYTranslationChanged()
292
304
  }
293
305
  }
@@ -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
+ }
@@ -1,9 +1,6 @@
1
1
  package com.swmansion.rnscreens.bottomsheet
2
2
 
3
3
  import android.animation.Animator
4
- import android.animation.AnimatorListenerAdapter
5
- import android.animation.AnimatorSet
6
- import android.animation.ValueAnimator
7
4
  import android.content.Context
8
5
  import android.os.Build
9
6
  import android.view.View
@@ -24,9 +21,6 @@ import com.swmansion.rnscreens.KeyboardState
24
21
  import com.swmansion.rnscreens.KeyboardVisible
25
22
  import com.swmansion.rnscreens.Screen
26
23
  import com.swmansion.rnscreens.ScreenStackFragment
27
- import com.swmansion.rnscreens.events.ScreenAnimationDelegate
28
- import com.swmansion.rnscreens.events.ScreenEventEmitter
29
- import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
30
24
  import com.swmansion.rnscreens.utils.isSoftKeyboardVisibleOrNull
31
25
 
32
26
  class SheetDelegate(
@@ -36,10 +30,7 @@ class SheetDelegate(
36
30
  private var isKeyboardVisible: Boolean = false
37
31
  private var keyboardState: KeyboardState = KeyboardNotVisible
38
32
 
39
- private var isSheetAnimationInProgress: Boolean = false
40
-
41
33
  private var lastTopInset: Int = 0
42
- private var lastKeyboardBottomOffset: Int = 0
43
34
 
44
35
  var lastStableDetentIndex: Int = screen.sheetInitialDetentIndex
45
36
  private set
@@ -348,36 +339,6 @@ class SheetDelegate(
348
339
  }
349
340
  }
350
341
 
351
- // This function calculates the Y offset to which the FormSheet should animate
352
- // when appearing (entering) or disappearing (exiting) with the on-screen keyboard (IME) present.
353
- // Its purpose is to ensure the FormSheet does not exceed the top edge of the screen.
354
- // It tries to display the FormSheet fully above the keyboard when there's enough space.
355
- // Otherwise, it shifts the sheet as high as possible, even if it means part of its content
356
- // will remain hidden behind the keyboard.
357
- internal fun computeSheetOffsetYWithIMEPresent(keyboardHeight: Int): Int {
358
- val containerHeight = tryResolveMaxFormSheetHeight()
359
- check(containerHeight != null) {
360
- "[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
361
- }
362
-
363
- if (screen.isSheetFitToContents()) {
364
- val contentHeight = screen.contentWrapper?.height ?: 0
365
- val offsetFromTop = maxOf(containerHeight - contentHeight, 0)
366
- // If the content is higher than the Screen, offsetFromTop becomes negative.
367
- // In such cases, we return 0 because a negative translation would shift the Screen
368
- // to the bottom, which is not intended.
369
- return minOf(offsetFromTop, keyboardHeight)
370
- }
371
-
372
- val detents = screen.sheetDetents
373
-
374
- val detentValue = detents.highest().coerceIn(0.0, 1.0)
375
- val sheetHeight = (detentValue * containerHeight).toInt()
376
- val offsetFromTop = containerHeight - sheetHeight
377
-
378
- return minOf(offsetFromTop, keyboardHeight)
379
- }
380
-
381
342
  // This is listener function, not the view's.
382
343
  override fun onApplyWindowInsets(
383
344
  v: View,
@@ -473,128 +434,18 @@ class SheetDelegate(
473
434
 
474
435
  // Sheet entering/exiting animations
475
436
 
476
- internal fun createSheetEnterAnimator(sheetAnimationContext: SheetAnimationContext): Animator {
477
- val animatorSet = AnimatorSet()
478
-
479
- val dimmingDelegate = sheetAnimationContext.dimmingDelegate
480
- val screenStackFragment = sheetAnimationContext.fragment
481
-
482
- val alphaAnimator = createDimmingViewAlphaAnimator(0f, dimmingDelegate.maxAlpha, dimmingDelegate)
483
- val slideAnimator = createSheetSlideInAnimator()
484
-
485
- animatorSet
486
- .play(slideAnimator)
487
- .takeIf {
488
- dimmingDelegate.willDimForDetentIndex(screen, screen.sheetInitialDetentIndex)
489
- }?.with(alphaAnimator)
490
-
491
- attachCommonListeners(animatorSet, isEnter = true, screenStackFragment)
492
-
493
- return animatorSet
494
- }
495
-
496
- internal fun createSheetExitAnimator(sheetAnimationContext: SheetAnimationContext): Animator {
497
- val animatorSet = AnimatorSet()
498
-
499
- val coordinatorLayout = sheetAnimationContext.coordinatorLayout
500
- val dimmingDelegate = sheetAnimationContext.dimmingDelegate
501
- val screenStackFragment = sheetAnimationContext.fragment
502
-
503
- val alphaAnimator =
504
- createDimmingViewAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f, dimmingDelegate)
505
- val slideAnimator = createSheetSlideOutAnimator(coordinatorLayout)
506
-
507
- animatorSet.play(alphaAnimator).with(slideAnimator)
508
-
509
- attachCommonListeners(animatorSet, isEnter = false, screenStackFragment)
510
-
511
- return animatorSet
512
- }
513
-
514
- private fun createDimmingViewAlphaAnimator(
515
- from: Float,
516
- to: Float,
517
- dimmingDelegate: DimmingViewManager,
518
- ): ValueAnimator =
519
- ValueAnimator.ofFloat(from, to).apply {
520
- addUpdateListener { animator ->
521
- (animator.animatedValue as? Float)?.let {
522
- dimmingDelegate.dimmingView.alpha = it
523
- }
524
- }
525
- }
437
+ internal fun createSheetEnterAnimator(sheetAnimationContext: SheetAnimationContext): Animator =
438
+ screen.sheetAnimationCoordinator.createSheetEnterAnimator(sheetAnimationContext)
526
439
 
527
- private fun createSheetSlideInAnimator(): ValueAnimator {
528
- val startValueCallback = { _: Number? -> screen.height.toFloat() }
529
- val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })
530
-
531
- return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
532
- addUpdateListener { updateSheetTranslationY(it.animatedValue as Float) }
533
- }
534
- }
440
+ internal fun createSheetExitAnimator(sheetAnimationContext: SheetAnimationContext): Animator =
441
+ screen.sheetAnimationCoordinator.createSheetExitAnimator(sheetAnimationContext)
535
442
 
536
- private fun createSheetSlideOutAnimator(coordinatorLayout: CoordinatorLayout): ValueAnimator {
537
- val endValue = (coordinatorLayout.bottom - screen.top - screen.translationY)
443
+ internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) =
444
+ screen.sheetAnimationCoordinator.handleKeyboardInsetsProgress(insets)
538
445
 
539
- return ValueAnimator.ofFloat(0f, endValue).apply {
540
- addUpdateListener {
541
- updateSheetTranslationY(it.animatedValue as Float)
542
- }
543
- }
544
- }
545
-
546
- private fun updateSheetTranslationY(baseTranslationY: Float) {
547
- val keyboardCorrection = lastKeyboardBottomOffset
548
- val bottomOffset = computeSheetOffsetYWithIMEPresent(keyboardCorrection).toFloat()
549
-
550
- screen.translationY = baseTranslationY - bottomOffset
551
- }
446
+ internal fun notifyKeyboardAnimationStart() = screen.sheetAnimationCoordinator.notifyKeyboardAnimationStart()
552
447
 
553
- internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) {
554
- lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
555
- // Prioritize enter/exit animations over direct keyboard inset reactions.
556
- // We store the latest keyboard offset in `lastKeyboardBottomOffset`
557
- // so that it can always be respected when applying translations in `updateSheetTranslationY`.
558
- //
559
- // This approach allows screen translation to be triggered from two sources, but without messing them together:
560
- // - During enter/exit animations, while accounting for the keyboard height.
561
- // - While interacting with a TextInput inside the bottom sheet, to handle keyboard show/hide events.
562
- if (!isSheetAnimationInProgress) {
563
- updateSheetTranslationY(0f)
564
- }
565
- }
566
-
567
- private fun attachCommonListeners(
568
- animatorSet: AnimatorSet,
569
- isEnter: Boolean,
570
- screenStackFragment: ScreenStackFragment,
571
- ) {
572
- animatorSet.addListener(
573
- ScreenAnimationDelegate(
574
- screenStackFragment,
575
- ScreenEventEmitter(screen),
576
- if (isEnter) {
577
- ScreenAnimationDelegate.AnimationType.ENTER
578
- } else {
579
- ScreenAnimationDelegate.AnimationType.EXIT
580
- },
581
- ),
582
- )
583
-
584
- animatorSet.addListener(
585
- object : AnimatorListenerAdapter() {
586
- override fun onAnimationStart(animation: Animator) {
587
- isSheetAnimationInProgress = true
588
- }
589
-
590
- override fun onAnimationEnd(animation: Animator) {
591
- isSheetAnimationInProgress = false
592
-
593
- screen.onSheetYTranslationChanged()
594
- }
595
- },
596
- )
597
- }
448
+ internal fun notifyKeyboardAnimationEnd() = screen.sheetAnimationCoordinator.notifyKeyboardAnimationEnd()
598
449
 
599
450
  private inner class KeyboardHandler : BottomSheetBehavior.BottomSheetCallback() {
600
451
  override fun onStateChanged(
@@ -158,3 +158,19 @@ fun Screen.sheetShouldUseDimmingView(): Boolean {
158
158
  * is reattached to container.
159
159
  */
160
160
  fun View.isLaidOutOrHasCachedLayout() = this.isLaidOut || height > 0 || width > 0
161
+
162
+ internal fun Screen.resolveClampedHeight(
163
+ targetHeight: Int,
164
+ currentTranslationY: Float,
165
+ ): Int {
166
+ val maxAvailableVerticalSpace =
167
+ fragment
168
+ ?.asScreenStackFragment()
169
+ ?.sheetDelegate
170
+ ?.tryResolveMaxFormSheetHeight() ?: return targetHeight
171
+
172
+ // Please note that currentTranslationY is rather < 0 here.
173
+ // The translation is included in constraining the available space, because the FormSheet can have some offset, e.g. to
174
+ // avoid the keyboard.
175
+ return targetHeight.coerceAtMost((maxAvailableVerticalSpace + currentTranslationY).toInt())
176
+ }
@@ -265,6 +265,19 @@ class TabsContainer internal constructor(
265
265
 
266
266
  super.onAttachedToWindow()
267
267
  setupFragmentManager()
268
+
269
+ // When TabsContainer is reattached to window, it might find new fragment manager (other
270
+ // than previous instance, e.g. in Stack v4 when screen is pushed & popped over screen with
271
+ // Tabs). In such case, we need to re-add currently selected tab screen fragment. As there
272
+ // might be another operation pending, we need to make sure that the state is restored
273
+ // before flushPendingUpdates is called. That's why inside restoreNavigationStateIfNeeded
274
+ // we're committing the transaction synchronously. This might lead to a crash if another
275
+ // transaction is currently being committed. If this happens to be problematic, we might need
276
+ // to reevaluate our approach. See #4035.
277
+ if (navState.isNotEmpty()) {
278
+ restoreNavigationStateIfNeeded()
279
+ }
280
+
268
281
  flushPendingUpdates()
269
282
 
270
283
  colorSchemeCoordinator.setup(this) { uiNightMode ->
@@ -275,6 +288,7 @@ class TabsContainer internal constructor(
275
288
  override fun onDetachedFromWindow() {
276
289
  super.onDetachedFromWindow()
277
290
  teardownFragmentManager()
291
+ colorSchemeCoordinator.teardown()
278
292
  }
279
293
 
280
294
  override fun onConfigurationChanged(newConfig: Configuration?) {
@@ -558,6 +572,36 @@ class TabsContainer internal constructor(
558
572
  return true
559
573
  }
560
574
 
575
+ /**
576
+ * When Tabs are reattached to window, they might find new fragment manager. In this case we
577
+ * need to restore navigation state. We're committing the transaction synchronously so that any
578
+ * following operations have valid restored state before their execution.
579
+ *
580
+ * This function is a no-op if navigation state is empty.
581
+ */
582
+ private fun restoreNavigationStateIfNeeded() {
583
+ if (navState.isEmpty()) {
584
+ return
585
+ }
586
+
587
+ val currentFragments =
588
+ requireFragmentManager.fragments
589
+ .filterIsInstance<TabsScreenFragment>()
590
+ .filter { it in tabsModel }
591
+ .toList()
592
+
593
+ if (currentFragments.size == 1 && currentFragments[0] === selectedTab) {
594
+ return
595
+ } else if (currentFragments.isEmpty()) {
596
+ requireFragmentManager
597
+ .createTransactionWithReordering()
598
+ .add(contentView.id, selectedTab)
599
+ .commitNowAllowingStateLoss()
600
+ } else {
601
+ error("[RNScreens] Unexpected fragment manager state.")
602
+ }
603
+ }
604
+
561
605
  private fun applyDayNightUiMode(uiMode: Int) {
562
606
  // update the appearance when user toggles between dark/light mode
563
607
  when (uiMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-screens",
3
- "version": "4.25.0",
3
+ "version": "4.25.1",
4
4
  "description": "Native navigation primitives for your React Native app.",
5
5
  "scripts": {
6
6
  "submodules": "git submodule update --init --recursive && (cd react-navigation && yarn && yarn build && cd ../)",