Recommended way of making React component/div draggable

后端 未结 9 664
耶瑟儿~
耶瑟儿~ 2020-11-28 18:13

I want to make a draggable (that is, repositionable by mouse) React component, which seems to necessarily involve global state and scattered event handlers. I can do it the

相关标签:
9条回答
  • 2020-11-28 18:29

    The answer by Jared Forsyth is horribly wrong and outdated. It follows a whole set of antipatterns such as usage of stopPropagation, initializing state from props, usage of jQuery, nested objects in state and has some odd dragging state field. If being rewritten, the solution will be the following, but it still forces virtual DOM reconciliation on every mouse move tick and is not very performant.

    UPD. My answer was horribly wrong and outdated. Now the code alleviates issues of slow React component lifecycle by using native event handlers and style updates, uses transform as it doesn't lead to reflows, and throttles DOM changes through requestAnimationFrame. Now it's consistently 60 FPS for me in every browser I tried.

    const throttle = (f) => {
        let token = null, lastArgs = null;
        const invoke = () => {
            f(...lastArgs);
            token = null;
        };
        const result = (...args) => {
            lastArgs = args;
            if (!token) {
                token = requestAnimationFrame(invoke);
            }
        };
        result.cancel = () => token && cancelAnimationFrame(token);
        return result;
    };
    
    class Draggable extends React.PureComponent {
        _relX = 0;
        _relY = 0;
        _ref = React.createRef();
    
        _onMouseDown = (event) => {
            if (event.button !== 0) {
                return;
            }
            const {scrollLeft, scrollTop, clientLeft, clientTop} = document.body;
            // Try to avoid calling `getBoundingClientRect` if you know the size
            // of the moving element from the beginning. It forces reflow and is
            // the laggiest part of the code right now. Luckily it's called only
            // once per click.
            const {left, top} = this._ref.current.getBoundingClientRect();
            this._relX = event.pageX - (left + scrollLeft - clientLeft);
            this._relY = event.pageY - (top + scrollTop - clientTop);
            document.addEventListener('mousemove', this._onMouseMove);
            document.addEventListener('mouseup', this._onMouseUp);
            event.preventDefault();
        };
    
        _onMouseUp = (event) => {
            document.removeEventListener('mousemove', this._onMouseMove);
            document.removeEventListener('mouseup', this._onMouseUp);
            event.preventDefault();
        };
    
        _onMouseMove = (event) => {
            this.props.onMove(
                event.pageX - this._relX,
                event.pageY - this._relY,
            );
            event.preventDefault();
        };
    
        _update = throttle(() => {
            const {x, y} = this.props;
            this._ref.current.style.transform = `translate(${x}px, ${y}px)`;
        });
    
        componentDidMount() {
            this._ref.current.addEventListener('mousedown', this._onMouseDown);
            this._update();
        }
    
        componentDidUpdate() {
            this._update();
        }
    
        componentWillUnmount() {
            this._ref.current.removeEventListener('mousedown', this._onMouseDown);
            this._update.cancel();
        }
    
        render() {
            return (
                <div className="draggable" ref={this._ref}>
                    {this.props.children}
                </div>
            );
        }
    }
    
    class Test extends React.PureComponent {
        state = {
            x: 100,
            y: 200,
        };
    
        _move = (x, y) => this.setState({x, y});
    
        // you can implement grid snapping logic or whatever here
        /*
        _move = (x, y) => this.setState({
            x: ~~((x - 5) / 10) * 10 + 5,
            y: ~~((y - 5) / 10) * 10 + 5,
        });
        */
    
        render() {
            const {x, y} = this.state;
            return (
                <Draggable x={x} y={y} onMove={this._move}>
                    Drag me
                </Draggable>
            );
        }
    }
    
    ReactDOM.render(
        <Test />,
        document.getElementById('container'),
    );
    

    and a bit of CSS

    .draggable {
        /* just to size it to content */
        display: inline-block;
        /* opaque background is important for performance */
        background: white;
        /* avoid selecting text while dragging */
        user-select: none;
    }
    

    Example on JSFiddle.

    0 讨论(0)
  • 2020-11-28 18:29

    I've updated the class using refs as all the solutions I see on here have things that are no longer supported or will soon be depreciated like ReactDOM.findDOMNode. Can be parent to a child component or a group of children :)

    import React, { Component } from 'react';
    
    class Draggable extends Component {
    
        constructor(props) {
            super(props);
            this.myRef = React.createRef();
            this.state = {
                counter: this.props.counter,
                pos: this.props.initialPos,
                dragging: false,
                rel: null // position relative to the cursor
            };
        }
    
        /*  we could get away with not having this (and just having the listeners on
         our div), but then the experience would be possibly be janky. If there's
         anything w/ a higher z-index that gets in the way, then you're toast,
         etc.*/
        componentDidUpdate(props, state) {
            if (this.state.dragging && !state.dragging) {
                document.addEventListener('mousemove', this.onMouseMove);
                document.addEventListener('mouseup', this.onMouseUp);
            } else if (!this.state.dragging && state.dragging) {
                document.removeEventListener('mousemove', this.onMouseMove);
                document.removeEventListener('mouseup', this.onMouseUp);
            }
        }
    
        // calculate relative position to the mouse and set dragging=true
        onMouseDown = (e) => {
            if (e.button !== 0) return;
            let pos = { left: this.myRef.current.offsetLeft, top: this.myRef.current.offsetTop }
            this.setState({
                dragging: true,
                rel: {
                    x: e.pageX - pos.left,
                    y: e.pageY - pos.top
                }
            });
            e.stopPropagation();
            e.preventDefault();
        }
    
        onMouseUp = (e) => {
            this.setState({ dragging: false });
            e.stopPropagation();
            e.preventDefault();
        }
    
        onMouseMove = (e) => {
            if (!this.state.dragging) return;
    
            this.setState({
                pos: {
                    x: e.pageX - this.state.rel.x,
                    y: e.pageY - this.state.rel.y
                }
            });
            e.stopPropagation();
            e.preventDefault();
        }
    
    
        render() {
            return (
                <span ref={this.myRef} onMouseDown={this.onMouseDown} style={{ position: 'absolute', left: this.state.pos.x + 'px', top: this.state.pos.y + 'px' }}>
                    {this.props.children}
                </span>
            )
        }
    }
    
    export default Draggable;
    
    
    0 讨论(0)
  • 2020-11-28 18:32

    I would like to add a 3rd Scenario

    The moving position is not saved in any way. Think of it as a mouse movement - your cursor is not a React-component, right?

    All you do, is to add a prop like "draggable" to your component and a stream of the dragging events that will manipulate the dom.

    setXandY: function(event) {
        // DOM Manipulation of x and y on your node
    },
    
    componentDidMount: function() {
        if(this.props.draggable) {
            var node = this.getDOMNode();
            dragStream(node).onValue(this.setXandY);  //baconjs stream
        };
    },
    

    In this case, a DOM manipulation is an elegant thing (I never thought I'd say this)

    0 讨论(0)
  • 2020-11-28 18:34

    I've updated polkovnikov.ph solution to React 16 / ES6 with enhancements like touch handling and snapping to a grid which is what I need for a game. Snapping to a grid alleviates the performance issues.

    import React from 'react';
    import ReactDOM from 'react-dom';
    import PropTypes from 'prop-types';
    
    class Draggable extends React.Component {
        constructor(props) {
            super(props);
            this.state = {
                relX: 0,
                relY: 0,
                x: props.x,
                y: props.y
            };
            this.gridX = props.gridX || 1;
            this.gridY = props.gridY || 1;
            this.onMouseDown = this.onMouseDown.bind(this);
            this.onMouseMove = this.onMouseMove.bind(this);
            this.onMouseUp = this.onMouseUp.bind(this);
            this.onTouchStart = this.onTouchStart.bind(this);
            this.onTouchMove = this.onTouchMove.bind(this);
            this.onTouchEnd = this.onTouchEnd.bind(this);
        }
    
        static propTypes = {
            onMove: PropTypes.func,
            onStop: PropTypes.func,
            x: PropTypes.number.isRequired,
            y: PropTypes.number.isRequired,
            gridX: PropTypes.number,
            gridY: PropTypes.number
        }; 
    
        onStart(e) {
            const ref = ReactDOM.findDOMNode(this.handle);
            const body = document.body;
            const box = ref.getBoundingClientRect();
            this.setState({
                relX: e.pageX - (box.left + body.scrollLeft - body.clientLeft),
                relY: e.pageY - (box.top + body.scrollTop - body.clientTop)
            });
        }
    
        onMove(e) {
            const x = Math.trunc((e.pageX - this.state.relX) / this.gridX) * this.gridX;
            const y = Math.trunc((e.pageY - this.state.relY) / this.gridY) * this.gridY;
            if (x !== this.state.x || y !== this.state.y) {
                this.setState({
                    x,
                    y
                });
                this.props.onMove && this.props.onMove(this.state.x, this.state.y);
            }        
        }
    
        onMouseDown(e) {
            if (e.button !== 0) return;
            this.onStart(e);
            document.addEventListener('mousemove', this.onMouseMove);
            document.addEventListener('mouseup', this.onMouseUp);
            e.preventDefault();
        }
    
        onMouseUp(e) {
            document.removeEventListener('mousemove', this.onMouseMove);
            document.removeEventListener('mouseup', this.onMouseUp);
            this.props.onStop && this.props.onStop(this.state.x, this.state.y);
            e.preventDefault();
        }
    
        onMouseMove(e) {
            this.onMove(e);
            e.preventDefault();
        }
    
        onTouchStart(e) {
            this.onStart(e.touches[0]);
            document.addEventListener('touchmove', this.onTouchMove, {passive: false});
            document.addEventListener('touchend', this.onTouchEnd, {passive: false});
            e.preventDefault();
        }
    
        onTouchMove(e) {
            this.onMove(e.touches[0]);
            e.preventDefault();
        }
    
        onTouchEnd(e) {
            document.removeEventListener('touchmove', this.onTouchMove);
            document.removeEventListener('touchend', this.onTouchEnd);
            this.props.onStop && this.props.onStop(this.state.x, this.state.y);
            e.preventDefault();
        }
    
        render() {
            return <div
                onMouseDown={this.onMouseDown}
                onTouchStart={this.onTouchStart}
                style={{
                    position: 'absolute',
                    left: this.state.x,
                    top: this.state.y,
                    touchAction: 'none'
                }}
                ref={(div) => { this.handle = div; }}
            >
                {this.props.children}
            </div>;
        }
    }
    
    export default Draggable;
    
    0 讨论(0)
  • 2020-11-28 18:34

    react-draggable is also easy to use. Github:

    https://github.com/mzabriskie/react-draggable

    import React, {Component} from 'react';
    import ReactDOM from 'react-dom';
    import Draggable from 'react-draggable';
    
    var App = React.createClass({
        render() {
            return (
                <div>
                    <h1>Testing Draggable Windows!</h1>
                    <Draggable handle="strong">
                        <div className="box no-cursor">
                            <strong className="cursor">Drag Here</strong>
                            <div>You must click my handle to drag me</div>
                        </div>
                    </Draggable>
                </div>
            );
        }
    });
    
    ReactDOM.render(
        <App />, document.getElementById('content')
    );
    

    And my index.html:

    <html>
        <head>
            <title>Testing Draggable Windows</title>
            <link rel="stylesheet" type="text/css" href="style.css" />
        </head>
        <body>
            <div id="content"></div>
            <script type="text/javascript" src="bundle.js" charset="utf-8"></script>    
        <script src="http://localhost:8080/webpack-dev-server.js"></script>
        </body>
    </html>
    

    You need their styles, which is short, or you don't get quite the expected behavior. I like the behavior more than some of the other possible choices, but there's also something called react-resizable-and-movable. I'm trying to get resize working with draggable, but no joy so far.

    0 讨论(0)
  • 2020-11-28 18:36

    Here's a simple modern approach to this with useState, useEffect and useRef in ES6.

    import React, { useRef, useState, useEffect } from 'react'
    
    const quickAndDirtyStyle = {
      width: "200px",
      height: "200px",
      background: "#FF9900",
      color: "#FFFFFF",
      display: "flex",
      justifyContent: "center",
      alignItems: "center"
    }
    
    const DraggableComponent = () => {
      const [pressed, setPressed] = useState(false)
      const [position, setPosition] = useState({x: 0, y: 0})
      const ref = useRef()
    
      // Monitor changes to position state and update DOM
      useEffect(() => {
        if (ref.current) {
          ref.current.style.transform = `translate(${position.x}px, ${position.y}px)`
        }
      }, [position])
    
      // Update the current position if mouse is down
      const onMouseMove = (event) => {
        if (pressed) {
          setPosition({
            x: position.x + event.movementX,
            y: position.y + event.movementY
          })
        }
      }
    
      return (
        <div
          ref={ ref }
          style={ quickAndDirtyStyle }
          onMouseMove={ onMouseMove }
          onMouseDown={ () => setPressed(true) }
          onMouseUp={ () => setPressed(false) }>
          <p>{ pressed ? "Dragging..." : "Press to drag" }</p>
        </div>
      )
    }
    
    export default DraggableComponent
    
    0 讨论(0)
提交回复
热议问题