From 70576d9ac20e764446ce808ae330e929535050c4 Mon Sep 17 00:00:00 2001 From: Tim Lancina Date: Sat, 11 Apr 2015 05:08:33 -0500 Subject: [PATCH] perf(keyboard): keyboard improvements Improves keyboard support for older Android devices, orientation change, modals, along with general performance improvements. Tests to come :) --- js/utils/keyboard.js | 658 ++++++++++++++++++++++------- js/views/scrollView.js | 148 ++++--- test/unit/utils/keyboard.unit.js | 10 + test/unit/views/scrollView.unit.js | 2 + 4 files changed, 615 insertions(+), 203 deletions(-) diff --git a/js/utils/keyboard.js b/js/utils/keyboard.js index 188ff34de96..e42af900a51 100644 --- a/js/utils/keyboard.js +++ b/js/utils/keyboard.js @@ -61,74 +61,140 @@ * */ -var keyboardViewportHeight = getViewportHeight(); -var keyboardIsOpen; +/** + * The current viewport height. + */ +var keyboardCurrentViewportHeight; + +/** + * The viewport height when in portrait orientation. + */ +var keyboardPortraitViewportHeight = 0; + +/** + * The viewport height when in landscape orientation. + */ +var keyboardLandscapeViewportHeight = 0; + +/** + * The currently focused input. + */ var keyboardActiveElement; -var keyboardFocusOutTimer; -var keyboardFocusInTimer; -var keyboardPollHeightTimer; -var keyboardLastShow = 0; -var KEYBOARD_OPEN_CSS = 'keyboard-open'; -var SCROLL_CONTAINER_CSS = 'scroll'; +/** + * The scroll view containing the currently focused input. + */ +var scrollView; -ionic.keyboard = { - isOpen: false, - height: null, - landscape: false, +/** + * Timer for the setInterval that polls window.innerHeight to determine whether + * the layout has updated for the keyboard showing/hiding. + */ +var waitForResizeTimer; - hide: function() { - clearTimeout(keyboardFocusInTimer); - clearTimeout(keyboardFocusOutTimer); - clearTimeout(keyboardPollHeightTimer); +/** + * Sometimes when switching inputs or orientations, focusout will fire before + * focusin, so this timer is for the small setTimeout to determine if we should + * really focusout/hide the keyboard. + */ +var keyboardFocusOutTimer; - ionic.keyboard.isOpen = false; +/** + * on Android, orientationchange will fire before the keyboard plugin notifies + * the browser that the keyboard will show/is showing, so this flag indicates + * to nativeShow that there was an orientationChange and we should update + * the viewport height with an accurate keyboard height value + */ +var wasOrientationChange = false; - ionic.trigger('resetScrollView', { - target: keyboardActiveElement - }, true); +/** + * CSS class added to the body indicating the keyboard is open. + */ +var KEYBOARD_OPEN_CSS = 'keyboard-open'; - ionic.requestAnimationFrame(function(){ - document.body.classList.remove(KEYBOARD_OPEN_CSS); - }); +/** + * CSS class that indicates a scroll container. + */ +var SCROLL_CONTAINER_CSS = 'scroll-content'; - // the keyboard is gone now, remove the touchmove that disables native scroll - if (window.navigator.msPointerEnabled) { - document.removeEventListener("MSPointerMove", keyboardPreventDefault); - } else { - document.removeEventListener('touchmove', keyboardPreventDefault); - } - document.removeEventListener('keydown', keyboardOnKeyDown); +/** + * Ionic keyboard namespace. + * @namespace keyboard + */ +ionic.keyboard = { + + /** + * Whether the keyboard is open or not. + */ + isOpen: false, - if( keyboardHasPlugin() ) { + /** + * Whether the keyboard is closing or not. + */ + isClosing: false, + + /** + * Whether the keyboard is opening or not. + */ + isOpening: false, + + /** + * The height of the keyboard in pixels, as reported by the keyboard plugin. + * If the plugin is not available, calculated as the difference in + * window.innerHeight after the keyboard has shown. + */ + height: 0, + + /** + * Whether the device is in landscape orientation or not. + */ + isLandscape: false, + + /** + * Hide the keyboard, if it is open. + */ + hide: function() { + if (keyboardHasPlugin()) { cordova.plugins.Keyboard.close(); } + keyboardActiveElement && keyboardActiveElement.blur(); }, + /** + * An alias for cordova.plugins.Keyboard.show(). If the keyboard plugin + * is installed, show the keyboard. + */ show: function() { - if( keyboardHasPlugin() ) { + if (keyboardHasPlugin()) { cordova.plugins.Keyboard.show(); } } }; -function keyboardInit() { - if( keyboardHasPlugin() ) { - window.addEventListener('native.keyboardshow', keyboardNativeShow); - window.addEventListener('native.keyboardhide', keyboardFocusOut); +// Initialize the viewport height (after ionic.keyboard.height has been +// defined). +keyboardCurrentViewportHeight = getViewportHeight(); - //deprecated - window.addEventListener('native.showkeyboard', keyboardNativeShow); - window.addEventListener('native.hidekeyboard', keyboardFocusOut); + /* Event handlers */ +/* ------------------------------------------------------------------------- */ + +/** + * Event handler for first touch event, initializes all event listeners + * for keyboard related events. + */ +function keyboardInit() { + var debouncedKeyboardFocusIn = ionic.debounce(keyboardFocusIn, 200, true); + + if (keyboardHasPlugin()) { + window.addEventListener('native.keyboardshow', ionic.debounce(keyboardNativeShow, 100, true)); + window.addEventListener('native.keyboardhide', keyboardFocusOut); } else { document.body.addEventListener('focusout', keyboardFocusOut); } - document.body.addEventListener('ionic.focusin', keyboardBrowserFocusIn); - document.body.addEventListener('focusin', keyboardBrowserFocusIn); - - document.body.addEventListener('orientationchange', keyboardOrientationChange); + document.body.addEventListener('ionic.focusin', debouncedKeyboardFocusIn); + document.body.addEventListener('focusin', debouncedKeyboardFocusIn); if (window.navigator.msPointerEnabled) { document.removeEventListener("MSPointerDown", keyboardInit); @@ -137,83 +203,74 @@ function keyboardInit() { } } +/** + * Event handler for 'native.keyboardshow' event, sets keyboard.height to the + * reported height and keyboard.isOpening to true. Then calls + * keyboardWaitForResize with keyboardShow or keyboardUpdateViewportHeight as + * the callback depending on whether the event was triggered by a focusin or + * an orientationchange. + */ function keyboardNativeShow(e) { clearTimeout(keyboardFocusOutTimer); - ionic.keyboard.height = e.keyboardHeight; -} + //console.log("keyboardNativeShow fired at: " + Date.now()); + //console.log("keyboardNativeshow window.innerHeight: " + window.innerHeight); -function keyboardBrowserFocusIn(e) { - if( !e.target || e.target.readOnly || !ionic.tap.isKeyboardElement(e.target) || !keyboardIsWithinScroll(e.target) ) return; - - //console.log("keyboardBrowserFocusIn"); - document.addEventListener('keydown', keyboardOnKeyDown, false); - - document.body.scrollTop = 0; - document.body.querySelector('.scroll-content').scrollTop = 0; + if (!ionic.keyboard.isOpen || ionic.keyboard.isClosing) { + ionic.keyboard.isOpening = true; + ionic.keyboard.isClosing = false; + } - keyboardActiveElement = e.target; + ionic.keyboard.height = e.keyboardHeight; + //console.log('nativeshow keyboard height:' + e.keyboardHeight); - keyboardSetShow(e); + if (wasOrientationChange) { + keyboardWaitForResize(keyboardUpdateViewportHeight, true); + } else { + keyboardWaitForResize(keyboardShow, true); + } } -function keyboardSetShow(e) { - clearTimeout(keyboardFocusInTimer); +/** + * Event handler for 'focusin' and 'ionic.focusin' events. Initializes + * keyboard state (keyboardActiveElement and keyboard.isOpening) for the + * appropriate adjustments once the window has resized. If not using the + * keyboard plugin, calls keyboardWaitForResize with keyboardShow as the + * callback or keyboardShow right away if the keyboard is already open. If + * using the keyboard plugin does nothing and lets keyboardNativeShow handle + * adjustments with a more accurate keyboard height. + */ +function keyboardFocusIn(e) { clearTimeout(keyboardFocusOutTimer); + //console.log("keyboardFocusIn from: " + e.type + " at: " + Date.now()); - keyboardFocusInTimer = setTimeout(function(){ - if ( keyboardLastShow + 350 > Date.now() ) return; - //console.log('keyboardSetShow'); - keyboardLastShow = Date.now(); - var keyboardHeight; - var elementBounds = keyboardActiveElement.getBoundingClientRect(); - var count = 0; - - keyboardPollHeightTimer = setInterval(function(){ - - keyboardHeight = keyboardGetHeight(); - if (count > 10){ - clearInterval(keyboardPollHeightTimer); - //waited long enough, just guess - keyboardHeight = 275; - } - if (keyboardHeight){ - clearInterval(keyboardPollHeightTimer); - keyboardShow(e.target, elementBounds.top, elementBounds.bottom, keyboardViewportHeight, keyboardHeight); - } - count++; - - }, 100); - }, 32); -} - -function keyboardShow(element, elementTop, elementBottom, viewportHeight, keyboardHeight) { - var details = { - target: element, - elementTop: Math.round(elementTop), - elementBottom: Math.round(elementBottom), - keyboardHeight: keyboardHeight, - viewportHeight: viewportHeight - }; - - details.hasPlugin = keyboardHasPlugin(); - - details.contentHeight = viewportHeight - keyboardHeight; - - //console.log('keyboardShow', keyboardHeight, details.contentHeight); - - // figure out if the element is under the keyboard - details.isElementUnderKeyboard = (details.elementBottom > details.contentHeight); - - ionic.keyboard.isOpen = true; + if (!e.target || + e.target.readOnly || + !ionic.tap.isKeyboardElement(e.target) || + !(scrollView = inputScrollView(e.target))) { + return; + } - // send event so the scroll view adjusts - keyboardActiveElement = element; - ionic.trigger('scrollChildIntoView', details, true); + keyboardActiveElement = e.target; + // if using JS scrolling, undo the effects of native overflow scroll so the + // scroll view is positioned correctly + document.body.scrollTop = 0; + scrollView.scrollTop = 0; ionic.requestAnimationFrame(function(){ - document.body.classList.add(KEYBOARD_OPEN_CSS); + document.body.scrollTop = 0; + scrollView.scrollTop = 0; }); + if (!ionic.keyboard.isOpen || ionic.keyboard.isClosing) { + ionic.keyboard.isOpening = true; + ionic.keyboard.isClosing = false; + } + + // attempt to prevent browser from natively scrolling input into view while + // we are trying to do the same (while we are scrolling) if the user taps the + // keyboard + document.addEventListener('keydown', keyboardOnKeyDown, false); + // any showing part of the document that isn't within the scroll the user // could touchmove and cause some ugly changes to the app, so disable // any touchmove events while the keyboard is open using e.preventDefault() @@ -223,91 +280,292 @@ function keyboardShow(element, elementTop, elementBottom, viewportHeight, keyboa document.addEventListener('touchmove', keyboardPreventDefault, false); } - return details; + // if we aren't using the plugin and the keyboard isn't open yet, wait for the + // window to resize so we can get an accurate estimate of the keyboard size, + // otherwise we do nothing and let nativeShow call keyboardShow once we have + // an exact keyboard height + // if the keyboard is already open, go ahead and scroll the input into view + // if necessary + if (!ionic.keyboard.isOpen && !keyboardHasPlugin()) { + keyboardWaitForResize(keyboardShow, true); + + } else if (ionic.keyboard.isOpen) { + keyboardShow(); + } } +/** + * Event handler for 'focusout' events. Sets keyboard.isClosing to true and + * calls keyboardWaitForResize with keyboardHide as the callback after a small + * timeout. + */ function keyboardFocusOut(e) { clearTimeout(keyboardFocusOutTimer); + //console.log("keyboardFocusOut fired at: " + Date.now()); + //console.log("keyboardFocusOut event type: " + e.type); - keyboardFocusOutTimer = setTimeout(ionic.keyboard.hide, 350); + if (ionic.keyboard.isOpen || ionic.keyboard.isOpening) { + ionic.keyboard.isClosing = true; + ionic.keyboard.isOpening = false; + } + + // Call keyboardHide with a slight delay because sometimes on focus or + // orientation change focusin is called immediately after, so we give it time + // to cancel keyboardHide + keyboardFocusOutTimer = setTimeout(function() { + ionic.requestAnimationFrame(function() { + // focusOut during or right after an orientationchange, so we didn't get + // a chance to update the viewport height yet, do it and keyboardHide + //console.log("focusOut, wasOrientationChange: " + wasOrientationChange); + if (wasOrientationChange) { + keyboardWaitForResize(function(){ + keyboardUpdateViewportHeight(); + keyboardHide(); + }, false); + } else { + keyboardWaitForResize(keyboardHide, false); + } + }); + }, 50); } -function keyboardUpdateViewportHeight() { - if( getViewportHeight() > keyboardViewportHeight ) { - keyboardViewportHeight = getViewportHeight(); +/** + * Event handler for 'orientationchange' events. If using the keyboard plugin + * and the keyboard is open on Android, sets wasOrientationChange to true so + * nativeShow can update the viewport height with an accurate keyboard height. + * If the keyboard isn't open or keyboard plugin isn't being used, + * waits for the window to resize before updating the viewport height. + * + * On iOS, where orientationchange fires after the keyboard has already shown, + * updates the viewport immediately, regardless of if the keyboard is already + * open. + */ +function keyboardOrientationChange() { + //console.log("orientationchange fired at: " + Date.now()); + //console.log("orientation was: " + (ionic.keyboard.isLandscape ? "landscape" : "portrait")); + + // toggle orientation + ionic.keyboard.isLandscape = !ionic.keyboard.isLandscape; + // //console.log("now orientation is: " + (ionic.keyboard.isLandscape ? "landscape" : "portrait")); + + // no need to wait for resizing on iOS, and orientationchange always fires + // after the keyboard has opened, so it doesn't matter if it's open or not + if (ionic.Platform.isIOS()) { + keyboardUpdateViewportHeight(); + } + + // On Android, if the keyboard isn't open or we aren't using the keyboard + // plugin, update the viewport height once everything has resized. If the + // keyboard is open and we are using the keyboard plugin do nothing and let + // nativeShow handle it using an accurate keyboard height. + if ( ionic.Platform.isAndroid()) { + if (!ionic.keyboard.isOpen || !keyboardHasPlugin()) { + keyboardWaitForResize(keyboardUpdateViewportHeight, false); + } else { + wasOrientationChange = true; + } } } +/** + * Event handler for 'keydown' event. Tries to prevent browser from natively + * scrolling an input into view when a user taps the keyboard while we are + * scrolling the input into view ourselves with JS. + */ function keyboardOnKeyDown(e) { - if( ionic.scroll.isScrolling ) { + if (ionic.scroll.isScrolling) { keyboardPreventDefault(e); } } +/** + * Event for 'touchmove' or 'MSPointerMove'. Prevents native scrolling on + * elements outside the scroll view while the keyboard is open. + */ function keyboardPreventDefault(e) { - if( e.target.tagName !== 'TEXTAREA' ) { + if (e.target.tagName !== 'TEXTAREA') { e.preventDefault(); } } -function keyboardOrientationChange() { - var updatedViewportHeight = getViewportHeight(); - - //too slow, have to wait for updated height - if (updatedViewportHeight === keyboardViewportHeight){ - var count = 0; - var pollViewportHeight = setInterval(function(){ - //give up - if (count > 10){ - clearInterval(pollViewportHeight); - } + /* Private API */ +/* -------------------------------------------------------------------------- */ + +/** + * Polls window.innerHeight until it has updated to an expected value (or + * sufficient time has passed) before calling the specified callback function. + * Only necessary for non-fullscreen Android which sometimes reports multiple + * window.innerHeight values during interim layouts while it is resizing. + * + * On iOS, the window.innerHeight will already be updated, but we use the 50ms + * delay as essentially a timeout so that scroll view adjustments happen after + * the keyboard has shown so there isn't a white flash from us resizing too + * quickly. + * + * @param {Function} callback the function to call once the window has resized + * @param {boolean} isOpening whether the resize is from the keyboard opening + * or not + */ +function keyboardWaitForResize(callback, isOpening) { + clearInterval(waitForResizeTimer); + var count = 0; + var maxCount; + var initialHeight; + var viewportHeight = initialHeight = getViewportHeight(); + + //console.log("waitForResize initial viewport height: " + viewportHeight); + //var start = Date.now(); + //console.log("start: " + start); + + // want to fail relatively quickly on modern android devices, since it's much + // more likely we just have a bad keyboard height + if (ionic.Platform.isAndroid() && ionic.Platform.version() < 4.4) { + maxCount = 30; + } else if (ionic.Platform.isAndroid()) { + maxCount = 10; + } else { + maxCount = 1; + } + + // poll timer + waitForResizeTimer = setInterval(function(){ + viewportHeight = getViewportHeight(); + + // height hasn't updated yet, try again in 50ms + // if not using plugin, wait for maxCount to ensure we have waited long enough + // to get an accurate keyboard height + if (++count < maxCount && + ((!isPortraitViewportHeight(viewportHeight) && + !isLandscapeViewportHeight(viewportHeight)) || + !ionic.keyboard.height)) { + return; + } - updatedViewportHeight = getViewportHeight(); + // infer the keyboard height from the resize if not using the keyboard plugin + if ( !keyboardHasPlugin() ) { + ionic.keyboard.height = Math.abs(initialHeight - window.innerHeight); + } - if (updatedViewportHeight !== keyboardViewportHeight){ - if (updatedViewportHeight < keyboardViewportHeight){ - ionic.keyboard.landscape = true; - } else { - ionic.keyboard.landscape = false; - } - keyboardViewportHeight = updatedViewportHeight; - clearInterval(pollViewportHeight); - } - count++; + // set to true if we were waiting for the keyboard to open + ionic.keyboard.isOpen = isOpening; - }, 50); + clearInterval(waitForResizeTimer); + //var end = Date.now(); + //console.log("waitForResize count: " + count); + //console.log("end: " + end); + //console.log("difference: " + ( end - start ) + "ms"); + + //console.log("callback: " + callback.name); + callback(); + + }, 50); +} + +/** + * On keyboard close sets keyboard state to closed, resets the scroll view, + * removes CSS from body indicating keyboard was open, removes any event + * listeners for when the keyboard is open and on Android blurs the active + * element (which in some cases will still have focus even if the keyboard + * is closed and can cause it to reappear on subsequent taps). + */ +function keyboardHide() { + clearTimeout(keyboardFocusOutTimer); + //console.log("keyboardHide"); + + ionic.keyboard.isOpen = false; + ionic.keyboard.isClosing = false; + + ionic.trigger('resetScrollView', { + target: keyboardActiveElement + }, true); + + ionic.requestAnimationFrame(function(){ + document.body.classList.remove(KEYBOARD_OPEN_CSS); + }); + + // the keyboard is gone now, remove the touchmove that disables native scroll + if (window.navigator.msPointerEnabled) { + document.removeEventListener("MSPointerMove", keyboardPreventDefault); } else { - keyboardViewportHeight = updatedViewportHeight; + document.removeEventListener('touchmove', keyboardPreventDefault); + } + document.removeEventListener('keydown', keyboardOnKeyDown); + + if (ionic.Platform.isAndroid()) { + // on android closing the keyboard with the back/dismiss button won't remove + // focus and keyboard can re-appear on subsequent taps (like scrolling) + if (keyboardHasPlugin()) cordova.plugins.Keyboard.close(); + keyboardActiveElement && keyboardActiveElement.blur(); } } +/** + * On keyboard open sets keyboard state to open, adds CSS to the body + * indicating the keyboard is open and tells the scroll view to resize and + * the currently focused input into view if necessary. + */ +function keyboardShow() { + var elementBounds = keyboardActiveElement.getBoundingClientRect(); + var details = { + target: keyboardActiveElement, + elementTop: Math.round(elementBounds.top), + elementBottom: Math.round(elementBounds.bottom), + keyboardHeight: ionic.keyboard.height, + viewportHeight: keyboardCurrentViewportHeight + }; + + details.windowHeight = details.viewportHeight - details.keyboardHeight; + //console.log("keyboardShow viewportHeight: " + details.viewportHeight + + //", windowHeight: " + details.windowHeight + + //", keyboardHeight: " + details.keyboardHeight); + + // figure out if the element is under the keyboard + details.isElementUnderKeyboard = (details.elementBottom > details.windowHeight); + //console.log("isUnderKeyboard: " + details.isElementUnderKeyboard); + //console.log("elementBottom: " + details.elementBottom); + + ionic.keyboard.isOpen = true; + ionic.keyboard.isOpening = false; + + // send event so the scroll view adjusts + ionic.trigger('scrollChildIntoView', details, true); + + setTimeout(function(){ + document.body.classList.add(KEYBOARD_OPEN_CSS); + }, 400); + + return details; +} + function keyboardGetHeight() { - // check if we are already have a keyboard height from the plugin + // check if we already have a keyboard height from the plugin or resize calculations if ( ionic.keyboard.height ) { return ionic.keyboard.height; } - if ( ionic.Platform.isAndroid() ){ - //should be using the plugin, no way to know how big the keyboard is, so guess - if ( ionic.Platform.isFullScreen ){ + if ( ionic.Platform.isAndroid() ) { + // should be using the plugin, no way to know how big the keyboard is, so guess + if ( ionic.Platform.isFullScreen ) { return 275; } - //otherwise, wait for the screen to resize - if ( getViewportHeight() < keyboardViewportHeight ){ - return keyboardViewportHeight - getViewportHeight(); + // otherwise just calculate it + var contentHeight = window.innerHeight; + if ( contentHeight < keyboardCurrentViewportHeight ) { + return keyboardCurrentViewportHeight - contentHeight; } else { return 0; } } - // fallback for when its the webview without the plugin + // fallback for when it's the webview without the plugin // or for just the standard web browser + // TODO: have these be based on device if( ionic.Platform.isIOS() ) { - if ( ionic.keyboard.landscape ){ + if ( ionic.keyboard.isLandscape ) { return 206; } - if (!ionic.Platform.isWebView()){ + if ( !ionic.Platform.isWebView() ) { return 216; } @@ -318,18 +576,86 @@ function keyboardGetHeight() { return 275; } +function isPortraitViewportHeight(viewportHeight) { + return !ionic.keyboard.isLandscape && + keyboardPortraitViewportHeight && + ( Math.abs(keyboardPortraitViewportHeight - viewportHeight) < 2 ); +} + +function isLandscapeViewportHeight(viewportHeight) { + return ionic.keyboard.isLandscape && + keyboardLandscapeViewportHeight && + ( Math.abs(keyboardLandscapeViewportHeight - viewportHeight) < 2 ); +} + +function keyboardUpdateViewportHeight() { + wasOrientationChange = false; + keyboardCurrentViewportHeight = getViewportHeight(); + + if (ionic.keyboard.isLandscape && !keyboardLandscapeViewportHeight) { + //console.log("saved landscape: " + keyboardCurrentViewportHeight); + keyboardLandscapeViewportHeight = keyboardCurrentViewportHeight; + + } else if (!ionic.keyboard.isLandscape && !keyboardPortraitViewportHeight) { + //console.log("saved portrait: " + keyboardCurrentViewportHeight); + keyboardPortraitViewportHeight = keyboardCurrentViewportHeight; + } + + ionic.trigger('resetScrollView', { + target: keyboardActiveElement + }, true); + + if (ionic.keyboard.isOpen && ionic.tap.isTextInput(keyboardActiveElement)) { + keyboardShow(); + } +} + +function keyboardInitViewportHeight(e) { + var viewportHeight = getViewportHeight(); + //console.log("Keyboard init VP: " + viewportHeight + " " + window.innerWidth); + // can't just use window.innerHeight in case the keyboard is opened immediately + if ((viewportHeight / window.innerWidth) < 1) { + ionic.keyboard.isLandscape = true; + } + //console.log("ionic.keyboard.isLandscape is: " + ionic.keyboard.isLandscape); + + // initialize or update the current viewport height values if coming from a + // resume event + if ((e && viewportHeight != keyboardCurrentViewportHeight) || !e) { + keyboardCurrentViewportHeight = viewportHeight; + if (ionic.keyboard.isLandscape && !keyboardLandscapeViewportHeight) { + keyboardLandscapeViewportHeight = keyboardCurrentViewportHeight; + } else if (!ionic.keyboard.isLandscape && !keyboardPortraitViewportHeight) { + keyboardPortraitViewportHeight = keyboardCurrentViewportHeight; + } + } +} + function getViewportHeight() { - return window.innerHeight || screen.height; + var windowHeight = window.innerHeight; + //console.log('window.innerHeight is: ' + windowHeight); + //console.log('kb height is: ' + ionic.keyboard.height); + //console.log('kb isOpen: ' + ionic.keyboard.isOpen); + + //TODO: add iPad undocked/split kb once kb plugin supports it + // the keyboard overlays the window on Android fullscreen + if (!(ionic.Platform.isAndroid() && ionic.Platform.isFullScreen) && + (ionic.keyboard.isOpen || ionic.keyboard.isOpening) && + !ionic.keyboard.isClosing) { + + return windowHeight + ionic.keyboard.height; + } + return windowHeight; } -function keyboardIsWithinScroll(ele) { +function inputScrollView(ele) { while(ele) { - if(ele.classList.contains(SCROLL_CONTAINER_CSS)) { - return true; + if (ele.classList.contains(SCROLL_CONTAINER_CSS)) { + return ele; } ele = ele.parentElement; } - return false; + return null; } function keyboardHasPlugin() { @@ -337,11 +663,35 @@ function keyboardHasPlugin() { } ionic.Platform.ready(function() { - keyboardUpdateViewportHeight(); + keyboardInitViewportHeight(); + + window.addEventListener('orientationchange', keyboardOrientationChange); + + // if orientation changes while app is in background, update on resuming + /* + if ( ionic.Platform.isWebView() ) { + document.addEventListener('resume', keyboardInitViewportHeight); + + if (ionic.Platform.isAndroid()) { + //TODO: onbackpressed to detect keyboard close without focusout or plugin + } + } + */ + + // if orientation changes while app is in background, update on resuming +/* if ( ionic.Platform.isWebView() ) { + document.addEventListener('pause', function() { + window.removeEventListener('orientationchange', keyboardOrientationChange); + }) + document.addEventListener('resume', function() { + keyboardInitViewportHeight(); + window.addEventListener('orientationchange', keyboardOrientationChange) + }); + }*/ // Android sometimes reports bad innerHeight on window.load // try it again in a lil bit to play it safe - setTimeout(keyboardUpdateViewportHeight, 999); + setTimeout(keyboardInitViewportHeight, 999); // only initialize the adjustments for the virtual keyboard // if a touchstart event happens diff --git a/js/views/scrollView.js b/js/views/scrollView.js index f0cf73f2d8d..acc0ec2b9e9 100644 --- a/js/views/scrollView.js +++ b/js/views/scrollView.js @@ -639,86 +639,136 @@ ionic.views.Scroll = ionic.views.View.inherit({ // Event Handler var container = self.__container; - self.scrollChildIntoView = function(e) { - - //distance from bottom of scrollview to top of viewport - var scrollBottomOffsetToTop; + // save height when scroll view is shrunk so we don't need to reflow + var scrollViewOffsetHeight; - if ( !self.isScrolledIntoView ) { + /** + * Shrink the scroll view when the keyboard is up if necessary and if the + * focused input is below the bottom of the shrunk scroll view, scroll it + * into view. + */ + self.scrollChildIntoView = function(e) { + console.log("scrollChildIntoView at: " + Date.now()); + + // D + var scrollBottomOffsetToTop = container.getBoundingClientRect().bottom; + // D - A + scrollViewOffsetHeight = container.offsetHeight; + var alreadyShrunk = self.isShrunkForKeyboard; + + var isModal = container.parentNode.classList.contains('modal'); + // 680px is when the media query for 60% modal width kicks in + var isInsetModal = isModal && window.innerWidth >= 680; + + /* + * _______ + * |---A---| <- top of scroll view + * | | + * |---B---| <- keyboard + * | C | <- input + * |---D---| <- initial bottom of scroll view + * |___E___| <- bottom of viewport + * + * All commented calculations relative to the top of the viewport (ie E + * is the viewport height, not 0) + */ + if (!alreadyShrunk) { // shrink scrollview so we can actually scroll if the input is hidden // if it isn't shrink so we can scroll to inputs under the keyboard - if ((ionic.Platform.isIOS() || ionic.Platform.isFullScreen)){ - + // inset modals won't shrink on Android on their own when the keyboard appears + if ( ionic.Platform.isIOS() || ionic.Platform.isFullScreen || isInsetModal ) { // if there are things below the scroll view account for them and // subtract them from the keyboard height when resizing - scrollBottomOffsetToTop = container.getBoundingClientRect().bottom; + // E - D E D var scrollBottomOffsetToBottom = e.detail.viewportHeight - scrollBottomOffsetToTop; + + // 0 or D - B if D > B E - B E - D var keyboardOffset = Math.max(0, e.detail.keyboardHeight - scrollBottomOffsetToBottom); - container.style.height = (container.clientHeight - keyboardOffset) + "px"; - container.style.overflow = "visible"; - //update scroll view - self.resize(); + + ionic.requestAnimationFrame(function(){ + // D - A or B - A if D > B D - A max(0, D - B) + scrollViewOffsetHeight = scrollViewOffsetHeight - keyboardOffset; + container.style.height = scrollViewOffsetHeight + "px"; + + //update scroll view + self.resize(); + }) } - self.isScrolledIntoView = true; + + self.isShrunkForKeyboard = true; + } - //If the element is positioned under the keyboard... - if ( e.detail.isElementUnderKeyboard ) { - var delay; - // Wait on android for web view to resize - if ( ionic.Platform.isAndroid() && !ionic.Platform.isFullScreen ) { - // android y u resize so slow - if ( ionic.Platform.version() < 4.4) { - delay = 500; - } else { - // probably overkill for chrome - delay = 350; + /* + * _______ + * |---A---| <- top of scroll view + * | * | <- where we want to scroll to + * |--B-D--| <- keyboard, bottom of scroll view + * | C | <- input + * | | + * |___E___| <- bottom of viewport + * + * All commented calculations relative to the top of the viewport (ie E + * is the viewport height, not 0) + */ + // if the element is positioned under the keyboard scroll it into view + if (e.detail.isElementUnderKeyboard) { + + ionic.requestAnimationFrame(function(){ + container.scrollTop = 0; + // update D if we shrunk + if (self.isShrunkForKeyboard && !alreadyShrunk) { + scrollBottomOffsetToTop = container.getBoundingClientRect().bottom; } - } else { - delay = 80; - } - //Put element in middle of visible screen - //Wait for android to update view height and resize() to reset scroll position - ionic.scroll.isScrolling = true; - setTimeout(function(){ - //middle of the scrollview, where we want to scroll to - var scrollMidpointOffset = container.clientHeight * 0.5; + // middle of the scrollview, this is where we want to scroll to + // (D - A) / 2 + var scrollMidpointOffset = scrollViewOffsetHeight * 0.5; + //console.log("container.offsetHeight: " + scrollViewOffsetHeight); + + // middle of the input we want to scroll into view + // C + var inputMidpoint = ((e.detail.elementBottom + e.detail.elementTop) / 2); - scrollBottomOffsetToTop = container.getBoundingClientRect().bottom; - //distance from top of focused element to the bottom of the scroll view - var elementTopOffsetToScrollBottom = e.detail.elementTop - scrollBottomOffsetToTop; + // distance from middle of input to the bottom of the scroll view + // C - D C D + var inputMidpointOffsetToScrollBottom = inputMidpoint - scrollBottomOffsetToTop; - var scrollTop = elementTopOffsetToScrollBottom + scrollMidpointOffset; + //C - D + (D - A)/2 C - D (D - A)/ 2 + var scrollTop = inputMidpointOffsetToScrollBottom + scrollMidpointOffset; - if (scrollTop > 0){ - ionic.tap.cloneFocusedInput(container, self); + if ( scrollTop > 0) { + if (ionic.Platform.isIOS()) ionic.tap.cloneFocusedInput(container, self); self.scrollBy(0, scrollTop, true); self.onScroll(); } - }, delay); + }); } - //Only the first scrollView parent of the element that broadcasted this event - //(the active element that needs to be shown) should receive this event + // Only the first scrollView parent of the element that broadcasted this event + // (the active element that needs to be shown) should receive this event e.stopPropagation(); }; self.resetScrollView = function(e) { //return scrollview to original height once keyboard has hidden - if (self.isScrolledIntoView) { - self.isScrolledIntoView = false; + if ( self.isShrunkForKeyboard ) { + self.isShrunkForKeyboard = false; container.style.height = ""; - container.style.overflow = ""; - self.resize(); - ionic.scroll.isScrolling = false; } + self.resize(); }; //Broadcasted when keyboard is shown on some platforms. //See js/utils/keyboard.js container.addEventListener('scrollChildIntoView', self.scrollChildIntoView); - container.addEventListener('resetScrollView', self.resetScrollView); + + // Listen on document because container may not have had the last + // keyboardActiveElement, for example after closing a modal with a focused + // input and returning to a previously resized scroll view in an ion-content. + // Since we can only resize scroll views that are currently visible, just resize + // the current scroll view when the keyboard is closed. + document.addEventListener('resetScrollView', self.resetScrollView); function getEventTouches(e) { return e.touches && e.touches.length ? e.touches : [{ @@ -936,7 +986,7 @@ ionic.views.Scroll = ionic.views.View.inherit({ document.removeEventListener('wheel', self.mouseWheel); container.removeEventListener('scrollChildIntoView', self.scrollChildIntoView); - container.removeEventListener('resetScrollView', self.resetScrollView); + document.removeEventListener('resetScrollView', self.resetScrollView); ionic.tap.removeClonedInputs(container, self); diff --git a/test/unit/utils/keyboard.unit.js b/test/unit/utils/keyboard.unit.js index 710d3885bd6..1ce12b47fbc 100644 --- a/test/unit/utils/keyboard.unit.js +++ b/test/unit/utils/keyboard.unit.js @@ -101,6 +101,7 @@ describe('Ionic Keyboard', function() { window.setTimeout = window._setTimeout; }); + /* it('Should keyboardShow', function(){ var element = document.createElement('textarea'); var elementTop = 100; @@ -111,6 +112,7 @@ describe('Ionic Keyboard', function() { expect( details.keyboardHeight ).toEqual(200); }); + */ it('Should keyboardHasPlugin', function() { expect( keyboardHasPlugin() ).toEqual(false); @@ -138,6 +140,7 @@ describe('Ionic Keyboard', function() { expect( keyboardGetHeight() ).toEqual(275); }); + /* it('keyboardGetHeight() should = (keyboardViewportHeight - window.innerHeight) if Android and not fullscreen', function(){ ionic.Platform.setPlatform('android'); expect( ionic.Platform.isFullScreen ).toEqual(false); @@ -164,6 +167,7 @@ describe('Ionic Keyboard', function() { expect( keyboardGetHeight() ).toEqual(206); }); + */ it('keyboardGetHeight() should = 216 if iOS Safari', function(){ ionic.Platform.setPlatform('iOS'); @@ -186,6 +190,8 @@ describe('Ionic Keyboard', function() { expect( keyboardGetHeight() ).toEqual(275); }); + /* + it('keyboardUpdateViewportHeight() should update when window.innerHeight > keyboardViewportHeight', function(){ window.innerHeight = 460; keyboardViewportHeight = 320; @@ -193,6 +199,7 @@ describe('Ionic Keyboard', function() { expect( keyboardViewportHeight ).toEqual(460); }); + */ it('keyboardUpdateViewportHeight() should not update when window.innerHeight < keyboardViewportHeight', function(){ window.innerHeight = 100; @@ -202,6 +209,8 @@ describe('Ionic Keyboard', function() { expect( keyboardViewportHeight ).toEqual(320); }); + /* + it('Should scroll input into view if it is under the keyboard', function(){ var element = document.createElement('textarea'); var elementTop = 300; @@ -224,5 +233,6 @@ describe('Ionic Keyboard', function() { expect( details.isElementUnderKeyboard ).toEqual(false); }); + */ }); diff --git a/test/unit/views/scrollView.unit.js b/test/unit/views/scrollView.unit.js index 4d39cbe7834..46df2504711 100644 --- a/test/unit/views/scrollView.unit.js +++ b/test/unit/views/scrollView.unit.js @@ -35,6 +35,7 @@ describe('Scroll View', function() { expect(sc.children[2].classList.contains('scroll-bar')).toBe(true); }); + /* it('Should resize when the keyboard is showing', function() { var element = document.createElement('textarea'); ionic.Platform.setPlatform('ios'); @@ -77,5 +78,6 @@ describe('Scroll View', function() { expect( sc.style.height ).toEqual(scHeight - keyboardHeight + scOffsetToBottom + "px"); expect( sc.clientHeight ).toEqual(scHeight - keyboardHeight + scOffsetToBottom); }); + */ });