Skip to content

Inconsistent Keyboard Height on Android during WindowInsetsAnimation (Proposed Patch) #2587

Description

@lincolnthree

Bug Description

On Android, the @capacitor/keyboard plugin can report inconsistent or incorrect keyboard heights (sometimes smaller than the actual keyboard, or 0) during keyboard animations or when WindowInsets are applied. This is particularly noticeable on newer Android versions (14/15) or when using edge-to-edge displays. The events keyboardWillShow and keyboardDidShow may fire with intermediate sizes instead of the final settled keyboard height, causing UI jumps or layout issues.

This is a root cause of the common "Grey bar above keyboard" issue reported in many other issues (such as ionic-team/capacitor#8525, ionic-team/capacitor#8575, ionic-team/capacitor#8329, and #2205).

This issue is very similar to the previously reported (and closed) Issue #2205 (Keyboard height is inconsistent).

Workaround / Proposed Patch

To solve this, we have developed a patch for @capacitor/keyboard@8.0.3 that introduces a new configuration option: eventMode: "LAST_KNOWN".

What the patch does:

  1. Tracks the Highest Known Height: It tracks the knownKeyboardHeight to avoid emitting intermediate sizes during the WindowInsetsAnimationCompat lifecycle (onPrepare, onProgress, onStart, onEnd).
  2. Smooths Event Emission: It only emits WILL_SHOW and DID_SHOW if the height actually changes, falling back to the LAST_KNOWN height instead of transient smaller heights during animation.
  3. Improves onApplyWindowInsets: Grabs a 28ms CSS head-start by firing WILL_SHOW immediately when insets apply.

Configuration (Added in Patch)

/// capacitor.config.ts
Keyboard: {
  resize: KeyboardResize.None,
  resizeOnFullScreen: false,
  eventMode: 'LAST_KNOWN', // Added by this patch
}

Attached Patch

The patch file @capacitor__keyboard@8.0.3.patch.zip is attached to this issue. It contains the diff for Keyboard.java, KeyboardPlugin.java, and definitions.d.ts.

Note: I am submitting this patch so the Capacitor team can review the approach. Could we look into getting a similar stabilization fix merged into the core plugin?

Click here to view the patch file
diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java
index 7cf87fc7ef661c2a13fb7a5f2c5c89df99bd730c..53d8abc7d634c587379454afe5f31644883ec28a 100644
--- a/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java
+++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java
@@ -31,6 +31,26 @@ public class Keyboard {
     private int usableHeightPrevious;
     private FrameLayout.LayoutParams frameLayoutParams;
     private View mChildOfContent;
+    private int lastImeHeight = 0;
+    public enum EventMode {
+        DEFAULT,
+        LAST_KNOWN
+    }
+
+    private boolean isAnimating = false;
+    private boolean justEndedAnimation = false;
+    private int knownKeyboardHeight = 0;
+    private EventMode eventMode = EventMode.DEFAULT;
+
+    public void setEventMode(String modeStr) {
+        if (modeStr != null) {
+            try {
+                this.eventMode = EventMode.valueOf(modeStr.toUpperCase());
+            } catch (IllegalArgumentException e) {
+                this.eventMode = EventMode.DEFAULT;
+            }
+        }
+    }
 
     public void setKeyboardEventListener(@Nullable KeyboardEventListener keyboardEventListener) {
         this.keyboardEventListener = keyboardEventListener;
@@ -59,26 +79,85 @@ public class Keyboard {
         rootView = content.getRootView();
 
         ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> {
-            boolean showingKeyboard = ViewCompat.getRootWindowInsets(rootView).isVisible(WindowInsetsCompat.Type.ime());
+            boolean showingKeyboard = insets.isVisible(WindowInsetsCompat.Type.ime());
+            int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
+            DisplayMetrics dm = activity.getResources().getDisplayMetrics();
+            final float density = dm.density;
+
+            if (showingKeyboard) {
+                int currentImeHeight = Math.round(imeHeight / density);
+                if (!isAnimating) {
+                    int emitHeight = currentImeHeight;
+                    if (eventMode == EventMode.LAST_KNOWN) {
+                        if (knownKeyboardHeight == 0) {
+                            knownKeyboardHeight = currentImeHeight;
+                        } else if (justEndedAnimation) {
+                            knownKeyboardHeight = currentImeHeight;
+                        } else if (currentImeHeight > knownKeyboardHeight) {
+                            emitHeight = knownKeyboardHeight;
+                        } else if (currentImeHeight < knownKeyboardHeight) {
+                            knownKeyboardHeight = currentImeHeight;
+                        }
+                    } else {
+                        knownKeyboardHeight = currentImeHeight;
+                    }
+                    
+                    if (emitHeight != lastImeHeight && keyboardEventListener != null) {
+                        lastImeHeight = emitHeight;
+                        // Fire WILL_SHOW immediately from onApplyWindowInsets for a 28ms CSS head-start
+                        keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, emitHeight);
+                        keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, emitHeight);
+                    }
+                    justEndedAnimation = false;
+                } else if (eventMode == EventMode.LAST_KNOWN) {
+                    // Even if animating, if we don't have a known height yet, grab it.
+                    if (knownKeyboardHeight == 0) {
+                        knownKeyboardHeight = currentImeHeight;
+                    }
+                }
+            } else {
+                lastImeHeight = 0;
+                justEndedAnimation = false;
+            }
+            
+            logDebug("onApplyWindowInsets - showingKeyboard=" + showingKeyboard, insets, imeHeight);
 
             if (showingKeyboard && resizeOnFullScreen) {
                 possiblyResizeChildOfContent(true);
+            } else if (!showingKeyboard && resizeOnFullScreen) {
+                possiblyResizeChildOfContent(false);
             }
 
-            v.onApplyWindowInsets(insets.toWindowInsets());
+            WindowInsetsCompat insetsToApply = insets;
+            if (!resizeOnFullScreen) {
+                insetsToApply = new WindowInsetsCompat.Builder(insets)
+                    .setInsets(WindowInsetsCompat.Type.ime(), androidx.core.graphics.Insets.NONE)
+                    .build();
+            }
 
-            return insets;
+            return ViewCompat.onApplyWindowInsets(v, insetsToApply);
         });
 
         ViewCompat.setWindowInsetsAnimationCallback(
             rootView,
             new WindowInsetsAnimationCompat.Callback(WindowInsetsAnimationCompat.Callback.DISPATCH_MODE_STOP) {
+                @Override
+                public void onPrepare(@NonNull WindowInsetsAnimationCompat animation) {
+                    isAnimating = true;
+                    super.onPrepare(animation);
+                }
+
                 @NonNull
                 @Override
                 public WindowInsetsCompat onProgress(
                     @NonNull WindowInsetsCompat insets,
                     @NonNull List<WindowInsetsAnimationCompat> runningAnimations
                 ) {
+                    if (!resizeOnFullScreen) {
+                        return new WindowInsetsCompat.Builder(insets)
+                            .setInsets(WindowInsetsCompat.Type.ime(), androidx.core.graphics.Insets.NONE)
+                            .build();
+                    }
                     return insets;
                 }
 
@@ -88,6 +167,8 @@ public class Keyboard {
                     @NonNull WindowInsetsAnimationCompat animation,
                     @NonNull WindowInsetsAnimationCompat.BoundsCompat bounds
                 ) {
+                    isAnimating = true;
+                    justEndedAnimation = false;
                     boolean showingKeyboard = ViewCompat.getRootWindowInsets(rootView).isVisible(WindowInsetsCompat.Type.ime());
                     WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView);
                     int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
@@ -99,16 +180,31 @@ public class Keyboard {
                     }
 
                     if (showingKeyboard) {
-                        keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, Math.round(imeHeight / density));
+                        int currentImeHeight = Math.round(imeHeight / density);
+                        int emitHeight = currentImeHeight;
+                        if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) {
+                            emitHeight = knownKeyboardHeight;
+                        }
+                        
+                        if (emitHeight != lastImeHeight) {
+                            lastImeHeight = emitHeight;
+                            keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_SHOW, lastImeHeight);
+                        }
                     } else {
+                        lastImeHeight = 0;
                         keyboardEventListener.onKeyboardEvent(EVENT_KB_WILL_HIDE, 0);
                     }
