pyqtdrag-and-droppyqt5qwidgetqgridlayout

PyQt: Is it possible to drag/drop QWidgets in a QGridLayout to rearrange them?


I am looking for a way to create a grid of graphs that can be dragged/dropped to rearrange the order. My first try was using QDockWidgets as they allow for drag/drop, however they were limited in a lot of other ways. Would it be possible to implement this function in a QGridLayout?

For now I have a QGridLayout with 3x3 matplotlib widgets.

Here is an example of the desired layout outcome.

enter image description here

Sample code:

import sys
from PyQt5 import QtWidgets
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import random

from PyQt5.QtWidgets import QGridLayout, QVBoxLayout, QHBoxLayout, QScrollArea, QWidget, QDialog, QApplication, QFrame

class IndicSelectWindow(QDialog):
    def __init__(self, parent=None):
        super(IndicSelectWindow, self).__init__(parent=parent)
        self.resize(1000, 800)

        self.layout = QtWidgets.QHBoxLayout(self)
        self.scrollArea = QScrollArea(self)
        self.scrollArea.setWidgetResizable(True)
        self.scrollAreaWidgetContents = QWidget()
        self.gridLayout = QGridLayout(self.scrollAreaWidgetContents)
        self.scrollArea.setWidget(self.scrollAreaWidgetContents)
        self.layout.addWidget(self.scrollArea)

        for i in range(3):
             for j in range(3):
                 self.Frame = QFrame(self)
                 self.Frame.setStyleSheet("background-color: white;")
                 self.Frame.setFrameStyle(QFrame.Panel | QFrame.Raised)
                 self.Frame.setLineWidth(2)
                 self.layout = QHBoxLayout(self.Frame)

                 self.figure = Figure()  # a figure to plot on
                 self.canvas = FigureCanvas(self.figure)
                 self.ax = self.figure.add_subplot(111)  # create an axis
                 data = [random.random() for i in range(10)]
                 self.ax.plot(data, '*-')  # plot data
                 self.canvas.draw()  # refresh canvas

                 self.layout.addWidget(self.canvas)

                 Box = QVBoxLayout()

                 Box.addWidget(self.Frame)

                 self.gridLayout.addLayout(Box, i, j)
                 self.gridLayout.setColumnStretch(i % 3, 1)
                 self.gridLayout.setRowStretch(j, 1)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = IndicSelectWindow()
    w.show()
    sys.exit(app.exec_())

Solution

  • Here is an implementation that will swap the positions of the items involved in a drag/drop. The 3 main steps are:

    (1) Reimplement mousePressEvent to get the index of the LayoutItem based on mouse coordinates.

    (2) Reimplement mouseMoveEvent to set up a QDrag of the FigureCanvas.

    (3) Reimplement dropEvent to swap the target items in the layout.

    Since the matplotlib widgets absorb mouse events you also need to reimplement eventFilter to detect them.

    import sys, random
    from PyQt5.QtWidgets import *
    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
    
    class IndicSelectWindow(QDialog):
    
        def __init__(self, parent=None):
            super(IndicSelectWindow, self).__init__(parent=parent)
            self.resize(1000, 800)
    
            self.target = None
            self.setAcceptDrops(True)
            self.layout = QHBoxLayout(self)
            self.scrollArea = QScrollArea(self)
            self.scrollArea.setWidgetResizable(True)
            self.scrollAreaWidgetContents = QWidget()
            self.gridLayout = QGridLayout(self.scrollAreaWidgetContents)
            self.scrollArea.setWidget(self.scrollAreaWidgetContents)
            self.layout.addWidget(self.scrollArea)
    
            for i in range(3):
                for j in range(3):
                    self.Frame = QFrame(self)
                    self.Frame.setStyleSheet("background-color: white;")
                    self.Frame.setFrameStyle(QFrame.Panel | QFrame.Raised)
                    self.Frame.setLineWidth(2)
                    self.layout = QHBoxLayout(self.Frame)
    
                    self.figure = Figure()  # a figure to plot on
                    self.canvas = FigureCanvas(self.figure)
                    self.ax = self.figure.add_subplot(111)  # create an axis
                    data = [random.random() for i in range(10)]
                    self.ax.plot(data, '*-')  # plot data
                    self.canvas.draw()  # refresh canvas
                    self.canvas.installEventFilter(self)
    
                    self.layout.addWidget(self.canvas)
    
                    Box = QVBoxLayout()
    
                    Box.addWidget(self.Frame)
    
                    self.gridLayout.addLayout(Box, i, j)
                    self.gridLayout.setColumnStretch(i % 3, 1)
                    self.gridLayout.setRowStretch(j, 1)
    
        def eventFilter(self, watched, event):
            if event.type() == QEvent.MouseButtonPress:
                self.mousePressEvent(event)
            elif event.type() == QEvent.MouseMove:
                self.mouseMoveEvent(event)
            elif event.type() == QEvent.MouseButtonRelease:
                self.mouseReleaseEvent(event)
            return super().eventFilter(watched, event)
    
        def get_index(self, pos):
            for i in range(self.gridLayout.count()):
                if self.gridLayout.itemAt(i).geometry().contains(pos) and i != self.target:
                    return i
    
        def mousePressEvent(self, event):
            if event.button() == Qt.LeftButton:
                self.target = self.get_index(event.windowPos().toPoint())
            else:
                self.target = None
    
        def mouseMoveEvent(self, event):
            if event.buttons() & Qt.LeftButton and self.target is not None:
                drag = QDrag(self.gridLayout.itemAt(self.target))
                pix = self.gridLayout.itemAt(self.target).itemAt(0).widget().grab()
                mimedata = QMimeData()
                mimedata.setImageData(pix)
                drag.setMimeData(mimedata)
                drag.setPixmap(pix)
                drag.setHotSpot(event.pos())
                drag.exec_()
    
        def mouseReleaseEvent(self, event):
            self.target = None
    
        def dragEnterEvent(self, event):
            if event.mimeData().hasImage():
                event.accept()
            else:
                event.ignore()
    
        def dropEvent(self, event):
            if not event.source().geometry().contains(event.pos()):
                source = self.get_index(event.pos())
                if source is None:
                    return
    
                i, j = max(self.target, source), min(self.target, source)
                p1, p2 = self.gridLayout.getItemPosition(i), self.gridLayout.getItemPosition(j)
    
                self.gridLayout.addItem(self.gridLayout.takeAt(i), *p2)
                self.gridLayout.addItem(self.gridLayout.takeAt(j), *p1)
    
    
    if __name__ == '__main__':
        app = QApplication(sys.argv)
        w = IndicSelectWindow()
        w.show()
        sys.exit(app.exec_())