mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(android): keep add-task input above keyboard on Android 10 (#8295)
* fix(android): keep add-task input above keyboard on Android 10 * fix(android): keep add-task input above keyboard on Android 10 - address comments
This commit is contained in:
parent
0ec8c207a8
commit
7d4c4b1877
6 changed files with 200 additions and 8 deletions
|
|
@ -185,9 +185,11 @@ class CapacitorMainActivity : BridgeActivity() {
|
|||
if (keypadHeight > screenHeight * 0.15) {
|
||||
// keyboard is opened
|
||||
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "true")
|
||||
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", keypadHeight.toString())
|
||||
} else {
|
||||
// keyboard is closed
|
||||
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "false")
|
||||
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", "0")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -153,9 +153,11 @@ class FullscreenActivity : AppCompatActivity() {
|
|||
if (keypadHeight > screenHeight * 0.15) {
|
||||
// keyboard is opened
|
||||
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "true")
|
||||
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", keypadHeight.toString())
|
||||
} else {
|
||||
// keyboard is closed
|
||||
callJSInterfaceFunctionIfExists("next", "isKeyboardShown$", "false")
|
||||
callJSInterfaceFunctionIfExists("next", "keyboardHeightPx$", "0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
118
src/app/core/theme/calc-keyboard-height.spec.ts
Normal file
118
src/app/core/theme/calc-keyboard-height.spec.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { calcKeyboardHeight } from './calc-keyboard-height';
|
||||
|
||||
describe('calcKeyboardHeight', () => {
|
||||
describe('visualViewport reports the IME (modern WebView, e.g. Android 14)', () => {
|
||||
it('returns the obscured area', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 500,
|
||||
nativeKeyboardHeight: 0,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visualViewport does NOT shrink for the IME (e.g. Android 10)', () => {
|
||||
it('falls back to the native height when the viewport did not shrink', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 800, // no shrink → obscured 0
|
||||
nativeKeyboardHeight: 300,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(300);
|
||||
});
|
||||
|
||||
it('returns 0 while the keyboard is closed', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 800,
|
||||
nativeKeyboardHeight: 0,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout viewport shrinks for the IME (adjustResize)', () => {
|
||||
it('cancels the native height against the shrink to avoid a double offset', () => {
|
||||
// innerHeight already shrank by 300 → bar is already above the IME → 0
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 500,
|
||||
visualViewportHeight: 500,
|
||||
nativeKeyboardHeight: 300,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reports only the residual when the shrink is partial', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 700, // shrank 100 of a 300px keyboard
|
||||
visualViewportHeight: 700,
|
||||
nativeKeyboardHeight: 300,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rotation while the keyboard is closed (regression guard)', () => {
|
||||
it('does not report a phantom keyboard from a stale portrait baseline once the baseline is refreshed', () => {
|
||||
// Portrait baseline 800, rotated to landscape innerHeight 400, keyboard
|
||||
// closed. With a fresh baseline (= current innerHeight) there is no
|
||||
// phantom layoutShrink, so the height stays 0.
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 400,
|
||||
visualViewportHeight: 400,
|
||||
nativeKeyboardHeight: 0,
|
||||
baseInnerHeight: 400,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the keyboard correctly in landscape after the baseline is refreshed', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 400,
|
||||
visualViewportHeight: 400, // no viewport shrink for IME
|
||||
nativeKeyboardHeight: 250,
|
||||
baseInnerHeight: 400, // refreshed on the close→landscape transition
|
||||
}),
|
||||
).toBe(250);
|
||||
});
|
||||
|
||||
it('a STALE portrait baseline would have hidden the keyboard (demonstrates the bug the refresh fixes)', () => {
|
||||
// baseInnerHeight still 800 (portrait) but we are in landscape (400) with
|
||||
// the keyboard open → layoutShrink 400 wipes out the 250px keyboard.
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 400,
|
||||
visualViewportHeight: 400,
|
||||
nativeKeyboardHeight: 250,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('visualViewport API absent', () => {
|
||||
it('relies solely on the native height', () => {
|
||||
expect(
|
||||
calcKeyboardHeight({
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: null,
|
||||
nativeKeyboardHeight: 300,
|
||||
baseInnerHeight: 800,
|
||||
}),
|
||||
).toBe(300);
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/app/core/theme/calc-keyboard-height.ts
Normal file
40
src/app/core/theme/calc-keyboard-height.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
export interface KeyboardHeightInput {
|
||||
/** Current layout viewport height (window.innerHeight), CSS px. */
|
||||
innerHeight: number;
|
||||
/** window.visualViewport.height, CSS px, or null when the API is absent. */
|
||||
visualViewportHeight: number | null;
|
||||
/** Native-measured keyboard height (CSS px), 0 when closed. */
|
||||
nativeKeyboardHeight: number;
|
||||
/** window.innerHeight captured while the keyboard was last closed, CSS px. */
|
||||
baseInnerHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-screen keyboard height (CSS px) from two independent signals,
|
||||
* whichever reports coverage:
|
||||
*
|
||||
* - `obscured` — the part of the layout viewport hidden by the IME according to
|
||||
* the Visual Viewport API. Correct on modern WebViews; reads 0 on e.g.
|
||||
* Android 10 where the IME does not shrink the visual viewport.
|
||||
* - `nativeCovered` — the natively measured keyboard height, minus however much
|
||||
* the layout viewport already shrank (`layoutShrink`). The subtraction keeps
|
||||
* devices where `adjustResize` genuinely shrinks the viewport from being
|
||||
* pushed up by a double offset (the fixed bar already sits above the IME).
|
||||
*
|
||||
* NOTE: `nativeKeyboardHeight` (screenHeight − visibleFrame.bottom on the native
|
||||
* side) includes the navigation-bar inset, not just the IME. The caller's
|
||||
* threshold (~100px, larger than a typical 24–48px navbar) is what discards that
|
||||
* phantom offset when the keyboard is closed — do not lower it without
|
||||
* subtracting the navbar inset here.
|
||||
*/
|
||||
export const calcKeyboardHeight = ({
|
||||
innerHeight,
|
||||
visualViewportHeight,
|
||||
nativeKeyboardHeight,
|
||||
baseInnerHeight,
|
||||
}: KeyboardHeightInput): number => {
|
||||
const obscured = visualViewportHeight !== null ? innerHeight - visualViewportHeight : 0;
|
||||
const layoutShrink = Math.max(0, baseInnerHeight - innerHeight);
|
||||
const nativeCovered = Math.max(0, nativeKeyboardHeight - layoutShrink);
|
||||
return Math.max(obscured, nativeCovered);
|
||||
};
|
||||
|
|
@ -47,6 +47,7 @@ import { FlexibleConnectedPositionStrategy } from '@angular/cdk/overlay';
|
|||
import { LS } from '../persistence/storage-keys.const';
|
||||
import { Log } from '../log';
|
||||
import { LayoutService } from '../../core-ui/layout/layout.service';
|
||||
import { calcKeyboardHeight } from './calc-keyboard-height';
|
||||
|
||||
interface NavigationBarPlugin {
|
||||
setColor(options: { color: string; style: 'LIGHT' | 'DARK' }): Promise<void>;
|
||||
|
|
@ -649,7 +650,6 @@ export class GlobalThemeService {
|
|||
*/
|
||||
private _initVisualViewportKeyboardTracking(): void {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const root = this.document.documentElement;
|
||||
// Filter out small differences from URL bar / overlay UI rather than the
|
||||
// IME — keeps us from setting a phantom keyboard offset.
|
||||
|
|
@ -665,15 +665,28 @@ export class GlobalThemeService {
|
|||
const KEYBOARD_RESIZE_DEBOUNCE_MS = 200;
|
||||
let resizeTimer: number | null = null;
|
||||
|
||||
let nativeKeyboardHeight = 0;
|
||||
let baseInnerHeight = window.innerHeight;
|
||||
|
||||
const measureKeyboardHeight = (): number =>
|
||||
calcKeyboardHeight({
|
||||
innerHeight: window.innerHeight,
|
||||
visualViewportHeight: vv ? vv.height : null,
|
||||
nativeKeyboardHeight,
|
||||
baseInnerHeight,
|
||||
});
|
||||
|
||||
const commit = (): void => {
|
||||
const obscured = window.innerHeight - vv.height;
|
||||
const keyboardHeight = obscured > KEYBOARD_THRESHOLD_PX ? obscured : 0;
|
||||
const raw = measureKeyboardHeight();
|
||||
const keyboardHeight = raw > KEYBOARD_THRESHOLD_PX ? raw : 0;
|
||||
root.style.setProperty(CSS_VAR_KEYBOARD_HEIGHT, `${keyboardHeight}px`);
|
||||
};
|
||||
|
||||
const onViewportResize = (): void => {
|
||||
const obscured = window.innerHeight - vv.height;
|
||||
if (obscured <= KEYBOARD_THRESHOLD_PX) {
|
||||
const scheduleCommit = (): void => {
|
||||
if (measureKeyboardHeight() <= KEYBOARD_THRESHOLD_PX) {
|
||||
// Keyboard is (or just became) closed — keep the baseline fresh so a
|
||||
// later rotation/resize doesn't leave a stale value behind.
|
||||
baseInnerHeight = window.innerHeight;
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = null;
|
||||
|
|
@ -691,9 +704,24 @@ export class GlobalThemeService {
|
|||
};
|
||||
|
||||
commit();
|
||||
vv.addEventListener('resize', onViewportResize, { passive: true });
|
||||
|
||||
if (vv) {
|
||||
vv.addEventListener('resize', scheduleCommit, { passive: true });
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW && androidInterface.keyboardHeightPx$) {
|
||||
androidInterface.keyboardHeightPx$
|
||||
.pipe(distinctUntilChanged(), takeUntilDestroyed(this._destroyRef))
|
||||
.subscribe((physicalPx) => {
|
||||
nativeKeyboardHeight = Math.max(0, physicalPx / (window.devicePixelRatio || 1));
|
||||
scheduleCommit();
|
||||
});
|
||||
}
|
||||
|
||||
this._destroyRef.onDestroy(() => {
|
||||
vv.removeEventListener('resize', onViewportResize);
|
||||
if (vv) {
|
||||
vv.removeEventListener('resize', scheduleCommit);
|
||||
}
|
||||
if (resizeTimer !== null) {
|
||||
window.clearTimeout(resizeTimer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export interface AndroidInterface {
|
|||
onPause$: Subject<void>;
|
||||
isInBackground$: Observable<boolean>;
|
||||
isKeyboardShown$: Subject<boolean>;
|
||||
keyboardHeightPx$: Subject<number>;
|
||||
|
||||
onShareWithAttachment$: Subject<AndroidShareData>;
|
||||
|
||||
|
|
@ -168,6 +169,7 @@ if (IS_ANDROID_WEB_VIEW) {
|
|||
androidInterface.onReminderSnooze$ = new ReplaySubject(20);
|
||||
androidInterface.onShareWithAttachment$ = new ReplaySubject(1);
|
||||
androidInterface.isKeyboardShown$ = new BehaviorSubject(false);
|
||||
androidInterface.keyboardHeightPx$ = new BehaviorSubject(0);
|
||||
|
||||
androidInterface.isInBackground$ = merge(
|
||||
androidInterface.onResume$.pipe(mapTo(false)),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue