/**
* Copyright 2016 Google Inc. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use strict';
/**
* Helper class for FLIP animations. FLIP is an approach to animations that
* takes remaps animating expensive properties, like width, height, left and top
* to significantly cheaper changes using transforms. It does this by taking two
* snapshots, one of the element's First position (F), another of its Last
* position (L). It then uses a transform to Invert (I) the element's changes,
* such that the element appears to still be in the First position. Lastly it
* Plays (P) the animation forward by removing the transformations applied in
* the Invert step.
*
* @see https://aerotwist.com/blog/flip-your-animations
* @author paullewis
*/
export default class FLIP {
/**
* Returns the library version.
*
* @static
* @returns {String} The library version as a String.
*/
static get version () {
return '@VERSION@';
}
/**
* Extends FLIP to be able to play. Allows for the use of libraries when it
* comes to playback. The functions in the player object are all copied and
* bound to the instance of the FLIP Helper on instantiation.
*
* @param {String} name - The name of the player, e.g. <code>'rAF'</code>.
* @param {Object} player - The object with the playback functions.
* @param {Function} player.play_ - The playback function to use.
*/
static extend (name, player) {
if (typeof this.players_ === 'undefined')
this.players_ = {};
if (typeof this.players_[name] !== 'undefined')
console.warn(`Player with name ${name} already exists`);
if (typeof player.play_ === 'undefined')
console.warn('Player does not contain a play_() function');
this.players_[name] = player;
}
/**
* @typedef FlipGroup
* @type Object
* @property {Array} flips_ - The FLIP helpers.
* @property {Function} first - Calls <code>first()</code> against all
* FLIP helpers.
* @property {Function} last - Calls <code>last()</code> against all
* FLIP helpers.
* @property {Function} invert - Calls <code>invert()</code> against all
* FLIP helpers.
* @property {Function} play - Calls <code>play()</code> against all
* FLIP helpers.
* @property {Function} addClass - Adds a class to all elements in the group.
* @property {Function} removeClass - Removes a class from all elements in
* the group.
*/
/**
* Creates a group of FLIP helpers, usually used when you want to have some
* form of composite animation, with related but visually independent
* elements.
*
* @static
* @returns {FlipGroup} An object which has the same API as an individual FLIP
* helper, but controls the group as a whole.
*/
static group (flips) {
if (!Array.isArray(flips))
throw new Error ('group() expects an array of objects.');
// Wrap each in a FLIP helper.
flips = flips.map(flip => new FLIP(flip));
return {
flips_: flips,
addClass: (className) => {
flips.forEach(flip => flip.addClass(className));
},
removeClass: (className) => {
flips.forEach(flip => flip.removeClass(className));
},
first: () => {
flips.forEach(flip => flip.first());
},
last: (lastClassName) => {
// To avoid layout thrashing apply all the classes up front
// then do a second pass where last() is called.
flips.forEach((flip, index) => {
let className = lastClassName;
if (Array.isArray(lastClassName))
className = lastClassName[index];
if (typeof className !== 'undefined')
flip.element_.classList.add(className);
});
flips.forEach(flip => flip.last());
},
invert: () => {
flips.forEach(flip => flip.invert());
},
play: (startTime) => {
if (typeof startTime === 'undefined')
startTime = window.performance.now();
flips.forEach(flip => flip.play(startTime));
}
}
}
/**
* Creates a new FLIP helper.
*
* @param {Object} config - The configuration for the helper.
* @param {HTMLElement} config.element - The element on which to operate.
* @param {Number} [config.duration=1000] - The duration of the animation
* in milliseconds.
* @param {Function} [config.ease] - The easing function of the animation.
* @param {Boolean} [config.transform=true] - Whether or not to animate
* transforms for the element.
* @param {Boolean} [config.opacity=true] - Whether or not to animate opacity
* for the element.
*/
constructor (options={}) {
let defaults = {
duration: 330,
delay: 0,
easing: function (t) { return t; },
transform: true,
opacity: true,
play: 'rAF'
};
let config = Object.assign({}, defaults, options);
if (typeof config.element === 'undefined')
throw new Error('Element must be provided.');
// If the easing property is not a function, check for a TweenMax/Lite style
// object with a getRatio function, and, if that exists, use it, otherwise
// throw an error.
if (typeof config.easing !== 'function') {
if (typeof config.easing.getRatio !== 'undefined') {
config.easing = config.easing.getRatio;
} else {
throw new Error('Element must be provided.');
}
}
this.element_ = config.element;
this.first_ = {
layout: null,
opacity: 0
};
this.last_ = {
layout: null,
opacity: 0
};
this.invert_ = {
x: 0, y: 0, sx: 1, sy: 1, a: 0
};
this.start_ = 0;
this.duration_ = config.duration;
this.delay_ = config.delay;
this.easing_ = config.easing;
this.updateTransform_ = config.transform;
this.updateOpacity_ = config.opacity;
let player = FLIP.players_[config.play];
if (typeof player === 'undefined')
throw new Error(`Unknown player type: ${config.play}`);
// Take a copy of the player's functions and bind them on so that
// they can be used by this FLIP Helper instance.
let playerFunctions = Object.keys(player);
let playerFunction;
playerFunctions.forEach(fn => {
playerFunction = player[fn];
this[fn] = playerFunction.bind(this);
});
}
/**
* Convenience method to add a class to the element.
* @param {string} className - The class name to add to the element.
*/
addClass (className) {
if (typeof className !== 'string')
return;
this.element_.classList.add(className);
}
/**
* Convenience method to remove a class to the element.
* @param {string} className - The class name to remove from the element.
*/
removeClass (className) {
if (typeof className !== 'string')
return;
this.element_.classList.remove(className);
}
/**
* Convenience method that calls <code>first()</code>, <code>last()</code>,
* and <code>invert()</code> immediately after each other.
*
* @param {string} [lastClassName] - The class name applied to the element
* that moves it to its final position.
*/
snapshot (lastClassName) {
this.first();
this.last(lastClassName);
this.invert();
}
/**
* Snapshots the layout and opacity information for the element.
*/
first () {
this.first_.layout = this.element_.getBoundingClientRect();
this.first_.opacity =
parseFloat(window.getComputedStyle(this.element_).opacity);
}
/**
* Applies the class that moves the element to its last position. It then
* takes a snapshot of the element's final location and opacity.
*
* @param {string} [lastClassName] - The class name applied to the element
* that moves it to its final position.
*/
last (lastClassName) {
if (typeof lastClassName !== 'undefined')
this.addClass(lastClassName);
this.last_.layout = this.element_.getBoundingClientRect();
this.last_.opacity =
parseFloat(window.getComputedStyle(this.element_).opacity);
}
/**
* Moves the element back to its start position, size, and opacity by applying
* changes to its transform and opacity values.
*/
invert () {
let willChange = [];
if (this.first_.layout === null)
throw new Error ('You must call first() before invert()');
if (this.last_.layout === null)
throw new Error ('You must call last() before invert()');
// Update the invert values.
this.invert_.x = this.first_.layout.left - this.last_.layout.left;
this.invert_.y = this.first_.layout.top - this.last_.layout.top;
this.invert_.sx = this.first_.layout.width / this.last_.layout.width;
this.invert_.sy = this.first_.layout.height / this.last_.layout.height;
this.invert_.a = this.last_.opacity - this.first_.opacity;
// Apply the transform.
if (this.updateTransform_) {
this.element_.style.transformOrigin = '0 0';
this.element_.style.transform =
`translate(${this.invert_.x}px, ${this.invert_.y}px)
scale(${this.invert_.sx}, ${this.invert_.sy})`;
willChange.push('transform');
}
if (this.updateOpacity_) {
this.element_.style.opacity = this.first_.opacity;
willChange.push('opacity');
}
this.element_.style.willChange = willChange.join(',');
}
/**
* Plays the animation.
*
* @param {Number} [startTime] - The time the animation should start (using
* <code>window.performance.now</code> as the source of truth).
*/
play (startTime) {
if (this.invert_ === null)
throw new Error('invert() must be called before play()');
if (typeof this.play_ === 'undefined')
throw new Error('No player specified.');
this.play_(startTime);
}
/**
* Fires an event on the element.
*
* @private
* @param {String} eventName - The name of the event.
* @param {} [detail=null] - The data to include in the event.
* @param {Boolean} [bubbles=true] - Whether the event should bubble.
* @param {Boolean} [cancelable=true] - Whether the event is cancelable.
*/
fire_ (eventName, detail=null, bubbles=true, cancelable=true) {
let evt = new CustomEvent(eventName, { detail, bubbles, cancelable });
this.element_.dispatchEvent(evt);
}
/**
* Clamps a value to min/max values.
*
* @param {Number} value - The value to clamp.
* @param {Number} min=Number.NEGATIVE_INFINITY - The minimum value. Defaults
* to negative infinity.
* @param {Number} max=Number.POSITIVE_INFINITY - The maximum value. Defaults
* to positive infinity.
* @private
*/
clamp_ (value, min=Number.NEGATIVE_INFINITY, max=Number.POSITIVE_INFINITY) {
return Math.min(max, Math.max(min, value));
}
/**
* Function to call to get rid of all the transforms, opacity, internal values
* and fire an event for the FLIP completion.
*/
cleanUpAndFireEvent_ () {
this.removeTransformsAndOpacity_();
this.resetFirstLastAndInvertValues_();
this.fire_('flipComplete');
}
/**
* Removes all transforms and opacity from the element. This should
* stop side-effects if the element has fixed position children that
* transition.
*
* @private
*/
removeTransformsAndOpacity_ () {
this.element_.style.transformOrigin = null;
this.element_.style.transform = null;
this.element_.style.opacity = null;
this.element_.style.willChange = null;
}
/**
* Reset the values held by first_, last_, and invert_.
*
* @private
*/
resetFirstLastAndInvertValues_ () {
this.first_.layout = null;
this.first_.opacity = 0;
this.last_.layout = null;
this.last_.opacity = 0;
this.invert_.x = 0;
this.invert_.y = 0;
this.invert_.sx = 1;
this.invert_.sy = 1;
this.invert_.a = 0;
}
}