Skip to content

Instantly share code, notes, and snippets.

@abruzzihraig
Created August 17, 2016 12:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save abruzzihraig/ae626b114dfcb85d28fad5bf62ce5568 to your computer and use it in GitHub Desktop.
Save abruzzihraig/ae626b114dfcb85d28fad5bf62ce5568 to your computer and use it in GitHub Desktop.
ScatterMenu snippet
require('animate.css');
require('./ScatterMenu.styl');
import React, { PropTypes } from 'react';
import { Motion, StaggeredMotion, spring } from 'react-motion';
import cx from 'classnames';
import { BOUNCE, FADE } from '../../constant';
import { Button } from '../Button';
const VIRTUAL_AREA_RATIO = 0.95;
const HOLDING_DURATION = {
ROOT: 500,
TAB: 600
};
const VALID_AREA = {
DISTANCE: 100,
MIN: 20,
MAX: 120
};
const MOVING_AREA = {
MAIN: 'main',
SUB: 'sub'
};
const propTypes = {
tabs: PropTypes.array,
position: PropTypes.string,
iconName: PropTypes.string,
useVirtualArea: PropTypes.bool
};
const defaultProps = {
tabs: [],
iconName: 'create',
position: 'left-bottom',
useVirtualArea: true
};
export default class ScatterMenu extends React.Component {
constructor(props) {
super(props);
let { tabs } = this.props;
this.state = {
rootPoint: {},
couldExpandMainTabs: false,
mainTabDelayer: null,
subTabDelayer: null,
rootHoldingTime: null,
tabHoldingTime: null,
tabs,
tabsLoaded: false,
activeMainTab: null,
activeSubTab: null,
activeArea: MOVING_AREA.MAIN
};
this.touchStart = ::this.touchStart;
this.touchMove = ::this.touchMove;
this.touchEnd = ::this.touchEnd;
this.getPointAddOn = ::this.getPointAddOn;
this.isUsefulPointOnMainArea = ::this.isUsefulPointOnMainArea;
this.isUsefulPointOnSubArea = ::this.isUsefulPointOnSubArea;
}
componentDidMount() {
let rootDOMNode = this.refs.rootPoint;
let { tabs } = this.state;
const { position, useVirtualArea } = this.props;
const distance = VALID_AREA.DISTANCE;
const getRad = (position, { length }, useVirtualArea = null, center={ x: 0, y: 0 }, middle = null) => {
let useDefiniteEdge = useVirtualArea === false; // Note the virtual area parameter here should be treat careful
let areaRatio = useVirtualArea ? VIRTUAL_AREA_RATIO : 1;
let distance = useDefiniteEdge ? VALID_AREA.DISTANCE : 1;
let eachRad = null;
let startRad = null;
let endRad = null;
let startEdge = null;
let endEdge = null;
let addOn = null;
middle = useVirtualArea ? null : middle;
addOn = this.getPointAddOn(position, distance, middle);
startEdge = { x: center.x + addOn.start.x, y: center.y + addOn.start.y };
endEdge = { x: center.x + addOn.end.x, y: center.y + addOn.end.y };
startRad = Math.atan((startEdge.y - center.y) / (startEdge.x - center.x));
endRad = Math.atan((endEdge.y - center.y) / (endEdge.x - center.x));
if (startRad === 0 && (startEdge.x - center.x) < 0) startRad = Math.PI;
if (endRad === 0 && (endEdge.x - center.x) < 0) endRad = -Math.PI;
let offsetRad = Math.abs(startRad - endRad) * (1 - areaRatio) / 2
if (startRad > endRad) {
startRad -= offsetRad;
endRad += offsetRad;
} else {
startRad += offsetRad;
endRad -= offsetRad;
}
eachRad = (endRad - startRad) / length;
eachRad = Math.sign(eachRad) * Math.floor(Math.abs(eachRad) * 1e3) / 1e3;
return { each: eachRad, start: startRad, end: endRad };
};
const generateTabInstance = (tab, idx, distance, radOfParent, radOfRoot = null) => {
let x = +(Math.cos(radOfParent.start+radOfParent.each*(idx+0.5))*distance).toFixed(2);
let y = +(Math.sin(radOfParent.start+radOfParent.each*(idx+0.5))*distance).toFixed(2);
tab.style = { x, y };
tab.radFromParent = (radOfParent.start+radOfParent.each*(idx+0.5));
tab.kFromParent = Math.tan(tab.radFromParent);
if (radOfRoot) {
tab.radFromRoot = (radOfRoot.start+radOfRoot.each*(idx+0.5));
tab.kFromRoot = Math.tan(tab.radFromRoot);
}
return tab;
};
let rad = getRad(position, tabs);
let rootPoint = {
eachRad: rad.each,
x: rootDOMNode.offsetLeft,
y: rootDOMNode.offsetTop,
radius: rootDOMNode.clientWidth / 2,
centerX: rootDOMNode.clientWidth / 2 + rootDOMNode.offsetLeft,
centerY: rootDOMNode.clientWidth / 2 + rootDOMNode.offsetTop
};
let computedTabs = tabs.map((tab, idx) => {
tab = generateTabInstance(tab, idx, distance, rad);
if (tab.subTabs) {
let center = { x: rootPoint.x, y: rootPoint.y };
let middle = { x: tab.style.x, y: tab.style.y };
let subRadFromParent = getRad(position, tab.subTabs);
let subRadFromRoot = getRad(position, tab.subTabs, useVirtualArea, center, middle);
tab.eachRadOfItself = subRadFromParent.each;
tab.eachRadOfRoot = subRadFromRoot.each;
tab.subTabs = tab.subTabs.map((subTab, subIdx) => {
return generateTabInstance(subTab, subIdx, distance, subRadFromParent, subRadFromRoot);
});
tab.motion = {
start: tab.subTabs.map(() => ({x: 0, y: 0, opacity: 0})),
end: tab.subTabs.map(v => {
return {...v.style, opacity: 1};
})
};
}
return tab;
});
const topMotion = {
start: tabs.map(() => ({x: 0, y: 0, opacity: 0})),
end: tabs.map(v => {
return {...v.style, opacity: 1};
})
};
this.setState({ rootPoint, topMotion, tabs: computedTabs, tabsLoaded: true });
}
touchStart() {
clearTimeout(this.state.mainTabDelayer);
let mainTabDelayer = setTimeout(() => {
let { rootHoldingTime: time } = this.state;
if (time && Date.now() - time >= HOLDING_DURATION.ROOT) {
this.setState({ couldExpandMainTabs: true });
}
}, HOLDING_DURATION.ROOT);
this.setState({
mainTabDelayer,
isFiring: true,
rootHoldingTime: Date.now()
});
}
touchMove(evt) {
if (!this.state.couldExpandMainTabs) return;
let { rootPoint, tabs, subTabDelayer, activeArea, activeMainTab: preActiveMainTab, activeSubTab: preActiveSubTab } = this.state;
const { pageX, pageY } = evt.touches[0];
const { position, useVirtualArea } = this.props;
const center = { x: rootPoint.centerX, y: rootPoint.centerY };
const curK = (pageY - center.y) / (pageX - center.x);
let curActiveMainTab = null;
let curActiveSubTab = null;
const checkIfTabActive = (tabRad, tabK, pointK, eachRad) => {
let start = tabK;
let end = start > 0 ? Math.tan(tabRad - Math.abs(eachRad)) : Math.tan(tabRad + Math.abs(eachRad)); // Sorry for my bad math:)
return pointK >= Math.min(start, end) && pointK < Math.max(start, end);
};
if (activeArea === MOVING_AREA.MAIN) {
if (!this.isUsefulPointOnMainArea(pageX, pageY, position, center)) return this.resetActiveTab();
// prevent calculating when the active tab not changed
if (preActiveMainTab && checkIfTabActive(preActiveMainTab.radFromParent, preActiveMainTab.kFromParent, curK, rootPoint.eachRad)) return;
let newTabs = tabs.map(tab => {
tab.couldExpand = false;
tab.active = checkIfTabActive(tab.radFromParent, tab.kFromParent, curK, rootPoint.eachRad);
if (tab.active) curActiveMainTab = tab;
return tab;
});
clearTimeout(subTabDelayer);
subTabDelayer = setTimeout(() => {
let { tabHoldingTime: time, activeMainTab } = this.state;
if (activeMainTab && activeMainTab.subTabs && time && (Date.now() - time >= HOLDING_DURATION.TAB)) {
activeMainTab.couldExpand = true;
this.setState({ activeMainTab, activeArea: MOVING_AREA.SUB });
}
}, HOLDING_DURATION.TAB);
this.setState({
activeMainTab: curActiveMainTab,
tabs: newTabs,
subTabDelayer,
tabHoldingTime: Date.now()
});
} else if (activeArea === MOVING_AREA.SUB) {
let distance = useVirtualArea ? 1 : VALID_AREA.DISTANCE;
let middle = useVirtualArea ? null : preActiveMainTab.style;
let addOn = this.getPointAddOn(position, distance, middle);
let startPoint = { x: center.x + addOn.start.x, y: center.y + addOn.start.y };
let endPoint = { x: center.x + addOn.end.x, y: center.y + addOn.end.y };
if (!this.isUsefulPointOnSubArea(pageX, pageY, position, center, startPoint, endPoint, useVirtualArea)) {
this.resetActiveTab({}, false);
return this.setState({ activeArea: MOVING_AREA.MAIN, activeSubTab: null, activeMainTab: preActiveMainTab });
}
// prevent calculating when the active tab not changed
if (preActiveSubTab && checkIfTabActive(preActiveSubTab.radFromRoot, preActiveSubTab.kFromRoot, curK, preActiveMainTab.eachRadOfRoot)) return;
preActiveMainTab.subTabs.map(subTab => {
subTab.active = checkIfTabActive(subTab.radFromRoot, subTab.kFromRoot, curK, preActiveMainTab.eachRadOfRoot);
if (subTab.active) curActiveSubTab = subTab;
return subTab;
});
this.setState({ activeSubTab: curActiveSubTab });
}
}
touchEnd() {
let { activeMainTab, activeSubTab } = this.state;
(activeSubTab && activeSubTab.handler()) || (activeMainTab && !activeMainTab.subTabs && activeMainTab.handler());
this.resetActiveTab({
isFiring: false,
rootHoldingTime: null,
tabHoldingTime: null,
couldExpandMainTabs: false
});
}
getPointAddOn(position, distance = 1, middle = null) {
let addOn = null;
switch (position) {
case 'left-top':
case 'top-left':
addOn = {
start: { x: 1 * distance, y: 0 },
end: { x: 0, y: 1 * distance }
};
break;
case 'left-bottom':
case 'bottom-left':
addOn = {
start: { x: 0, y: -1 * distance},
end: { x: 1 * distance, y: 0 }
};
break;
case 'right-top':
case 'top-right':
addOn = {
start: { x: -1 * distance, y: 0 },
end: { x: 0, y: 1 * distance }
};
break;
case 'right-bottom':
case 'bottom-right':
addOn = {
start: { x: 0, y: -1 * distance },
end: { x: -1 * distance, y: 0 }
};
break;
default:
throw('Unknown position');
}
if (middle) {
addOn = {
start: {
x: addOn.start.x + middle.x,
y: addOn.start.y + middle.y,
},
end: {
x: addOn.end.x + middle.x,
y: addOn.end.y + middle.y,
}
};
}
return addOn;
}
isUsefulPointOnMainArea(pageX, pageY, position, center) {
let distance = Math.sqrt(Math.pow((pageY - center.y), 2) + Math.pow((pageX - center.x), 2));
if (distance < VALID_AREA.MIN || distance > VALID_AREA.MAX) return false;
switch (position) {
case 'left-top':
case 'top-left':
return pageX >= center.x && pageY >= center.y;
case 'left-bottom':
case 'bottom-left':
return pageX >= center.x && pageY <= center.y;
case 'right-top':
case 'top-right':
return pageX <= center.x && pageY >= center.y;
case 'right-bottom':
case 'bottom-right':
return pageX <= center.x && pageY <= center.y;
default:
throw('Unknown position');
}
}
isUsefulPointOnSubArea(pageX, pageY, position, center, startPoint, endPoint, useVirtualArea) {
let distance = Math.sqrt(Math.pow((pageY - center.y), 2) + Math.pow((pageX - center.x), 2));
if (distance < VALID_AREA.MIN || distance > VALID_AREA.MAX) return false;
let areaRatio = useVirtualArea ? VIRTUAL_AREA_RATIO : 1;
let curK = (pageY - center.y) / (pageX - center.x);
let startRad = Math.atan((startPoint.y - center.y) / (startPoint.x - center.x));
let endRad = Math.atan((endPoint.y - center.y) / (endPoint.x - center.x));
if (startRad === 0 && (startPoint.x - center.x) < 0) startRad = Math.PI;
if (endRad === 0 && (endPoint.x - center.x) < 0) endRad = -Math.PI;
let offsetRad = Math.abs(startRad - endRad) * (1 - areaRatio) / 2
if (startRad > endRad) {
startRad -= offsetRad;
endRad += offsetRad;
} else {
startRad += offsetRad;
endRad -= offsetRad;
}
let startK = Math.tan(startRad);
let endK = Math.tan(endRad);
switch (position) {
case 'left-top':
case 'top-left':
case 'left-bottom':
case 'bottom-left':
return curK >= startK && curK <= endK;
case 'right-top':
case 'top-right':
case 'right-bottom':
case 'bottom-right':
return curK >= endK && curK <= startK;
default:
throw('Unknown position');
}
}
resetActiveTab(opt = {}, resetAll = true) {
const { tabs, mainTabDelayer, subTabDelayer, activeMainTab } = this.state;
clearTimeout(mainTabDelayer);
clearTimeout(subTabDelayer);
let newTabs = tabs.map(tab => {
if (resetAll) tab.active = false;
if (tab.subTabs) {
tab.subTabs = tab.subTabs.map(subTab => {
subTab.active = false;
return subTab;
});
tab.couldExpand = false;
}
return tab;
});
this.setState({
tabs: newTabs,
activeMainTab: resetAll ? null : activeMainTab,
activeSubTab: null,
...opt
});
}
render() {
let { position, iconName, children } = this.props;
let { tabs, topMotion, couldExpandMainTabs, isFiring, tabsLoaded } = this.state;
let tabList = null;
let scatterCls = cx({
[position]: true,
'scatter-menu': true,
'firing': this.state.isFiring
});
let scatterIconCls = cx({
['icon-' + iconName]: true,
'scatter-icon': true
});
let btnMotion = {
opacity: isFiring ? spring(1, BOUNCE) : spring(0.9, BOUNCE),
scale: isFiring ? spring(2, BOUNCE) : spring(1, BOUNCE)
};
if (tabsLoaded) {
let easing = couldExpandMainTabs ? BOUNCE : FADE;
tabList = (
<StaggeredMotion
defaultStyles={topMotion.start}
styles={prev => prev.map((val, i) => {
if (i === 0) {
let state = couldExpandMainTabs ? 'end' : 'start';
return {
x: spring(topMotion[state][0].x, easing),
y: spring(topMotion[state][0].y, easing),
opacity: spring(topMotion[state][0].opacity, FADE)
};
}
return {
x: spring(prev[i-1].x / topMotion.end[i-1].x * topMotion.end[i].x, easing),
y: spring(prev[i-1].y / topMotion.end[i-1].y * topMotion.end[i].y, easing),
opacity: spring(prev[i-1].opacity / topMotion.end[i-1].opacity * topMotion.end[i].opacity, FADE)
};
})}>
{ styles =>
<ul className="scatter-list">
{tabs.map((tab, idx) => {
let scatterTabCls = cx({
active: tab.active,
'scatter-tab': true
});
return (<li
key={idx}
className={scatterTabCls}
style={{transform: `translate3d(${styles[idx].x}px, ${styles[idx].y}px, 0)`, opacity: styles[idx].opacity}}
onClick={tab.handler}
>
{tab.subTabs ? null : <h6 className="scatter-item-title">{tab.title}</h6>}
<i className={'icon-' + tab.iconName} />
{tab.subTabs && tab.active ?
<StaggeredMotion
defaultStyles={tab.motion.start}
styles={prev => prev.map((val, i) => {
if (i === 0) {
let state = tab.couldExpand ? 'end' : 'start';
return {
x: spring(tab.motion[state][0].x, easing),
y: spring(tab.motion[state][0].y, easing),
opacity: spring(tab.motion[state][0].opacity, FADE)
};
}
return {
x: spring(prev[i-1].x / tab.motion.end[i-1].x * tab.motion.end[i].x, easing),
y: spring(prev[i-1].y / tab.motion.end[i-1].y * tab.motion.end[i].y, easing),
opacity: spring(prev[i-1].opacity / tab.motion.end[i-1].opacity * tab.motion.end[i].opacity, FADE)
};
})}>
{ styles =>
<ul className="scatter-sub-list">
{tab.subTabs.map((subTab, subIdx) => {
let scatterSubTabCls = cx({
active: subTab.active,
'scatter-sub-tab': true
});
return (<li
key={'sub' + subIdx}
className={scatterSubTabCls}
style={{transform: `translate3d(${styles[subIdx].x}px, ${styles[subIdx].y}px, 0)`, opacity: styles[subIdx].opacity}}
onClick={subTab.handler}
>
<h6 className="scatter-item-title">{subTab.title}></h6>
<i className={'icon-' + subTab.iconName} />
</li>);}
)}
</ul>
}
</StaggeredMotion>
: null}
</li>);}
)}
</ul>
}
</StaggeredMotion>
);
}
return (
<div className={scatterCls}
onTouchStart={this.touchStart}
onTouchMove={this.touchMove}
onTouchEnd={this.touchEnd}
ref="rootPoint"
>
<Motion defaultStyle={{opacity: 0.9, scale: 1}} style={{...btnMotion}}>
{ style => <button className="scatter-btn" style={{...style, transform: `scale(${style.scale})`}}/> }
</Motion>
{children ? children : <i className={scatterIconCls} />}
{tabList}
</div>
);
}
}
ScatterMenu.propTypes = propTypes;
ScatterMenu.defaultProps = defaultProps;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment