react-native-screens 4.25.0 → 4.25.2

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
  }
@@ -358,7 +248,7 @@ class Screen(
358
248
  }
359
249
 
360
250
  if (coordinatorLayoutDidChange) {
361
- updateShadowNodeScreenSize(width, height, top)
251
+ updateShadowNodeScreenSize(width, height, top + translationY.toInt())
362
252
  }
363
253
 
364
254
  footer?.onParentLayout(coordinatorLayoutDidChange, left, top, right, bottom, container!!.height)
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ package com.swmansion.rnscreens.gamma.tabs.container
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import com.google.android.material.bottomnavigation.BottomNavigationView
6
+
7
+ @SuppressLint("ViewConstructor") // Should not be restored & should only be constructed by us.
8
+ class CustomBottomNavigationView(
9
+ context: Context,
10
+ val container: TabsContainer,
11
+ ) : BottomNavigationView(context) {
12
+ private var actionOrigin: TabsActionOrigin? = null
13
+
14
+ internal fun setSelectedItemIdWithActionOrigin(
15
+ itemId: Int,
16
+ actionOrigin: TabsActionOrigin,
17
+ ) {
18
+ require(actionOrigin !== TabsActionOrigin.USER) {
19
+ "[RNScreens] User-triggered actions should be processed via regular setSelectedItemId callback"
20
+ }
21
+ this.actionOrigin = actionOrigin
22
+ selectedItemId = itemId
23
+ this.actionOrigin = null
24
+ }
25
+
26
+ override fun setSelectedItemId(itemId: Int) {
27
+ if (this.actionOrigin == null) {
28
+ this.actionOrigin = TabsActionOrigin.USER
29
+ }
30
+
31
+ val actionOrigin = checkNotNull(this.actionOrigin)
32
+ super.setSelectedItemId(itemId)
33
+ container.onAfterSetSelectedItemId(itemId, actionOrigin)
34
+
35
+ this.actionOrigin = null
36
+ }
37
+ }
@@ -109,8 +109,8 @@ class TabsContainer internal constructor(
109
109
  R.style.Theme_Material3_DayNight_NoActionBar,
110
110
  )
111
111
 
