Pyside - Draggable Collapsible Panels

07/01/2025, Tue
Categories: #python
Tags: #pyside

Reorder Containers that can Expand and Collapse

To further expand upon prior discussion of collapsible panels from previous posts

Superqt - Distributing QCollapsible Containers

PyQT - Collapsible Sections

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