Skip to content

KeyboardChatScrollView inverted: no close animation when dismissing keyboard via Android IME close button #1474

@ACHP

Description

@ACHP

Describe the bug
When using KeyboardChatScrollView with inverted props, dismissing the keyboard from the Android IME close buton (the down-arrow in the navigation bar) snaps the content instantly instead of animating. A non-inverted list animates correctly.

Repo for reproducing
Using the provided example and selecting "use flashlist" + "inverted"

To Reproduce
Steps to reproduce the behavior:

  1. Go to Chat Kit
  2. Click on open config
  3. Select "Toggle inverted"
  4. Select "use Flash List"

Expected behavior
Clicking the IME close button should animate the keyboard.

Screenshots

WhatsApp.Video.2026-05-27.at.22.40.43.mp4

Notes: Sorry I'm dumb I just realize we don't see the IME button on the video, it's this button:

Image

Smartphone (please complete the following information):

  • Desktop OS: /
  • Device: Samsung galaxy A56
  • OS: Android 36
  • RN version: Whatever is provided in the example (but on my app it's RN 81, expo 54)
  • RN architecture: new arch
  • JS engine: Hermes
  • Library version: 1.21.8

Additional context
Add any other context about the problem here.

Root cause
Disclaimer : All the following content is found/suggested by AI (Claude opus 4.7)

The inverted branch of useChatKeyboard skips every event with duration === -1:

 // src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
  if (inverted && e.duration === -1) { return; }   // onStart
  if (e.duration === -1) { return; }               // onMove

The guard was meant to drop the post-interactive snap-back events that follow a
finger swipe-dismiss. But duration === -1 is not unique to snap-back — it's emitted
by any app-controlled inset animation (controlWindowInsetsAnimation(..., -1, ...) in
KeyboardAnimationController.kt). On some Android devices/IMEs (reproduced on a
Samsung SM-A566B, API 36) the IME close button drives the hide through that same
controller, so it produces a full animated onMove sequence carrying duration === -1.
The inverted branch discards the whole sequence → no animation.

Suggested fix

Distinguish a real interactive gesture from an animated close instead of keying off duration alone. A finger
dismiss emits onInteractive events first; the close button does not. Track an interactive flag (set in
onInteractive, reset on a genuine duration !== -1 start) and only skip duration === -1 events when that flag is
set.

For what it worth we're using this patch from claude:

Index: src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts
--- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts	(revision adafcc8cda54a4dce84140ae3014bfa7365c208c)
+++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ts	(date 1779916883510)
@@ -50,6 +50,11 @@
   const closing = useSharedValue(false);
   const minimumPaddingFractionOnOpen = useSharedValue(0);
   const actualOpenShift = useSharedValue(0);
+  // Tracks whether a finger-driven interactive gesture just happened. Used to
+  // distinguish a genuine post-interactive snap-back (which must be skipped) from
+  // an animated close that Android reports with `duration === -1` but no preceding
+  // interactive events (e.g. the IME close button on some devices).
+  const recentlyInteractive = useSharedValue(false);
   const {
     layout,
     size,
@@ -86,6 +91,12 @@
           return;
         }
 
+        // A genuine (system-driven) start resets the interactive flag. Snap-back
+        // and close-button starts arrive with `duration === -1` and must not reset it.
+        if (e.duration !== -1) {
+          recentlyInteractive.value = false;
+        }
+
         if (e.height > 0) {
           // eslint-disable-next-line react-compiler/react-compiler
           targetKeyboardHeight.value = e.height;
@@ -128,10 +139,12 @@
           minimumPaddingAbsorbed,
         );
 
-        if (inverted && e.duration === -1) {
+        if (inverted && e.duration === -1 && recentlyInteractive.value) {
           // Android inverted: skip post-interactive snap-back events
-          // (duration === -1 means the keyboard is re-establishing its
-          // position after an interactive gesture, not a real animation)
+          // (duration === -1 after an interactive gesture means the keyboard is
+          // re-establishing its position, not a real animation). Only skip when a
+          // finger gesture actually preceded it — an animated close (e.g. IME close
+          // button) also uses duration === -1 but must animate.
           return;
         } else if (e.height > 0) {
           // Android: keyboard opening — set padding + capture scroll position
@@ -173,8 +186,9 @@
         currentHeight.value = e.height;
 
         if (inverted) {
-          // Skip post-interactive snap-back (duration === -1)
-          if (e.duration === -1) {
+          // Skip post-interactive snap-back (duration === -1), but only when a
+          // finger gesture preceded it — an animated close also uses duration === -1.
+          if (e.duration === -1 && recentlyInteractive.value) {
             return;
           }
 
@@ -357,6 +371,13 @@
           actualOpenShift.value = scroll.value - offsetBeforeScroll.value;
         }
       },
+      onInteractive: () => {
+        "worklet";
+
+        // A finger-driven gesture happened; the following `duration === -1`
+        // snap-back events (if any) should be skipped by onStart/onMove.
+        recentlyInteractive.value = true;
+      },
     },
     [inverted, keyboardLiftBehavior, offset],
   );

With this patch, no more problems:

WhatsApp.Video.2026-05-27.at.23.24.56.mp4

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions