├── .gitignore ├── .babelrc ├── .storybook ├── config.js └── webpack.config.js ├── .eslintrc.js ├── stories ├── index.stories.js └── demo │ ├── Column.jsx │ ├── Item.jsx │ └── List.jsx ├── webpack.build.js ├── package.json ├── README.md └── src └── index.jsx /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | .npmrc 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/react", "@babel/env"], 3 | "plugins": ["@babel/proposal-class-properties"] 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /.stories.js$/); 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'airbnb' 4 | ], 5 | parser: 'babel-eslint', 6 | plugins: ['react'], 7 | env: { 8 | es6: true, 9 | browser: true, 10 | node: true 11 | }, 12 | rules: { 13 | 'react/destructuring-assignment': 'off', 14 | 'react/jsx-filename-extension': 'off', 15 | 'react/prop-types': 'off', 16 | 'import/no-extraneous-dependencies': 'off' 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { storiesOf } from '@storybook/react'; 4 | 5 | import List from './demo/List'; 6 | 7 | storiesOf('Natural Drag Animation', module) 8 | .add('default', () => ) 9 | .add('animationRotationFade = 0.5', () => ) 10 | .add('animationRotationFade = 0.99', () => ) 11 | .add('rotationMultiplier = 1', () => ) 12 | .add('rotationMultiplier = 3', () => ); 13 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // you can use this file to add your custom webpack plugins, loaders and anything you like. 2 | // This is just the basic way to add additional webpack configurations. 3 | // For more information refer the docs: https://storybook.js.org/configurations/custom-webpack-config 4 | 5 | // IMPORTANT 6 | // When you add this file, we won't add the default configurations which is similar 7 | // to "React Create App". This only has babel loader to load JavaScript. 8 | 9 | module.exports = { 10 | plugins: [ 11 | // your custom plugins 12 | ], 13 | module: { 14 | rules: [ 15 | // add your custom rules. 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /webpack.build.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | const pkg = require('./package.json'); 4 | 5 | const libraryName = pkg.name; 6 | 7 | module.exports = { 8 | entry: { 9 | main: ['./src/index.jsx'], 10 | }, 11 | mode: 'production', 12 | output: { 13 | filename: 'index.js', 14 | path: path.resolve(__dirname, './build'), 15 | publicPath: '/', 16 | library: libraryName, 17 | libraryTarget: 'umd', 18 | umdNamedDefine: true, 19 | }, 20 | externals: { 21 | react: { 22 | commonjs: 'react', 23 | commonjs2: 'react', 24 | amd: 'React', 25 | root: 'React', 26 | }, 27 | }, 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.jsx$/, 32 | exclude: /node_modules/, 33 | use: [ 34 | { 35 | loader: 'babel-loader', 36 | }, 37 | ], 38 | }, 39 | ], 40 | }, 41 | plugins: [ 42 | new CopyWebpackPlugin([ 43 | { from: 'README.md', to: './' }, 44 | { from: 'package.json', to: './' }, 45 | ]), 46 | ], 47 | }; 48 | -------------------------------------------------------------------------------- /stories/demo/Column.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Droppable } from 'react-beautiful-dnd'; 4 | 5 | import Item from './Item'; 6 | 7 | const grid = 8; 8 | 9 | const getListStyle = isDraggingOver => ({ 10 | background: isDraggingOver ? 'lightblue' : 'lightgrey', 11 | padding: grid, 12 | width: 250, 13 | }); 14 | 15 | class Column extends Component { 16 | static propTypes = { 17 | droppableId: PropTypes.string.isRequired, 18 | data: PropTypes.arrayOf(PropTypes.shape()).isRequired, 19 | }; 20 | 21 | render() { 22 | const { droppableId, data, ...props } = this.props; 23 | 24 | return ( 25 | 26 | {(provided, snapshot) => ( 27 |
31 | {this.props.data.map((item, index) => ( 32 | 33 | ))} 34 | {provided.placeholder} 35 |
36 | )} 37 |
38 | ); 39 | } 40 | } 41 | 42 | export default Column; 43 | -------------------------------------------------------------------------------- /stories/demo/Item.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Draggable } from 'react-beautiful-dnd'; 3 | 4 | import NaturalDragAnimation from '../../src'; 5 | 6 | const grid = 8; 7 | 8 | const getItemStyle = (isDragging, draggableStyle) => ({ 9 | // some basic styles to make the items look a bit nicer 10 | userSelect: 'none', 11 | padding: grid * 2, 12 | margin: `0 0 ${grid}px 0`, 13 | 14 | // change background colour if dragging 15 | background: isDragging ? 'lightgreen' : 'grey', 16 | 17 | // styles we need to apply on draggables 18 | ...draggableStyle, 19 | }); 20 | 21 | class Item extends Component { 22 | render() { 23 | const { item, index, ...props } = this.props; 24 | 25 | return ( 26 | 31 | {(provided, snapshot) => ( 32 | 40 | {style => ( 41 |
47 | {item.content} 48 |
49 | )} 50 |
51 | )} 52 |
53 | ); 54 | } 55 | } 56 | 57 | export default Item; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "natural-drag-animation-rbdnd", 3 | "version": "2.1.1", 4 | "description": "Addon for the 'react-beautiful-dnd' that adds natural dragging animation", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --config=webpack.build.js", 8 | "storybook": "start-storybook -p 6006", 9 | "deploy-storybook": "storybook-to-ghpages" 10 | }, 11 | "author": "Dmytro Lytvynenko ", 12 | "license": "MIT", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/rokborf/natural-drag-animation-rbdnd.git" 16 | }, 17 | "dependencies": { 18 | "prop-types": "^15.7.2" 19 | }, 20 | "peerDependencies": { 21 | "react": "^16.3.1" 22 | }, 23 | "devDependencies": { 24 | "@babel/cli": "^7.13.10", 25 | "@babel/core": "^7.13.10", 26 | "@babel/plugin-proposal-class-properties": "^7.13.0", 27 | "@babel/preset-env": "^7.13.10", 28 | "@babel/preset-react": "^7.12.13", 29 | "@storybook/addon-actions": "^4.1.18", 30 | "@storybook/addon-links": "^4.1.18", 31 | "@storybook/addons": "^4.1.18", 32 | "@storybook/react": "^7.0.7", 33 | "@storybook/storybook-deployer": "^2.8.7", 34 | "babel-eslint": "^9.0.0", 35 | "babel-loader": "^8.2.2", 36 | "copy-webpack-plugin": "^11.0.0", 37 | "eslint": "^5.16.0", 38 | "eslint-config-airbnb": "^17.1.1", 39 | "eslint-plugin-import": "^2.22.1", 40 | "eslint-plugin-jsx-a11y": "^6.4.1", 41 | "eslint-plugin-react": "^7.22.0", 42 | "lodash": "^4.17.21", 43 | "react": "^16.14.0", 44 | "react-beautiful-dnd": "^13.0.0", 45 | "react-dom": "^16.14.0", 46 | "webpack": "^4.46.0", 47 | "webpack-cli": "^3.3.12" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Info 2 | Addon for the [react-beautiful-dnd](https://github.com/atlassian/react-beautiful-dnd) that adds natural dragging animation. 3 | 4 | ## Demo 5 | https://rokborf.github.io/natural-drag-animation-rbdnd/ 6 | 7 | ## Installation 8 | ``` 9 | # yarn 10 | yarn add natural-drag-animation-rbdnd 11 | 12 | # npm 13 | npm install natural-drag-animation-rbdnd 14 | ``` 15 | 16 | If you're using typescript in your project, you might as well want to install the types. 17 | ``` 18 | npm i @types/natural-drag-animation-rbdnd 19 | ``` 20 | 21 | ## Example 22 | ``` 23 | import NaturalDragAnimation from 'natural-drag-animation-rbdnd'; 24 | 25 | ... 26 | 27 | 28 | {(provided, snapshot) => ( 29 | 33 | {style => ( 34 |
40 | Item content 41 |
42 | )} 43 |
44 | )} 45 |
46 | 47 | ... 48 | 49 | ``` 50 | 51 | #### Note 52 | The component modifies styles from `draggableProps`, so `style` prop should be placed after `{...provided.draggableProps}` 53 | to override styles from it. 54 | 55 | ## Props 56 | 57 | ### snapshot (required) 58 | **Object.** Pass `snapshot` from `Draggable`. 59 | 60 | ### style (required) 61 | **Object.** In most cases just pass `provided.draggableProps.style` from `Draggable`. `NaturalDragAnimation` patches `transform` from it. 62 | 63 | ### animationRotationFade 64 | **Number.** Use it to define fade speed of end rotation animation. Must be 0 < `animationRotationFade` < 1. 65 | 66 | **default = 0.9** 67 | 68 | ### rotationMultiplier 69 | Number. Use it to define rotation multiplier. 70 | 71 | **default = 1.3** 72 | 73 | ## Compatibility 74 | 75 | ### Version 2 76 | Compatible with react-beautiful-dnd v.10+ 77 | 78 | ### Version 1 79 | Compatible with react-beautiful-dnd v.9 80 | 81 | ## Author 82 | Dmytro Lytvynenko lytvynenko.dmytrij@gmail.com 83 | 84 | ## License 85 | MIT 86 | 87 | ## Thanks 88 | Nash Vail for his [article](https://uxdesign.cc/how-to-fix-dragging-animation-in-ui-with-simple-math-4bbc10deccf7) 89 | -------------------------------------------------------------------------------- /stories/demo/List.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { DragDropContext } from 'react-beautiful-dnd'; 3 | import Column from './Column'; 4 | 5 | // fake data generator 6 | const getItems = (count, offset = 0) => Array.from({ length: count }, (v, k) => k).map(k => ({ 7 | id: `item-${k + offset}`, 8 | content: `item ${k + offset}`, 9 | })); 10 | 11 | // a little function to help us with reordering the result 12 | const reorder = (list, startIndex, endIndex) => { 13 | const result = Array.from(list); 14 | const [removed] = result.splice(startIndex, 1); 15 | result.splice(endIndex, 0, removed); 16 | 17 | return result; 18 | }; 19 | 20 | /** 21 | * Moves an item from one list to another list. 22 | */ 23 | const move = (source, destination, droppableSource, droppableDestination) => { 24 | const sourceClone = Array.from(source); 25 | const destClone = Array.from(destination); 26 | const [removed] = sourceClone.splice(droppableSource.index, 1); 27 | 28 | destClone.splice(droppableDestination.index, 0, removed); 29 | 30 | const result = {}; 31 | result[droppableSource.droppableId] = sourceClone; 32 | result[droppableDestination.droppableId] = destClone; 33 | 34 | return result; 35 | }; 36 | 37 | class List extends Component { 38 | state = { 39 | items: getItems(10), 40 | selected: getItems(5, 10), 41 | }; 42 | 43 | /** 44 | * A semi-generic way to handle multiple lists. Matches 45 | * the IDs of the droppable container to the names of the 46 | * source arrays stored in the state. 47 | */ 48 | id2List = { 49 | droppable: 'items', 50 | droppable2: 'selected', 51 | }; 52 | 53 | getList = id => this.state[this.id2List[id]]; 54 | 55 | onDragEnd = (res) => { 56 | const { source, destination } = res; 57 | 58 | // dropped outside the list 59 | if (!destination) { 60 | return; 61 | } 62 | 63 | if (source.droppableId === destination.droppableId) { 64 | const items = reorder( 65 | this.getList(source.droppableId), 66 | source.index, 67 | destination.index, 68 | ); 69 | 70 | let state = { items }; 71 | 72 | if (source.droppableId === 'droppable2') { 73 | state = { selected: items }; 74 | } 75 | 76 | this.setState(state); 77 | } else { 78 | const result = move( 79 | this.getList(source.droppableId), 80 | this.getList(destination.droppableId), 81 | source, 82 | destination, 83 | ); 84 | 85 | this.setState({ 86 | items: result.droppable, 87 | selected: result.droppable2, 88 | }); 89 | } 90 | }; 91 | 92 | render() { 93 | return ( 94 |
100 | 101 | 102 | 103 | 104 |
105 | ); 106 | } 107 | } 108 | 109 | export default List; 110 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component, Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | let animationId; 5 | const sigmoid = x => x / (1 + Math.abs(x)); 6 | const initialState = { 7 | transform: null, 8 | prevX: 0, 9 | rotation: 0, 10 | }; 11 | 12 | class NaturalDragAnimation extends Component { 13 | static propTypes = { 14 | snapshot: PropTypes.shape({ 15 | isDragging: PropTypes.bool.isRequired, 16 | dropAnimation: PropTypes.shape(), 17 | }).isRequired, 18 | style: PropTypes.shape().isRequired, 19 | children: PropTypes.func.isRequired, 20 | animationRotationFade: PropTypes.number, 21 | rotationMultiplier: PropTypes.number, 22 | sigmoidFunction: PropTypes.func, 23 | }; 24 | 25 | static defaultProps = { 26 | animationRotationFade: 0.9, 27 | rotationMultiplier: 1.3, 28 | sigmoidFunction: sigmoid, 29 | }; 30 | 31 | static getDerivedStateFromProps(props, state) { 32 | if (props.snapshot.dropAnimation && state.transform) { 33 | return { 34 | ...initialState, 35 | }; 36 | } 37 | 38 | return null; 39 | } 40 | 41 | state = { 42 | ...initialState, 43 | }; 44 | 45 | // added to support React.Portal 46 | componentDidMount() { 47 | if (this.props.snapshot.isDragging) { 48 | animationId = requestAnimationFrame(this.patchTransform); 49 | } 50 | } 51 | 52 | componentDidUpdate(prevProps) { 53 | if (!prevProps.snapshot.isDragging && this.props.snapshot.isDragging) { 54 | animationId = requestAnimationFrame(this.patchTransform); 55 | } 56 | 57 | if (prevProps.snapshot.isDragging && !this.props.snapshot.isDragging) { 58 | cancelAnimationFrame(animationId); 59 | } 60 | } 61 | 62 | componentWillUnmount() { 63 | cancelAnimationFrame(animationId); 64 | } 65 | 66 | patchTransform = () => { 67 | const { 68 | snapshot: { 69 | isDragging, 70 | }, 71 | style, 72 | animationRotationFade, 73 | rotationMultiplier, 74 | sigmoidFunction, 75 | } = this.props; 76 | 77 | if (isDragging && style.transform) { 78 | const currentX = style.transform 79 | .match(/translate\(.{1,}\)/g)[0] 80 | .match(/-?[0-9]{1,}/g)[0]; 81 | 82 | const velocity = currentX - this.state.prevX; 83 | const prevRotation = this.state.rotation; 84 | 85 | let rotation = prevRotation * animationRotationFade 86 | + sigmoidFunction(velocity) * rotationMultiplier; 87 | 88 | const newTransform = `${style.transform} rotate(${rotation}deg)`; 89 | 90 | if (Math.abs(rotation) < 0.01) rotation = 0; 91 | 92 | this.setState({ 93 | transform: newTransform, 94 | prevX: currentX, 95 | rotation, 96 | }, () => { 97 | animationId = requestAnimationFrame(this.patchTransform); 98 | }); 99 | } else { 100 | animationId = requestAnimationFrame(this.patchTransform); 101 | } 102 | }; 103 | 104 | render() { 105 | const { snapshot: { isDragging, dropAnimation } } = this.props; 106 | 107 | const style = isDragging && !dropAnimation 108 | ? { 109 | ...this.props.style, 110 | transform: this.state.transform, 111 | } 112 | : this.props.style; 113 | 114 | return {this.props.children(style)}; 115 | } 116 | } 117 | 118 | export default NaturalDragAnimation; 119 | --------------------------------------------------------------------------------