Last active
December 14, 2018 17:16
-
-
Save Stnaire/6cd5a07f404b3e50bd7f390762ebc349 to your computer and use it in GitHub Desktop.
[Ionic 3] Keep focused form input in visible area when keyboard is opened
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Directive, ElementRef, Inject, OnDestroy, OnInit, Renderer2 } from "@angular/core"; | |
import { DOCUMENT } from "@angular/common"; | |
import { proxy } from "utils"; | |
import { easeOutCubic } from "easing"; | |
@Directive({ | |
selector: '[keyboard-scroll-adjuster]' | |
}) | |
export class KeyboardScrollAdjusterDirective implements OnInit, OnDestroy { | |
/** | |
* How much time does it take to go from a scroll position A to B | |
* when adjusting the view to center a focused input in the viewport? (in milliseconds) | |
*/ | |
private static SCROLL_ANIMATION_DURATION: number = 400; | |
/** | |
* The scroll container on which we will modify the scroll. | |
*/ | |
private scrollContent: HTMLElement; | |
/** | |
* Html element used to increase the scroll container height so we can scroll the | |
* selected input in the visible area. | |
*/ | |
private spacer: HTMLElement; | |
/** | |
* Is the spacer attached? | |
*/ | |
private attached: boolean; | |
/** | |
* Reference on proxy functions to be be able to remove event listeners while still proxying them. | |
*/ | |
private onKeyboardShowProxy: any; | |
private onKeyboardHideProxy: any; | |
private onFocusProxy: any; | |
private onBlurProxy: any; | |
/** | |
* Amount of scroll that have been added so the focused element is in the visible area. | |
*/ | |
private scrollShiftAmount: number; | |
/** | |
* Reference on the focused dom element. | |
*/ | |
private activeElement: any; | |
/** | |
* Keep the keyboard height so we can trigger the onKeyboardShow method without the need | |
* for the keyboard visibility to change. | |
*/ | |
private keyboardHeight: number; | |
/** | |
* Track if the keyboard is visible so we only use the onFocus event when the keyboard is already visible. | |
*/ | |
private isKeyboardVisible: boolean; | |
/** | |
* Positions used to do the scroll animation. | |
*/ | |
private scrollAnimationStartPosition: number; | |
private scrollAnimationEndPosition: number; | |
/** | |
* Id of the last requestAnimationFrame call. | |
*/ | |
private nextAnimationFrameTimerId: number; | |
/** | |
* Timestamp at which the animation started. | |
*/ | |
private scrollAnimationStartTime: number; | |
constructor(private element: ElementRef, | |
private renderer: Renderer2, | |
@Inject(DOCUMENT) private document) { | |
this.spacer = this.document.createElement("div"); | |
this.spacer.style.height = "0px"; | |
this.isKeyboardVisible = false; | |
this.keyboardHeight = null; | |
this.attached = false; | |
this.scrollShiftAmount = 0; | |
this.scrollContent = null; | |
this.activeElement = null; | |
this.scrollAnimationStartPosition = null; | |
this.scrollAnimationEndPosition = null; | |
this.nextAnimationFrameTimerId = null; | |
this.scrollAnimationStartTime = null; | |
this.onKeyboardShowProxy = proxy(this.onKeyboardShow, this); | |
this.onKeyboardHideProxy = proxy(this.onKeyboardHide, this); | |
this.onFocusProxy = proxy(this.onFocus, this); | |
this.onBlurProxy = proxy(this.onBlur, this); | |
} | |
/** | |
* @inheritDoc | |
*/ | |
public ngOnInit(): void { | |
this.scrollContent = this.getScrollContainer(); | |
// No point in doing anything if we don't find the container. | |
if (this.scrollContent !== null) { | |
window.addEventListener("native.keyboardshow", this.onKeyboardShowProxy); | |
window.addEventListener("native.keyboardhide", this.onKeyboardHideProxy); | |
window.addEventListener("focus", this.onFocusProxy, true); | |
window.addEventListener("blur", this.onBlurProxy, true); | |
} | |
} | |
/** | |
* @inheritDoc | |
*/ | |
public ngOnDestroy(): void { | |
if (this.scrollContent !== null) { | |
window.removeEventListener("native.keyboardshow", this.onKeyboardShowProxy); | |
window.removeEventListener("native.keyboardhide", this.onKeyboardHideProxy); | |
window.removeEventListener("focus", this.onFocusProxy); | |
window.removeEventListener("blur", this.onBlurProxy); | |
} | |
} | |
/** | |
* Called when the keyboard shows. | |
*/ | |
private onKeyboardShow(e): void { | |
this.isKeyboardVisible = true; | |
if (e) { | |
this.keyboardHeight = e.keyboardHeight; | |
} | |
if (!this.keyboardHeight) { | |
return ; | |
} | |
if (!this.attached) { | |
this.attached = true; | |
this.renderer.appendChild(this.scrollContent, this.spacer); | |
} | |
this.spacer.style.height = this.keyboardHeight + "px"; | |
if (this.document.activeElement) { | |
// Reset the current shift before calculating the new target position | |
// But because we will animate this we we set back the value to where it is right now so we can animate from it. | |
const currentScrollShiftAmount: number = this.scrollShiftAmount; | |
this.scrollContent.scrollTop -= this.scrollShiftAmount; | |
this.scrollShiftAmount = 0; | |
const ae = this.document.activeElement; | |
const aeRect: any = ae.getBoundingClientRect(); | |
const viewportHeight: number = this.scrollContent.clientHeight - this.keyboardHeight; | |
const targetCenterPos: number = Math.round(viewportHeight * 0.5) + this.scrollContent.scrollTop; | |
this.scrollShiftAmount = Math.round(((aeRect.top + this.scrollContent.scrollTop + (ae.clientHeight * 0.5)) - targetCenterPos)); | |
const targetScroll: number = this.scrollContent.scrollTop + this.scrollShiftAmount; | |
this.scrollContent.scrollTop += currentScrollShiftAmount; | |
this.scrollTo(targetScroll); | |
} | |
} | |
/** | |
* Called when the keyboard hides. | |
*/ | |
private onKeyboardHide(): void { | |
this.isKeyboardVisible = false; | |
this.spacer.style.height = "0px"; | |
this.scrollTo(this.scrollContent.scrollTop - this.scrollShiftAmount); | |
this.scrollShiftAmount = 0; | |
} | |
/** | |
* Called when the focused element changes. | |
*/ | |
private onFocus(): void { | |
if (this.isKeyboardVisible && this.activeElement !== this.document.activeElement) { | |
this.activeElement = this.document.activeElement; | |
this.onKeyboardShow(null); | |
} | |
} | |
/** | |
* Called when the focused is cleared. | |
*/ | |
private onBlur(): void { | |
this.activeElement = null; | |
if (!this.isKeyboardVisible) { | |
this.spacer.style.height = "0px"; | |
this.scrollShiftAmount = 0; | |
} | |
} | |
/** | |
* Animate the view to the target scroll value. | |
* | |
* @param {number} value | |
*/ | |
private scrollTo(value: number): void { | |
this.scrollAnimationStartTime = performance.now(); | |
this.scrollAnimationStartPosition = this.scrollContent.scrollTop; | |
this.scrollAnimationEndPosition = value; | |
this.nextScrollAnimationFrame(); | |
} | |
/** | |
* Request an animation frame to the browser, then moves the animation for 1 frame | |
* and call itself if not on the target position. | |
*/ | |
private nextScrollAnimationFrame(): void { | |
if (this.nextAnimationFrameTimerId !== null) { | |
window.cancelAnimationFrame(this.nextAnimationFrameTimerId); | |
} | |
this.nextAnimationFrameTimerId = window.requestAnimationFrame((timestamp: number) => { | |
this.nextAnimationFrameTimerId = null; | |
const elapsed: number = timestamp - this.scrollAnimationStartTime; | |
let percent: number = easeOutCubic(Math.max(0, elapsed / KeyboardScrollAdjusterDirective.SCROLL_ANIMATION_DURATION)); | |
if (percent < 1) { | |
this.scrollContent.scrollTop = ((this.scrollAnimationEndPosition - this.scrollAnimationStartPosition) * percent) + this.scrollAnimationStartPosition; | |
this.nextScrollAnimationFrame(); | |
} | |
}); | |
} | |
/** | |
* Try to get the scroll content element. | |
* | |
* @returns {HTMLElement} | |
*/ | |
private getScrollContainer(): HTMLElement { | |
if (!this.element.nativeElement.children) { | |
return null; | |
} | |
for (const child of this.element.nativeElement.children) { | |
if (child.className.split(" ").indexOf("scroll-content") >= 0) { | |
return child; | |
} | |
} | |
return null; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<ion-content keyboard-scroll-adjuster> | |
<h1>My page with form inputs</h1> | |
[...] | |
</ion-content> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Easing functions | |
* Source: https://github.com/AndrewRayCode/easing-utils | |
*/ | |
/* tslint:disable:no-conditional-assignment */ | |
// No easing, no acceleration | |
export function linear( t ) { | |
return t; | |
} | |
// Slight acceleration from zero to full speed | |
export function easeInSine( t ) { | |
return -1 * Math.cos( t * ( Math.PI / 2 ) ) + 1; | |
} | |
// Slight deceleration at the end | |
export function easeOutSine( t ) { | |
return Math.sin( t * ( Math.PI / 2 ) ); | |
} | |
// Slight acceleration at beginning and slight deceleration at end | |
export function easeInOutSine( t ) { | |
return -0.5 * ( Math.cos( Math.PI * t ) - 1 ); | |
} | |
// Accelerating from zero velocity | |
export function easeInQuad( t ) { | |
return t * t; | |
} | |
// Decelerating to zero velocity | |
export function easeOutQuad( t ) { | |
return t * ( 2 - t ); | |
} | |
// Acceleration until halfway, then deceleration | |
export function easeInOutQuad( t ) { | |
return t < 0.5 ? 2 * t * t : - 1 + ( 4 - 2 * t ) * t; | |
} | |
// Accelerating from zero velocity | |
export function easeInCubic( t ) { | |
return t * t * t; | |
} | |
// Decelerating to zero velocity | |
export function easeOutCubic( t ) { | |
const t1 = t - 1; | |
return t1 * t1 * t1 + 1; | |
} | |
// Acceleration until halfway, then deceleration | |
export function easeInOutCubic( t ) { | |
return t < 0.5 ? 4 * t * t * t : ( t - 1 ) * ( 2 * t - 2 ) * ( 2 * t - 2 ) + 1; | |
} | |
// Accelerating from zero velocity | |
export function easeInQuart( t ) { | |
return t * t * t * t; | |
} | |
// Decelerating to zero velocity | |
export function easeOutQuart( t ) { | |
const t1 = t - 1; | |
return 1 - t1 * t1 * t1 * t1; | |
} | |
// Acceleration until halfway, then deceleration | |
export function easeInOutQuart( t ) { | |
const t1 = t - 1; | |
return t < 0.5 ? 8 * t * t * t * t : 1 - 8 * t1 * t1 * t1 * t1; | |
} | |
// Accelerating from zero velocity | |
export function easeInQuint( t ) { | |
return t * t * t * t * t; | |
} | |
// Decelerating to zero velocity | |
export function easeOutQuint( t ) { | |
const t1 = t - 1; | |
return 1 + t1 * t1 * t1 * t1 * t1; | |
} | |
// Acceleration until halfway, then deceleration | |
export function easeInOutQuint( t ) { | |
const t1 = t - 1; | |
return t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * t1 * t1 * t1 * t1 * t1; | |
} | |
// Accelerate exponentially until finish | |
export function easeInExpo( t ) { | |
if ( t === 0 ) { | |
return 0; | |
} | |
return Math.pow( 2, 10 * ( t - 1 ) ); | |
} | |
// Initial exponential acceleration slowing to stop | |
export function easeOutExpo( t ) { | |
if ( t === 1 ) { | |
return 1; | |
} | |
return ( -Math.pow( 2, -10 * t ) + 1 ); | |
} | |
// Exponential acceleration and deceleration | |
export function easeInOutExpo( t ) { | |
if ( t === 0 || t === 1 ) { | |
return t; | |
} | |
const scaledTime = t * 2; | |
const scaledTime1 = scaledTime - 1; | |
if ( scaledTime < 1 ) { | |
return 0.5 * Math.pow( 2, 10 * ( scaledTime1 ) ); | |
} | |
return 0.5 * ( -Math.pow( 2, -10 * scaledTime1 ) + 2 ); | |
} | |
// Increasing velocity until stop | |
export function easeInCirc( t ) { | |
const scaledTime = t / 1; | |
return -1 * ( Math.sqrt( 1 - scaledTime * t ) - 1 ); | |
} | |
// Start fast, decreasing velocity until stop | |
export function easeOutCirc( t ) { | |
const t1 = t - 1; | |
return Math.sqrt( 1 - t1 * t1 ); | |
} | |
// Fast increase in velocity, fast decrease in velocity | |
export function easeInOutCirc( t ) { | |
const scaledTime = t * 2; | |
const scaledTime1 = scaledTime - 2; | |
if ( scaledTime < 1 ) { | |
return -0.5 * ( Math.sqrt( 1 - scaledTime * scaledTime ) - 1 ); | |
} | |
return 0.5 * ( Math.sqrt( 1 - scaledTime1 * scaledTime1 ) + 1 ); | |
} | |
// Slow movement backwards then fast snap to finish | |
export function easeInBack( t, magnitude = 1.70158 ) { | |
return t * t * ( ( magnitude + 1 ) * t - magnitude ); | |
} | |
// Fast snap to backwards point then slow resolve to finish | |
export function easeOutBack( t, magnitude = 1.70158 ) { | |
const scaledTime = ( t / 1 ) - 1; | |
return ( | |
scaledTime * scaledTime * ( ( magnitude + 1 ) * scaledTime + magnitude ) | |
) + 1; | |
} | |
// Slow movement backwards, fast snap to past finish, slow resolve to finish | |
export function easeInOutBack( t, magnitude = 1.70158 ) { | |
const scaledTime = t * 2; | |
const scaledTime2 = scaledTime - 2; | |
const s = magnitude * 1.525; | |
if ( scaledTime < 1) { | |
return 0.5 * scaledTime * scaledTime * ( | |
( ( s + 1 ) * scaledTime ) - s | |
); | |
} | |
return 0.5 * ( | |
scaledTime2 * scaledTime2 * ( ( s + 1 ) * scaledTime2 + s ) + 2 | |
); | |
} | |
// Bounces slowly then quickly to finish | |
export function easeInElastic( t, magnitude = 0.7 ) { | |
if ( t === 0 || t === 1 ) { | |
return t; | |
} | |
const scaledTime = t / 1; | |
const scaledTime1 = scaledTime - 1; | |
const p = 1 - magnitude; | |
const s = p / ( 2 * Math.PI ) * Math.asin( 1 ); | |
return -( | |
Math.pow( 2, 10 * scaledTime1 ) * | |
Math.sin( ( scaledTime1 - s ) * ( 2 * Math.PI ) / p ) | |
); | |
} | |
// Fast acceleration, bounces to zero | |
export function easeOutElastic( t, magnitude = 0.7 ) { | |
const p = 1 - magnitude; | |
const scaledTime = t * 2; | |
if ( t === 0 || t === 1 ) { | |
return t; | |
} | |
const s = p / ( 2 * Math.PI ) * Math.asin( 1 ); | |
return ( | |
Math.pow( 2, -10 * scaledTime ) * | |
Math.sin( ( scaledTime - s ) * ( 2 * Math.PI ) / p ) | |
) + 1; | |
} | |
// Slow start and end, two bounces sandwich a fast motion | |
export function easeInOutElastic( t, magnitude = 0.65 ) { | |
const p = 1 - magnitude; | |
if ( t === 0 || t === 1 ) { | |
return t; | |
} | |
const scaledTime = t * 2; | |
const scaledTime1 = scaledTime - 1; | |
const s = p / ( 2 * Math.PI ) * Math.asin( 1 ); | |
if ( scaledTime < 1 ) { | |
return -0.5 * ( | |
Math.pow( 2, 10 * scaledTime1 ) * | |
Math.sin( ( scaledTime1 - s ) * ( 2 * Math.PI ) / p ) | |
); | |
} | |
return ( | |
Math.pow( 2, -10 * scaledTime1 ) * | |
Math.sin( ( scaledTime1 - s ) * ( 2 * Math.PI ) / p ) * 0.5 | |
) + 1; | |
} | |
// Bounce to completion | |
export function easeOutBounce( t ) { | |
const scaledTime = t / 1; | |
if ( scaledTime < ( 1 / 2.75 ) ) { | |
return 7.5625 * scaledTime * scaledTime; | |
} else if ( scaledTime < ( 2 / 2.75 ) ) { | |
const scaledTime2 = scaledTime - ( 1.5 / 2.75 ); | |
return ( 7.5625 * scaledTime2 * scaledTime2 ) + 0.75; | |
} else if ( scaledTime < ( 2.5 / 2.75 ) ) { | |
const scaledTime2 = scaledTime - ( 2.25 / 2.75 ); | |
return ( 7.5625 * scaledTime2 * scaledTime2 ) + 0.9375; | |
} else { | |
const scaledTime2 = scaledTime - ( 2.625 / 2.75 ); | |
return ( 7.5625 * scaledTime2 * scaledTime2 ) + 0.984375; | |
} | |
} | |
// Bounce increasing in velocity until completion | |
export function easeInBounce( t ) { | |
return 1 - easeOutBounce( 1 - t ); | |
} | |
// Bounce in and bounce out | |
export function easeInOutBounce( t ) { | |
if ( t < 0.5 ) { | |
return easeInBounce( t * 2 ) * 0.5; | |
} | |
return ( easeOutBounce( ( t * 2 ) - 1 ) * 0.5 ) + 0.5; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Bind a function to a context, optionally partially applying any arguments. | |
* Extracted from jQuery 3.2.1 with minor modifications. | |
* | |
* @param {function} fn | |
* @param {any} context | |
* | |
* @return {function|undefined} | |
*/ | |
export function proxy(fn: (...args: any[]) => any, context) { | |
let tmp; | |
let args; | |
if (typeof context === "string") { | |
tmp = fn[context]; | |
context = fn; | |
fn = tmp; | |
} | |
// Quick check to determine if target is callable, in the spec | |
// this throws a TypeError, but we will just return undefined. | |
if (!isFunction(fn)) { | |
return undefined; | |
} | |
// Simulated bind | |
args = Array.prototype.slice.call(arguments, 2); | |
return function() { | |
return fn.apply(context || this, args.concat(Array.prototype.slice.call(arguments))); | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment