Pyside - Draggable Collapsible Panels
07/01/2025, TueReorder Containers that can Expand and Collapse
To further expand upon prior discussion of collapsible panels from previous posts
Superqt - Distributing QCollapsible Containers
In the previous article, the panels were referred to as containers, but in this article they will be referred to as panels instead. The term container might be confused with the element that houses all the panels.
We will want to improve our panels beyond being only collapsible by adding draggable/sortable capabilities.
Demo of Behavior
The following will be implemented without using third-party libraries.
We will begin by introducing three classes that will help divide our logic into the different behaviors for the panels.
PanelContainer
This class will create the container that houses all the panels and will maintain most of the methods required to handle the dragging and reordering of the panels. Most of the logic for panel dragging and dropping will be located in this class because they of a higher level concern where it needs to track changes and information across the different panels.
CollapsiblePanel
This will help represent a single panel element. This class will only need to manage the expanding and collapsible of itself when the header area is clicked.
DraggableCollapsiblePanel
Although most responsibility is located in the PanelContainer
for detecting
the drag events, we will need to create this class to record the start
of the mouse move event. This will be the initiator for the record of the drag event. In addition, this will extend the CollapsiblePanel
class with
the draggable functionality.
To get an overall sense of how these classes are used, we can look at the final instantiations when we bring up our application.
if __name__ == "__main__":
app = QApplication([])
container = PanelContainer()
for i in range(5):
panel = DraggableCollapsiblePanel(f"Panel {i+1}")
content_layout = QVBoxLayout()
content_layout.addWidget(QLabel(f"Text in Panel {i+1}"))
panel.setContentLayout(content_layout)
container.addPanel(panel)
container.show()
app.exec()
We will start by looking at the CollapsiblePanel
. The CollapsiblePanel
class makes use of a QToolButton possesses the text title to indicate
the click region of the panel to toggle the show and hide display of the contents of the panel.
The contents of the panel is a QFrame container. We have to set the height of this element to be zero since it has a default height. In addition to the contents being visible to suggest the state of the panel, we also have a triangle marker on the left of the ToolButton text that points to the right when the panel is collapsed and one that points downward when the panel is opened.
class CollapsiblePanel(QWidget):
def __init__(self, title="", parent=None):
super().__init__(parent)
# The panel is composed of a ToolButton and a QFrame
self.toggle_button = QToolButton(text=title,
checkable=True, checked=False)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
# The toggle indicator triangle is initially in the 'closed' state
self.toggle_button.setArrowType(Qt.ArrowType.RightArrow)
self.toggle_button.clicked.connect(self.on_toggle)
self.content_area = QFrame()
# Content should be initially hidden
self.content_area.setFixedHeight(0)
# Content layout and spacing for the panel
main_layout = QVBoxLayout(self)
main_layout.addWidget(self.toggle_button)
main_layout.addWidget(self.content_area)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.setLayout(main_layout)
def setContentLayout(self, layout):
self.content_area.setLayout(layout)
def on_toggle(self):
# The toggle indicator triangle changes based on the
# expanded or collapsed state
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
)
self.content_area.setMaximumHeight(16777215 if checked else 0)
Next, the drag behavior needs to be implemented. This is a shorter class
because most of the dragging and index tracking will be situated in the PanelContainer class.
The DraggableCollapsiblePanel
class only has the mouseMoveEvent
to start the tracking of the drag event.
class DraggableCollapsiblePanel(CollapsiblePanel):
def mouseMoveEvent(self, event):
if event.buttons() == Qt.MouseButton.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec(Qt.DropAction.MoveAction)
The PanelContainer
class below will add in all the logic necessary for
the drag behavior of the panels.
It is responsible for adding the panels into the layout as well as tracking
which panel were dragged along with the anticipated drop locations.
Within this class, there is a 'drop' index marker which is visual helper element that shows where the drop location of the panel is going to go.
There is also a 'stretch spacer' that added to the group of panels to maintain the extra space on the bottom of all the panels, if this was not included, the panels would be evenly distributed with space between each panel.
class PanelContainer(QWidget):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
# Add drop indicator, but not to layout yet
self.drop_indicator = QWidget(self)
self.drop_indicator.setFixedHeight(2)
self.drop_indicator \
.setStyleSheet("background-color: #15c467; opacity: 0.5")
self.drop_indicator.hide()
# Add stretchable spacer at bottom
self.layout.addStretch()
# Give the window a larger size for visibility
self.resize(500, 400)
def addPanel(self, panel):
# Always insert before the stretch spacer,
# which is always the last item
self.layout.insertWidget(self.layout.count() - 1, panel)
def dragEnterEvent(self, event):
event.accept()
# Disregard the stretch spacer
panel_count = self.layout.count() - 1
dragged_item = event.source()
# Have to iterate through the list of element
# to get the current element's index
collapsible_items = []
for i in range(panel_count):
item = self.layout.itemAt(i)
w = item.widget()
if isinstance(w, DraggableCollapsiblePanel):
collapsible_items.append(self.layout.itemAt(i).widget())
# Track the index when the drag event starts
self.dragged_item_index = collapsible_items \
.index(dragged_item)
def dragMoveEvent(self, event):
pos = event.position().y()
insert_index = None
last_panel_bottom = 0
# Only consider widgets before the stretch spacer
panel_count = self.layout.count() - 1
# Track the drop indicator
found = False
for i in range(panel_count):
item = self.layout.itemAt(i)
w = item.widget()
if isinstance(w, DraggableCollapsiblePanel):
y = w.y()
h = w.height()
if pos < y + h // 2:
self.drop_indicator.setGeometry(0, y, self.width(), 2)
self.drop_indicator.show()
insert_index = i
found = True
break
last_panel_bottom = y + h
if not found:
# If not before any panel, show drop indicator
# after the last panel, but above the stretch spacer
self.drop_indicator.setGeometry(0, last_panel_bottom,
self.width(), 2)
self.drop_indicator.show()
insert_index = panel_count
# Record for dropEvent
self._drop_insert_index = insert_index
event.accept()
def dragLeaveEvent(self, event):
# Hide the drop indicator when the drop event has completed
self.drop_indicator.hide()
event.accept()
def dropEvent(self, event):
widget = event.source()
self.layout.removeWidget(widget)
# Use the insert index determined in dragMoveEvent
insert_index = getattr(self, "_drop_insert_index",
self.layout.count() - 1)
diff_in_index_drag = abs(self._drop_insert_index
- self.dragged_item_index)
dragged_in_same_spot = (
(diff_in_index_drag <= 1)
and (self._drop_insert_index > self.dragged_item_index)
) or (self._drop_insert_index == self.dragged_item_index)
bottom_to_top_drag = self.dragged_item_index > self._drop_insert_index
# Different drag location index drop location must be adjusted
# on on the initial starting element location
if (dragged_in_same_spot):
if (diff_in_index_drag == 0):
self.layout.insertWidget(insert_index, widget)
else:
self.layout.insertWidget(insert_index - 1, widget)
else:
if bottom_to_top_drag:
self.layout.insertWidget(insert_index, widget)
else:
self.layout.insertWidget(insert_index - 1, widget)
self.drop_indicator.hide()
event.accept()
The completed example is shown below
from PySide6.QtWidgets import (
QWidget,
QVBoxLayout,
QToolButton,
QLabel,
QFrame,
QApplication,
QSizePolicy
)
from PySide6.QtCore import Qt, QMimeData
from PySide6.QtGui import QDrag
class PanelContainer(QWidget):
def __init__(self):
super().__init__()
self.setAcceptDrops(True)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
# Add drop indicator, but not to layout yet
self.drop_indicator = QWidget(self)
self.drop_indicator.setFixedHeight(2)
self.drop_indicator \
.setStyleSheet("background-color: #15c467; opacity: 0.5")
self.drop_indicator.hide()
# Add stretchable spacer at bottom
self.layout.addStretch()
# Give the window a larger size for visibility
self.resize(500, 400)
def addPanel(self, panel):
# Always insert before the stretch spacer,
# which is always the last item
self.layout.insertWidget(self.layout.count() - 1, panel)
def dragEnterEvent(self, event):
event.accept()
# Disregard the stretch spacer
panel_count = self.layout.count() - 1
dragged_item = event.source()
# Have to iterate through the list of element
# to get the current element's index
collapsible_items = []
for i in range(panel_count):
item = self.layout.itemAt(i)
w = item.widget()
if isinstance(w, DraggableCollapsiblePanel):
collapsible_items.append(self.layout.itemAt(i).widget())
# Track the index when the drag event starts
self.dragged_item_index = collapsible_items \
.index(dragged_item)
def dragMoveEvent(self, event):
pos = event.position().y()
insert_index = None
last_panel_bottom = 0
# Only consider widgets before the stretch spacer
panel_count = self.layout.count() - 1
# Track the drop indicator
found = False
for i in range(panel_count):
item = self.layout.itemAt(i)
w = item.widget()
if isinstance(w, DraggableCollapsiblePanel):
y = w.y()
h = w.height()
if pos < y + h // 2:
self.drop_indicator.setGeometry(0, y, self.width(), 2)
self.drop_indicator.show()
insert_index = i
found = True
break
last_panel_bottom = y + h
if not found:
# If not before any panel, show drop indicator
# after the last panel, but above the stretch spacer
self.drop_indicator.setGeometry(0, last_panel_bottom,
self.width(), 2)
self.drop_indicator.show()
insert_index = panel_count
# Record for dropEvent
self._drop_insert_index = insert_index
event.accept()
def dragLeaveEvent(self, event):
# Hide the drop indicator when the drop event has completed
self.drop_indicator.hide()
event.accept()
def dropEvent(self, event):
widget = event.source()
self.layout.removeWidget(widget)
# Use the insert index determined in dragMoveEvent
insert_index = getattr(self, "_drop_insert_index",
self.layout.count() - 1)
diff_in_index_drag = abs(self._drop_insert_index
- self.dragged_item_index)
dragged_in_same_spot = (
(diff_in_index_drag <= 1)
and (self._drop_insert_index > self.dragged_item_index)
) or (self._drop_insert_index == self.dragged_item_index)
bottom_to_top_drag = self.dragged_item_index > self._drop_insert_index
# Different drag location index drop location must be adjusted
# on on the initial starting element location
if (dragged_in_same_spot):
if (diff_in_index_drag == 0):
self.layout.insertWidget(insert_index, widget)
else:
self.layout.insertWidget(insert_index - 1, widget)
else:
if bottom_to_top_drag:
self.layout.insertWidget(insert_index, widget)
else:
self.layout.insertWidget(insert_index - 1, widget)
self.drop_indicator.hide()
event.accept()
class CollapsiblePanel(QWidget):
def __init__(self, title="", parent=None):
super().__init__(parent)
# The panel is composed of a ToolButton and a QFrame
self.toggle_button = QToolButton(text=title,
checkable=True, checked=False)
self.toggle_button.setStyleSheet(
"""
QToolButton {
border-top: 1px solid #8e8d8e;
border-bottom: 1px solid #8e8d8e;
width: 100%;
}
"""
)
self.toggle_button.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon
)
# The toggle indicator triangle is initially in the 'closed' state
self.toggle_button.setArrowType(Qt.ArrowType.RightArrow)
self.toggle_button.clicked.connect(self.on_toggle)
# Make the toolbutton use the full width of the window
self.toggle_button.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
)
self.content_area = QFrame()
# Content should be initially hidden
self.content_area.setFixedHeight(0)
# Content layout and spacing for the panel
main_layout = QVBoxLayout(self)
main_layout.addWidget(self.toggle_button)
main_layout.addWidget(self.content_area)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
self.setLayout(main_layout)
def setContentLayout(self, layout):
self.content_area.setLayout(layout)
def on_toggle(self):
# The toggle indicator triangle changes based on the
# expanded or collapsed state
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
Qt.ArrowType.DownArrow if checked else Qt.ArrowType.RightArrow
)
self.content_area.setMaximumHeight(16777215 if checked else 0)
class DraggableCollapsiblePanel(CollapsiblePanel):
def mouseMoveEvent(self, event):
if event.buttons() == Qt.MouseButton.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
drag.exec(Qt.DropAction.MoveAction)
if __name__ == "__main__":
app = QApplication([])
container = PanelContainer()
for i in range(5):
panel = DraggableCollapsiblePanel(f"Panel {i+1}")
content_layout = QVBoxLayout()
content_layout.addWidget(QLabel(f"Text in Panel {i+1}"))
panel.setContentLayout(content_layout)
container.addPanel(panel)
container.show()
app.exec()