Skip to content

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

Description

@lincolnthree

Inconsistent Keyboard Height on Android during WindowInsetsAnimation (Proposed Patch)

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 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.

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