Skip to content

Commit 4793906

Browse files
authored
fix: predictive back gesture handler (correct end event) (#814)
## 📜 Description Fixed incorrect `end` event after cancelling predictive back gesture. ## 💡 Motivation and Context The problem was in fact that when keyboard gets shown in `onEnd` callback we get keyboard height as `0`, because insets were not applied yet. I had this problem two years ago (when I implemented interactive keyboard dismissal), but back to the times I solved it via adding `InteractiveProvider.isShown` and it was working well, because the only one class could control keyboard position and it was my class. However starting from Android 15 keyboard position can be controlled by OS itself, so the trick with `InteractiveProvider.isShown` is not working anymore. After discovering various approaches how to handle it I found only two ways: #### 1️⃣ Pre-save insets in `onApplyWindowInsets` While it seems preferable option, I still don't understand how it can be utilized in that fix, because `onApplyWindowInsets` will be dispatched in the beginning of the animation and in `onEnd` we will not be able to determine whether keyboard has been actually closed or returned back. > [!WARNING] > The approach with saving last keyboard height in `onProgress` handler and based on that dispatch an event in `onEnd` may be not good, because keyboard animation can be interrupted and potentially it can cause more bugs, see #704 for more details #### 2️⃣ Check insets after layout pass This seems to be one and the most reliable solution (though it adds a delay to `onEnd` event, but in my understanding it's not very critical). With this approach we can be sure, that all new insets are applied and we can reak keyboard frame properly. <hr> Upcoming questions that popped up in my head: - **should we remove `InteractiveKeyboardProvider` completely/should we consider predictive back gesture dismissal as interactive keyboard dismissal** - is a good question, but if we do this, we'll treat back predictive gesture as interactive keyboard dismissal (in fact it is interactive dismissal), and in this case we'll send `onInteractive` instead of `onMove`. It can be a breaking change and people (including me) may associate `onInteractive` event only to be present with `KeyboardGestureArea`, so for now let's keep it for a sake of backward compatibility. - **is async the only one way to manage this? Can't we pre-memoize `start`/`onApplyInsets` and re-use it there?** - at the moment I don't understand how memoization of insets in `onApplyInsets`/`onStart` can help us to detect final keyboard position in `onEnd`. Maybe it's doable, but for now let's stick with async approach. If we discover a new way how to handle everything synchronously we always can re-work that piece of the code and improve it 😎 Closes #810 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### Android - added `isKeyboardInteractive` getter; - wrap `onEnd` body in `Runnable`; - based on `isKeyboardInteractive` either execute `runnable` immediately or after a layout pass; - removed `InteractiveProvider.isShown`; ## 🤔 How Has This Been Tested? Tested manually on Pixel 7 Pro (Android 15). ## 📸 Screenshots (if appropriate): https://github.com/user-attachments/assets/2f6e41dc-b1e9-4187-b703-5c0682c84bf4 ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent 80d8662 commit 4793906

File tree

3 files changed

+40
-44
lines changed

3 files changed

+40
-44
lines changed
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.reactnativekeyboardcontroller.interactive
22

33
object InteractiveKeyboardProvider {
4-
var shown = false
54
var isInteractive = false
65
}

android/src/main/java/com/reactnativekeyboardcontroller/interactive/KeyboardAnimationController.kt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -233,22 +233,18 @@ internal class KeyboardAnimationController {
233233
when (current) {
234234
// The current inset matches either the shown/hidden inset, finish() immediately
235235
shown -> {
236-
InteractiveKeyboardProvider.shown = true
237236
controller.finish(true)
238237
}
239238
hidden -> {
240-
InteractiveKeyboardProvider.shown = false
241239
controller.finish(false)
242240
}
243241
else -> {
244242
// Otherwise, we'll look at the current position...
245243
if (controller.currentFraction >= SCROLL_THRESHOLD) {
246244
// If the IME is past the 'threshold' we snap to the toggled state
247-
InteractiveKeyboardProvider.shown = !isImeShownAtStart
248245
controller.finish(!isImeShownAtStart)
249246
} else {
250247
// ...otherwise, we snap back to the original visibility
251-
InteractiveKeyboardProvider.shown = isImeShownAtStart
252248
controller.finish(isImeShownAtStart)
253249
}
254250
}
@@ -287,11 +283,9 @@ internal class KeyboardAnimationController {
287283
)
288284
// The current inset matches either the shown/hidden inset, finish() immediately
289285
current == shown -> {
290-
InteractiveKeyboardProvider.shown = true
291286
controller.finish(true)
292287
}
293288
current == hidden -> {
294-
InteractiveKeyboardProvider.shown = false
295289
controller.finish(false)
296290
}
297291
else -> {

android/src/main/java/com/reactnativekeyboardcontroller/listeners/KeyboardAnimationCallback.kt

Lines changed: 40 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ class KeyboardAnimationCallback(
6262
private var duration = 0
6363
private var viewTagFocused = -1
6464
private var animationsToSkip = hashSetOf<WindowInsetsAnimationCompat>()
65+
private val isKeyboardInteractive: Boolean
66+
get() = duration == -1
6567
override var isSuspended: Boolean = false
6668

6769
// listeners
@@ -293,46 +295,47 @@ class KeyboardAnimationCallback(
293295
isTransitioning = false
294296
duration = animation.durationMillis.toInt()
295297

296-
var keyboardHeight = this.persistentKeyboardHeight
297-
// if keyboard becomes shown after interactive animation completion
298-
// getCurrentKeyboardHeight() will be `0` and isKeyboardVisible will be `false`
299-
// it's not correct behavior, so we are handling it here
300-
val isKeyboardShown = InteractiveKeyboardProvider.shown
301-
if (!isKeyboardShown) {
302-
keyboardHeight = getCurrentKeyboardHeight()
303-
} else {
304-
// if keyboard is shown after interactions and the animation has finished
305-
// then we need to reset the state
306-
InteractiveKeyboardProvider.shown = false
307-
}
308-
isKeyboardVisible = isKeyboardVisible || isKeyboardShown
309-
prevKeyboardHeight = keyboardHeight
298+
val runnable =
299+
Runnable {
300+
val keyboardHeight = getCurrentKeyboardHeight()
310301

311-
if (animation in animationsToSkip) {
312-
duration = 0
313-
animationsToSkip.remove(animation)
314-
return
315-
}
302+
isKeyboardVisible = isKeyboardVisible()
303+
prevKeyboardHeight = keyboardHeight
316304

317-
context.emitEvent(
318-
"KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow",
319-
getEventParams(keyboardHeight),
320-
)
321-
context.dispatchEvent(
322-
eventPropagationView.id,
323-
KeyboardTransitionEvent(
324-
surfaceId,
325-
eventPropagationView.id,
326-
KeyboardTransitionEvent.End,
327-
keyboardHeight,
328-
if (!isKeyboardVisible) 0.0 else 1.0,
329-
duration,
330-
viewTagFocused,
331-
),
332-
)
305+
if (animation in animationsToSkip) {
306+
duration = 0
307+
animationsToSkip.remove(animation)
308+
return@Runnable
309+
}
333310

334-
// reset to initial state
335-
duration = 0
311+
context.emitEvent(
312+
"KeyboardController::" + if (!isKeyboardVisible) "keyboardDidHide" else "keyboardDidShow",
313+
getEventParams(keyboardHeight),
314+
)
315+
context.dispatchEvent(
316+
eventPropagationView.id,
317+
KeyboardTransitionEvent(
318+
surfaceId,
319+
eventPropagationView.id,
320+
KeyboardTransitionEvent.End,
321+
keyboardHeight,
322+
if (!isKeyboardVisible) 0.0 else 1.0,
323+
duration,
324+
viewTagFocused,
325+
),
326+
)
327+
328+
// reset to initial state
329+
duration = 0
330+
}
331+
332+
if (isKeyboardInteractive) {
333+
// in case of interactive keyboard we can not read keyboard frame straight away
334+
// (because we'll always read `0`), so we are posting runnable to the main thread
335+
view.post(runnable)
336+
} else {
337+
runnable.run()
338+
}
336339
}
337340

338341
fun syncKeyboardPosition(

0 commit comments

Comments
 (0)