From 70d9854d8df5259ed715e282a6ca40ca3bea6192 Mon Sep 17 00:00:00 2001 From: Liam DeBeasi Date: Tue, 16 May 2023 15:41:12 -0400 Subject: [PATCH] fix(footer, tab-bar): wait for resize before re-showing (#27417) Issue number: resolves #25990 --------- ## What is the current behavior? 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? - The tab bar and footer wait until after the webview has resized before showing again | before | after | | - | - | | | | 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 ## Other information Dev build: `7.0.6-dev.11683905366.13943af0` --- core/src/components/footer/footer.tsx | 13 +- core/src/components/modal/utils.ts | 2 +- core/src/components/tab-bar/tab-bar.tsx | 13 +- core/src/utils/animation/animation.ts | 2 +- core/src/utils/{window => browser}/index.ts | 4 +- .../src/utils/keyboard/keyboard-controller.ts | 158 +++++++++++++++++- .../keyboard/test/keyboard-controller.spec.ts | 12 +- core/src/utils/native/keyboard.ts | 2 +- core/src/utils/native/status-bar.ts | 2 +- 9 files changed, 185 insertions(+), 23 deletions(-) rename core/src/utils/{window => browser}/index.ts (80%) diff --git a/core/src/components/footer/footer.tsx b/core/src/components/footer/footer.tsx index fcdb283c1b..11e3947ec5 100644 --- a/core/src/components/footer/footer.tsx +++ b/core/src/components/footer/footer.tsx @@ -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 }); } diff --git a/core/src/components/modal/utils.ts b/core/src/components/modal/utils.ts index 99de3ba0e1..9781a4f593 100644 --- a/core/src/components/modal/utils.ts +++ b/core/src/components/modal/utils.ts @@ -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 diff --git a/core/src/components/tab-bar/tab-bar.tsx b/core/src/components/tab-bar/tab-bar.tsx index 327f54f09c..b0a976e08a 100644 --- a/core/src/components/tab-bar/tab-bar.tsx +++ b/core/src/components/tab-bar/tab-bar.tsx @@ -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 }); } diff --git a/core/src/utils/animation/animation.ts b/core/src/utils/animation/animation.ts index 53681440f9..df3eeece63 100644 --- a/core/src/utils/animation/animation.ts +++ b/core/src/utils/animation/animation.ts @@ -1,5 +1,5 @@ +import { win } from '../browser'; import { raf } from '../helpers'; -import { win } from '../window'; import type { Animation, diff --git a/core/src/utils/window/index.ts b/core/src/utils/browser/index.ts similarity index 80% rename from core/src/utils/window/index.ts rename to core/src/utils/browser/index.ts index c34190e0e0..5b3e914a45 100644 --- a/core/src/utils/window/index.ts +++ b/core/src/utils/browser/index.ts @@ -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; diff --git a/core/src/utils/keyboard/keyboard-controller.ts b/core/src/utils/keyboard/keyboard-controller.ts index 845bb6f1d0..a0e2a7c230 100644 --- a/core/src/utils/keyboard/keyboard-controller.ts +++ b/core/src/utils/keyboard/keyboard-controller.ts @@ -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 +): Promise => { 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 | 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 }; }; diff --git a/core/src/utils/keyboard/test/keyboard-controller.spec.ts b/core/src/utils/keyboard/test/keyboard-controller.spec.ts index e623ac9d10..02cb75d673 100644 --- a/core/src/utils/keyboard/test/keyboard-controller.spec.ts +++ b/core/src/utils/keyboard/test/keyboard-controller.spec.ts @@ -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); }); }); diff --git a/core/src/utils/native/keyboard.ts b/core/src/utils/native/keyboard.ts index 2c1bc7e4c4..7325b2bee9 100644 --- a/core/src/utils/native/keyboard.ts +++ b/core/src/utils/native/keyboard.ts @@ -1,4 +1,4 @@ -import { win } from '../window'; +import { win } from '../browser'; // Interfaces source: https://capacitorjs.com/docs/apis/keyboard#interfaces export interface KeyboardResizeOptions { diff --git a/core/src/utils/native/status-bar.ts b/core/src/utils/native/status-bar.ts index a12dfc5d99..f57a15c5be 100644 --- a/core/src/utils/native/status-bar.ts +++ b/core/src/utils/native/status-bar.ts @@ -1,4 +1,4 @@ -import { win } from '../window'; +import { win } from '../browser'; interface StyleOptions { style: Style;