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.
- package/android/src/main/java/com/swmansion/rnscreens/Screen.kt +6 -116
- package/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +12 -0
- package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt +364 -0
- package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt +8 -157
- package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetUtils.kt +16 -0
- package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/CustomBottomNavigationView.kt +37 -0
- package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt +107 -7
- package/package.json +1 -1
|
@@ -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
|
}
|
package/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetAnimationCoordinator.kt
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
443
|
+
internal fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) =
|
|
444
|
+
screen.sheetAnimationCoordinator.handleKeyboardInsetsProgress(insets)
|
|
538
445
|
|
|
539
|
-
|
|
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
|
|
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:
|
|
113
|
-
|
|
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
|
-
|
|
460
|
+
performSelectedTabUpdate()
|
|
404
461
|
}
|
|
462
|
+
}
|
|
405
463
|
|
|
464
|
+
private fun updateBottomNavigationViewAppearanceIfNeeded() {
|
|
406
465
|
if (invalidationFlags.isNavigationMenuAppearanceInvalidated) {
|
|
407
466
|
invalidationFlags.isNavigationMenuAppearanceInvalidated = false
|
|
408
|
-
|
|
467
|
+
updateBottomNavigationViewAppearance()
|
|
409
468
|
a11yCoordinator.setA11yPropertiesToAllTabItems()
|
|
410
469
|
}
|
|
411
470
|
}
|
|
412
471
|
|
|
413
|
-
private fun
|
|
472
|
+
private fun performSelectedTabUpdate() {
|
|
414
473
|
if (pendingStateUpdateRequest == null) {
|
|
415
|
-
RNSLog.w(TAG, "TabsContainer::
|
|
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.
|
|
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.
|
|
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 ../)",
|