Ant Design's Tree Component: Drag 'n' Drop explained
Ant Design is an extensive widget toolkit for building web applications. It originated in React, but later variants were also created for integration with Vue and Angular. I have now been working for a number of years at Digital-Ink with Ant Design for React, and I must say that I am generally very satisfied, for the following reasons:
-
extensive collection of components, each with rich functionality
-
active development, mature toolkit
-
sufficient documentation and code examples
With every GUI toolkit there are components that are perhaps not documented as well as others. And perhaps also not as well as you would have wanted. In Ant Design, in my opinion, this applies to the tree component. Given the complex functionality this component offers, it contains only a handful of example code that describes a number of aspects of the component. And with respect to drag-and-drop there is only one example. I myself struggled for a while with dragging nodes within the tree component, and while searching for solutions and information on the internet, I found many kindred spirits who were dealing with similar questions, but no clarifying answers. I have dug into this matter, and I am now going to try to fill that gap with this technical blog post.
The problem
I use the tree component, among other things, to display the structure of a novel. In my application, a node represents a chapter, or a scene, or so-called ‘front- or back matter’, to throw in a publishing-world term. I want to enable the user to drag nodes within the tree, so that he can easily adjust the structure of his novel. Not every type of node may be dragged to every arbitrary place. To enforce your ‘business rules’, the tree component provides the ‘allowDrop’ callback, which is called by the tree component every time the dragged node passes an underlying node. This callback returns a boolean, which indicates whether the node may land at the specified location or not. At the moment you release the mouse above an allowed location, the tree component calls the ‘onDrop’ callback. In this callback you implement the logic to give the dragged node its new place in the tree component and remove it from its old location. Confusion caused by the (too) brief - and in one place incorrect - documentation is reinforced by a bad API. I will explain and substantiate this below.
allowDrop
allowDrop has the following, simple signature: ({ dropNode, dropPosition }) ⇒ boolean. The dropPosition parameter is an integer and can take three values, namely -1, 0 or +1. These indicate the position where the dragged node will land if the mouse button is released at that moment, namely: before (-1), on (0) or after (+1) the node you are hovering over. When you let the dragged node land on the dropNode, then the user wants the dragged node to become a child of the dropNode.
The only thing you additionally need besides the dropNode and dropPosition to implement your business rules is the node that is being dragged. Because if this is, for example, a chapter, then you cannot let it land on a scene, because a scene cannot contain chapters. Unfortunately, the dragNode is not made available by the tree component in the callback parameters. In my opinion that is a shortcoming in the API, but one that is easy enough to work around.
We can solve this, for example, by registering the dragged node and making it available in the allowDrop callback, for example by using the onDragStart and onDragEnd callbacks. These callbacks receive the dragged node. In React you can use the useRef() hook to capture the dragged node in onDragStart and clear it again in onDragEnd. In the example code on GitHub I used this solution.
When allowDrop returns false, onDrop will not be called when dragging ends. If allowDrop returns true, then this is visualized with a horizontal guide line that indicates where the dragged node will land (if you were to release the mouse button at that moment). Sometimes you need to drag your mouse a little to the right, to indicate that you want to drop the dragged node on the dropNode, and not after it.
This brings us to a subtle and corner-case problem with the tree component: to force allowDrop ON an empty dropNode and not after it, you must move your mouse to the right until around the 12th to 15th character of the dropNode label or of the sibling node label below it, otherwise the tree component does not generate the expected allowDrop. Empty nodes with short labels cause problems, because then the 'hover area' is too short, with the result that in that situation you cannot, for example, add a scene to an empty chapter. In my opinion this is a bug in the tree component. This blog post includes example code with which you can reproduce this bug (try moving a scene to the empty container ‘Act 4’).
onDrop
The documentation gives the following description for the onDrop callback:
“Callback function for when the onDrop event occurs: function({event, node, dragNode, dragNodesKeys}).”
This description is far too vague and the signature of the callback is not complete; it should be:
function({event, node, dragNode, dragNodesKeys, dropToGap, dropPosition})
And precisely those last two parameters, which are not mentioned in the documentation, are crucial for determining the new position of the dragged element. Unfortunately, this makes the API ugly. Because the dropPosition parameter of this callback has a completely different meaning than the one in the allowDrop callback. It would have been better named dropIndex, because it contains the index into an array. But note: you have to interpret that index depending on the value of the boolean parameter dropToGap. A name that I cannot explain based on the meanings it has within the onDrop callback.
If dropToGap is false, this indicates that dropPosition contains the index of the dropNode in its parent. Since we already have the dropNode via the node parameter in the callback call, this dropPosition has in this case little value. In practice, false for the dropToGap parameter means that the user wants to insert the dragged node as the first child in the dropNode.
If dropToGap is true, then it means that the dragged node must be placed at the dropPosition of the parent of the dropNode. Here too there are a number of special cases to mention, namely:
-
if in this situation the dropPosition is -1, then the dragged node must be inserted as the first child element at the root of the tree.
-
when you drag a node within a container, for example you want to move a scene within a chapter, then it matters whether you drag the scene upward or downward. Because the dropPosition contains the index where insertion must happen, before the dragNode has been removed. If you move upward, then this is not a problem, but if you move downward, then it matters whether you first insert the dragNode at its new position and then remove it from its old position, or vice versa. (In the latter case you must correct the dropPosition by lowering it by 1).
“Show me the code”
The theory above is abstract and complex, which is why I have made a piece of demo code available on GitHub GitHub and on Stackblitz, in which all of the above aspects are covered. In the demo I have created three different types of nodes, which are shown on screen with different background colors. These are a scene (gray background), front- and back matter (red background) and a container (black background). The rules are simple: a scene can only occur in a container and a container can contain a scene or another container. The root of the tree can contain a container or back- or front matter. Try it out, and drag the nodes back and forth. Where may they be placed and where not?
The behavior described in this blog applies to the versions of Ant Design for React v4.x through v6.3.x. Perhaps the implementation of the tree component will change in the future. Until the mentioned issues are resolved, I hope that this blog post offers you enough guidance to implement your own business logic in the allowDrop and onDrop callbacks.
Red Star Development