ionic-team / ionic-team/capacitor-keyboard

Inconsistent Keyboard Height on Android during WindowInsetsAnimation (Proposed Patch)

Open
#80 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
3
Forks
21
PR merge metrics
No merged PRs in 30d

Description

## 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 ionic-team/capacitor-plugins#2205).

This issue is very similar to the previously reported (and closed) **Issue ionic-team/capacitor-plugins#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)
```typescript
/// 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
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 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:
```java
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

```mermaid
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
```

Contributor guide

Open the contributing guide

Research direction

Start by reviewing the attached changes in android/src/main/java/com/capacitorjs/plugins/keyboard/Keyboard.java, then check KeyboardPlugin.java and definitions.d.ts for configuration and event wiring. Compare the animation callbacks and inset handling with the current behavior; done means the proposed eventMode configuration and stabilized keyboard-height events are consistently represented across these files.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, java, typescript
Domain
mobile-dev
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.