112
- internal val bottomNavigationView: BottomNavigationView =
113
- BottomNavigationView(themedContext).apply {
112
+ internal val bottomNavigationView: CustomBottomNavigationView =
113
+ CustomBottomNavigationView(themedContext, this).apply {
114
114
  layoutParams =
115
115
  LayoutParams(
116
116
  LayoutParams.MATCH_PARENT,
@@ -256,6 +256,16 @@ class TabsContainer internal constructor(
256
256
  setPendingNavigationStateUpdate(null)
257
257
  }
258
258
 
259
+ internal fun onAfterSetSelectedItemId(
260
+ itemId: Int,
261
+ actionOrigin: TabsActionOrigin,
262
+ ) {
263
+ if (actionOrigin === TabsActionOrigin.USER) {
264
+ // For non-user actions these will be performed in [performContainerUpdate]
265
+ performPostSelectedTabUpdateActions()
266
+ }
267
+ }
268
+
259
269
  // endregion
260
270
 
261
271
  // region View lifecycle / insets / appearance
@@ -265,6 +275,19 @@ class TabsContainer internal constructor(
265
275
 
266
276
  super.onAttachedToWindow()
267
277
  setupFragmentManager()
278
+
279
+ // When TabsContainer is reattached to window, it might find new fragment manager (other
280
+ // than previous instance, e.g. in Stack v4 when screen is pushed & popped over screen with
281
+ // Tabs). In such case, we need to re-add currently selected tab screen fragment. As there
282
+ // might be another operation pending, we need to make sure that the state is restored
283
+ // before flushPendingUpdates is called. That's why inside restoreNavigationStateIfNeeded
284
+ // we're committing the transaction synchronously. This might lead to a crash if another
285
+ // transaction is currently being committed. If this happens to be problematic, we might need
286
+ // to reevaluate our approach. See #4035.
287
+ if (navState.isNotEmpty()) {
288
+ restoreNavigationStateIfNeeded()
289
+ }
290
+
268
291
  flushPendingUpdates()
269
292
 
270
293
  colorSchemeCoordinator.setup(this) { uiNightMode ->
@@ -275,6 +298,7 @@ class TabsContainer internal constructor(
275
298
  override fun onDetachedFromWindow() {
276
299
  super.onDetachedFromWindow()
277
300
  teardownFragmentManager()
301
+ colorSchemeCoordinator.teardown()
278
302
  }
279
303
 
280
304
  override fun onConfigurationChanged(newConfig: Configuration?) {
@@ -392,27 +416,62 @@ class TabsContainer internal constructor(
392
416
 
393
417
  // region Private helpers
394
418
 
419
+ /**
420
+ * This is where programmatic update flow starts.
421
+ * This includes any JS-triggered action (navigation state update, prop update, etc.)
422
+ * and native programmatic actions - tab change.
423
+ *
424
+ * This method is supposed to perform all the necessary steps, satisfying all invalidation
425
+ * signals in a coordinated manner.
426
+ *
427
+ * The update actions are split into three phases: pre-, selection change, and post-.
428
+ * Pre-selection actions are run only here, in programmatic flow. This is not a hard requirement,
429
+ * it is just not needed now.
430
+ *
431
+ * Post-selection actions are performed here or in parallel flow triggered on user selection.
432
+ *
433
+ * The selected tab update takes place here only for programmatic changes.
434
+ * User triggered changes have separate entry-point.
435
+ */
395
436
  private fun performContainerUpdate() {
437
+ performPreSelectedTabUpdateActions()
438
+ performSelectedTabUpdateIfNeeded()
439
+ performPostSelectedTabUpdateActions()
440
+ }
441
+
442
+ private fun performPreSelectedTabUpdateActions() {
443
+ updateNavigationMenuStructureIfNeeded()
444
+ }
445
+
446
+ private fun performPostSelectedTabUpdateActions() {
447
+ updateBottomNavigationViewAppearanceIfNeeded()
448
+ }
449
+
450
+ private fun updateNavigationMenuStructureIfNeeded() {
396
451
  if (invalidationFlags.isNavigationMenuStructureInvalidated) {
397
452
  invalidationFlags.isNavigationMenuStructureInvalidated = false
398
453
  updateNavigationMenuStructure()
399
454
  }
455
+ }
400
456
 
457
+ private fun performSelectedTabUpdateIfNeeded() {
401
458
  if (invalidationFlags.isSelectedTabInvalidated) {
402
459
  invalidationFlags.isSelectedTabInvalidated = false
403
- performOperation()
460
+ performSelectedTabUpdate()
404
461
  }
462
+ }
405
463
 
464
+ private fun updateBottomNavigationViewAppearanceIfNeeded() {
406
465
  if (invalidationFlags.isNavigationMenuAppearanceInvalidated) {
407
466
  invalidationFlags.isNavigationMenuAppearanceInvalidated = false
408
- this.updateBottomNavigationViewAppearance()
467
+ updateBottomNavigationViewAppearance()
409
468
  a11yCoordinator.setA11yPropertiesToAllTabItems()
410
469
  }
411
470
  }
412
471
 
413
- private fun performOperation() {
472
+ private fun performSelectedTabUpdate() {
414
473
  if (pendingStateUpdateRequest == null) {
415
- RNSLog.w(TAG, "TabsContainer::performOperation called w/o pending operation; skipping update")
474
+ RNSLog.w(TAG, "TabsContainer::performSelectedTabUpdate called w/o pending operation; skipping update")
416
475
  return
417
476
  }
418
477
 
@@ -436,7 +495,7 @@ class TabsContainer internal constructor(
436
495
  if (bottomNavigationView.selectedItemId != nextSelectedMenuItemId || navState.isEmpty()) {
437
496
  isInExternalOperationContext = true
438
497
  // This triggers on OnMenuItemClicked callback, where we perform actual update from
439
- bottomNavigationView.selectedItemId = nextSelectedMenuItemId
498
+ bottomNavigationView.setSelectedItemIdWithActionOrigin(nextSelectedMenuItemId, stateUpdateRequest.actionOrigin)
440
499
  isInExternalOperationContext = false
441
500
  } else {
442
501
  observerRegistry.emitOnNavigationStateUpdateRejected(
@@ -545,6 +604,13 @@ class TabsContainer internal constructor(
545
604
  val hasTriggeredSpecialEffect =
546
605
  if (isRepeated) specialEffectsHandler.handleRepeatedTabSelection() else false
547
606
 
607
+ if (stateChanged && !isRepeated) {
608
+ // If we've effectively changed the tab, we need to raise appropriate flags.
609
+ // This line assumes that any required e.g. appearance actions will be performed
610
+ // synchronously later in the flow.
611
+ invalidationFlags.invalidateOnSelectedTabChanged()
612
+ }
613
+
548
614
  if (stateChanged) {
549
615
  observerRegistry.emitOnNavigationStateUpdate(
550
616
  navState,
@@ -558,6 +624,36 @@ class TabsContainer internal constructor(
558
624
  return true
559
625
  }
560
626
 
627
+ /**
628
+ * When Tabs are reattached to window, they might find new fragment manager. In this case we
629
+ * need to restore navigation state. We're committing the transaction synchronously so that any
630
+ * following operations have valid restored state before their execution.
631
+ *
632
+ * This function is a no-op if navigation state is empty.
633
+ */
634
+ private fun restoreNavigationStateIfNeeded() {
635
+ if (navState.isEmpty()) {
636
+ return
637
+ }
638
+
639
+ val currentFragments =
640
+ requireFragmentManager.fragments
641
+ .filterIsInstance<TabsScreenFragment>()
642
+ .filter { it in tabsModel }
643
+ .toList()
644
+
645
+ if (currentFragments.size == 1 && currentFragments[0] === selectedTab) {
646
+ return
647
+ } else if (currentFragments.isEmpty()) {
648
+ requireFragmentManager
649
+ .createTransactionWithReordering()
650
+ .add(contentView.id, selectedTab)
651
+ .commitNowAllowingStateLoss()
652
+ } else {
653
+ error("[RNScreens] Unexpected fragment manager state.")
654
+ }
655
+ }
656
+
561
657
  private fun applyDayNightUiMode(uiMode: Int) {
562
658
  // update the appearance when user toggles between dark/light mode
563
659
  when (uiMode) {
@@ -650,4 +746,8 @@ internal class TabsContainerInvalidationFlags(
650
746
  isNavigationMenuAppearanceInvalidated = true
651
747
  isNavigationMenuStructureInvalidated = true
652
748
  }
749
+
750
+ internal fun invalidateOnSelectedTabChanged() {
751
+ isNavigationMenuAppearanceInvalidated = true
752
+ }
653
753
  }
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.2",
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 ../)",