diff --git a/angular/src/providers/platform.ts b/angular/src/providers/platform.ts index 282b85f196..c757075af3 100644 --- a/angular/src/providers/platform.ts +++ b/angular/src/providers/platform.ts @@ -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 { @@ -20,6 +20,18 @@ export class Platform { */ backButton: BackButtonEmitter = new Subject() as any; + /** + * The keyboardDidShow event emits when the + * on-screen keyboard is presented. + */ + keyboardDidShow = new Subject() as any; + + /** + * The keyboardDidHide event emits when the + * on-screen keyboard is hidden. + */ + keyboardDidHide = new Subject(); + /** * 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; }); diff --git a/core/src/components/app/app.tsx b/core/src/components/app/app.tsx index 89c5076f55..29174bd29d 100644 --- a/core/src/components/app/app.tsx +++ b/core/src/components/app/app.tsx @@ -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()); }); } diff --git a/core/src/interface.d.ts b/core/src/interface.d.ts index d422496903..38e7b30148 100644 --- a/core/src/interface.d.ts +++ b/core/src/interface.d.ts @@ -56,6 +56,10 @@ export interface BackButtonEventDetail { register(priority: number, handler: (processNextHandler: () => void) => Promise | void): void; } +export interface KeyboardEventDetail { + keyboardHeight: number; +} + export interface StyleEventDetail { [styleName: string]: boolean; } diff --git a/core/src/utils/input-shims/hacks/scroll-assist.ts b/core/src/utils/input-shims/hacks/scroll-assist.ts index 6c3514f44a..0a893d9580 100644 --- a/core/src/utils/input-shims/hacks/scroll-assist.ts +++ b/core/src/utils/input-shims/hacks/scroll-assist.ts @@ -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 - await contentEl.scrollByPoint(0, scrollData.scrollAmount, scrollData.scrollDuration); + 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 diff --git a/core/src/utils/input-shims/input-shims.ts b/core/src/utils/input-shims/input-shims.ts index e811215724..aec12625dc 100644 --- a/core/src/utils/input-shims/input-shims.ts +++ b/core/src/utils/input-shims/input-shims.ts @@ -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); } }; diff --git a/core/src/utils/keyboard/index.ts b/core/src/utils/keyboard/index.ts new file mode 100644 index 0000000000..d57ddcd798 --- /dev/null +++ b/core/src/utils/keyboard/index.ts @@ -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 + }; +}; diff --git a/core/src/utils/keyboard/test/index.html b/core/src/utils/keyboard/test/index.html new file mode 100644 index 0000000000..505e204575 --- /dev/null +++ b/core/src/utils/keyboard/test/index.html @@ -0,0 +1,68 @@ + + + + + + App - Keyboard + + + + + + + + + + + + + App - Keyboard + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/utils/keyboard/test/keyboard.spec.ts b/core/src/utils/keyboard/test/keyboard.spec.ts new file mode 100644 index 0000000000..19258a04a8 --- /dev/null +++ b/core/src/utils/keyboard/test/keyboard.spec.ts @@ -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); + }); +});