pythonuser-interfacedrag-and-droppyqt6

Drag and Drop Widgets within QScrollArea in PyQt6 Not Working Correctly


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()

Solution

  • 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.