Skip to content
182 changes: 131 additions & 51 deletions android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package com.swmansion.rnscreens

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
Expand Down Expand Up @@ -63,6 +65,10 @@ class ScreenStackFragment :
var searchView: CustomSearchView? = null
var onSearchViewCreate: ((searchView: CustomSearchView) -> Unit)? = null

private var fadeAnimationRunning = false

private var lastKeyboardBottomOffset: Int? = null

private lateinit var coordinatorLayout: ScreensCoordinatorLayout

private val screenStack: ScreenStack
Expand Down Expand Up @@ -241,20 +247,34 @@ class ScreenStackFragment :
)
coordinatorLayout.layout(0, 0, container.width, container.height)

// Replace InsetsAnimationCallback created by BottomSheetBehavior with empty
// implementation so it does not interfere with our custom formSheet entering animation
// More details: https://github.com/software-mansion/react-native-screens/pull/2909
ViewCompat.setWindowInsetsAnimationCallback(
screen,
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
ViewCompat.setOnApplyWindowInsetsListener(screen) { _, windowInsets ->
handleKeyboardInsetsProgress(windowInsets)
windowInsets
}
}

val insetsAnimationCallback =
object : WindowInsetsAnimationCompat.Callback(
DISPATCH_MODE_STOP,
WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP,
) {
// Replace InsetsAnimationCallback created by BottomSheetBehavior
// to avoid interfering with custom animations.
// See: https://github.com/software-mansion/react-native-screens/pull/2909
override fun onProgress(
insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
): WindowInsetsCompat = insets
},
)
): WindowInsetsCompat {
// On API 30+, we handle keyboard inset animation progress here.
// On lower APIs, we rely on ViewCompat.setOnApplyWindowInsetsListener instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
handleKeyboardInsetsProgress(insets)
}
return insets
}
}

ViewCompat.setWindowInsetsAnimationCallback(screen, insetsAnimationCallback)
}

return coordinatorLayout
Expand Down Expand Up @@ -286,64 +306,124 @@ class ScreenStackFragment :
return null
}

return if (enter) createEnterAnimator() else createExitAnimator()
}

private fun createEnterAnimator(): Animator {
val animatorSet = AnimatorSet()
val dimmingDelegate = requireDimmingDelegate()

if (enter) {
val alphaAnimator =
ValueAnimator.ofFloat(0f, dimmingDelegate.maxAlpha).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { dimmingDelegate.dimmingView.alpha = it }
}
}
val startValueCallback = { initialStartValue: Number? -> screen.height.toFloat() }
val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })
val slideAnimator =
ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { screen.translationY = it }
}
}
val alphaAnimator = createAlphaAnimator(0f, dimmingDelegate.maxAlpha)
val slideAnimator = createSlideInAnimator()

animatorSet
.play(slideAnimator)
.takeIf {
dimmingDelegate.willDimForDetentIndex(
screen,
screen.sheetInitialDetentIndex,
)
}?.with(alphaAnimator)
} else {
val alphaAnimator =
ValueAnimator.ofFloat(dimmingDelegate.dimmingView.alpha, 0f).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { dimmingDelegate.dimmingView.alpha = it }
}
}
val slideAnimator =
ValueAnimator.ofFloat(0f, (coordinatorLayout.bottom - screen.top).toFloat()).apply {
addUpdateListener { anim ->
val animatedValue = anim.animatedValue as? Float
animatedValue?.let { screen.translationY = it }
}
animatorSet
.play(slideAnimator)
.takeIf {
dimmingDelegate.willDimForDetentIndex(
screen,
screen.sheetInitialDetentIndex,
)
}?.with(alphaAnimator)

attachCommonListeners(animatorSet, isEnter = true)

return animatorSet
}

private fun createExitAnimator(): Animator {
val animatorSet = AnimatorSet()
val dimmingDelegate = requireDimmingDelegate()

val alphaAnimator = createAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f)
val slideAnimator = createSlideOutAnimator()

animatorSet.play(alphaAnimator).with(slideAnimator)

attachCommonListeners(animatorSet, isEnter = false)

return animatorSet
}

private fun createAlphaAnimator(
from: Float,
to: Float,
): ValueAnimator =
ValueAnimator.ofFloat(from, to).apply {
addUpdateListener { animator ->
(animator.animatedValue as? Float)?.let {
requireDimmingDelegate().dimmingView.alpha = it
}
animatorSet.play(alphaAnimator).with(slideAnimator)
}
}

private fun createSlideInAnimator(): ValueAnimator {
val startValueCallback = { _: Number? -> screen.height.toFloat() }
val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })

