Skip to content

Commit

Permalink
fix(footer, tab-bar): wait for resize before re-showing (#27417)
Browse files Browse the repository at this point in the history
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. -->

<!-- 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.

<!-- 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.

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->

<!-- 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`
  • Loading branch information
liamdebeasi authored and Brandy Carney committed May 22, 2023
1 parent 70dbc9d commit 8889324
Show file tree
Hide file tree
Showing 8 changed files with 184 additions and 22 deletions.
13 changes: 11 additions & 2 deletions core/src/components/footer/footer.tsx
Expand Up @@ -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
});
}
Expand Down
13 changes: 11 additions & 2 deletions core/src/components/tab-bar/tab-bar.tsx
Expand Up @@ -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
});
}
Expand Down
2 changes: 1 addition & 1 deletion 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,
Expand Down
@@ -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.
Expand All @@ -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;
158 changes: 150 additions & 8 deletions core/src/utils/keyboard/keyboard-controller.ts
@@ -1,33 +1,175 @@
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.
*
* @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!);
Expand All @@ -37,7 +179,7 @@ export const createKeyboardController = (

const isKeyboardVisible = () => keyboardVisible;

init();
await init();
return { init, destroy, isKeyboardVisible };
};

Expand Down
12 changes: 6 additions & 6 deletions 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);
Expand All @@ -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);
});
});
2 changes: 1 addition & 1 deletion 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 {
Expand Down
2 changes: 1 addition & 1 deletion core/src/utils/native/status-bar.ts
@@ -1,4 +1,4 @@
import { win } from '../window';
import { win } from '../browser';

interface StyleOptions {
style: Style;
Expand Down

0 comments on commit 8889324

Please sign in to comment.