capacitor icon indicating copy to clipboard operation
capacitor copied to clipboard

[Bug]: Incorrect visualViewport/innerHeight values on Android < 15: UI partially overlapped by keyboard

Open Ashe3 opened this issue 2 months ago • 3 comments

Capacitor Version

💊 Capacitor Doctor 💊

Latest Dependencies:

@capacitor/cli: 7.4.3 @capacitor/core: 7.4.3 @capacitor/android: 7.4.3 @capacitor/ios: 7.4.3

Installed Dependencies:

@capacitor/cli: 7.4.3 @capacitor/android: 7.4.3 @capacitor/core: 7.4.3 @capacitor/ios: 7.4.3

[success] iOS looking great! 👌 [success] Android looking great! 👌

Other API Details

npm --version output: 10.9.2
node --version output: v22.17.0
pod --version output: 1.15.2

Platforms Affected

  • [ ] iOS
  • [x] Android
  • [ ] Web

Current Behavior

Summary

After introducing new WebView resize settings (adjustMarginsForEdgeToEdge: 'force', Keyboard.resizeOnFullScreen: true) to support edge-to-edge and better keyboard handling on Android, an issue has been identified on Android < 15.

Problem Details

  • On devices running Android < 15, when the keyboard is open and navigation buttons or gesture navigation are enabled, the values of window.visualViewport.height and window.innerHeight do not account for the bottom navigation bar.
  • As a result, modals sized using these values are partially overlapped by the keyboard (specifically, the bottom area). The "Save" button and other lower UI elements become inaccessible.
  • CSS variables like safe-area-inset-bottom are always zero, so padding-bottom is not automatically added. As far as I understand, this is expected when these settings are used.

How the Issue Manifests

  • On Android < 15 (e.g., OnePlus 12r, Android 14, Samsung S23 Android 13, Pixel 8 Android 14), when opening a modal and the keyboard appears:
    • The calculated modal height (visualViewport.height or window.innerHeight) does not account for the bottom bar.
    • If the modal is anchored to the bottom or uses full available height up to the keyboard, its bottom part is covered by the keyboard.
    • safe-area-inset-bottom is not available, padding-bottom == 0.
  • On Android 15+ (adjustMarginsForEdgeToEdge: 'force'), this issue does not occur because the system correctly subtracts the height of navigation elements from the available viewport.

Logs Demonstrating the Issue

  • Android 14 OnePlus 12R (problem):
    • screen.height = 795
    • window.innerHeight (without keyboard) = 710 → difference 85px (navigation bar)
    • With keyboard open: window.innerHeight = 499
    • The navigation bar still takes up 44px at the bottom—UI is overlapped.
    • safe-area-inset-bottom = 0px
    • Keyboard heigh: 299 pixels
  • Android 15 OnePlus 13R (ok):
    • screen.height = 795
    • window.innerHeight (without keyboard) = 710 → same difference
    • With keyboard open: window.innerHeight = 455
    • The system correctly shrinks the viewport, UI is not overlapped.
    • safe-area-inset-bottom = 0px (but still compensated at the viewport level)
    • Keyboard height: 299 pixels

Visual Examples

Android 13 Android 15

Why This Matters

  • On older Android devices, the UI breaks when entering text in modals: part of the buttons and lower content are hidden by the keyboard.
  • The issue reliably reproduces on all Android < 15 devices with navigation buttons or gesture navigation enabled.
  • Without a way to correctly obtain the bottom safe-area-inset height, we have to use workarounds or hacks, making code maintenance harder and degrading UX on legacy devices.

Expected Behavior

What Is Needed

  • Viewport height should be calculated correctly on all Android versions supported by WebView/Capacitor, so that modal and fixed UI elements are never overlapped by the keyboard or navigation bar. This is expected behavior for modern mobile web libraries.
  • Alternatively, an official API or method should exist to reliably obtain the bottom navigation bar/system panel height for use in layout calculations—making it possible to programmatically avoid UI overlap.

Currently, AFAIK there are no tools or APIs to obtain this data directly , so we are forced to manually estimate the panel height, which is not reliable and leads to inconsistent user experience across devices.

Project Reproduction

None

Additional Information

No response

Ashe3 avatar Oct 08 '25 17:10 Ashe3

We have also encountered this bug and are waiting for some kind of solution.

artpol avatar Oct 30 '25 09:10 artpol

@artpol, for the time being, @Ashe3 assembled the following hook in our app that compensates for the missing bottom padding for Android <15 devices:

import { useEffect, useMemo, useState } from 'react';

import { Keyboard } from '@capacitor/keyboard';

// You can use Capacitor's `Device.getInfo()` here
import { getAppInfo } from '../../config/mobileAppInfo';
import { useIonSafeArea } from '../../hooks/useIonSafeArea';

