I'm developing a PyQt6 application where I want to enable drag-and-drop functionality for widgets within a QScrollArea. While the drag-and-drop functionality works initially, it starts behaving incorrectly when there are too many widgets, especially when scrolling is involved. The dragged widget does not always drop at the correct position.
Here's the relevant part of my code:
from PyQt6.QtWidgets import QVBoxLayout, QApplication, QScrollArea, QToolButton, QWidget, QPushButton
from PyQt6.QtGui import QIcon, QDrag, QPixmap
from PyQt6.QtCore import Qt, QSize, QMimeData
class DragButton(QToolButton):
def mouseMoveEvent(self, e):
if e.buttons() == Qt.MouseButton.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
pixmap = QPixmap(self.size())
self.render(pixmap)
drag.setPixmap(pixmap)
drag.exec(Qt.DropAction.MoveAction)
class Window(QWidget):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
# Main Layout
self.main_layout = QVBoxLayout()
self.main_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Add Button
add_button = QPushButton(parent = self, text ='add button')
add_button.clicked.connect(self.add_button)
self.main_layout.addWidget(add_button)
# button frame/layout
self.button_layout = QVBoxLayout()
self.button_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignTop)
self.button_layout.setSpacing(30)
self.button_frame = QWidget()
self.button_frame.setLayout(self.button_layout)
# Scroll area
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.scroll_area.setWidget(self.button_frame)
self.main_layout.addWidget(self.scroll_area)
self.setLayout(self.main_layout)
def add_button(self):
n = self.button_layout.count()
btn = DragButton(parent=self, text=f'{n}')
self.button_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter)
def dragEnterEvent(self, e):
e.accept()
def dropEvent(self, e):
pos = e.position().toPoint()
widget = e.source()
# Temporarily remove the widget
self.button_layout.removeWidget(widget)
insert_at = self.button_layout.count()
for i in range(self.button_layout.count()):
w = self.button_layout.itemAt(i).widget()
if pos.y() < w.y():
insert_at = i
break
# Insert the widget back to the layout at the new position
self.button_layout.insertWidget(insert_at, widget, alignment=Qt.AlignmentFlag.AlignHCenter)
e.accept()
if __name__ == '__main__':
app = QApplication([])
window = Window()
window.show()
app.exec()
I’ve considered the scroll offset in the dropEvent method, but it doesn’t seem to solve the problem.
def dropEvent(self, e):
pos = e.position().toPoint()
widget = e.source()
# Temporarily remove the widget
self.button_layout.removeWidget(widget)
insert_at = self.button_layout.count()
scroll_value = self.scroll_area.verticalScrollBar().value()
for i in range(self.button_layout.count()):
w = self.button_layout.itemAt(i).widget()
widget_pos = w.mapToParent(w.pos())
widget_y = widget_pos.y() - scroll_value
if pos.y() < widget_y:
insert_at = i
break
# Insert the widget back to the layout at the new position
self.button_layout.insertWidget(insert_at, widget, alignment=Qt.AlignmentFlag.AlignHCenter)
e.accept()
Most UI toolkits and graphics related software are based on the concepts of relative coordinate systems and parent/child relations (which is common in OOP).
Your first attempt is invalid, because the position of the drop event is relative to the object that receives it (the Window
instance), while the position of the widgets you're checking is relative to their parent (self.button_frame
).
Note that this is completely unrelated to the fact that you're using a scroll area, as the same would've happened if you just added self.button_frame
to the main layout. The usage of the scroll area only shows much more the ineffectiveness of the approach when contents are further scrolled.
Your attempt in using mapToParent()
in the second code snippet is also invalid for the same reasons; see the pos
documentation:
This property holds the position of the widget within its parent widget.
w.pos()
is already in parent coordinates, so mapping that to the parent is wrong, because it will give a completely unrelated result.
In fact, if you do w.mapToParent(QPoint())
you will get exactly the same value as w.pos()
. The reason is simple: in the context of a widget, coordinates are always relative to its origin point (0, 0
, as it is in the Cartesian coordinate system), meaning that QPoint()
(implicitly, QPoint(0, 0)
) refers to that origin point; since the widget pos()
is in parent coordinates, the origin point coincides with the widget position in that coordinate system.
This means that if you want to compare the position of a widget with a mouse position, you need to ensure that both use the same reference coordinate system.
You already have the widget position (based on its parent), so you could map the mouse to the same parent.
Since the widget structure may be uncertain, a better approach is to pass through a common reference system, which is normally the global position (the position relative to the screen): map the local position to the global, then map it again to the reference parent.
QDropEvent doesn't provide a globalPosition()
member as QMouseEvent does, so we can just use mapToGlobal()
:
def dropEvent(self, e):
globalPos = self.mapToGlobal(e.position())
y = self.button_frame.mapFromGlobal(globalPos).toPoint().y()
widget = e.source()
self.button_layout.removeWidget(widget)
count = self.button_layout.count()
for insert_at in range(count):
w = self.button_layout.itemAt(insert_at).widget()
if y < w.y():
break
else:
insert_at = count
self.button_layout.insertWidget(
insert_at, widget, alignment=Qt.AlignmentFlag.AlignHCenter)
e.accept()
Further notes.
You should generally implement drag&drop events in the widget that actually has to handle them. Doing so in a parent is generally discouraged, especially when many parent/child levels are used, just like in your case: Window
-> scroll_area
-> the scroll area viewport -> button_frame
(the widget of the scroll area used in setWidget()
). Failing to do so is the reason for which the implementation above becomes mandatory, which would have been unrequired if the drag&drop implementation was done in the correct object.
At the very least, button_frame
(which is the context in which the drag&drop behavior should be handled) should be a subclass that overrides the related handlers. You could even do it for the scroll area, but then you still need to map coordinates relative to the scroll area viewport, which is the actual parent of the scroll contents. It's also possible to do the same without subclassing and using an event filter, but for this purpose it would only make things more difficult.
Overriding dragMoveEvent()
is recommended, even if it's just a matter of event.accept()
, unless you're completely sure about the default behavior of the classes you inherit from. In your case it may not be necessary (QWidget just accepts it by default if drag enter was), but it may be in case you were subclassing from another type. This also involves the accurate implementation of drag&drop handling on the proper widget, as explained above.
You should consider the target geometry to define if a drop actually happened above or below a widget: right now you're just comparing to the vertical position of the widget, which makes it quite unintuitive; if the user just drops within a few pixels near the above margins of the widget, the drop will consider the position below it. A more accurate approach should compare the drop position to the center()
of the widget's geometry.
Finally, setting the alignment of a layout that contains expanding widgets (the default size policy of scroll areas) is pointless, especially for main layouts of windows.