feat(app): keyboard open and close events (#18478)

This commit is contained in:
Liam DeBeasi
2020-04-30 16:07:44 -04:00
committed by GitHub
parent dea9248763
commit ae5f1ddff0
8 changed files with 524 additions and 9 deletions

View File

@ -1,6 +1,6 @@
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, NgZone } from '@angular/core';
import { BackButtonEventDetail, Platforms, getPlatforms, isPlatform } from '@ionic/core';
import { BackButtonEventDetail, KeyboardEventDetail, Platforms, getPlatforms, isPlatform } from '@ionic/core';
import { Subject, Subscription } from 'rxjs';
export interface BackButtonEmitter extends Subject<BackButtonEventDetail> {
@ -20,6 +20,18 @@ export class Platform {
*/
backButton: BackButtonEmitter = new Subject<BackButtonEventDetail>() as any;
/**
* The keyboardDidShow event emits when the
* on-screen keyboard is presented.
*/
keyboardDidShow = new Subject<KeyboardEventDetail>() as any;
/**
* The keyboardDidHide event emits when the
* on-screen keyboard is hidden.
*/
keyboardDidHide = new Subject<void>();
/**
* The pause event emits when the native platform puts the application
* into the background, typically when the user switches to a different
@ -55,6 +67,8 @@ export class Platform {
proxyEvent(this.resume, doc, 'resume');
proxyEvent(this.backButton, doc, 'ionBackButton');
proxyEvent(this.resize, this.win, 'resize');
proxyEvent(this.keyboardDidShow, this.win, 'ionKeyboardDidShow');
proxyEvent(this.keyboardDidHide, this.win, 'ionKeyboardDidHide');
let readyResolve: (value: string) => void;
this._readyPromise = new Promise(res => { readyResolve = res; });

View File

@ -28,6 +28,9 @@ export class App implements ComponentInterface {
if (config.getBoolean('hardwareBackButton', isHybrid)) {
import('../../utils/hardware-back-button').then(module => module.startHardwareBackButton());
}
if (typeof (window as any) !== 'undefined') {
import('../../utils/keyboard').then(module => module.startKeyboardAssist(window));
}
import('../../utils/focus-visible').then(module => module.startFocusVisible());
});
}

View File

@ -56,6 +56,10 @@ export interface BackButtonEventDetail {
register(priority: number, handler: (processNextHandler: () => void) => Promise<any> | void): void;
}
export interface KeyboardEventDetail {
keyboardHeight: number;
}
export interface StyleEventDetail {
[styleName: string]: boolean;
}

View File

@ -6,7 +6,8 @@ import { getScrollData } from './scroll-data';
export const enableScrollAssist = (
componentEl: HTMLElement,
inputEl: HTMLInputElement | HTMLTextAreaElement,
contentEl: HTMLIonContentElement,
contentEl: HTMLIonContentElement | null,
footerEl: HTMLIonFooterElement | null,
keyboardHeight: number
) => {
let coord: any;
@ -29,7 +30,7 @@ export const enableScrollAssist = (
ev.stopPropagation();
// begin the input focus process
jsSetFocus(componentEl, inputEl, contentEl, keyboardHeight);
jsSetFocus(componentEl, inputEl, contentEl, footerEl, keyboardHeight);
}
};
componentEl.addEventListener('touchstart', touchStart, true);
@ -44,11 +45,14 @@ export const enableScrollAssist = (
const jsSetFocus = (
componentEl: HTMLElement,
inputEl: HTMLInputElement | HTMLTextAreaElement,
contentEl: HTMLIonContentElement,
contentEl: HTMLIonContentElement | null,
footerEl: HTMLIonFooterElement | null,
keyboardHeight: number
) => {
const scrollData = getScrollData(componentEl, contentEl, keyboardHeight);
if (Math.abs(scrollData.scrollAmount) < 4) {
if (!contentEl && !footerEl) { return; }
const scrollData = getScrollData(componentEl, (contentEl || footerEl)!, keyboardHeight);
if (contentEl && Math.abs(scrollData.scrollAmount) < 4) {
// the text input is in a safe position that doesn't
// require it to be scrolled into view, just set focus now
inputEl.focus();
@ -73,7 +77,9 @@ const jsSetFocus = (
window.removeEventListener('keyboardWillShow', scrollContent);
// scroll the input into place
if (contentEl) {
await contentEl.scrollByPoint(0, scrollData.scrollAmount, scrollData.scrollDuration);
}
// the scroll view is in the correct position now
// give the native text input focus

View File

@ -30,6 +30,7 @@ export const startInputShims = (config: Config) => {
const inputRoot = componentEl.shadowRoot || componentEl;
const inputEl = inputRoot.querySelector('input') || inputRoot.querySelector('textarea');
const scrollEl = componentEl.closest('ion-content');
const footerEl = (!scrollEl) ? componentEl.closest('ion-footer') as HTMLIonFooterElement | null : null;
if (!inputEl) {
return;
@ -40,8 +41,8 @@ export const startInputShims = (config: Config) => {
hideCaretMap.set(componentEl, rmFn);
}
if (SCROLL_ASSIST && !!scrollEl && scrollAssist && !scrollAssistMap.has(componentEl)) {
const rmFn = enableScrollAssist(componentEl, inputEl, scrollEl, keyboardHeight);
if (SCROLL_ASSIST && (!!scrollEl || !!footerEl) && scrollAssist && !scrollAssistMap.has(componentEl)) {
const rmFn = enableScrollAssist(componentEl, inputEl, scrollEl, footerEl, keyboardHeight);
scrollAssistMap.set(componentEl, rmFn);
}
};

View File

@ -0,0 +1,176 @@
export const KEYBOARD_DID_OPEN = 'ionKeyboardDidShow';
export const KEYBOARD_DID_CLOSE = 'ionKeyboardDidHide';
const KEYBOARD_THRESHOLD = 150;
let previousVisualViewport: any = {};
let currentVisualViewport: any = {};
let previousLayoutViewport: any = {};
let currentLayoutViewport: any = {};
let keyboardOpen = false;
/**
* This is only used for tests
*/
export const resetKeyboardAssist = () => {
previousVisualViewport = {};
currentVisualViewport = {};
previousLayoutViewport = {};
currentLayoutViewport = {};
keyboardOpen = false;
};
export const startKeyboardAssist = (win: Window) => {
startNativeListeners(win);
if (!(win as any).visualViewport) { return; }
currentVisualViewport = copyVisualViewport((win as any).visualViewport);
currentLayoutViewport = copyLayoutViewport(win);
(win as any).visualViewport.onresize = () => {
trackViewportChanges(win);
if (keyboardDidOpen() || keyboardDidResize(win)) {
setKeyboardOpen(win);
} else if (keyboardDidClose(win)) {
setKeyboardClose(win);
}
};
};
/**
* Listen for events fired by native keyboard plugin
* in Capacitor/Cordova so devs only need to listen
* in one place.
*/
const startNativeListeners = (win: Window) => {
win.addEventListener('keyboardDidShow', ev => setKeyboardOpen(win, ev));
win.addEventListener('keyboardDidHide', () => setKeyboardClose(win));
};
export const setKeyboardOpen = (win: Window, ev?: any) => {
fireKeyboardOpenEvent(win, ev);
keyboardOpen = true;
};
export const setKeyboardClose = (win: Window) => {
fireKeyboardCloseEvent(win);
keyboardOpen = false;
};
/**
* Returns `true` if the `keyboardOpen` flag is not
* set, the previous visual viewport width equal the current
* visual viewport width, and if the scaled difference
* of the previous visual viewport height minus the current
* visual viewport height is greater than KEYBOARD_THRESHOLD
*
* We need to be able to accomodate users who have zooming
* enabled in their browser (or have zoomed in manually) which
* is why we take into account the current visual viewport's
* scale value.
*/
export const keyboardDidOpen = (): boolean => {
const scaledHeightDifference = (previousVisualViewport.height - currentVisualViewport.height) * currentVisualViewport.scale;
return (
!keyboardOpen &&
previousVisualViewport.width === currentVisualViewport.width &&
scaledHeightDifference > KEYBOARD_THRESHOLD &&
!layoutViewportDidChange()
);
};
/**
* Returns `true` if the keyboard is open,
* but the keyboard did not close
*/
export const keyboardDidResize = (win: Window): boolean => {
return keyboardOpen && !keyboardDidClose(win);
};
/**
* Determine if the keyboard was closed
* Returns `true` if the `keyboardOpen` flag is set and
* the current visual viewport height equals the
* layout viewport height.
*/
export const keyboardDidClose = (win: Window): boolean => {
return keyboardOpen && currentVisualViewport.height === win.innerHeight;
};
/**
* Determine if the layout viewport has
* changed since the last visual viewport change.
* It is rare that a layout viewport change is not
* associated with a visual viewport change so we
* want to make sure we don't get any false positives.
*/
const layoutViewportDidChange = (): boolean => {
return (
currentLayoutViewport.width !== previousLayoutViewport.width ||
currentLayoutViewport.height !== previousLayoutViewport.height
);
};
/**
* Dispatch a keyboard open event
*/
const fireKeyboardOpenEvent = (win: Window, nativeEv?: any): void => {
const keyboardHeight = nativeEv ? nativeEv.keyboardHeight : win.innerHeight - currentVisualViewport.height;
const ev = new CustomEvent(KEYBOARD_DID_OPEN, {
detail: { keyboardHeight }
});
win.dispatchEvent(ev);
};
/**
* Dispatch a keyboard close event
*/
const fireKeyboardCloseEvent = (win: Window): void => {
const ev = new CustomEvent(KEYBOARD_DID_CLOSE);
win.dispatchEvent(ev);
};
/**
* Given a window object, create a copy of
* the current visual and layout viewport states
* while also preserving the previous visual and
* layout viewport states
*/
export const trackViewportChanges = (win: Window) => {
previousVisualViewport = { ...currentVisualViewport };
currentVisualViewport = copyVisualViewport((win as any).visualViewport);
previousLayoutViewport = { ...currentLayoutViewport };
currentLayoutViewport = copyLayoutViewport(win);
};
/**
* Creates a deep copy of the visual viewport
* at a given state
*/
export const copyVisualViewport = (visualViewport: any): any => {
return {
width: Math.round(visualViewport.width),
height: Math.round(visualViewport.height),
offsetTop: visualViewport.offsetTop,
offsetLeft: visualViewport.offsetLeft,
pageTop: visualViewport.pageTop,
pageLeft: visualViewport.pageLeft,
scale: visualViewport.scale
};
};
/**
* Creates a deep copy of the layout viewport
* at a given state
*/
export const copyLayoutViewport = (win: Window): any => {
return {
width: win.innerWidth,
height: win.innerHeight
};
};

View File

@ -0,0 +1,68 @@
<!DOCTYPE html>
<html dir="ltr">
<head>
<meta charset="UTF-8">
<title>App - Keyboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link href="../../../../css/ionic.bundle.css" rel="stylesheet">
<link href="../../../../scripts/testing/styles.css" rel="stylesheet">
<script src="../../../../scripts/testing/scripts.js"></script>
<script src="../../../../dist/ionic.js"></script>
<style>
f {
display: block;
margin: 15px auto;
max-width: 150px;
height: 150px;
background: blue;
}
ion-footer {
transition: all 0.5s cubic-bezier(0.38, 0.7, 0.128, 1);
}
</style>
</head>
<body>
<ion-app>
<ion-header>
<ion-toolbar color="primary">
<ion-title>App - Keyboard</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<f></f>
<f></f>
<f></f>
<f></f>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-input type="text" placeholder="Enter some text..."></ion-input>
<ion-input type="text" placeholder="Enter some text..."></ion-input>
</ion-toolbar>
</ion-footer>
</ion-app>
<script>
const input = document.querySelector('ion-footer');
window.addEventListener('ionKeyboardDidShow', (e) => {
console.log('ionKeyboardDidShow');
setInputOffset(e.detail.keyboardHeight);
});
window.addEventListener('ionKeyboardDidHide', () => {
console.log('ionKeyboardDidHide');
setInputOffset(0);
});
function setInputOffset(offset) {
input.setAttribute('style', `transform: translate3d(0, ${-offset}px, 0)`);
}
</script>
</body>
</html>

View File

@ -0,0 +1,243 @@
import { copyLayoutViewport, copyVisualViewport, setKeyboardClose, setKeyboardOpen, keyboardDidClose, keyboardDidOpen, keyboardDidResize, resetKeyboardAssist, startKeyboardAssist, trackViewportChanges, KEYBOARD_DID_OPEN, KEYBOARD_DID_CLOSE } from '../';
const mockVisualViewport = (win: Window, visualViewport: any = { width: 320, height: 568 }, layoutViewport = { innerWidth: 320, innerHeight: 568 }): any => {
win.visualViewport = {
width: 320,
height: 568,
offsetTop: 0,
offsetLeft: 0,
pageTop: 0,
pageLeft: 0,
scale: 1,
onresize: undefined,
onscroll: undefined
};
win.visualViewport = Object.assign(win.visualViewport, visualViewport);
win = Object.assign(win, layoutViewport);
win.dispatchEvent = jest.fn(() => {});
trackViewportChanges(win);
return win;
}
const resizeVisualViewport = (win: Window, visualViewport: any = {}) => {
win.visualViewport = Object.assign(win.visualViewport, visualViewport);
if (win.visualViewport.onresize) {
win.visualViewport.onresize();
} else {
trackViewportChanges(win);
}
}
describe('Keyboard Assist Tests', () => {
describe('copyLayoutViewport()', () => {
it('should properly copy the layout viewport', () => {
const win = {
innerWidth: 100,
innerHeight: 200
};
const copiedViewport = copyLayoutViewport(win);
win.innerWidth = 400;
win.innerHeight = 800;
expect(copiedViewport.width).toEqual(100);
expect(copiedViewport.height).toEqual(200);
});
});
describe('copyVisualViewport()', () => {
it('should properly copy the visual viewport', () => {
const visualViewport = {
width: 100,
height: 200,
offsetTop: 5,
offsetLeft: 10,
pageTop: 0,
pageLeft: 0,
scale: 2
};
const copiedViewport = copyVisualViewport(visualViewport);
visualViewport.width = 400;
visualViewport.height = 800;
visualViewport.scale = 3;
visualViewport.offsetTop = 0;
expect(copiedViewport.width).toEqual(100);
expect(copiedViewport.height).toEqual(200);
expect(copiedViewport.scale).toEqual(2);
expect(copiedViewport.offsetTop).toEqual(5);
});
});
describe('setKeyboardOpen()', () => {
it('should dispatch the keyboard open event on the window', () => {
window.dispatchEvent = jest.fn(() => {});
setKeyboardOpen(window);
expect(window.dispatchEvent.mock.calls.length).toEqual(1);
expect(window.dispatchEvent.mock.calls[0][0].type).toEqual(KEYBOARD_DID_OPEN);
});
});
describe('setKeyboardClose()', () => {
it('should dispatch the keyboard close event on the window', () => {
window.dispatchEvent = jest.fn(() => {});
setKeyboardClose(window);
expect(window.dispatchEvent.mock.calls.length).toEqual(1);
expect(window.dispatchEvent.mock.calls[0][0].type).toEqual(KEYBOARD_DID_CLOSE);
});
});
describe('keyboardDidOpen()', () => {
beforeEach(() => {
resetKeyboardAssist(window);
mockVisualViewport(window);
});
it('should return true when visual viewport height < layout viewport height and meets or exceeds the keyboard threshold', () => {
resizeVisualViewport(window, { height: 200 });
expect(keyboardDidOpen(window)).toEqual(true);
});
it('should return false when visual viewport height < layout viewport heigh but does not meet the keyboard threshold', () => {
resizeVisualViewport(window, { height: 500 });
expect(keyboardDidOpen(window)).toEqual(false);
});
it('should return false on orientation change', () => {
resizeVisualViewport(window, { width: 320, height: 250 });
resizeVisualViewport(window, { width: 250, height: 320 });
expect(keyboardDidOpen(window)).toEqual(false);
});
it('should return false when both the visual and layout viewports change', () => {
resizeVisualViewport(window, { width: 250, height: 320 }, { innerWidth: 250, innerHeight: 320 });
expect(keyboardDidOpen(window)).toEqual(false);
});
it('should return true when the keyboard shows even if the user is zoomed in', () => {
// User zooms in
resizeVisualViewport(window, { width: 160, height: 284, scale: 2 });
// User taps input and keyboard appears
resizeVisualViewport(window, { width: 160, height: 184, scale: 2 });
expect(keyboardDidOpen(window)).toEqual(true);
});
});
describe('keyboardDidClose()', () => {
beforeEach(() => {
resetKeyboardAssist(window);
mockVisualViewport(window);
});
it('should return false when keyboard is not open', () => {
expect(keyboardDidClose(window)).toEqual(false);
});
it('should return false when keyboard is open but visual viewport !== layout viewport', () => {
resizeVisualViewport(window, { width: 320, height: 250 });
setKeyboardOpen(window);
expect(keyboardDidClose(window)).toEqual(false);
});
it('should return true when keyboard is open and viewport === layout viewport', () => {
resizeVisualViewport(window, { width: 320, height: 250 });
setKeyboardOpen(window);
resizeVisualViewport(window, { width: 320, height: 568 });
expect(keyboardDidClose(window)).toEqual(true);
});
it('should return false on orientation change', () => {
resizeVisualViewport(window, { width: 320, height: 250 });
setKeyboardOpen(window);
resizeVisualViewport(window, { width: 250, height: 320 });
expect(keyboardDidClose(window)).toEqual(false);
});
});
describe('keyboardDidResize()', () => {
it('should return true when the keyboard is open but did not close', () => {
mockVisualViewport(window, { width: 250, height: 320 });
setKeyboardOpen(window);
mockVisualViewport(window, { width: 250, height: 300 });
expect(keyboardDidResize(window)).toEqual(true);
});
it('should return false when the keyboard is not open', () => {
mockVisualViewport(window);
expect(keyboardDidResize(window)).toEqual(false);
});
it('should return false when the keyboard has closed', () => {
mockVisualViewport(window, { width: 320, height: 250 });
setKeyboardOpen(window);
setKeyboardClose(window);
expect(keyboardDidResize(window)).toEqual(false);
});
});
});
describe('Keyboard Assist Integration', () => {
beforeEach(() => {
resetKeyboardAssist(window);
mockVisualViewport(window);
startKeyboardAssist(window);
});
it('should properly set the keyboard to be open', () => {
resizeVisualViewport(window, { width: 320, height: 350 });
expect(window.dispatchEvent.mock.calls.length).toEqual(1);
expect(window.dispatchEvent.mock.calls[0][0].type).toEqual(KEYBOARD_DID_OPEN);
});
it('should properly set the keyboard to be closed', () => {
resizeVisualViewport(window, { width: 320, height: 350 });
resizeVisualViewport(window, { width: 320, height: 568 });
expect(window.dispatchEvent.mock.calls.length).toEqual(2);
expect(window.dispatchEvent.mock.calls[1][0].type).toEqual(KEYBOARD_DID_CLOSE);
});
it('should properly set the keyboard to be resized', () => {
resizeVisualViewport(window, { width: 320, height: 350 });
resizeVisualViewport(window, { width: 320, height: 360 });
expect(window.dispatchEvent.mock.calls.length).toEqual(2);
expect(window.dispatchEvent.mock.calls[0][0].type).toEqual(KEYBOARD_DID_OPEN);
expect(window.dispatchEvent.mock.calls[1][0].type).toEqual(KEYBOARD_DID_OPEN);
});
it('should not set keyboard open on orientation change', () => {
resizeVisualViewport(window, { width: 568, height: 320 });
expect(window.dispatchEvent.mock.calls.length).toEqual(0);
});
});