Skip to content

Instantly share code, notes, and snippets.

@Stnaire
Last active December 14, 2018 17:16
Show Gist options
  • Save Stnaire/6cd5a07f404b3e50bd7f390762ebc349 to your computer and use it in GitHub Desktop.
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
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;
}
}
<ion-content keyboard-scroll-adjuster>
<h1>My page with form inputs</h1>
[...]
</ion-content>
/**
* 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;
}
/**
* 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