Improve animations

This commit is contained in:
Tom Raithel
2017-03-01 20:34:08 +01:00
parent 5f20c00bec
commit 6d4f1d0c09
5 changed files with 315 additions and 42 deletions

134
js/animation.js Normal file
View File

@@ -0,0 +1,134 @@
export const createAnimationController = (animations, component) => {
return {
animations,
start: () => {
Object.entries(animations).map(([name, animation]) => animation.run((state) => {
component.setState({
...component.state,
[name]: state,
});
}));
},
prepare: () => {
Object.entries(animations).map(([name, animation]) => animation.prepare((state) => {
component.setState({
...component.state,
[name]: state,
});
}));
}
};
}
export const createAnimation = (stateA, stateB, delay) => ({
stateA,
stateB,
delay,
});
const getAnimationState = (animation, stateName = 'stateA') => {
if (animation instanceof Array) {
return animation.map(a => getAnimationState(a, stateName));
}
return animation[stateName];
};
const getMaxTransitionTime = (transition) => {
const re = /(\d+)ms/g;
const times = [];
let matches;
while ((matches = re.exec(transition)) != null) {
times.push(parseInt(matches[1], 10));
}
return Math.max.apply(null, times);
};
const getAnimationDuration = (animation) => {
if (animation instanceof Array) {
return animation.reduce((maxDuration, a) => {
const duration = getAnimationDuration(a);
if (duration > maxDuration) {
return duration;
}
return maxDuration;
}, 0);
}
const state = animation.stateB;
const maxTransition = state.transition ? getMaxTransitionTime(state.transition) : 0;
return maxTransition + animation.delay;
};
const getMaxAnimationsDuration = (animations) => (
getAnimationDuration(Object.values(animations))
);
export const createAnimationRunner = (animations, subscriber) => {
let state = Object.entries(animations).reduce((state, [name, animation]) => ({
...state,
[name]: getAnimationState(animation),
}), {});
const animationsDuration = getMaxAnimationsDuration(animations);
const animate = (name, animation, stateName, getDelay) => {
if (animation instanceof Array) {
animation.map((a, index) => {
window.requestAnimationFrame(() => {
window.setTimeout(() => {
state = {
...state,
[name]: [
...(state[name].slice(0, index)),
a[stateName],
...(state[name].slice(index + 1, state[name].length)),
],
};
subscriber();
}, getDelay(a))
});
});
} else {
window.requestAnimationFrame(() => {
window.setTimeout(() => {
state = {
...state,
[name]: animation[stateName],
};
subscriber();
}, getDelay(animation))
});
}
}
return {
getState() {
return state;
},
run() {
Object.entries(animations).forEach(([name, animation]) => {
animate(name, animation, 'stateB', a => a.delay)
})
},
runReverse() {
Object.entries(animations).reverse().forEach(([name, animation]) => {
animate(name, animation, 'stateA', a => animationsDuration - a.delay)
})
},
awaitAnimationComplete(callback) {
window.setTimeout(callback, animationsDuration);
},
}
}
// prepare(callback) {
// callback(stateA);
// },
// run(callback) {
// window,requestAnimationFrame(() => {
// window.setTimeout(() => {
// callback(stateB);
// }, delay)
// });
// },

View File

