Skip to content

Commit

Permalink
animated droppable placeholder spike
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredcrowe committed Sep 6, 2017
1 parent 9a4ac81 commit 124af62
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 24 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"react-redux": "5.0.6",
"redux": "^3.6.0",
"redux-thunk": "2.2.0",
"reselect": "3.0.1"
"reselect": "3.0.1",
"uuid": "^3.1.0"
},
"devDependencies": {
"@atlaskit/css-reset": "1.1.5",
Expand Down
7 changes: 6 additions & 1 deletion src/view/animation.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const physics = (() => {
return { standard, fast };
})();

const transitionCurve = 'cubic-bezier(0.2, 0, 0, 1)';
const transitionTime = '0.2s';

export const css = {
outOfTheWay: 'transform 0.2s cubic-bezier(0.2, 0, 0, 1)',
outOfTheWay: `transform ${transitionTime} ${transitionCurve}`,
transitionCurve,
transitionTime,
};
2 changes: 1 addition & 1 deletion src/view/draggable/draggable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
Provided as DragHandleProvided,
} from '../drag-handle/drag-handle-types';
import getCenterPosition from '../get-center-position';
import Placeholder from '../placeholder';
import { StaticPlaceholder as Placeholder } from '../placeholder';
import { droppableIdKey } from '../context-keys';
import { add } from '../../state/position';
import type {
Expand Down
13 changes: 7 additions & 6 deletions src/view/droppable/connected-droppable.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,10 @@ export const makeSelector = (): Selector => {
);

const getMapProps = memoizeOne(
(isDraggingOver: boolean, placeholder: ?Placeholder): MapProps => ({
(isDraggingOver: boolean, placeholder: ?Placeholder, phase: ?string): MapProps => ({
isDraggingOver,
placeholder,
phase,
})
);

Expand All @@ -107,7 +108,7 @@ export const makeSelector = (): Selector => {
if (phase === 'DRAGGING') {
if (!drag) {
console.error('cannot determine dragging over as there is not drag');
return getMapProps(false, null);
return getMapProps(false, null, phase);
}

const isDraggingOver = getIsDraggingOver(id, drag.impact.destination);
Expand All @@ -117,13 +118,13 @@ export const makeSelector = (): Selector => {
drag.impact.destination,
draggable
);
return getMapProps(isDraggingOver, placeholder);
return getMapProps(isDraggingOver, placeholder, phase);
}

if (phase === 'DROP_ANIMATING') {
if (!pending) {
console.error('cannot determine dragging over as there is no pending result');
return getMapProps(false, null);
return getMapProps(false, null, phase);
}

const isDraggingOver = getIsDraggingOver(id, pending.impact.destination);
Expand All @@ -133,10 +134,10 @@ export const makeSelector = (): Selector => {
pending.result.destination,
draggable
);
return getMapProps(isDraggingOver, placeholder);
return getMapProps(isDraggingOver, placeholder, phase);
}

return getMapProps(false, null);
return getMapProps(false, null, phase);
},
);
};
Expand Down
2 changes: 2 additions & 0 deletions src/view/droppable/droppable-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export type MapProps = {|
// not the user is dragging over a list that
// is not the source list
placeholder: ?Placeholder,
// whether the entire application is currently in the dragging state
phase: ?string,
|}

export type OwnProps = {|
Expand Down
14 changes: 9 additions & 5 deletions src/view/droppable/droppable.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ type Context = {|
[droppableIdKey]: DroppableId
|}

const defaultPlaceholderDimensions = { height: 0, width: 0 };

export default class Droppable extends Component {
/* eslint-disable react/sort-comp */
props: Props
Expand Down Expand Up @@ -62,14 +64,16 @@ export default class Droppable extends Component {
}

getPlaceholder() {
if (!this.props.placeholder) {
return null;
}
const { phase } = this.props;
const isDraggedOver = Boolean(this.props.placeholder);
const { height, width } = this.props.placeholder || defaultPlaceholderDimensions;

return (
<Placeholder
height={this.props.placeholder.height}
width={this.props.placeholder.width}
phase={phase}
isDraggedOver={isDraggedOver}
height={height}
width={width}
/>
);
}
Expand Down
21 changes: 21 additions & 0 deletions src/view/placeholder/StaticPlaceholder.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// @flow
import React, { PureComponent } from 'react';

type Props = {|
height: number,
width: number,
|};

export default class StaticPlaceholder extends PureComponent {
props: Props

render() {
const { height, width } = this.props;
const style = {
height,
pointerEvents: 'none',
width,
};
return <div style={style} />;
}
}
3 changes: 2 additions & 1 deletion src/view/placeholder/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export default from './placeholder';
export default from './Placeholder';
export { default as StaticPlaceholder } from './StaticPlaceholder';
190 changes: 181 additions & 9 deletions src/view/placeholder/placeholder.jsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,192 @@
// @flow
import React, { PureComponent } from 'react';
import v4 from 'uuid/v4';
import { css as transitionStyles } from '../animation';
import type { Phase } from '../../types';

type AnimatedProperties = {|
/** The height of the placeholder */
height: number,
/** The width of the placeholder */
width: number,
|};

type Props = {|
...AnimatedProperties,
/** Whether the parent droppable is being dragged over */
isDraggedOver: boolean,
/** The phase of the drag */
phase: Phase,
|};

type State = {|
...AnimatedProperties,
/** Whether the placeholder should be animating in */
isAnimatingIn: boolean,
/** Whether the placeholder should be animating out */
isAnimatingOut: boolean,
|}

const VENDOR_PREFIXES: string[] = ['-ms-', '-moz-', '-webkit-', ''];
// We inject a stylesheet into the document so that we can dynamically
// add CSS animations.
const stylesheet: HTMLStyleElement = document.createElement('style');
stylesheet.type = 'text/css';
document.getElementsByTagName('head')[0].appendChild(stylesheet);
// A global counter keeping track of how many style rules have been
// added to the stylesheet.
let globalRuleIndex = 0;

export default class Placeholder extends PureComponent {
props: {|
height: number,
width: number,
|}
// eslint-disable-next-line react/sort-comp
props: Props

render() {
const style = {
width: this.props.width,
height: this.props.height,
static defaultProps = {
isDraggedOver: false,
}

state: State = {
height: this.props.height,
isAnimatingIn: false,
isAnimatingOut: false,
width: this.props.width,
}

// Every time we instantiate a new placeholder we record the index
// of its animation rules, then increment the global counter
ruleIndex: number = (() => {
const ruleIndex = globalRuleIndex;
globalRuleIndex += VENDOR_PREFIXES.length;
return ruleIndex;
})()

componentWillReceiveProps(newProps: Props) {
if (newProps.phase === this.props.phase &&
newProps.isDraggedOver === this.props.isDraggedOver) {
return;
}

if (newProps.phase === 'DRAGGING') {
const height = newProps.height || this.state.height;
const width = newProps.width || this.state.width;
const isAnimatingIn = newProps.isDraggedOver;
const isAnimatingOut = !newProps.isDraggedOver;
this.setState({ height, isAnimatingIn, isAnimatingOut, width });
}

// Reset the state once the drop completes
if (newProps.phase === 'DROP_COMPLETE') {
this.setState({
height: 0,
isAnimatingIn: false,
isAnimatingOut: false,
width: 0,
});
}
}

getAnimationKeyframes = (animationName: string): string[] => {
const { height, isAnimatingIn, isAnimatingOut, width } = this.state;
const expanded = `height: ${height}px; width: ${width}px;`;
const collapsed = 'height: 0; width: 0;';
const keyframeSteps = (() => {
if (isAnimatingIn) {
return `from { ${collapsed} } to { ${expanded} }`;
}
if (isAnimatingOut) {
return `from { ${expanded} } to { ${collapsed} }`;
}
return '';
})();

return VENDOR_PREFIXES.map(
prefix => `@${prefix}keyframes ${animationName} { ${keyframeSteps} }`
);
}
createAnimation = (): ?string => {
const { sheet } = stylesheet;
// Stop flow complaining about possibly undefined properties
if (!sheet ||
!sheet.cssRules ||
!sheet.insertRule ||
!sheet.deleteRule) {
return null;
}
// We need to generate a random name for the animation every time
// because you can't dynamically update CSS animations
const animationName = `rbdnd-placeholder-animation-${v4()}`;
const keyframes = this.getAnimationKeyframes(animationName);
// It's easier to manage if we inject a dummy rule when the
// browser doesn't support a prefixed version
const dummyRule = '.rbdnd-placeholder-donotuse { display: initial; }';
const { ruleIndex } = this;
for (let i = 0; i < VENDOR_PREFIXES.length; i++) {
const thisRuleIndex = ruleIndex + i;
// `sheet` is a CSSStyleSheet but the definition of HTMLStyleElement indicates that it
// contains a StyleSheet which doesn't have all the methods of its child CSSStyleSheet
// $ExpectError - property cssRules not found in StyleSheet
if (sheet.cssRules[thisRuleIndex]) {
// $ExpectError - property deleteRule not found in StyleSheet
sheet.deleteRule(thisRuleIndex);
}
// The browser will throw if it doesn't support a prefixed version of the rule
try {
// $ExpectError - property insertRule not found in StyleSheet
sheet.insertRule(keyframes[i], thisRuleIndex);
} catch (err) {
// If it doesn't like it we inject a dummy rule instead
// $ExpectError - property insertRule not found in StyleSheet
sheet.insertRule(dummyRule, thisRuleIndex);
}
}

return animationName;
}

getPlaceholderStyle = () => {
const { isDraggedOver, phase } = this.props;
const { height, width } = this.state;

const animationName = this.createAnimation();

const staticStyles = {
height,
pointerEvents: 'none',
width,
};

// Hold the full height during a drop
if (phase === 'DROP_ANIMATING' && isDraggedOver) {
return staticStyles;
}

// Animate in/out during a drag
if (phase === 'DRAGGING') {
return {
...staticStyles,
animationName,
animationDuration: transitionStyles.transitionTime,
animationTimingFunction: transitionStyles.transitionCurve,
animationDelay: '0.0s',
animationIterationCount: 1,
animationDirection: 'normal',
animationFillMode: 'forwards',
};
}

// Otherwise don't display
return {
display: 'none',
};
}

render() {
return (
<div style={style} />
<div style={this.getPlaceholderStyle()} />
);
}
}

0 comments on commit 124af62

Please sign in to comment.