Je souhaite créer un composant déplaçable (c'est-à-dire repositionnable à l'aide de la souris) React, qui semble impliquer nécessairement des gestionnaires d'état globaux et dispersés. Je peux le faire de manière incorrecte, avec une variable globale dans mon fichier JS, et pourrait probablement même l'envelopper dans une interface de fermeture Nice, mais je veux savoir s'il existe un moyen qui engrène avec React mieux.
De plus, comme je n'avais jamais fait cela en JavaScript brut auparavant, j'aimerais voir comment un expert le fait, pour m'assurer que tous les cas critiques sont traités, en particulier en ce qui concerne React.
Merci.
Je devrais probablement transformer cela en un article de blog, mais voici un exemple assez solide.
Les commentaires devraient expliquer assez bien les choses, mais laissez-moi savoir si vous avez des questions.
Et voici le violon avec lequel jouer: 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))
}
})
"Qui devrait posséder quel état" est une question importante à laquelle répondre dès le départ. Dans le cas d'un composant "déplaçable", je pouvais voir quelques scénarios différents.
le parent devrait posséder la position actuelle du draggable. Dans ce cas, le draggable posséderait toujours l'état "suis-je en train de faire glisser", mais appellerait this.props.onChange(x, y)
chaque fois qu'un événement mousemove se produirait.
le parent n'a besoin que de posséder la "position non mobile", ainsi le "glissable" posséderait sa "position de glissement", mais onmouseup appelerait this.props.onChange(x, y)
et reporterait la décision finale au parent. Si le parent n'aime pas le résultat final du glissable, il ne mettra simplement pas à jour son état et le "glissable" reviendra à sa position initiale avant de glisser.
@ssorallen a fait remarquer que, parce que "glissable" est plus un attribut qu'une chose en soi, il pourrait mieux servir de mix. Mon expérience avec les mixins est limitée, donc je n'ai pas vu comment ils pourraient aider ou gêner dans des situations compliquées. Cela pourrait bien être la meilleure option.
J'ai implémenté react-dnd, un mixin HTML5 flexible par glisser-déposer pour React = avec contrôle total du DOM.
Les bibliothèques existantes de glisser-déposer ne correspondaient pas à mon cas d'utilisation et j'ai donc écrit le mien. Il ressemble au code que nous utilisons depuis environ un an sur Stampsy.com, mais réécrit pour tirer parti de React et de Flux.
Les principales exigences que j'avais
Si cela vous semble familier, lisez la suite.
Commencez par déclarer les types de données pouvant être déplacés.
Ils servent à vérifier la "compatibilité" des sources de glissement et des cibles de dépôt:
// ItemTypes.js
module.exports = {
BLOCK: 'block',
IMAGE: 'image'
};
(Si vous n'avez pas plusieurs types de données, cette bibliothèque peut ne pas vous convenir.)
Ensuite, créons un composant glissable très simple qui, une fois glissé, représente 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)} />
);
}
);
En spécifiant configureDragDrop
, nous indiquons à DragDropMixin
le comportement de ce composant par glisser-déposer. Les composants déplaçables et stockables utilisent le même mixin.
Dans configureDragDrop
, nous devons appeler registerType
pour chacun de nos composants personnalisés ItemTypes
pris en charge par ce composant. Par exemple, votre application peut comporter plusieurs représentations d'images, chacune fournissant un dragSource
pour ItemTypes.IMAGE
.
Un dragSource
n'est qu'un objet spécifiant le fonctionnement de la source de glissement. Vous devez implémenter beginDrag
pour renvoyer un élément représentant les données que vous faites glisser et, éventuellement, quelques options permettant de régler l'interface utilisateur de glisser. Vous pouvez éventuellement implémenter canDrag
pour interdire le glissement, ou endDrag(didDrop)
pour exécuter une logique lorsque la suppression s'est produite (ou non). Et vous pouvez partager cette logique entre les composants en laissant un mixin partagé générer dragSource
pour eux.
Enfin, vous devez utiliser {...this.dragSourceFor(itemType)}
sur certains (un ou plusieurs) éléments de render
pour attacher des gestionnaires de glissement. Cela signifie que vous pouvez avoir plusieurs "poignées de déplacement" dans un élément, et qu'elles peuvent même correspondre à différents types d'élément. (Si vous ne connaissez pas la syntaxe de Attributs JSX Spread , vérifiez-le).
Disons que nous voulons que ImageBlock
soit une cible de dépôt pour IMAGE
s. C'est à peu près la même chose, sauf que nous devons donner à registerType
une implémentation de dropTarget
:
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>
);
}
);
Supposons que nous voulions maintenant que l'utilisateur puisse faire glisser une image hors de ImageBlock
. Nous avons juste besoin d’ajouter dragSource
approprié et quelques gestionnaires:
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>
);
}
);
Je n'ai pas tout couvert, mais il est possible d'utiliser cette API de plusieurs manières:
getDragState(type)
et getDropState(type)
pour savoir si le glissement est actif et utilisez-le pour changer de classe ou d'attribut CSS.dragPreview
comme étant Image
pour utiliser les images comme espaces réservés au glissement (utilisez ImagePreloaderMixin
pour les charger);ImageBlocks
réordonnable. Nous avons seulement besoin d'eux pour implémenter dropTarget
et dragSource
pour ItemTypes.BLOCK
.dropTargetFor(...types)
permet de spécifier plusieurs types à la fois, ainsi une zone de dépôt peut capturer plusieurs types différents.Pour une documentation à jour et les instructions d’installation, rendez-vous à repo dnd sur Github.
La réponse de Jared Forsyth est terriblement erronée et dépassée. Il s'ensuit tout un ensemble d'anti-modèles tels que tilisation de stopPropagation
, état d'initialisation à partir de props , utilisation de jQuery, objets imbriqués en état et ayant quelques dragging
champ d'état. Si elle est réécrite, la solution sera la suivante, mais elle obligera toujours la réconciliation virtuelle du DOM à chaque tick de déplacement de souris et sera peu performante.
UPD. Ma réponse était horriblement erronée et obsolète. Désormais, le code atténue les problèmes de cycle de vie lent des composants React en utilisant des gestionnaires d’événements natifs et des mises à jour de styles, utilise transform
car il n’entraîne pas de reflux et limite les modifications du DOM par requestAnimationFrame
. . Maintenant, il est toujours de 60 FPS pour moi dans chaque navigateur que j'ai essayé.
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'),
);
et un peu de 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;
}
react-draggable est également facile à utiliser. 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')
);
Et mon 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>
Vous avez besoin de leurs styles, qui sont courts, ou vous n'obtenez pas le comportement attendu. J'aime le comportement plus que certains des autres choix possibles, mais il y a aussi quelque chose appelé réact-redimensionnable-et-déplaçable . J'essaie de redimensionner le travail avec draggable, mais pas de joie jusqu'à présent.
J'ai mis à jour la solution polkovnikov.ph avec React 16/ES6) avec des améliorations telles que la manipulation tactile et la capture sur une grille, ce dont j'ai besoin pour un jeu. La capture sur une grille atténue les problèmes de performances.
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;
Je voudrais ajouter un ème scénario
La position en mouvement n'est enregistrée d'aucune façon. Pensez-y comme à un mouvement de souris - votre curseur n'est pas un composant de React, n'est-ce pas?
Tout ce que vous faites, est d’ajouter un accessoire comme "glissable" à votre composant et un flux des événements de glissement qui manipuleront le 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
};
},
Dans ce cas, une manipulation DOM est une chose élégante (je n'aurais jamais pensé dire ça)