PyQt6's QTreeView supports drag and drop out of the box when you use built-in models like QStandardItemModel. But when you build your own custom model by subclassing QAbstractItemModel, you need to implement several methods yourself to make drag and drop work. In this tutorial, we'll build a complete working example of a tree view with a custom model that lets you drag nodes around to reorder and reparent them.
The Tree Data Structure
Before we get into the model, we need a simple data structure to represent nodes in our tree. Each node holds some data, knows its parent, and keeps a list of children.
class TreeNode:
def __init__(self, data, parent=None):
self.data = data
self.parent = parent
self.children = []
def append_child(self, child):
child.parent = self
self.children.append(child)
def remove_child(self, row):
if 0 <= row < len(self.children):
child = self.children.pop(row)
child.parent = None
return child
return None
def child(self, row):
if 0 <= row < len(self.children):
return self.children[row]
return None
def child_count(self):
return len(self.children)
def row(self):
if self.parent:
return self.parent.children.index(self)
return 0
def __repr__(self):
return f"TreeNode({self.data!r})"
The row() method returns the position of this node within its parent's children list. This is used heavily by the model to create QModelIndex objects.
Building the Custom Tree Model
Now let's build our custom model. We subclass QAbstractItemModel and implement the required methods: index(), parent(), rowCount(), columnCount(), and data().
On top of these, to support drag and drop, we also need to implement:
flags()— to mark items as draggable and droppablesupportedDropActions()— to declare which drop actions we allowmimeTypes()— to declare the MIME type for our drag datamimeData()— to serialize dragged itemsdropMimeData()— to deserialize and insert dropped itemsremoveRows()— to remove items from their original position after a move
That's quite a list, but we'll work through each one.
import json
from PyQt6.QtCore import (
QAbstractItemModel, QModelIndex, Qt, QMimeData, QByteArray
)
class TreeModel(QAbstractItemModel):
def __init__(self, parent=None):
super().__init__(parent)
self.root = TreeNode("Root")
def index(self, row, column, parent=QModelIndex()):
if not self.hasIndex(row, column, parent):
return QModelIndex()
parent_node = parent.internalPointer() if parent.isValid() else self.root
child_node = parent_node.child(row)
if child_node:
return self.createIndex(row, column, child_node)
return QModelIndex()
def parent(self, index):
if not index.isValid():
return QModelIndex()
child_node = index.internalPointer()
parent_node = child_node.parent
if parent_node is None or parent_node is self.root:
return QModelIndex()
return self.createIndex(parent_node.row(), 0, parent_node)
def rowCount(self, parent=QModelIndex()):
if parent.column() > 0:
return 0
node = parent.internalPointer() if parent.isValid() else self.root
return node.child_count()
def columnCount(self, parent=QModelIndex()):
return 1
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
node = index.internalPointer()
if role == Qt.DisplayRole:
return node.data
return None
This gives us a read-only tree model. The internalPointer() on each QModelIndex stores a reference to the underlying TreeNode, which makes it straightforward to navigate the tree.
Adding Drag and Drop Support
Now we add the methods that enable drag and drop. Let's start with flags() and supportedDropActions():
def flags(self, index):
default_flags = super().flags(index)
if index.isValid():
return (
default_flags
| Qt.ItemIsDragEnabled
| Qt.ItemIsDropEnabled
)
# The root (invalid index) should accept drops too,
# so items can be dropped at the top level.
return default_flags | Qt.ItemIsDropEnabled
def supportedDropActions(self):
return Qt.MoveAction
Qt.ItemIsDragEnabled allows a node to be picked up. Qt.ItemIsDropEnabled allows a node to receive drops (children dropped onto it). We also enable Qt.ItemIsDropEnabled on the invalid index (which represents the root) so users can drop items at the top level of the tree.
supportedDropActions() returns Qt.MoveAction, meaning we want items to be moved rather than copied.
Serializing Nodes with MIME Data
When a user drags a node, Qt needs to package the node into a portable format. We use a custom MIME type and serialize the node path (its position in the tree) as JSON.
MIME_TYPE = "application/x-treenode"
def mimeTypes(self):
return [self.MIME_TYPE]
def mimeData(self, indexes):
mime_data = QMimeData()
paths = []
for index in indexes:
if index.isValid():
path = self._get_node_path(index)
paths.append(path)
mime_data.setData(
self.MIME_TYPE,
QByteArray(json.dumps(paths).encode("utf-8")),
)
return mime_data
def _get_node_path(self, index):
"""Return the path from root to this index as a list of row numbers."""
path = []
while index.isValid():
path.insert(0, index.row())
index = index.parent()
return path
def _node_from_path(self, path):
"""Retrieve a TreeNode from a path (list of row numbers from root)."""
node = self.root
for row in path:
node = node.child(row)
if node is None:
return None
return node
We encode the tree path — a list of row indices from the root down to the node — because this lets us find the node again later, even after the model has changed. We'll use _node_from_path in dropMimeData() to retrieve the actual node.
Handling the Drop
The dropMimeData() method is called when the user releases the mouse to complete a drop. This is where we insert the dragged node into its new position.
def dropMimeData(self, data, action, row, column, parent):
if action == Qt.IgnoreAction:
return True
if not data.hasFormat(self.MIME_TYPE):
return False
raw = bytes(data.data(self.MIME_TYPE)).decode("utf-8")
paths = json.loads(raw)
parent_node = parent.internalPointer() if parent.isValid() else self.root
# Determine the insertion row.
if row == -1:
# Dropped directly on a node — append as last child.
insert_row = parent_node.child_count()
else:
insert_row = row
for path in paths:
source_node = self._node_from_path(path)
if source_node is None:
continue
# Prevent dropping a node onto itself or its own descendant.
check = parent_node
while check is not None:
if check is source_node:
return False
check = check.parent
# Remove from old position.
old_parent = source_node.parent
old_row = source_node.row()
old_parent_index = self._index_for_node(old_parent)
self.beginRemoveRows(old_parent_index, old_row, old_row)
old_parent.remove_child(old_row)
self.endRemoveRows()
# Adjust insert_row if the removal shifted things.
if old_parent is parent_node and old_row < insert_row:
insert_row -= 1
# Insert at new position.
self.beginInsertRows(parent, insert_row, insert_row)
source_node.parent = parent_node
parent_node.children.insert(insert_row, source_node)
self.endInsertRows()
insert_row += 1 # Next dragged node goes after this one.
return True
def _index_for_node(self, node):
"""Return a QModelIndex for the given node (invalid index for root)."""
if node is self.root or node is None:
return QModelIndex()
return self.createIndex(node.row(), 0, node)
There's a lot happening in dropMimeData(), so let's walk through it:
-
Determine the parent and row. If
rowis-1, the item was dropped directly on top of a node, so we append it as the last child. Otherwise,rowtells us the exact position within the parent's children. -
Prevent circular drops. We walk up the parent chain from the drop target to make sure we're not trying to drop a node onto itself or one of its own descendants. Without this check, you could break the tree structure.
-
Remove from the old position. We call
beginRemoveRows()/endRemoveRows()around the actual removal. This notifies the view that rows are disappearing. -
Adjust the insertion index. If the source and destination share the same parent, and the source was above the insertion point, removing the source shifts everything up by one, so we need to decrement
insert_row. -
Insert at the new position. We call
beginInsertRows()/endInsertRows()around the insertion.
Because we handle the removal and insertion ourselves inside dropMimeData(), we return True to tell Qt the drop is fully handled. Qt won't try to call removeRows() after a successful move when the drop handler manages everything internally like this.
Putting It All Together
Here's the complete working example. It creates a small tree with a few nested items and a QTreeView with drag and drop enabled.
import sys
import json
from PyQt6.QtWidgets import QApplication, QTreeView, QVBoxLayout, QWidget
from PyQt6.QtCore import (
QAbstractItemModel, QModelIndex, Qt, QMimeData, QByteArray
)
class TreeNode:
def __init__(self, data, parent=None):
self.data = data
self.parent = parent
self.children = []
def append_child(self, child):
child.parent = self
self.children.append(child)
def remove_child(self, row):
if 0 <= row < len(self.children):
child = self.children.pop(row)
child.parent = None
return child
return None
def child(self, row):
if 0 <= row < len(self.children):
return self.children[row]
return None
def child_count(self):
return len(self.children)
def row(self):
if self.parent:
return self.parent.children.index(self)
return 0
def __repr__(self):
return f"TreeNode({self.data!r})"
class TreeModel(QAbstractItemModel):
MIME_TYPE = "application/x-treenode"
def __init__(self, parent=None):
super().__init__(parent)
self.root = TreeNode("Root")
self._setup_sample_data()
def _setup_sample_data(self):
fruits = TreeNode("Fruits")
fruits.append_child(TreeNode("Apple"))
fruits.append_child(TreeNode("Banana"))
fruits.append_child(TreeNode("Cherry"))
self.root.append_child(fruits)
vegetables = TreeNode("Vegetables")
vegetables.append_child(TreeNode("Carrot"))
vegetables.append_child(TreeNode("Broccoli"))
self.root.append_child(vegetables)
grains = TreeNode("Grains")
grains.append_child(TreeNode("Rice"))
grains.append_child(TreeNode("Wheat"))
grains.append_child(TreeNode("Oats"))
self.root.append_child(grains)
# --- Core model methods ---
def index(self, row, column, parent=QModelIndex()):
if not self.hasIndex(row, column, parent):
return QModelIndex()
parent_node = parent.internalPointer() if parent.isValid() else self.root
child_node = parent_node.child(row)
if child_node:
return self.createIndex(row, column, child_node)
return QModelIndex()
def parent(self, index):
if not index.isValid():
return QModelIndex()
child_node = index.internalPointer()
parent_node = child_node.parent
if parent_node is None or parent_node is self.root:
return QModelIndex()
return self.createIndex(parent_node.row(), 0, parent_node)
def rowCount(self, parent=QModelIndex()):
if parent.column() > 0:
return 0
node = parent.internalPointer() if parent.isValid() else self.root
return node.child_count()
def columnCount(self, parent=QModelIndex()):
return 1
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
node = index.internalPointer()
if role == Qt.DisplayRole:
return node.data
return None
# --- Drag and drop methods ---
def flags(self, index):
default_flags = super().flags(index)
if index.isValid():
return (
default_flags
| Qt.ItemIsDragEnabled
| Qt.ItemIsDropEnabled
)
return default_flags | Qt.ItemIsDropEnabled
def supportedDropActions(self):
return Qt.MoveAction
def mimeTypes(self):
return [self.MIME_TYPE]
def mimeData(self, indexes):
mime_data = QMimeData()
paths = []
for index in indexes:
if index.isValid():
paths.append(self._get_node_path(index))
mime_data.setData(
self.MIME_TYPE,
QByteArray(json.dumps(paths).encode("utf-8")),
)
return mime_data
def dropMimeData(self, data, action, row, column, parent):
if action == Qt.IgnoreAction:
return True
if not data.hasFormat(self.MIME_TYPE):
return False
raw = bytes(data.data(self.MIME_TYPE)).decode("utf-8")
paths = json.loads(raw)
parent_node = (
parent.internalPointer() if parent.isValid() else self.root
)
if row == -1:
insert_row = parent_node.child_count()
else:
insert_row = row
for path in paths:
source_node = self._node_from_path(path)
if source_node is None:
continue
# Prevent dropping a node onto itself or a descendant.
check = parent_node
while check is not None:
if check is source_node:
return False
check = check.parent
# Remove from old location.
old_parent = source_node.parent
old_row = source_node.row()
old_parent_index = self._index_for_node(old_parent)
self.beginRemoveRows(old_parent_index, old_row, old_row)
old_parent.remove_child(old_row)
self.endRemoveRows()
# Adjust if removing shifted the target.
if old_parent is parent_node and old_row < insert_row:
insert_row -= 1
# Insert at new location.
self.beginInsertRows(parent, insert_row, insert_row)
source_node.parent = parent_node
parent_node.children.insert(insert_row, source_node)
self.endInsertRows()
insert_row += 1
return True
# --- Helpers ---
def _get_node_path(self, index):
path = []
while index.isValid():
path.insert(0, index.row())
index = index.parent()
return path
def _node_from_path(self, path):
node = self.root
for row in path:
node = node.child(row)
if node is None:
return None
return node
def _index_for_node(self, node):
if node is self.root or node is None:
return QModelIndex()
return self.createIndex(node.row(), 0, node)
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Tree Drag & Drop Example")
self.resize(400, 500)
layout = QVBoxLayout(self)
self.model = TreeModel()
self.tree = QTreeView()
self.tree.setModel(self.model)
self.tree.setDragEnabled(True)
self.tree.setAcceptDrops(True)
self.tree.setDropIndicatorShown(True)
self.tree.setDragDropMode(QTreeView.InternalMove)
self.tree.expandAll()
layout.addWidget(self.tree)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())
Run this and you'll see a tree with three categories — Fruits, Vegetables, and Grains — each with a few child items. You can drag any node and drop it somewhere else in the tree.
Try these interactions to see how it works:
- Reorder within a group: drag "Cherry" above "Apple" within the Fruits group.
- Move between groups: drag "Carrot" from Vegetables and drop it onto Fruits.
- Reparent to root level: drag "Rice" and drop it between the top-level groups.
- Move a whole group: drag the "Grains" node and drop it above "Fruits" to reorder the categories themselves.
How the Drag and Drop Flow Works
Understanding the sequence of calls Qt makes during a drag and drop helps when debugging:
- The user starts dragging an item. Qt calls
mimeData()to serialize the selected items. - As the user hovers over potential drop targets, Qt calls
flags()on each item to checkQt.ItemIsDropEnabled. - When the user drops, Qt calls
dropMimeData()with the serialized data, the drop action, and the target position. - If
dropMimeData()returnsTrue, the operation is complete. Since we handle both removal and insertion insidedropMimeData(), there's nothing else for Qt to do.
Bring Your PyQt/PySide Application to Market
Stuck in development hell? I'll help you get your project focused, finished and released. Benefit from years of practical experience releasing software with Python.