I came across this as one of the only posts I could find for how to implement a click & drag to swap widgets around in a QGridLayout
using python. @Alec's answer above helped me out a lot, but unfortunately it was designed for PyQt5, which is not forwards compatible with PyQt6.
I spent quite a while understanding then re-creating a working solution, and wanted to provide it to hopefully help anyone in the future who stumbles across this post.
One quick note: I had no need to utilize matplotlib in my project, and so to keep it simple, my answer focuses on moving around a basic QGroupBox
widget in a QGridLayout
using PyQt6. In theory, you could likely add a plot to the base movable widget you need to make, and adjust anything else here accordingly.
Steps:
- Create a simple widget object (see
class MovableWidget
) that's really just a standin for whatever more complex widget you intend to drag around.
- Implement a
QMainWindow
class, where we reimplement the eventFilter, mousePressEvent, mousePressEvent, mouseMoveEvent, and dropEvent, similar to @Alec's solution above.
Changes:
- I had to add an event trap (
self.eventTrap
), because for some reason, the mousePressEvent
will trigger twice when you click on a widget to move it, which otherwise unsets the self.target
variable. If anyone knows why that happens or has an improved solution I'll update this post accordingly.
- I do not have a layout wrapping around the widget object and instead move them between slots in the
QGridLayout
directly. I also removed a lot of the added complexity of the QScrollArea
and extra QFrame
object as I implemented my movable object as its own separate class.
from PyQt6.QtWidgets import (
QWidget,QGridLayout,QVBoxLayout,QApplication,
QPushButton,QMainWindow,QGroupBox,QLabel)
from PyQt6.QtCore import Qt, QEvent,QMimeData
from PyQt6.QtGui import (QMouseEvent, QDrag, QMouseEvent, QDragEnterEvent,
QDropEvent,QColor,QPalette,QFont)
class MovableWidget(QGroupBox):
"""Exteremely simple widget with a button and a label to move around"""
def __init__(self,r,c,parent=None):
super().__init__(parent=parent)
self.setFixedSize(400,200) # So we have some area to grab on to
# Store row/column for label
self.r = r
self.c = c
# Setup a button for a basic QObject element to use
self.btn = QPushButton(f"BUTTON: {r} {c}")
self.btn.clicked.connect(self.btnClick)
# Setup a Qlabel to show updated row/column
self.lbl = QLabel(f"Current Loc: {r} {c}")
# Set font larger
fnt = QFont()
fnt.setPointSize(15)
self.btn.setFont(fnt)
self.lbl.setFont(fnt)
# Basic layout to setup widget
tmpLayout = QVBoxLayout()
tmpLayout.addWidget(self.btn)
tmpLayout.addWidget(self.lbl)
self.setLayout(tmpLayout)
# Fill widget background with some color, so we can better see it.
self.setAutoFillBackground(True)
palette = self.palette()
palette.setColor(QPalette.ColorRole.Window, QColor("#C5E0B4"))
self.setPalette(palette)
def btnClick(self,btn):
"""Display button text to console """
sender:QPushButton = self.sender()
textSender = sender.text()
print(f"Button Pressed: [{textSender}]")
def relabel(self,r,c):
"""Set button label text given row/col"""
self.lbl.setText(f"Current Loc: {r} {c}")
class MyAppMainWin(QMainWindow):
""" Basic Main Window, but with gridLayout configured to allow for movable objects """
def __init__(self, parent=None):
super().__init__()
self.target = None
self.setAcceptDrops(True)
self.gridLayout = QGridLayout()
for i in range(3):
for j in range(3):
myobj = MovableWidget(i,j) # a widget to move around
myobj.installEventFilter(self)
self.gridLayout.addWidget(myobj, i, j)
self.gridLayout.setColumnStretch(i % 3, 1)
self.gridLayout.setRowStretch(j, 1)
# Assign base widget w/ given layout as main/central widget
self.baseMainWidget = QWidget()
self.baseMainWidget.setLayout(self.gridLayout)
self.setCentralWidget(self.baseMainWidget)
# I was experiencing a double 'mousePressEvent', every time that I
# clicked into a widget. The event trap holds the press event till
# release or dropEvent
self.eventTrap = None
def eventFilter(self, watched, event:QEvent):
if event.type() == QEvent.Type.MouseButtonPress:
self.mousePressEvent(event)
elif event.type() == QEvent.Type.MouseMove:
self.mouseMoveEvent(event)
elif event.type() == QEvent.Type.MouseButtonRelease:
self.mouseReleaseEvent(event)
return super().eventFilter(watched, event)
def get_index(self, pos) -> int:
"""Helper Function = get widget index that we click into/drop into"""
for i in range(self.gridLayout.count()):
contains = self.gridLayout.itemAt(i).geometry().contains(pos)
if contains and i != self.target:
return i
def mousePressEvent(self, event:QMouseEvent):
if event.button() == Qt.MouseButton.LeftButton:
if self.eventTrap is None:
self.eventTrap = event
self.target = self.get_index(event.scenePosition().toPoint())
elif self.eventTrap == event:
pass
else:
print("Something broke")
else:
self.target = None
def mouseMoveEvent(self, event:QMouseEvent):
if event.buttons() & Qt.MouseButton.LeftButton and self.target is not None:
drag = QDrag(self.gridLayout.itemAt(self.target).widget())
pix = self.gridLayout.itemAt(self.target).widget().grab()
mimedata = QMimeData()
mimedata.setImageData(pix)
drag.setMimeData(mimedata)
drag.setPixmap(pix)
drag.setHotSpot(event.pos())
drag.exec()
def mouseReleaseEvent(self, event):
# Reset our event trap & target handles. Note 'mouseReleaseEvent' is not called
# if dropEvent happens, at least in my experience
self.target = None
self.eventTrap = None
def dragEnterEvent(self, event:QDragEnterEvent):
if event.mimeData().hasImage():
event.accept()
else:
event.ignore()
def dropEvent(self, event:QDropEvent):
"""Check if swap needs to occur and perform swap if so. """
eventSource:QVBoxLayout = event.source() # For typehinting the next line
if not eventSource.geometry().contains(event.position().toPoint()):
source = self.get_index(event.position().toPoint())
if source is None:
self.eventTrap = None
self.target = None
return
i, j = max(self.target, source), min(self.target, source)
p1, p2 = self.gridLayout.getItemPosition(i), self.gridLayout.getItemPosition(j)
# Update widget.lbl prior to moving items, while item handles are useful
self.gridLayout.itemAt(i).widget().relabel(p2[0],p2[1])
self.gridLayout.itemAt(j).widget().relabel(p1[0],p1[1])
# The magic - pop item out of grid, then add item at new row/col/rowspan/colspan
self.gridLayout.addItem(self.gridLayout.takeAt(i), *p2)
self.gridLayout.addItem(self.gridLayout.takeAt(j), *p1)
# Always reset our event trap & target handles.
self.target = None
self.eventTrap = None
if __name__ == '__main__':
app = QApplication([])
w = MyAppMainWin()
w.show()
app.exec()
I hope this helps anyone in the future looking to click & drag & drop widget objects around in python / PyQt6, and snap them into a QGridLayout
. If anyone wishes to comment & correct me on something I'll update this accordingly. Thanks!