-
-
Notifications
You must be signed in to change notification settings - Fork 595
fix(Android, Stack): Moving formsheet above keyboard #3248
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2c72a6e
739312a
9a7e62e
c85c55e
2af4a0f
5f9df1d
2c44e39
113a0de
15d71ee
cf80859
e800b3b
93c23bf
9852fe3
a52b93a
c935b5d
541be9d
6a5476d
58d2d1f
f1e63f2
690420f
08f272e
bcfbf55
39db00f
7187586
743d8d7
1eaf8b0
d354569
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I'll update the comment if I get this There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wrote this code intitially, but I do not remember that. 😄 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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>() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -175,6 +175,8 @@ class SheetDelegate( | |
| } | ||
|
|
||
| is KeyboardVisible -> { | ||
| val isOnScreenKeyboardVisible = keyboardState.height != 0 | ||
|
|
||
| when (screen.sheetDetents.count()) { | ||
| 1 -> | ||
| behavior.apply { | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
|
|
@@ -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( | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| val detentValue = detents[detents.size - 1].coerceIn(0.0, 1.0) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
@@ -270,7 +323,6 @@ class SheetDelegate( | |
| this.configureBottomSheetBehaviour(it, KeyboardDidHide) | ||
| } else if (keyboardState != KeyboardNotVisible) { | ||
| this.configureBottomSheetBehaviour(it, KeyboardNotVisible) | ||
| } else { | ||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.