return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
addUpdateListener { updateScreenTranslation(it.animatedValue as Float) }
}
}

private fun createSlideOutAnimator(): ValueAnimator {
val endValue = (coordinatorLayout.bottom - screen.top - screen.translationY)
return ValueAnimator.ofFloat(0f, endValue).apply {
addUpdateListener {
updateScreenTranslation(it.animatedValue as Float)
}
}
}

private fun updateScreenTranslation(baseTranslationY: Float) {
val keyboardCorrection = lastKeyboardBottomOffset ?: 0
val bottomOffset = sheetDelegate?.calculateSheetOffsetY(keyboardCorrection)?.toFloat() ?: 0f

screen.translationY = baseTranslationY - bottomOffset
}

private fun attachCommonListeners(
animatorSet: AnimatorSet,
isEnter: Boolean,
) {
animatorSet.addListener(
ScreenAnimationDelegate(
this,
ScreenEventEmitter(this.screen),
if (enter) {
if (isEnter) {
ScreenAnimationDelegate.AnimationType.ENTER
} else {
ScreenAnimationDelegate.AnimationType.EXIT
},
),
)
return animatorSet

animatorSet.addListener(
object : AnimatorListenerAdapter() {
override fun onAnimationStart(animation: Animator) {
super.onAnimationStart(animation)
fadeAnimationRunning = true
}

override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
fadeAnimationRunning = false
}
},
)
}

private fun handleKeyboardInsetsProgress(insets: WindowInsetsCompat) {
lastKeyboardBottomOffset = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
// Prioritize enter/exit animations over direct keyboard inset reactions.
// We store the latest keyboard offset in `lastKeyboardBottomOffset`
// so that it can always be respected when applying translations in `updateScreenTranslation`.
//
// This approach allows screen translation to be triggered from two sources, but without messing them together:
// - During enter/exit animations, while accounting for the keyboard height.
// - While interacting with a TextInput inside the bottom sheet, to handle keyboard show/hide events.
if (!fadeAnimationRunning) {
updateScreenTranslation(0f)
}
}

private fun createBottomSheetBehaviour(): BottomSheetBehavior<Screen> = BottomSheetBehavior<Screen>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,23 @@ class SheetDelegate(
behavior.removeBottomSheetCallback(keyboardHandlerCallback)
when (screen.sheetDetents.count()) {
1 ->
behavior.useSingleDetent(
height = (screen.sheetDetents.first() * containerHeight).toInt(),
forceExpandedState = false,
)
behavior.apply {
val height =
if (screen.isSheetFitToContents()) {
screen.contentWrapper?.let { contentWrapper ->
contentWrapper.height.takeIf {
// subtree might not be laid out, e.g. after fragment reattachment
// and view recreation, however since it is retained by
// react-native it has its height cached. We want to use it.
// Otherwise we would have to trigger RN layout manually.
contentWrapper.isLaidOutOrHasCachedLayout()
}
}
} else {
(screen.sheetDetents.first() * containerHeight).toInt()
}
useSingleDetent(height = height, forceExpandedState = false)
}

2 ->
behavior.useTwoDetents(
Expand All @@ -237,6 +250,30 @@ class SheetDelegate(
}
}

internal fun calculateSheetOffsetY(keyboardHeight: Int): Int {
val containerHeight = tryResolveContainerHeight()
check(containerHeight != null) {
"[RNScreens] Failed to find window height during bottom sheet behaviour configuration"
}

if (screen.isSheetFitToContents()) {
val contentHeight = screen.contentWrapper?.height ?: 0
val offsetFromTop = containerHeight - contentHeight
return minOf(offsetFromTop, keyboardHeight)
}

val detents = screen.sheetDetents
if (detents.isEmpty()) {
throw IllegalStateException("[RNScreens] Cannot determine sheet detent - detents list is empty")
}

val detentValue = detents[detents.size - 1].coerceIn(0.0, 1.0)
val sheetHeight = (detentValue * containerHeight).toInt()
val offsetFromTop = containerHeight - sheetHeight

return minOf(offsetFromTop, keyboardHeight)
}

// This is listener function, not the view's.
override fun onApplyWindowInsets(
v: View,
Expand Down Expand Up @@ -270,7 +307,6 @@ class SheetDelegate(
this.configureBottomSheetBehaviour(it, KeyboardDidHide)
} else if (keyboardState != KeyboardNotVisible) {
this.configureBottomSheetBehaviour(it, KeyboardNotVisible)
} else {
}
}

Expand Down
Loading
Loading