Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2c72a6e
Minimal POC with translateY
t0maboro Sep 25, 2025
739312a
Fix initial slide in animation
t0maboro Sep 25, 2025
9a7e62e
Separating slide-in and inset animations
t0maboro Sep 26, 2025
c85c55e
Split animators logic for entering and keyboard progressing
t0maboro Sep 29, 2025
2af4a0f
Fix calculating detent on keyboardDidHide with fitToContents
t0maboro Sep 29, 2025
5f9df1d
Drop unnecessary state listener
t0maboro Sep 29, 2025
2c44e39
Remove logs
t0maboro Sep 29, 2025
113a0de
Minor cleanup
t0maboro Sep 29, 2025
15d71ee
Add example
t0maboro Sep 29, 2025
cf80859
Use different API for reading insets on android 29 or lower
t0maboro Sep 29, 2025
e800b3b
Remove variable
t0maboro Sep 29, 2025
93c23bf
Update comment
t0maboro Sep 29, 2025
9852fe3
Merge branch 'main' into @t0maboro/keyboard-avoiding-formsheet-android
kkafar Nov 5, 2025
a52b93a
Rename
t0maboro Nov 6, 2025
c935b5d
Rename
t0maboro Nov 6, 2025
541be9d
Rename
t0maboro Nov 6, 2025
6a5476d
Rename
t0maboro Nov 6, 2025
58d2d1f
Rename
t0maboro Nov 6, 2025
f1e63f2
Rename
t0maboro Nov 6, 2025
690420f
Rename
t0maboro Nov 6, 2025
08f272e
Drop optional
t0maboro Nov 6, 2025
bcfbf55
Rename + comment
t0maboro Nov 6, 2025
39db00f
Fix typo
t0maboro Nov 6, 2025
7187586
Fix onscreen keyboard case
t0maboro Nov 6, 2025
743d8d7
Remove arrow functions
t0maboro Nov 6, 2025
1eaf8b0
Formatting
t0maboro Nov 6, 2025
d354569
Cleanup
t0maboro Nov 6, 2025
e8fbee5
refactor(Android, Stack): Refactor sheet animation code (#3378)
t0maboro Nov 7, 2025
8278813
Linter
t0maboro Nov 7, 2025
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 @@ -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,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 +293 to +296
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 +313,6 @@ class SheetDelegate(
this.configureBottomSheetBehaviour(it, KeyboardDidHide)
} else if (keyboardState != KeyboardNotVisible) {
this.configureBottomSheetBehaviour(it, KeyboardNotVisible)
} else {
}
}

Expand Down
Loading
Loading