// Minimum gap required to match design guidelines
const DESIGN_MINIMUM_GAP = 8;
// Android version threshold below which we apply extra padding compensation
const ANDROID_VERSION_THRESHOLD = 15;
// Threshold for detecting gesture navigation vs. classic navigation bar
const NAVIGATION_BAR_HEIGHT_THRESHOLD = 50;
// Extra padding for gesture navigation (usually a thin bar)
const GESTURE_NAVIGATION_COMPENSATION = 10;
// Extra padding for classic navigation bar (usually a thicker bar)
const CLASSIC_NAVIGATION_COMPENSATION = 44;

/**
 * Computes dialog padding for the annotator, taking into account safe area insets and Android-specific quirks.
 *
 * On Android versions below 15, the system navigation buttons (gesture bar or classic buttons)
 * are not properly accounted for in the browser's visualViewport.height or window.innerHeight calculations.
 * This leads to dialogs or popups being positioned too low, sometimes hidden behind the navigation bar or keyboard.
 *
 * To work around this, we manually add extra bottom padding when:
 *   - The platform is Android and the version is less than 15
 *   - The keyboard is open (since this is when the issue is most visible)
 *
 * The extra padding value is determined heuristically, based on the difference between available screen height
 * and the actual viewport height, or a fixed value if needed. This ensures that annotator dialogs remain visible
 * and accessible to the user, regardless of device navigation mode or system UI quirks.
 *
 * For all other platforms and Android 15+, only the safe area insets and standard header/footer heights are used.
 *
 * This hook should be used to provide dialogFloatingPadding to the annotator component.
 *
 * The issue is tracked in https://github.com/ionic-team/capacitor/issues/8181
 */
export function useAnnotatorDialogPadding() {
	const ionSafeAreaPadding = useIonSafeArea();
	const [shouldCompensatePadding, setShouldCompensatePadding] = useState(false);
	const [compensationSize, setCompensationSize] = useState(0);

	useEffect(() => {
		const appInfo = getAppInfo();

		appInfo.then((info) => {
			if (
				info.deviceInfo.platform === 'android' &&
				Number(info.deviceInfo.osVersion) < ANDROID_VERSION_THRESHOLD
			) {
				setShouldCompensatePadding(true);
			}
		});
	}, []);

	useEffect(() => {
		if (!shouldCompensatePadding) return;

		const compensationSizeBase =
			(window.screen.height - window.innerHeight < NAVIGATION_BAR_HEIGHT_THRESHOLD
				? GESTURE_NAVIGATION_COMPENSATION
				: CLASSIC_NAVIGATION_COMPENSATION) + DESIGN_MINIMUM_GAP;

		const showListener = Keyboard.addListener('keyboardWillShow', () => setCompensationSize(compensationSizeBase));
		const hideListener = Keyboard.addListener('keyboardWillHide', () => setCompensationSize(0));
		return () => {
			showListener.then(({ remove }) => remove());
			hideListener.then(({ remove }) => remove());
		};
	}, [shouldCompensatePadding]);

	return useMemo(() => {
		const { bottom: ionSafeAreaBottom } = ionSafeAreaPadding;
		return {
			...ionSafeAreaPadding,
			bottom: ionSafeAreaBottom + compensationSize
		};
	}, [ionSafeAreaPadding, compensationSize]);
}
import { useEffect, useState } from 'react';

import { useWindowSize } from 'usehooks-ts';

export interface IonSafeArea {
	top: number;
	right: number;
	bottom: number;
	left: number;
}

export const useIonSafeArea = () => {
	const [ionSafeArea, setIonSafeArea] = useState<IonSafeArea>(() => getIonSafeArea());

	const { width, height } = useWindowSize({ debounceDelay: 20, initializeWithValue: true });
	useEffect(() => setIonSafeArea(getIonSafeArea()), [width, height]);

	return ionSafeArea;
};

const getIonSafeArea = () => {
	const style = getComputedStyle(document.documentElement);
	return {
		top: parseFloat(style.getPropertyValue('--ion-safe-area-top')),
		right: parseFloat(style.getPropertyValue('--ion-safe-area-right')),
		bottom: parseFloat(style.getPropertyValue('--ion-safe-area-bottom')),
		left: parseFloat(style.getPropertyValue('--ion-safe-area-left'))
	};
};

This issue is indeed super frustrating. Kudos to @Ashe3 for figuring out the required compensations. I hope it helps.

oleksandr-danylchenko avatar Oct 30 '25 11:10 oleksandr-danylchenko

@oleksandr-danylchenko @artpol i have found a better solution for this can you pelase review it. pr - #8221

ish1416 avatar Nov 08 '25 06:11 ish1416