@@ -2,7 +2,7 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Link from './Link'; import Link from './Link';
export default function Item({ item, noLeadingBorder = false, active = false}) { export default function Item({ item, noLeadingBorder = false, active = false, style = {}}) {
return ( return (
<Link <Link
className={classNames('item', { className={classNames('item', {
@@ -10,6 +10,7 @@ export default function Item({ item, noLeadingBorder = false, active = false}) {
'is-active': active, 'is-active': active,
})} })}
pageName={`${item.quadrant}/${item.name}`} pageName={`${item.quadrant}/${item.name}`}
style={style}
> >
<div className="item__title">{item.title}</div> <div className="item__title">{item.title}</div>
{ {

View File

@@ -1,20 +1,21 @@
import React from 'react'; import React from 'react';
import Item from './Item'; import Item from './Item';
export default function ItemList({ children, items, activeItem, noLeadingBorder }) { export default function ItemList({ children, items, activeItem, noLeadingBorder, headerStyle = {}, itemStyle = [] }) {
return ( return (
<div className="item-list"> <div className="item-list">
<div className="item-list__header"> <div className="item-list__header" style={headerStyle}>
{children} {children}
</div> </div>
<div className="item-list__list"> <div className="item-list__list">
{ {
items.map(item => ( items.map((item, i) => (
<Item <Item
key={item.name} key={item.name}
item={item} item={item}
noLeadingBorder={noLeadingBorder} noLeadingBorder={noLeadingBorder}
active={typeof activeItem === 'object' && activeItem !== null && activeItem.name === item.name} active={typeof activeItem === 'object' && activeItem !== null && activeItem.name === item.name}
style={itemStyle[i]}
/> />
)) ))
} }

View File

@@ -2,14 +2,14 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import actions from '../actions'; import actions from '../actions';
function Link({ pageName, children, navigate, className}) { function Link({ pageName, children, navigate, className, style = {}}) {
const handleClick = (e) => { const handleClick = (e) => {
e.preventDefault(); e.preventDefault();
navigate(pageName); navigate(pageName);
}; };
return ( return (
<a href={`/${pageName}.html`} onClick={handleClick} {...{ className }}> <a href={`/${pageName}.html`} onClick={handleClick} style={style} {...{ className }}>
{children} {children}
</a> </a>
); );

View File

@@ -4,52 +4,189 @@ import Badge from './Badge';
import ItemList from './ItemList'; import ItemList from './ItemList';
import Link from './Link'; import Link from './Link';
import Fadeable from './Fadeable'; import Fadeable from './Fadeable';
import { createAnimation, createAnimationRunner } from '../animation';
import { groupByQuadrants } from '../../common/model'; import { groupByQuadrants } from '../../common/model';
export default function PageItems({ leaving, onLeave, pageName, items }) { const items = [1, 2]; // TODO use real items
const [quadrantName, itemName] = pageName.split('/');
const item = items.filter(item => item.quadrant === quadrantName && item.name === itemName)[0];
const itemsInRing = groupByQuadrants(items)[item.quadrant][item.ring];
return ( const animationsIn = {
<Fadeable leaving={leaving} onLeave={onLeave}> background: createAnimation({
<div className="item-page"> transform: 'translateX(calc((100vw - 1200px) / 2 + 800px))',
<div className="item-page__nav"> transition: 'transform 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98)',
<div className="item-page__nav__inner"> }, {
<div className="item-page__header"> transition: 'transform 450ms cubic-bezier(0.24, 1.12, 0.71, 0.98)',
<h3 className="headline">Languages &amp; Frameworks</h3> transform: 'translateX(0)',
</div> },
<ItemList items={itemsInRing} activeItem={item}> 0
<div className="split"> ),
<div className="split__left"> navHeader: createAnimation({
<Badge big type={item.ring}>{item.ring}</Badge> transform: 'translateX(-40px)',
</div> opacity: '0',
<div className="split__right"> }, {
<Link className="icon-link" pageName={item.quadrant}> transition: 'opacity 150ms ease-out, transform 300ms ease-out',
<span className="icon icon--pie icon-link__icon"></span>Quadrant Overview transform: 'translateX(0px)',
</Link> opacity: '1',
</div> },
300
),
text: createAnimation({
transform: 'translateY(-20px)',
opacity: '0',
}, {
transition: 'opacity 150ms ease-out, transform 300ms ease-out',
transform: 'translateY(0px)',
opacity: '1',
},
600
),
items: items.map((item, i) => (createAnimation({
transform: 'translateX(-40px)',
opacity: '0',
}, {
transition: 'opacity 150ms ease-out, transform 300ms ease-out',
transform: 'translateX(0px)',
opacity: '1',
},
400 + 100 * i
)))
};
const animationsOut = {
background: createAnimation(
animationsIn.background.stateB,
animationsIn.background.stateA,
0
),
navHeader: createAnimation(
animationsIn.navHeader.stateB,
{
transition: 'opacity 150ms ease-out, transform 300ms ease-out',
transform: 'translateX(40px)',
opacity: '0',
},
0
),
text: createAnimation(
animationsIn.text.stateB,
{
transform: 'translateY(20px)',
transition: 'opacity 150ms ease-out, transform 300ms ease-out',
opacity: '0',
},
0
),
items: items.map((item, i) => (createAnimation(
animationsIn.items[i].stateB,
{
transition: 'opacity 150ms ease-out, transform 300ms ease-out',
transform: 'translateX(40px)',
opacity: '0',
},
100 + 100 * i
)))
};
const setAnimations = (state, animations) => ({
...state,
animations,
});
class PageItem extends React.Component {
constructor(props) {
super(props);
if (props.leaving) { // entering from an other page
this.state = setAnimations({}, createAnimationRunner(animationsIn).getState());
} else { // Hard refresh
this.state = {};
}
}
componentWillReceiveProps({ leaving }) {
if (!this.props.leaving && leaving) { // page will be left
this.animationRunner = createAnimationRunner(animationsOut, this.handleAnimationsUpdate);
this.animationRunner.run();
this.animationRunner.awaitAnimationComplete(this.props.onLeave);
}
if (this.props.leaving && !leaving) { // page is entered
this.animationRunner = createAnimationRunner(animationsIn, this.handleAnimationsUpdate);
this.animationRunner.run();
}
}
handleTransitionEnd = () => {
if (this.state.faded) {
this.props.onLeave();
}
};
handleAnimationsUpdate = () => {
this.setState(setAnimations(this.state, this.animationRunner.getState()));
};
getAnimationState = (name) => {
if (!this.state.animations) {
return undefined;
}
return this.state.animations[name];
};
render() {
const { leaving, onLeave, pageName, items } = this.props;
const [quadrantName, itemName] = pageName.split('/');
const item = items.filter(item => item.quadrant === quadrantName && item.name === itemName)[0];
const itemsInRing = groupByQuadrants(items)[item.quadrant][item.ring];
// console.log(this.getAnimationState('items'));
return (
<div>
<div className="item-page">
<div className="item-page__nav">
<div className="item-page__nav__inner">
<div className="item-page__header" style={this.getAnimationState('navHeader')}>
<h3 className="headline">Languages &amp; Frameworks</h3>
</div> </div>
</ItemList>
<ItemList
items={itemsInRing}
activeItem={item}
headerStyle={this.getAnimationState('navHeader')}
itemStyle={this.getAnimationState('items')}
>
<div className="split">
<div className="split__left">
<Badge big type={item.ring}>{item.ring}</Badge>
</div>
<div className="split__right">
<Link className="icon-link" pageName={item.quadrant}>
<span className="icon icon--pie icon-link__icon"></span>Quadrant Overview
</Link>
</div>
</div>
</ItemList>
</div>
</div> </div>
</div> <div className="item-page__content" style={this.getAnimationState('background')}>
<div className="item-page__content"> <div className="item-page__content__inner" style={this.getAnimationState('text')}>
<div className="item-page__content__inner"> <div className="item-page__header">
<div className="item-page__header"> <div className="split">
<div className="split"> <div className="split__left">
<div className="split__left"> <h1 className="hero-headline hero-headline--inverse">{item.title}</h1>
<h1 className="hero-headline hero-headline--inverse">{item.title}</h1> </div>
</div> <div className="split__right">
<div className="split__right"> <Badge big type={item.ring}>{item.ring}</Badge>
<Badge big type={item.ring}>{item.ring}</Badge> </div>
</div> </div>
</div> </div>
<div className="markdown" dangerouslySetInnerHTML={{__html: item.body}} />
</div> </div>
<div className="markdown" dangerouslySetInnerHTML={{__html: item.body}} />
</div> </div>
</div> </div>
</div> </div>
</Fadeable> );
); }
} }
export default PageItem;