+                    
+                    logDebug("onStart - showingKeyboard=" + showingKeyboard, insets, imeHeight);
+                    
                     return super.onStart(animation, bounds);
                 }
 
                 @Override
                 public void onEnd(@NonNull WindowInsetsAnimationCompat animation) {
                     super.onEnd(animation);
+                    isAnimating = false;
+                    justEndedAnimation = true;
                     boolean showingKeyboard = ViewCompat.getRootWindowInsets(rootView).isVisible(WindowInsetsCompat.Type.ime());
                     WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(rootView);
                     int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;
@@ -116,10 +212,27 @@ public class Keyboard {
                     final float density = dm.density;
 
                     if (showingKeyboard) {
-                        keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, Math.round(imeHeight / density));
+                        int currentImeHeight = Math.round(imeHeight / density);
+                        int emitHeight = currentImeHeight;
+                        if (eventMode == EventMode.LAST_KNOWN && knownKeyboardHeight > 0 && currentImeHeight > knownKeyboardHeight) {
+                            emitHeight = knownKeyboardHeight;
+                        }
+                        
+                        if (emitHeight != lastImeHeight) {
+                            lastImeHeight = emitHeight;
+                            keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_SHOW, lastImeHeight);
+                        }
+                        
+                        // Update the known keyboard height to the final settled height after animation
+                        if (eventMode == EventMode.LAST_KNOWN) {
+                            knownKeyboardHeight = currentImeHeight;
+                        }
                     } else {
+                        lastImeHeight = 0;
                         keyboardEventListener.onKeyboardEvent(EVENT_KB_DID_HIDE, 0);
                     }
