Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -64,6 +66,10 @@ class ScreenStackFragment :
var searchView: CustomSearchView? = null
var onSearchViewCreate: ((searchView: CustomSearchView) -> Unit)? = null

private var isSheetAnimationInProgress = false

private var lastKeyboardBottomOffset: Int = 0

private lateinit var coordinatorLayout: ScreensCoordinatorLayout

private val screenStack: ScreenStack
Expand Down Expand Up @@ -242,20 +248,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 @@ -287,64 +307,124 @@ class ScreenStackFragment :
return null
}

return if (enter) createSheetEnterAnimator() else createSheetExitAnimator()
}

private fun createSheetEnterAnimator(): 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 = createDimmingViewAlphaAnimator(0f, dimmingDelegate.maxAlpha)
val slideAnimator = createSheetSlideInAnimator()

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 createSheetExitAnimator(): Animator {
val animatorSet = AnimatorSet()
val dimmingDelegate = requireDimmingDelegate()

val alphaAnimator = createDimmingViewAlphaAnimator(dimmingDelegate.dimmingView.alpha, 0f)
val slideAnimator = createSheetSlideOutAnimator()

animatorSet.play(alphaAnimator).with(slideAnimator)

attachCommonListeners(animatorSet, isEnter = false)

return animatorSet
}

private fun createDimmingViewAlphaAnimator(
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 createSheetSlideInAnimator(): ValueAnimator {
val startValueCallback = { _: Number? -> screen.height.toFloat() }
val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f })

return ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading this for the first time & I don't get why on slide in animator the start value is height and target value is 0, at least passed here. I also see that there is an evaluator.

I'll update the comment if I get this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote this code intitially, but I do not remember that. 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, is it because the sheet has already target position & we translate it back to the animation start position? That might be it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial sheet position is when the sheet bottom edge is equal to device's bottom edge, we're translating Y with a positive value to hide the sheet under the bottom edge of the device

addUpdateListener { updateSheetTranslationY(it.animatedValue as Float) }
}
}

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

private fun updateSheetTranslationY(baseTranslationY: Float) {
val keyboardCorrection = lastKeyboardBottomOffset
val bottomOffset = sheetDelegate?.computeSheetOffsetYWithIMEPresent(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)
isSheetAnimationInProgress = true
}

override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
isSheetAnimationInProgress = 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 `updateSheetTranslationY`.
//
// 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 (!isSheetAnimationInProgress) {
updateSheetTranslationY(0f)
}
}

private fun createBottomSheetBehaviour(): BottomSheetBehavior<Screen> = BottomSheetBehavior<Screen>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ class SheetDelegate(
}

is KeyboardVisible -> {
val isOnScreenKeyboardVisible = keyboardState.height != 0

when (screen.sheetDetents.count()) {
1 ->
behavior.apply {
Expand All @@ -183,17 +185,25 @@ class SheetDelegate(

2 ->
behavior.apply {
useTwoDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
if (isOnScreenKeyboardVisible) {
useTwoDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
} else {
useTwoDetents()
}
addBottomSheetCallback(keyboardHandlerCallback)
}

3 ->
behavior.apply {
useThreeDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
if (isOnScreenKeyboardVisible) {
useThreeDetents(
state = BottomSheetBehavior.STATE_EXPANDED,
)
} else {
useThreeDetents()
}
addBottomSheetCallback(keyboardHandlerCallback)
}

Expand All @@ -211,10 +221,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 +260,36 @@ class SheetDelegate(
}
}

// This function calculates the Y offset to which the FormSheet should animate
// when appearing (entering) or disappearing (exiting) with the on-screen keyboard (IME) present.
// Its purpose is to ensure the FormSheet does not exceed the top edge of the screen.
// It tries to display the FormSheet fully above the keyboard when there's enough space.
// Otherwise, it shifts the sheet as high as possible, even if it means part of its content
// will remain hidden behind the keyboard.
internal fun computeSheetOffsetYWithIMEPresent(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")
}
Comment on lines +281 to +284
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have separate data structure for detents, making sure of this invariant. Let's create ticket for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


val detentValue = detents[detents.size - 1].coerceIn(0.0, 1.0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get this part. Why do you take value of the largest detent here?

Copy link
Member

@kkafar kkafar Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is used only when there is keyboard present, is that right? If so, let's name it appropriately, cause right now it's not obvious at all.

e.g. computeSheetOffsetYWithIMEPresent or something.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this place deserves its own comment, because it really defines the behaviour of the sheet -> that it expands to max detent when the keyboard shows, right?

Copy link
Contributor Author

@t0maboro t0maboro Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, the sheet expands to its max detent; the purpose of this code is to determine whether we're able to show the full sheet or if we need to cover it partially with the keyboard, definitely deserves some description, giving the information about the final offset from bottom, to which we should animate

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 +323,6 @@ class SheetDelegate(
this.configureBottomSheetBehaviour(it, KeyboardDidHide)
} else if (keyboardState != KeyboardNotVisible) {
this.configureBottomSheetBehaviour(it, KeyboardNotVisible)
} else {
}
}

Expand Down
Loading
Loading