Skip to content

fix(scroll-assist): re-run when keyboard changes #28174

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion core/src/utils/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@
* Note: Code inside of this if-block will
* not run in an SSR environment.
*/
export const win: Window | undefined = typeof window !== 'undefined' ? window : undefined;

/**
* Even listeners on the window typically expect
* Event types for the listener parameter. If you want to listen
* on the window for certain CustomEvent types you can add that definition
* here as long as you are using the "win" utility below.
*/
type IonicWindow = Window & {
addEventListener(
type: 'ionKeyboardDidShow',
listener: (ev: CustomEvent<{ keyboardHeight: number }>) => void,
options?: boolean | AddEventListenerOptions
): void;
removeEventListener(
type: 'ionKeyboardDidShow',
listener: (ev: CustomEvent<{ keyboardHeight: number }>) => void,
options?: boolean | AddEventListenerOptions
): void;
};

export const win: IonicWindow | undefined = typeof window !== 'undefined' ? window : undefined;

export const doc: Document | undefined = typeof document !== 'undefined' ? document : undefined;
118 changes: 114 additions & 4 deletions core/src/utils/input-shims/hacks/scroll-assist.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { KeyboardResizeOptions } from '@capacitor/keyboard';
import { win } from '@utils/browser';

import { getScrollElement, scrollByPoint } from '../../content';
import { raf } from '../../helpers';
Expand Down Expand Up @@ -34,6 +35,98 @@ export const enableScrollAssist = (
const addScrollPadding =
enableScrollPadding && (keyboardResize === undefined || keyboardResize.mode === KeyboardResize.None);

/**
* This tracks whether or not the keyboard has been
* presented for a single focused text field. Note
* that it does not track if the keyboard is open
* in general such as if the keyboard is open for
* a different focused text field.
*/
let hasKeyboardBeenPresentedForTextField = false;

/**
* When adding scroll padding we need to know
* how much of the viewport the keyboard obscures.
* We do this by subtracting the keyboard height
* from the platform height.
*
* If we compute this value when switching between
* inputs then the webview may already be resized.
* At this point, `win.innerHeight` has already accounted
* for the keyboard meaning we would then subtract
* the keyboard height again. This will result in the input
* being scrolled more than it needs to.
*/
const platformHeight = win !== undefined ? win.innerHeight : 0;

/**
* Scroll assist is run when a text field
* is focused. However, it may need to
* re-run when the keyboard size changes
* such that the text field is now hidden
* underneath the keyboard.
* This function re-runs scroll assist
* when that happens.
*
* One limitation of this is on a web browser
* where native keyboard APIs do not have cross-browser
* support. `ionKeyboardDidShow` relies on the Visual Viewport API.
* This means that if the keyboard changes but does not change
* geometry, then scroll assist will not re-run even if
* the user has scrolled the text field under the keyboard.
* This is not a problem when running in Cordova/Capacitor
* because `ionKeyboardDidShow` uses the native events
* which fire every time the keyboard changes.
*/
const keyboardShow = (ev: CustomEvent<{ keyboardHeight: number }>) => {
/**
* If the keyboard has not yet been presented
* for this text field then the text field has just
* received focus. In that case, the focusin listener
* will run scroll assist.
*/
if (hasKeyboardBeenPresentedForTextField === false) {
hasKeyboardBeenPresentedForTextField = true;
return;
}

/**
* Otherwise, the keyboard has already been presented
* for the focused text field.
* This means that the keyboard likely changed
* geometry, and we need to re-run scroll assist.
* This can happen when the user rotates their device
* or when they switch keyboards.
*
* Make sure we pass in the computed keyboard height
* rather than the estimated keyboard height.
*
* Since the keyboard is already open then we do not
* need to wait for the webview to resize, so we pass
* "waitForResize: false".
*/
jsSetFocus(
componentEl,
inputEl,
contentEl,
footerEl,
ev.detail.keyboardHeight,
addScrollPadding,
disableClonedInput,
platformHeight,
false
);
};

/**
* Reset the internal state when the text field loses focus.
*/
const focusOut = () => {
hasKeyboardBeenPresentedForTextField = false;
win?.removeEventListener('ionKeyboardDidShow', keyboardShow);
componentEl.removeEventListener('focusout', focusOut, true);
};

/**
* When the input is about to receive
* focus, we need to move it to prevent
Expand All @@ -50,12 +143,27 @@ export const enableScrollAssist = (
inputEl.removeAttribute(SKIP_SCROLL_ASSIST);
return;
}
jsSetFocus(componentEl, inputEl, contentEl, footerEl, keyboardHeight, addScrollPadding, disableClonedInput);
jsSetFocus(
componentEl,
inputEl,
contentEl,
footerEl,
keyboardHeight,
addScrollPadding,
disableClonedInput,
platformHeight
);

win?.addEventListener('ionKeyboardDidShow', keyboardShow);
componentEl.addEventListener('focusout', focusOut, true);
};

componentEl.addEventListener('focusin', focusIn, true);

return () => {
componentEl.removeEventListener('focusin', focusIn, true);
win?.removeEventListener('ionKeyboardDidShow', keyboardShow);
componentEl.removeEventListener('focusout', focusOut, true);
};
};

Expand Down Expand Up @@ -84,12 +192,14 @@ const jsSetFocus = async (
footerEl: HTMLIonFooterElement | null,
keyboardHeight: number,
enableScrollPadding: boolean,
disableClonedInput = false
disableClonedInput = false,
platformHeight = 0,
waitForResize = true
) => {
if (!contentEl && !footerEl) {
return;
}
const scrollData = getScrollData(componentEl, (contentEl || footerEl)!, keyboardHeight);
const scrollData = getScrollData(componentEl, (contentEl || footerEl)!, keyboardHeight, platformHeight);

if (contentEl && Math.abs(scrollData.scrollAmount) < 4) {
// the text input is in a safe position that doesn't
Expand Down Expand Up @@ -191,7 +301,7 @@ const jsSetFocus = async (
* bandwidth to become available.
*/
const totalScrollAmount = scrollEl.scrollHeight - scrollEl.clientHeight;
if (scrollData.scrollAmount > totalScrollAmount - scrollEl.scrollTop) {
if (waitForResize && scrollData.scrollAmount > totalScrollAmount - scrollEl.scrollTop) {
/**
* On iOS devices, the system will show a "Passwords" bar above the keyboard
* after the initial keyboard is shown. This prevents the webview from resizing
Expand Down
9 changes: 7 additions & 2 deletions core/src/utils/input-shims/hacks/scroll-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ export interface ScrollData {
inputSafeY: number;
}

export const getScrollData = (componentEl: HTMLElement, contentEl: HTMLElement, keyboardHeight: number): ScrollData => {
export const getScrollData = (
componentEl: HTMLElement,
contentEl: HTMLElement,
keyboardHeight: number,
platformHeight: number
): ScrollData => {
const itemEl = (componentEl.closest('ion-item,[ion-item]') as HTMLElement) ?? componentEl;
return calcScrollData(
itemEl.getBoundingClientRect(),
contentEl.getBoundingClientRect(),
keyboardHeight,
(componentEl as any).ownerDocument.defaultView.innerHeight // TODO(FW-2832): type
platformHeight
);
};

Expand Down