Recommended way of making React component/div draggable

后端 未结 9 665
耶瑟儿~
耶瑟儿~ 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:40

    I should probably turn this into a blog post, but here's pretty solid example.

    The comments should explain things pretty well, but let me know if you have questions.

    And here's the fiddle to play with: http://jsfiddle.net/Af9Jt/2/

    var Draggable = React.createClass({
      getDefaultProps: function () {
        return {
          // allow the initial position to be passed in as a prop
          initialPos: {x: 0, y: 0}
        }
      },
      getInitialState: function () {
        return {
          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: function (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: function (e) {
        // only left mouse button
        if (e.button !== 0) return
        var pos = $(this.getDOMNode()).offset()
        this.setState({
          dragging: true,
          rel: {
            x: e.pageX - pos.left,
            y: e.pageY - pos.top
          }
        })
        e.stopPropagation()
        e.preventDefault()
      },
      onMouseUp: function (e) {
        this.setState({dragging: false})
        e.stopPropagation()
        e.preventDefault()
      },
      onMouseMove: function (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: function () {
        // transferPropsTo will merge style & other props passed into our
        // component to also be on the child DIV.
        return this.transferPropsTo(React.DOM.div({
          onMouseDown: this.onMouseDown,
          style: {
            left: this.state.pos.x + 'px',
            top: this.state.pos.y + 'px'
          }
        }, this.props.children))
      }
    })
    

    Thoughts on state ownership, etc.

    "Who should own what state" is an important question to answer, right from the start. In the case of a "draggable" component, I could see a few different scenarios.

    Scenario 1

    the parent should own the current position of the draggable. In this case, the draggable would still own the "am I dragging" state, but would call this.props.onChange(x, y) whenever a mousemove event occurs.

    Scenario 2

    the parent only needs to own the "non-moving position", and so the draggable would own it's "dragging position" but onmouseup it would call this.props.onChange(x, y) and defer the final decision to the parent. If the parent doesn't like where the draggable ended up, it would just not update it's state, and the draggable would "snap back" to it's initial position before dragging.

    Mixin or component?

    @ssorallen pointed out that, because "draggable" is more an attribute than a thing in itself, it might serve better as a mixin. My experience with mixins is limited, so I haven't seen how they might help or get in the way in complicated situations. This might well be the best option.

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

    I implemented react-dnd, a flexible HTML5 drag-and-drop mixin for React with full DOM control.

    Existing drag-and-drop libraries didn't fit my use case so I wrote my own. It's similar to the code we've been running for about a year on Stampsy.com, but rewritten to take advantage of React and Flux.

    Key requirements I had:

    • Emit zero DOM or CSS of its own, leaving it to the consuming components;
    • Impose as little structure as possible on consuming components;
    • Use HTML5 drag and drop as primary backend but make it possible to add different backends in the future;
    • Like original HTML5 API, emphasize dragging data and not just “draggable views”;
    • Hide HTML5 API quirks from the consuming code;
    • Different components may be “drag sources” or “drop targets” for different kinds of data;
    • Allow one component to contain several drag sources and drop targets when needed;
    • Make it easy for drop targets to change their appearance if compatible data is being dragged or hovered;
    • Make it easy to use images for drag thumbnails instead of element screenshots, circumventing browser quirks.

    If these sound familiar to you, read on.

    Usage

    Simple Drag Source

    First, declare types of data that can be dragged.

    These are used to check “compatibility” of drag sources and drop targets:

    // ItemTypes.js
    module.exports = {
      BLOCK: 'block',
      IMAGE: 'image'
    };
    

    (If you don't have multiple data types, this libary may not be for you.)

    Then, let's make a very simple draggable component that, when dragged, represents IMAGE:

    var { DragDropMixin } = require('react-dnd'),
        ItemTypes = require('./ItemTypes');
    
    var Image = React.createClass({
      mixins: [DragDropMixin],
    
      configureDragDrop(registerType) {
    
        // Specify all supported types by calling registerType(type, { dragSource?, dropTarget? })
        registerType(ItemTypes.IMAGE, {
    
          // dragSource, when specified, is { beginDrag(), canDrag()?, endDrag(didDrop)? }
          dragSource: {
    
            // beginDrag should return { item, dragOrigin?, dragPreview?, dragEffect? }
            beginDrag() {
              return {
                item: this.props.image
              };
            }
          }
        });
      },
    
      render() {
    
        // {...this.dragSourceFor(ItemTypes.IMAGE)} will expand into
        // { draggable: true, onDragStart: (handled by mixin), onDragEnd: (handled by mixin) }.
    
        return (
          <img src={this.props.image.url}
               {...this.dragSourceFor(ItemTypes.IMAGE)} />
        );
      }
    );
    

    By specifying configureDragDrop, we tell DragDropMixin the drag-drop behavior of this component. Both draggable and droppable components use the same mixin.

    Inside configureDragDrop, we need to call registerType for each of our custom ItemTypes that component supports. For example, there might be several representations of images in your app, and each would provide a dragSource for ItemTypes.IMAGE.

    A dragSource is just an object specifying how the drag source works. You must implement beginDrag to return item that represents the data you're dragging and, optionally, a few options that adjust the dragging UI. You can optionally implement canDrag to forbid dragging, or endDrag(didDrop) to execute some logic when the drop has (or has not) occured. And you can share this logic between components by letting a shared mixin generate dragSource for them.

    Finally, you must use {...this.dragSourceFor(itemType)} on some (one or more) elements in render to attach drag handlers. This means you can have several “drag handles” in one element, and they may even correspond to different item types. (If you're not familiar with JSX Spread Attributes syntax, check it out).

    Simple Drop Target

    Let's say we want ImageBlock to be a drop target for IMAGEs. It's pretty much the same, except that we need to give registerType a dropTarget implementation:

    var { DragDropMixin } = require('react-dnd'),
        ItemTypes = require('./ItemTypes');
    
    var ImageBlock = React.createClass({
      mixins: [DragDropMixin],
    
      configureDragDrop(registerType) {
    
        registerType(ItemTypes.IMAGE, {
    
          // dropTarget, when specified, is { acceptDrop(item)?, enter(item)?, over(item)?, leave(item)? }
          dropTarget: {
            acceptDrop(image) {
              // Do something with image! for example,
              DocumentActionCreators.setImage(this.props.blockId, image);
            }
          }
        });
      },
    
      render() {
    
        // {...this.dropTargetFor(ItemTypes.IMAGE)} will expand into
        // { onDragEnter: (handled by mixin), onDragOver: (handled by mixin), onDragLeave: (handled by mixin), onDrop: (handled by mixin) }.
    
        return (
          <div {...this.dropTargetFor(ItemTypes.IMAGE)}>
            {this.props.image &&
              <img src={this.props.image.url} />
            }
          </div>
        );
      }
    );
    

    Drag Source + Drop Target In One Component

    Say we now want the user to be able to drag out an image out of ImageBlock. We just need to add appropriate dragSource to it and a few handlers:

    var { DragDropMixin } = require('react-dnd'),
        ItemTypes = require('./ItemTypes');
    
    var ImageBlock = React.createClass({
      mixins: [DragDropMixin],
    
      configureDragDrop(registerType) {
    
        registerType(ItemTypes.IMAGE, {
    
          // Add a drag source that only works when ImageBlock has an image:
          dragSource: {
            canDrag() {
              return !!this.props.image;
            },
    
            beginDrag() {
              return {
                item: this.props.image
              };
            }
          }
    
          dropTarget: {
            acceptDrop(image) {
              DocumentActionCreators.setImage(this.props.blockId, image);
            }
          }
        });
      },
    
      render() {
    
        return (
          <div {...this.dropTargetFor(ItemTypes.IMAGE)}>
    
            {/* Add {...this.dragSourceFor} handlers to a nested node */}
            {this.props.image &&
              <img src={this.props.image.url}
                   {...this.dragSourceFor(ItemTypes.IMAGE)} />
            }
          </div>
        );
      }
    );
    

    What Else Is Possible?

    I have not covered everything but it's possible to use this API in a few more ways:

    • Use getDragState(type) and getDropState(type) to learn if dragging is active and use it to toggle CSS classes or attributes;
    • Specify dragPreview to be Image to use images as drag placeholders (use ImagePreloaderMixin to load them);
    • Say, we want to make ImageBlocks reorderable. We only need them to implement dropTarget and dragSource for ItemTypes.BLOCK.
    • Suppose we add other kinds of blocks. We can reuse their reordering logic by placing it in a mixin.
    • dropTargetFor(...types) allows to specify several types at once, so one drop zone can catch many different types.
    • When you need more fine-grained control, most methods are passed drag event that caused them as the last parameter.

    For up-to-date documentation and installation instructions, head to react-dnd repo on Github.

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

    Here's a 2020 answer with a Hook:

    function useDragging() {
      const [isDragging, setIsDragging] = useState(false);
      const [pos, setPos] = useState({ x: 0, y: 0 });
      const ref = useRef(null);
    
      function onMouseMove(e) {
        if (!isDragging) return;
        setPos({
          x: e.x - ref.current.offsetWidth / 2,
          y: e.y - ref.current.offsetHeight / 2,
        });
        e.stopPropagation();
        e.preventDefault();
      }
    
      function onMouseUp(e) {
        setIsDragging(false);
        e.stopPropagation();
        e.preventDefault();
      }
    
      function onMouseDown(e) {
        if (e.button !== 0) return;
        setIsDragging(true);
    
        setPos({
          x: e.x - ref.current.offsetWidth / 2,
          y: e.y - ref.current.offsetHeight / 2,
        });
    
        e.stopPropagation();
        e.preventDefault();
      }
    
      // When the element mounts, attach an mousedown listener
      useEffect(() => {
        ref.current.addEventListener("mousedown", onMouseDown);
    
        return () => {
          ref.current.removeEventListener("mousedown", onMouseDown);
        };
      }, [ref.current]);
    
      // Everytime the isDragging state changes, assign or remove
      // the corresponding mousemove and mouseup handlers
      useEffect(() => {
        if (isDragging) {
          document.addEventListener("mouseup", onMouseUp);
          document.addEventListener("mousemove", onMouseMove);
        } else {
          document.removeEventListener("mouseup", onMouseUp);
          document.removeEventListener("mousemove", onMouseMove);
        }
        return () => {
          document.removeEventListener("mouseup", onMouseUp);
          document.removeEventListener("mousemove", onMouseMove);
        };
      }, [isDragging]);
    
      return [ref, pos.x, pos.y, isDragging];
    }
    

    Then a component that uses the hook:

    
    function Draggable() {
      const [ref, x, y, isDragging] = useDragging();
    
      return (
        <div
          ref={ref}
          style={{
            position: "absolute",
            width: 50,
            height: 50,
            background: isDragging ? "blue" : "gray",
            left: x,
            top: y,
          }}
        ></div>
      );
    }
    
    0 讨论(0)
提交回复
热议问题