f1
Version:
A stateful ui library
590 lines (501 loc) • 16.7 kB
JavaScript
var kimi = require('kimi');
var getTween = require('tween-function');
var noop = require('no-op');
var extend = require('deep-extend');
var Emitter = require('events').EventEmitter;
var getParser = require('./lib/parsers/getParser');
var parseStates = require('./lib/states/parseStates');
var parseTransitions = require('./lib/transitions/parseTransitions');
var parseTargets = require('./lib/targets/parseTargets');
var numInstances = 0;
module.exports = f1;
/**
* To construct a new `f1` instance you can do it in two ways.
*
* ```javascript
* ui = f1([ settigns ]);
* ```
* or
* ```javascript
* ui = new f1([ settings ]);
* ```
*
* To construct an `f1` instance you can pass in an optional settings object. The following are properties you can pass in settings:
* ```javascript
* {
* onState: listenerState, // this callback will be called whenever f1 reaches a state
* onUpdate: listenerUpdater, // this callback will be called whenever f1 is updating
*
* // you can pass a name for the ui. This is useful when you're using an external tool or want
* // to differentiate between f1 instances
* name: 'someNameForTheUI',
*
* // this is an object which contains all elements/items that you will be animating
* targets: {
* bg: bgElement
* },
*
* // all states for the ui
* // states are the top level object and anything after that are the properties
* // for that state
* states: {
* out: {
*
* bg: { alpha: 0 }
* },
*
* idle: {
*
* bg: { alpha: 1 }
* }
* },
*
* // an array which defines the transitions for the ui
* transitions: [
* 'out', 'idle', // this ui can go from out to idle
* 'idle', 'out' // and idle to out
* ],
*
* // an Object contains init and update functions. These will be used
* // to initialize your ui elements and apply state to targets during update
* parsers: {
* init: [ initPosition ],
* update: [ applyPosition ]
* }
* }
* ```
*
* @param {Object} [settings] An optional settings Object described above
* @chainable
*/
function f1(settings) {
if(!(this instanceof f1)) {
return new f1(settings);
}
settings = settings || {};
var emitter = this;
var onUpdate = settings.onUpdate || noop;
var onState = settings.onState || noop;
// this is used to generate a "name" for an f1 instance if one isn't given
numInstances++;
this.onState = function() {
emitter.emit.apply(emitter, getEventArgs('state', arguments));
if(onState) {
onState.apply(undefined, arguments);
}
};
this.onUpdate = function() {
emitter.emit.apply(emitter, getEventArgs('update', arguments));
if(onUpdate) {
onUpdate.apply(undefined, arguments);
}
};
this.name = settings.name || 'ui_' + numInstances;
this.isInitialized = false;
this.data = null; // current animation data
this.defTargets = null;
this.defStates = null;
this.defTransitions = null;
this.parser = null;
if(settings.transitions) {
this.transitions(settings.transitions);
}
if(settings.states) {
this.states(settings.states);
}
if(settings.targets) {
this.targets(settings.targets);
}
if(settings.parsers) {
this.parsers(settings.parsers);
}
// kimi is the man who does all the work under the hood
this.driver = kimi( {
manualStep: settings.autoUpdate === undefined ? false : !settings.autoUpdate,
onState: _onState.bind(this),
onUpdate: _onUpdate.bind(this)
});
}
f1.prototype = extend(Emitter.prototype, {
/**
* define which items are going to be animated. Pass in an object
* which will look something like this:
* ```javascript
* var ui = require('f1')();
*
* ui.targets( {
*
* itemToAnimate1: find('#itemToAnimate1'),
* itemToAnimate2: find('#itemToAnimate2')
* });
* ```
* The `Object` being passed in should have variable names which will
* associate to data which will be defined when setting up states in the
* `f1.states` method. The value which you pass these can be anything.
*
* In this case `itemToAnimate1` and `itemToAnimate2` will be a HTML Elements.
*
* @param {Object} targets An Object which will define which items will be animated
* @chainable
*/
targets: function(targets) {
this.defTargets = targets;
this.parsedTargets = parseTargets(targets);
return this;
},
/**
* defines the states which this `f1` instance will use.
*
* States are defined as objects. It could look something like this:
* ```javascript
* var ui = require('f1')();
*
* ui.states( {
*
* out: {
* itemToAnimate1: {
* variableToAnimate: 0
* },
*
* itemToAnimate2: {
* variableToAnimate: 0
* }
* },
*
* idle: {
* itemToAnimate1: {
* variableToAnimate: 1
* },
*
* itemToAnimate2: {
* variableToAnimate: 2
* }
* }
* });
* ```
* Above two states would be created: `out` and `idle`. Both would animate two
* objects: `itemToAnimate1` and `itemToAnimate2`. And in both of those objects
* the property `variableToAnimate` is defined. So if we were to transition from
* `out` to `idle` in `itemToAnimate1` `variableToAnimate` would transition from
* 0 to 1 and in `itemToAnimate2` from 0 to 2.
*
* States can also be defined by passing in objects for instance the above could
* be changed to look like this:
* ```javascript
* var ui = require('f1')();
*
* ui.states( {
*
* out: function(stateName) {
*
* console.log(stateName); // "out"
*
* return {
* itemToAnimate1: {
* variableToAnimate: 0
* },
*
* itemToAnimate2: {
* variableToAnimate: 0
* }
* };
* },
*
* idle: function(stateName) {
*
* console.log(stateName); // "idle"
*
* return {
* itemToAnimate1: {
* variableToAnimate: 1
* },
*
* itemToAnimate2: {
* variableToAnimate: 2
* }
* };
* }
* });
* ```
* The above can be handy when there are many items which states must be defined for
* instance a menu with many buttons.
*
* @param {Object} states defines all of the states for an `f1` instance
* @chainable
*/
states: function(states) {
this.defStates = states;
return this;
},
/**
* defines how this `f1` instance can move between states.
*
* For instance if we had two states out and idle you could define your transitions
* like this:
*
* ```javascript
* var ui = require('f1')();
*
* ui.transitions( [
* 'out', 'idle', // defines that you can go from the out state to the idle state
* 'idle', 'out' // defines that you can go from the idle state to the out state
* ]);
* ```
*
* Note that transitions are not bi-directional.
*
* If you simply just defined state names a default animation would be applied between
* states. This default transition will have a duration of 0.5 seconds and use no ease.
*
* If you want to modify the animation duration and ease you can define your transitions
* like this:
*
* ```javascript
* var eases = require('eases');
* var ui = require('f1')();
*
* ui.transitions( [
* 'out', 'idle', { duration: 1, ease: eases.expoOut },
* 'idle', 'out', { duration: 0.5, ease: eases.expoIn }
* ]);
* ```
*
* Defining your transitions using the above syntax will cause all properties to animate
* using the duration and ease defined.
*
* Ease functions should take a time property between 0-1 and return a modified value between
* 0-1.
*
* You can also animate properties individually. Here passing a delay maybe sometimes
* userful:
*
* ```javascript
* var eases = require('eases');
* var ui = require('f1')();
*
* ui.transitions( [
* 'out', 'idle', {
* duration: 1, ease: eases.expoOut,
*
* position: { duration: 0.5, delay: 0.5, ease: eases.quadOut },
* alpha: { duration: 0.5 }
* },
* 'idle', 'out', { duration: 0.5, ease: eases.expoIn }
* ]);
* ```
*
* In that example every property besides `position` and `alpha` will have a duration of one second
* using the `eases.quadOut` ease equation. `position` will have a duration of 0.5 seconds and will
* be delayed 0.5 seconds and will use the `eases.quadOut` easing function. `alpha` will simply have
* a duration of 0.5 seconds.
*
* For advanced transitions you can pass in a function instead like so:
* ```javascript
*
* ui.transitions( [
* 'out', 'idle', {
* duration: 1, ease: eases.expoOut,
*
* position: { duration: 0.5, delay: 0.5, ease: eases.quadOut },
*
* alpha: function(time, start, end) {
*
* return (end - start) * time + start;
* }
* },
* 'idle', 'out', { duration: 0.5, ease: eases.expoIn }
* ]);
* ```
*
* There the animation is the same as in the previous example however `alpha` will be calculated using
* a custom transition function.
*
* @param {Array} transitions An array which descriptes transitions
* @chainable
*/
transitions: function(transitions) {
this.defTransitions = Array.isArray(transitions) ? transitions : Array.prototype.slice.apply(arguments);
return this;
},
/**
* `f1` can target many different platforms. How it does this is by using parsers which
* can target different platforms. Parsers apply calculated state objects to targets.
*
* If working with the dom for instance your state could define values which will be applied
* by the parser to the dom elements style object.
*
* When calling parsers pass in an Object that can contain variables init, and update. Both should contain
* an Array's of functions which will be used to either init or update ui.
*
* init's functions will receive: states definition, targets definition, and transitions definition.
* update functions will receive: target and state. Where target could be for instance a dom element and
* state is the currently calculated state.
*
* @param {Object} parsersDefinitions an Object which may define arrays of init and update functions
* @chainable
*/
parsers: function(parsersDefinitions) {
// check that the parsersDefinitions is an object
if(typeof parsersDefinitions !== 'object' || Array.isArray(parsersDefinitions)) {
throw new Error('parsers should be an Object that contains arrays of functions under init and update');
}
this.parser = this.parser || getParser();
this.parser.add(parsersDefinitions);
return this;
},
/**
* Initializes `f1`. `init` will throw errors if required parameters such as
* states and transitions are missing. The initial state for the `f1` instance
* should be passed in.
*
* @param {String} Initial state for the `f1` instance
* @chainable
*/
init: function(initState) {
if(!this.isInitialized) {
this.isInitialized = true;
var driver = this.driver;
if(!this.defStates) {
throw new Error('You must define states before attempting to call init');
} else if(!this.defTransitions) {
throw new Error('You must define transitions before attempting to call init');
} else if(!this.parser) {
throw new Error('You must define parsers before attempting to call init');
} else if(!this.defTargets) {
throw new Error('You must define targets before attempting to call init');
} else {
parseStates(driver, this.defStates);
parseTransitions(driver, this.defStates, this.defTransitions);
this.parser.init(this.defStates, this.defTargets, this.defTransitions);
driver.init(initState);
}
if(global.__f1__) {
global.__f1__.init(this);
}
}
return this;
},
/**
* Destroys an `f1` instances. This should be called when you don't need the f1 instance anymore.
*/
destroy: function() {
if(global.__f1__) {
global.__f1__.destroy(this);
}
this.driver.destroy();
},
/**
* Will tell `f1` to go to animate to another state. Calling `go` will cause `f1` to calculate a path defined
* through transitions to the state which was passed to it.
*
* @param {String} state The new state you'd like to go to
* @param {Function} [cb] An optional callback which will be called once f1 reaches the state
* @chainable
*/
go: function(state, cb) {
this.driver.go(state, cb);
return this;
},
/**
* Will tell `f1` to go to immediately jump to another state without animating. If an animation is currently
* happening that animation is stopped and the jump to state will happen immediately.
*
* @param {String} state The new state you'd like to go to
* @chainable
*/
set: function(state) {
this.driver.set(state);
return this;
},
/**
* This method can be used to manually update f1 by certain deltaTime. deltaTime should be in milliseconds.
* In order to use this function you must pass in autoUpdate: false otherwise a raf loop will be run after
* go.
*
* @param {Number} deltaTime How much time has passed since the last render in milliseconds
* @chainable
*/
step: function(deltaTime) {
this.driver.step(deltaTime);
return this;
},
/**
* Will force `f1` to update. This is useful if you're updating a states values lets say by mouse movement.
* You'd call `f1.update` to ensure the state gets applied.
*
* @chainable
*/
update: function() {
_onUpdate.call(this, this.data, this.state, this.time, this.duration);
return this;
},
/**
* An advanced method where you can apply the current state f1
* has calculated to any object.
*
* Basically allows you to have one f1 object control multiple objects
* or manually apply animations to objects.
*
* @param {String} pathToTarget A path in the current state to the object you'd like to apply. The path should
* be defined using dot notation. So if your state had an object named `thing` and it
* contained another object you'd like to apply called `data`. Your `pathToTarget`
* would be `'thing.data'`
* @param {Object} target The object you'd like to apply the currently calculated state to. For instance target
* could be an html element.
* @param {Object} [parserDefinition] An optional Object which defines init and update functions for a parser.
*/
apply: function(pathToTarget, target, parserDefinition) {
var data = this.data;
var parser = this.parser;
var animationData;
// if parse functions were passed in then create a new parser
if(parserDefinition) {
parser = new getParser(parserDefinition);
}
// if we have a parser then apply the parsers (parsers set css etc)
if(parser) {
if(typeof pathToTarget === 'string') {
pathToTarget = pathToTarget.split('.');
}
animationData = data[ pathToTarget[ 0 ] ];
for(var i = 1, len = pathToTarget.length; i < len; i++) {
animationData = animationData[ pathToTarget[ i ] ];
}
parser.update(target, animationData);
}
}
});
function getEventArgs(name, args) {
args = Array.prototype.slice.apply(args);
args.unshift(name);
return args;
}
function _onUpdate(data, state, time, duration) {
var pathToTarget;
var target;
if(data !== undefined && state !== undefined && time !== undefined) {
this.data = data;
this.state = state;
this.time = time;
this.duration = duration;
if(this.parsedTargets) {
for(var i = 0, len = this.parsedTargets.length; i < len; i += 2) {
pathToTarget = this.parsedTargets[ i ];
target = this.parsedTargets[ i + 1 ];
this.apply(pathToTarget, target);
}
}
// this is kind nasty because _onUpdate is called manually on update manual calls
// in this case we should emit an event duration could be undefined in that case
if(duration !== undefined) {
this.onUpdate(data, state, time, duration);
}
}
}
function _onState(data, state) {
this.data = data;
this.state = state;
this.time = 0;
this.duration = undefined;
this.onState(data, state);
}