mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-11-10 00:27:41 +08:00
fix(footer, tab-bar): wait for resize before re-showing (#27417)
Issue number: resolves #25990 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> The tab bar and footer are being shown too soon after the keyboard begins to hide. This is happening because the webview resizes _after_ the keyboard begins to dismiss. As a result, it is possible for the tab bar and footer to briefly appear on the top of the keyboard in environments where the webview resizes. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> - The tab bar and footer wait until after the webview has resized before showing again | before | after | | - | - | | <video src="https://user-images.githubusercontent.com/2721089/236905066-42ac17a5-a5bf-458b-9c62-005fcce05e20.MP4"></video> | <video src="https://user-images.githubusercontent.com/2721089/236905185-d2f539d1-6d93-4385-b1cb-24dd7aa06393.MP4"></video> | This code works by adding an optional parameter to the keyboard controller callback called `waitForResize`. When defined, code within Ionic can wait for the webview to resize as a result of the keyboard opening or closing. Tab bar and footer wait for this `waitForResize` promise to resolve before re-showing the relevant elements. This `waitForResize` parameter is only only defined when all of the following are two: **1. The webview resize mode is known and is _not_ "None".** If the webview resize mode is unknown then either the Keyboard plugin is not installed (in which case the tab bar/footer are never hidden in the first place) or the app is being deployed in a browser/PWA environment (in which case the web content typically does not resize). If the webview resize mode is "None" then that means the keyboard plugin is installed, but the webview is configured to never resize when the keyboard opens/closes. As a result, there is no need to wait for the webview to resize. **2. The webview has previously resized.** If the keyboard is closed _before_ the opening keyboard animation completes then it is possible for the webview to never resize. In this case, the webview is at full height and the tab bar/footer can immediately be re-shown. ------ Under the hood, we use a [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) to listen for when the web content resizes. Which element we listen on depends on the resize mode set in the developer's Capacitor app. We determine this in the `getResizeContainer` function. From there, we wait for the ResizeObserver callback, then wait 1 more frame so the promise resolves _after_ the resize has finished. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> Dev build: `7.0.6-dev.11683905366.13943af0`
This commit is contained in:
@ -51,8 +51,17 @@ export class Footer implements ComponentInterface {
|
||||
this.checkCollapsibleFooter();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.keyboardCtrl = createKeyboardController((keyboardOpen) => {
|
||||
async connectedCallback() {
|
||||
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
|
||||
/**
|
||||
* If the keyboard is hiding, then we need to wait
|
||||
* for the webview to resize. Otherwise, the footer
|
||||
* will flicker before the webview resizes.
|
||||
*/
|
||||
if (keyboardOpen === false && waitForResize !== undefined) {
|
||||
await waitForResize;
|
||||
}
|
||||
|
||||
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { win } from '../../utils/browser';
|
||||
import { StatusBar, Style } from '../../utils/native/status-bar';
|
||||
import { win } from '../../utils/window';
|
||||
|
||||
/**
|
||||
* Use y = mx + b to
|
||||
|
||||
@ -61,8 +61,17 @@ export class TabBar implements ComponentInterface {
|
||||
this.selectedTabChanged();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.keyboardCtrl = createKeyboardController((keyboardOpen) => {
|
||||
async connectedCallback() {
|
||||
this.keyboardCtrl = await createKeyboardController(async (keyboardOpen, waitForResize) => {
|
||||
/**
|
||||
* If the keyboard is hiding, then we need to wait
|
||||
* for the webview to resize. Otherwise, the tab bar
|
||||
* will flicker before the webview resizes.
|
||||
*/
|
||||
if (keyboardOpen === false && waitForResize !== undefined) {
|
||||
await waitForResize;
|
||||
}
|
||||
|
||||
this.keyboardVisible = keyboardOpen; // trigger re-render by updating state
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { win } from '../browser';
|
||||
import { raf } from '../helpers';
|
||||
import { win } from '../window';
|
||||
|
||||
import type {
|
||||
Animation,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* When accessing the window, it is important
|
||||
* When accessing the document or window, it is important
|
||||
* to account for SSR applications where the
|
||||
* window is not available. Code that accesses
|
||||
* window when it is not available will crash.
|
||||
@ -21,3 +21,5 @@
|
||||
* not run in an SSR environment.
|
||||
*/
|
||||
export const win: Window | undefined = typeof window !== 'undefined' ? window : undefined;
|
||||
|
||||
export const doc: Document | undefined = typeof document !== 'undefined' ? document : undefined;
|
||||
@ -1,4 +1,45 @@
|
||||
import { win } from '../window';
|
||||
import { doc, win } from '@utils/browser';
|
||||
|
||||
import { KeyboardResize, Keyboard } from '../native/keyboard';
|
||||
|
||||
/**
|
||||
* The element that resizes when the keyboard opens
|
||||
* is going to depend on the resize mode
|
||||
* which is why we check that here.
|
||||
*/
|
||||
const getResizeContainer = (resizeMode?: KeyboardResize): HTMLElement | null => {
|
||||
/**
|
||||
* If doc is undefined then we are
|
||||
* in an SSR environment, so the keyboard
|
||||
* adjustment does not apply.
|
||||
* If the webview does not resize then there
|
||||
* is no container to resize.
|
||||
*/
|
||||
if (doc === undefined || resizeMode === KeyboardResize.None || resizeMode === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The three remaining resize modes: Native, Ionic, and Body
|
||||
* all cause `ion-app` to resize, so we can listen for changes
|
||||
* on that. In the event `ion-app` is not available then
|
||||
* we can fall back to `body`.
|
||||
*/
|
||||
const ionApp = doc.querySelector('ion-app');
|
||||
|
||||
return ionApp ?? doc.body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the height of ion-app or body.
|
||||
* This is used for determining if the webview
|
||||
* has resized before the keyboard closed.
|
||||
* */
|
||||
const getResizeContainerHeight = (resizeMode?: KeyboardResize) => {
|
||||
const containerElement = getResizeContainer(resizeMode);
|
||||
|
||||
return containerElement === null ? 0 : containerElement.clientHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a controller that tracks and reacts to opening or closing the keyboard.
|
||||
@ -6,28 +47,129 @@ import { win } from '../window';
|
||||
* @internal
|
||||
* @param keyboardChangeCallback A function to call when the keyboard opens or closes.
|
||||
*/
|
||||
export const createKeyboardController = (
|
||||
keyboardChangeCallback?: (keyboardOpen: boolean) => void
|
||||
): KeyboardController => {
|
||||
export const createKeyboardController = async (
|
||||
keyboardChangeCallback?: (keyboardOpen: boolean, resizePromise?: Promise<void>) => void
|
||||
): Promise<KeyboardController> => {
|
||||
let keyboardWillShowHandler: (() => void) | undefined;
|
||||
let keyboardWillHideHandler: (() => void) | undefined;
|
||||
let keyboardVisible: boolean;
|
||||
/**
|
||||
* This lets us determine if the webview content
|
||||
* has resized as a result of the keyboard.
|
||||
*/
|
||||
let initialResizeContainerHeight: number;
|
||||
|
||||
const init = async () => {
|
||||
const resizeOptions = await Keyboard.getResizeMode();
|
||||
const resizeMode = resizeOptions === undefined ? undefined : resizeOptions.mode;
|
||||
|
||||
const init = () => {
|
||||
keyboardWillShowHandler = () => {
|
||||
/**
|
||||
* We need to compute initialResizeContainerHeight right before
|
||||
* the keyboard opens to guarantee the resize container is visible.
|
||||
* The resize container may not be visible if we compute this
|
||||
* as soon as the keyboard controller is created.
|
||||
* We should only need to do this once to avoid additional clientHeight
|
||||
* computations.
|
||||
*/
|
||||
if (initialResizeContainerHeight === undefined) {
|
||||
initialResizeContainerHeight = getResizeContainerHeight(resizeMode);
|
||||
}
|
||||
|
||||
keyboardVisible = true;
|
||||
if (keyboardChangeCallback) keyboardChangeCallback(true);
|
||||
fireChangeCallback(keyboardVisible, resizeMode);
|
||||
};
|
||||
|
||||
keyboardWillHideHandler = () => {
|
||||
keyboardVisible = false;
|
||||
if (keyboardChangeCallback) keyboardChangeCallback(false);
|
||||
fireChangeCallback(keyboardVisible, resizeMode);
|
||||
};
|
||||
|
||||
win?.addEventListener('keyboardWillShow', keyboardWillShowHandler);
|
||||
win?.addEventListener('keyboardWillHide', keyboardWillHideHandler);
|
||||
};
|
||||
|
||||
const fireChangeCallback = (state: boolean, resizeMode: KeyboardResize | undefined) => {
|
||||
if (keyboardChangeCallback) {
|
||||
keyboardChangeCallback(state, createResizePromiseIfNeeded(resizeMode));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Code responding to keyboard lifecycles may need
|
||||
* to show/hide content once the webview has
|
||||
* resized as a result of the keyboard showing/hiding.
|
||||
* createResizePromiseIfNeeded provides a way for code to wait for the
|
||||
* resize event that was triggered as a result of the keyboard.
|
||||
*/
|
||||
const createResizePromiseIfNeeded = (resizeMode: KeyboardResize | undefined): Promise<void> | undefined => {
|
||||
if (
|
||||
/**
|
||||
* If we are in an SSR environment then there is
|
||||
* no window to resize. Additionally, if there
|
||||
* is no resize mode or the resize mode is "None"
|
||||
* then initialResizeContainerHeight will be 0
|
||||
*/
|
||||
initialResizeContainerHeight === 0 ||
|
||||
/**
|
||||
* If the keyboard is closed before the webview resizes initially
|
||||
* then the webview will never resize.
|
||||
*/
|
||||
initialResizeContainerHeight === getResizeContainerHeight(resizeMode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resize container so we can
|
||||
* attach the ResizeObserver below to
|
||||
* the correct element.
|
||||
*/
|
||||
const containerElement = getResizeContainer(resizeMode);
|
||||
if (containerElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some part of the web content should resize,
|
||||
* and we need to listen for a resize.
|
||||
*/
|
||||
return new Promise((resolve) => {
|
||||
const callback = () => {
|
||||
/**
|
||||
* As per the spec, the ResizeObserver
|
||||
* will fire when observation starts if
|
||||
* the observed element is rendered and does not
|
||||
* have a size of 0 x 0. However, the watched element
|
||||
* may or may not have resized by the time this first
|
||||
* callback is fired. As a result, we need to check
|
||||
* the dimensions of the element.
|
||||
*
|
||||
* https://www.w3.org/TR/resize-observer/#intro
|
||||
*/
|
||||
if (containerElement.clientHeight === initialResizeContainerHeight) {
|
||||
/**
|
||||
* The resize happened, so stop listening
|
||||
* for resize on this element.
|
||||
*/
|
||||
ro.disconnect();
|
||||
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* In Capacitor there can be delay between when the window
|
||||
* resizes and when the container element resizes, so we cannot
|
||||
* rely on a 'resize' event listener on the window.
|
||||
* Instead, we need to determine when the container
|
||||
* element resizes using a ResizeObserver.
|
||||
*/
|
||||
const ro = new ResizeObserver(callback);
|
||||
ro.observe(containerElement);
|
||||
});
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
win?.removeEventListener('keyboardWillShow', keyboardWillShowHandler!);
|
||||
win?.removeEventListener('keyboardWillHide', keyboardWillHideHandler!);
|
||||
@ -37,7 +179,7 @@ export const createKeyboardController = (
|
||||
|
||||
const isKeyboardVisible = () => keyboardVisible;
|
||||
|
||||
init();
|
||||
await init();
|
||||
return { init, destroy, isKeyboardVisible };
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { createKeyboardController } from '../keyboard-controller';
|
||||
|
||||
describe('Keyboard Controller', () => {
|
||||
it('should update isKeyboardVisible', () => {
|
||||
const keyboardCtrl = createKeyboardController();
|
||||
it('should update isKeyboardVisible', async () => {
|
||||
const keyboardCtrl = await createKeyboardController();
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillShow'));
|
||||
expect(keyboardCtrl.isKeyboardVisible()).toBe(true);
|
||||
@ -11,14 +11,14 @@ describe('Keyboard Controller', () => {
|
||||
expect(keyboardCtrl.isKeyboardVisible()).toBe(false);
|
||||
});
|
||||
|
||||
it('should run the callback', () => {
|
||||
it('should run the callback', async () => {
|
||||
const callbackMock = jest.fn();
|
||||
createKeyboardController(callbackMock);
|
||||
await createKeyboardController(callbackMock);
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillShow'));
|
||||
expect(callbackMock).toHaveBeenCalledWith(true);
|
||||
expect(callbackMock).toHaveBeenCalledWith(true, undefined);
|
||||
|
||||
window.dispatchEvent(new Event('keyboardWillHide'));
|
||||
expect(callbackMock).toHaveBeenCalledWith(false);
|
||||
expect(callbackMock).toHaveBeenCalledWith(false, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { win } from '../window';
|
||||
import { win } from '../browser';
|
||||
|
||||
// Interfaces source: https://capacitorjs.com/docs/apis/keyboard#interfaces
|
||||
export interface KeyboardResizeOptions {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { win } from '../window';
|
||||
import { win } from '../browser';
|
||||
|
||||
interface StyleOptions {
|
||||
style: Style;
|
||||
|
||||
Reference in New Issue
Block a user