+                    
+                    logDebug("onEnd - showingKeyboard=" + showingKeyboard, insets, imeHeight);
                 }
             }
         );
@@ -179,4 +292,23 @@ public class Keyboard {
             (window.getDecorView().getSystemUiVisibility() & View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) == View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
         );
     }
+    
+    private void logDebug(String context, WindowInsetsCompat insets, int imeHeight) {
+        if (bridge == null || bridge.getWebView() == null) return;
+        Rect r = new Rect();
+        mChildOfContent.getWindowVisibleDisplayFrame(r);
+        int screenHeight = rootView.getRootView().getHeight();
+        int navBottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
+        int statusTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top;
+        int sysBottom = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom;
+        int displayCutoutTop = insets.getInsets(WindowInsetsCompat.Type.displayCutout()).top;
+
+        String logStr = String.format(
+            "console.log('[KeyboardDebug] %s: ime.bottom=%d, nav.bottom=%d, status.top=%d, sys.bottom=%d, cutout.top=%d, r.bottom=%d, r.height=%d, screenHeight=%d');",
+            context, imeHeight, navBottom, statusTop, sysBottom, displayCutoutTop, r.bottom, r.height(), screenHeight
+        );
+        bridge.getActivity().runOnUiThread(() -> {
+            bridge.getWebView().evaluateJavascript(logStr, null);
+        });
+    }
 }
diff --git a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java
index bf6735a85ff72fea5fe4d84b24d682d4080a95d6..4308cb699697f02413255f69a7e5097c04fda0ab 100644
--- a/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java
+++ b/android/src/main/java/com/capacitorjs/plugins/keyboard/KeyboardPlugin.java
@@ -17,7 +17,9 @@ public class KeyboardPlugin extends Plugin {
     public void load() {
         execute(() -> {
             boolean resizeOnFullScreen = getConfig().getBoolean("resizeOnFullScreen", false);
+            String eventMode = getConfig().getString("eventMode", "DEFAULT");
             implementation = new Keyboard(getBridge(), resizeOnFullScreen);
+            implementation.setEventMode(eventMode);
 
             implementation.setKeyboardEventListener(this::onKeyboardEvent);
         });
diff --git a/dist/esm/definitions.d.ts b/dist/esm/definitions.d.ts
index ceae326dfcbd47c0ebbff2b721c60fb9f52c41f9..23869fdae0535e46b8abe0d33c5fe440d9388c62 100644
--- a/dist/esm/definitions.d.ts
+++ b/dist/esm/definitions.d.ts
@@ -36,9 +36,19 @@ declare module '@capacitor/cli' {
              * @example true
              */
             resizeOnFullScreen?: boolean;
+            /**
+             * The event mode determines how the plugin emits lifecycle events during keyboard animations.
+             *
+             * Only available for Android
+             *
+             * @since 8.0.3
+             * @example "LAST_KNOWN"
+             */
+            eventMode?: KeyboardEventMode;
         };
     }
 }
+export declare type KeyboardEventMode = "DEFAULT" | "LAST_KNOWN";
 export interface KeyboardInfo {
     /**
      * Height of the keyboard.

🕵️ Root Cause Analysis

To help provide context on why this patch is necessary, we performed a line-by-line analysis of Keyboard.java and Android's WindowInsetsAnimationCompat lifecycle.

Conclusion: Android OS itself natively reports incorrect/fluctuating values during WindowInsets animations, which Capacitor was previously passing directly to JavaScript.

1. The Call Chain Proof

In WindowInsetsAnimationCompat.Callback, the plugin calculates the height like this:

int imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom;

This is a direct query to the Android OS. Capacitor does not perform any layout estimation here; it just acts as a passthrough, asking the system: "What is the bottom inset right now?"

2. The Fluctuation Bug

During the animation cycle (onPrepare -> onStart -> onProgress -> onEnd), the Android OS frequently experiences race conditions with the physical rendering of the System UI and edge-to-edge calculations.

When the OS calculates this incorrectly or drops intermediate frames to 0 (often seen in Android 14/15 edge-to-edge transitions), Capacitor was blindly reading those 0 or intermediate values and broadcasting them via keyboardEventListener.onKeyboardEvent(), creating the JS layout glitch.

3. Why LAST_KNOWN Fixes It

Our LAST_KNOWN event mode acts as a debounce/smoothing layer over the flaky Android OS API to ensure JavaScript only receives the finalized, maximum height. It shields the web layer from Android's native rendering jitter.

Architecture Flow

Click to expand execution flow graph
graph TD
    SystemUI["Android System UI (IME)"]
    Animation["WindowInsetsAnimationCompat (OS Level)"]
    PluginCallback["Keyboard.java: Callback"]
    OnStart["onStart (Fetch imeHeight)"]
    OnProgress["onProgress (Mid-animation insets)"]
    OnEnd["onEnd (Final frame fetch)"]
    JSBridge["WebView JavaScript Events"]

    SystemUI -->|"User taps Input"| Animation
    Animation -->|"Trigger"| PluginCallback
    
    PluginCallback --> OnStart
    PluginCallback --> OnProgress
    PluginCallback --> OnEnd
    
    OnStart -->|"Raw 'bottom' inset"| JSBridge
    OnProgress -->|"Frame-by-frame 'bottom' (Jittery / Buggy in Android)"| JSBridge
    OnEnd -->|"Raw 'bottom' inset (Occasionally inaccurate)"| JSBridge

    style OnProgress fill:#f9d0c4,stroke:#333,stroke-width:2px
    style OnEnd fill:#f9d0c4,stroke:#333,stroke-width:2px
Loading

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions