How to Make an Expandable/Collapsable Section Widget in Qt

How to make an expandable/collapsable section widget in Qt

I stumbled upon the same problem and solved it by implementing the collapsible widget as a QScrollArea whose maximum height is animated by a QPropertyAnimation.

But since I don't use QDesigner, I can't tell you if it works there.

I still have one problem: Instead of only expanding towards the bottom direction, the collapsible widget can expand towards the top and bottom. This can cause widgets located above it to shrink if they haven't reached their minimum height, yet. But this is really a detail compared to the fact that we have to build this thing ourselves…

Spoiler.h

#include <QFrame>
#include <QGridLayout>
#include <QParallelAnimationGroup>
#include <QScrollArea>
#include <QToolButton>
#include <QWidget>

class Spoiler : public QWidget {
Q_OBJECT
private:
QGridLayout mainLayout;
QToolButton toggleButton;
QFrame headerLine;
QParallelAnimationGroup toggleAnimation;
QScrollArea contentArea;
int animationDuration{300};
public:
explicit Spoiler(const QString & title = "", const int animationDuration = 300, QWidget *parent = 0);
void setContentLayout(QLayout & contentLayout);
};

Spoiler.cpp

#include <QPropertyAnimation>

#include "Spoiler.h"

Spoiler::Spoiler(const QString & title, const int animationDuration, QWidget *parent) : QWidget(parent), animationDuration(animationDuration) {
toggleButton.setStyleSheet("QToolButton { border: none; }");
toggleButton.setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
toggleButton.setArrowType(Qt::ArrowType::RightArrow);
toggleButton.setText(title);
toggleButton.setCheckable(true);
toggleButton.setChecked(false);

headerLine.setFrameShape(QFrame::HLine);
headerLine.setFrameShadow(QFrame::Sunken);
headerLine.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum);

contentArea.setStyleSheet("QScrollArea { background-color: white; border: none; }");
contentArea.setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
// start out collapsed
contentArea.setMaximumHeight(0);
contentArea.setMinimumHeight(0);
// let the entire widget grow and shrink with its content
toggleAnimation.addAnimation(new QPropertyAnimation(this, "minimumHeight"));
toggleAnimation.addAnimation(new QPropertyAnimation(this, "maximumHeight"));
toggleAnimation.addAnimation(new QPropertyAnimation(&contentArea, "maximumHeight"));
// don't waste space
mainLayout.setVerticalSpacing(0);
mainLayout.setContentsMargins(0, 0, 0, 0);
int row = 0;
mainLayout.addWidget(&toggleButton, row, 0, 1, 1, Qt::AlignLeft);
mainLayout.addWidget(&headerLine, row++, 2, 1, 1);
mainLayout.addWidget(&contentArea, row, 0, 1, 3);
setLayout(&mainLayout);
QObject::connect(&toggleButton, &QToolButton::clicked, [this](const bool checked) {
toggleButton.setArrowType(checked ? Qt::ArrowType::DownArrow : Qt::ArrowType::RightArrow);
toggleAnimation.setDirection(checked ? QAbstractAnimation::Forward : QAbstractAnimation::Backward);
toggleAnimation.start();
});
}

void Spoiler::setContentLayout(QLayout & contentLayout) {
delete contentArea.layout();
contentArea.setLayout(&contentLayout);
const auto collapsedHeight = sizeHint().height() - contentArea.maximumHeight();
auto contentHeight = contentLayout.sizeHint().height();
for (int i = 0; i < toggleAnimation.animationCount() - 1; ++i) {
QPropertyAnimation * spoilerAnimation = static_cast<QPropertyAnimation *>(toggleAnimation.animationAt(i));
spoilerAnimation->setDuration(animationDuration);
spoilerAnimation->setStartValue(collapsedHeight);
spoilerAnimation->setEndValue(collapsedHeight + contentHeight);
}
QPropertyAnimation * contentAnimation = static_cast<QPropertyAnimation *>(toggleAnimation.animationAt(toggleAnimation.animationCount() - 1));
contentAnimation->setDuration(animationDuration);
contentAnimation->setStartValue(0);
contentAnimation->setEndValue(contentHeight);
}

How to use it:


auto * anyLayout = new QVBoxLayout();
anyLayout->addWidget(…);

Spoiler spoiler;
spoiler.setContentLayout(*anyLayout);

Spoiler example

Is there a standard component for collapsible panel in Qt?

I decided to follow the general approach laid out in the link provided by Joey.

Specifically, I created a widget for each collapsible list. This widget consists of a QPushButton at the top and a QListView at the bottom.

Then, I wired the button clicked signal to a handler to toggle the geometry of the QListView between having height of 0 when it is hidden and its original height when it reappears.

I find that this approach is much simpler compared to customizing the paint event as suggested by Claudio. Furthermore, I can use QAnimationProperty to animate the change in geometry to make the list appear to "slide" in and out of view.

But anyway thanks for the replies!

How to create collapsible box in PyQt

Using as base the logic that is implemented in the solution of @xsquared modifying certain parts we obtain the following:

PyQt4 version

from PyQt4 import QtCore, QtGui

class CollapsibleBox(QtGui.QWidget):
def __init__(self, title="", parent=None):
super(CollapsibleBox, self).__init__(parent)

self.toggle_button = QtGui.QToolButton(
text=title, checkable=True, checked=False
)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(
QtCore.Qt.ToolButtonTextBesideIcon
)
self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
self.toggle_button.pressed.connect(self.on_pressed)

self.toggle_animation = QtCore.QParallelAnimationGroup(self)

self.content_area = QtGui.QScrollArea(maximumHeight=0, minimumHeight=0)
self.content_area.setSizePolicy(
QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Fixed
)
self.content_area.setFrameShape(QtGui.QFrame.NoFrame)

lay = QtGui.QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)

self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"minimumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"maximumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
)

@QtCore.pyqtSlot()
def on_pressed(self):
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
)
self.toggle_animation.setDirection(
QtCore.QAbstractAnimation.Forward
if not checked
else QtCore.QAbstractAnimation.Backward
)
self.toggle_animation.start()

def setContentLayout(self, layout):
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = (
self.sizeHint().height() - self.content_area.maximumHeight()
)
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
animation.setDuration(500)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)

content_animation = self.toggle_animation.animationAt(
self.toggle_animation.animationCount() - 1
)
content_animation.setDuration(500)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)

if __name__ == "__main__":
import sys
import random

app = QtGui.QApplication(sys.argv)

w = QtGui.QMainWindow()
w.setCentralWidget(QtGui.QWidget())
dock = QtGui.QDockWidget("Collapsible Demo")
w.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
scroll = QtGui.QScrollArea()
dock.setWidget(scroll)
content = QtGui.QWidget()
scroll.setWidget(content)
scroll.setWidgetResizable(True)

vlay = QtGui.QVBoxLayout(content)

for i in range(10):
box = CollapsibleBox("Collapsible Box Header-{}".format(i))
vlay.addWidget(box)
lay = QtGui.QVBoxLayout()
for j in range(8):
label = QtGui.QLabel("{}".format(j))
color = QtGui.QColor(*[random.randint(0, 255) for _ in range(3)])
label.setStyleSheet(
"background-color: {}; color : white;".format(color.name())
)
label.setAlignment(QtCore.Qt.AlignCenter)
lay.addWidget(label)

box.setContentLayout(lay)
vlay.addStretch()
w.resize(640, 480)
w.show()

sys.exit(app.exec_())

Sample Image

PyQt5 version

from PyQt5 import QtCore, QtGui, QtWidgets

class CollapsibleBox(QtWidgets.QWidget):
def __init__(self, title="", parent=None):
super(CollapsibleBox, self).__init__(parent)

self.toggle_button = QtWidgets.QToolButton(
text=title, checkable=True, checked=False
)
self.toggle_button.setStyleSheet("QToolButton { border: none; }")
self.toggle_button.setToolButtonStyle(
QtCore.Qt.ToolButtonTextBesideIcon
)
self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
self.toggle_button.pressed.connect(self.on_pressed)

self.toggle_animation = QtCore.QParallelAnimationGroup(self)

self.content_area = QtWidgets.QScrollArea(
maximumHeight=0, minimumHeight=0
)
self.content_area.setSizePolicy(
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed
)
self.content_area.setFrameShape(QtWidgets.QFrame.NoFrame)

lay = QtWidgets.QVBoxLayout(self)
lay.setSpacing(0)
lay.setContentsMargins(0, 0, 0, 0)
lay.addWidget(self.toggle_button)
lay.addWidget(self.content_area)

self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"minimumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self, b"maximumHeight")
)
self.toggle_animation.addAnimation(
QtCore.QPropertyAnimation(self.content_area, b"maximumHeight")
)

@QtCore.pyqtSlot()
def on_pressed(self):
checked = self.toggle_button.isChecked()
self.toggle_button.setArrowType(
QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
)
self.toggle_animation.setDirection(
QtCore.QAbstractAnimation.Forward
if not checked
else QtCore.QAbstractAnimation.Backward
)
self.toggle_animation.start()

def setContentLayout(self, layout):
lay = self.content_area.layout()
del lay
self.content_area.setLayout(layout)
collapsed_height = (
self.sizeHint().height() - self.content_area.maximumHeight()
)
content_height = layout.sizeHint().height()
for i in range(self.toggle_animation.animationCount()):
animation = self.toggle_animation.animationAt(i)
animation.setDuration(500)
animation.setStartValue(collapsed_height)
animation.setEndValue(collapsed_height + content_height)

content_animation = self.toggle_animation.animationAt(
self.toggle_animation.animationCount() - 1
)
content_animation.setDuration(500)
content_animation.setStartValue(0)
content_animation.setEndValue(content_height)

if __name__ == "__main__":
import sys
import random

app = QtWidgets.QApplication(sys.argv)

w = QtWidgets.QMainWindow()
w.setCentralWidget(QtWidgets.QWidget())
dock = QtWidgets.QDockWidget("Collapsible Demo")
w.addDockWidget(QtCore.Qt.LeftDockWidgetArea, dock)
scroll = QtWidgets.QScrollArea()
dock.setWidget(scroll)
content = QtWidgets.QWidget()
scroll.setWidget(content)
scroll.setWidgetResizable(True)
vlay = QtWidgets.QVBoxLayout(content)
for i in range(10):
box = CollapsibleBox("Collapsible Box Header-{}".format(i))
vlay.addWidget(box)
lay = QtWidgets.QVBoxLayout()
for j in range(8):
label = QtWidgets.QLabel("{}".format(j))
color = QtGui.QColor(*[random.randint(0, 255) for _ in range(3)])
label.setStyleSheet(
"background-color: {}; color : white;".format(color.name())
)
label.setAlignment(QtCore.Qt.AlignCenter)
lay.addWidget(label)

box.setContentLayout(lay)
vlay.addStretch()
w.resize(640, 480)
w.show()
sys.exit(app.exec_())

List of expandable items that contains custom (and complex) widgets

To summarize, the question here was divided in two parts:

  • About the existence of an expandable widget.
  • About the existence of a list view which can handle any widgets including expandable ones.

As such containers do not exist in the Qt framework, I have written my own c++ implementation of these which can be found here.
In qt_widgets_extensions.hpp, I defined qt_ext::QExpandableWidget and qt_ext::QListWidgetView which completely answer both aspects of the question. You can check the link for more details about the features provided by these containers.

I think it could be easy to translate this implementation into another language like Python for example.

Making collapsible GroupBoxes in Qt - what determines the collapsed size?

I got back to this old code of mine some time ago and it appears that I finally came up with a reasonable solution. The complete code follows:

** CollapsibleGroupBox.h **

#ifndef COLLAPSIBLEGROUPBOX_H
#define COLLAPSIBLEGROUPBOX_H

#include <QGroupBox>
#include <QMap>
#include <QMargins>
#include <QPair>

class QResizeEvent;
class CollapseExpandButton;
class QSpacerItem;

class CollapsibleGroupBox : public QGroupBox
{
public:
explicit CollapsibleGroupBox(QWidget *parent = nullptr);

protected:
void resizeEvent(QResizeEvent *);

private:
void resizeCollapseButton();
void collapseLayout(QLayout *layout);
void collapseSpacer(QSpacerItem *spacer);
void expandLayout(QLayout *layout);
void expandSpacer(QSpacerItem *spacer);

CollapseExpandButton *m_clExpButton;
QMap<const void *, QMargins> m_layoutMargins;
QMap<const void *, QPair<QSize, QSizePolicy>> m_spacerSizes;

private slots:
void onScreenChanged();
void onVisibilityChanged();

};

#endif // COLLAPSIBLEGROUPBOX_H

** CollapsibleGroupBox.cpp **

#include "collapsiblegroupbox.h"
#include "collapseexpandbutton.h"
#include <QApplication>
#include <QGuiApplication>
#include <QLayout>
#include <QResizeEvent>
#include <QScreen>
#include <QStyle>
#include <QTimer>
#include <QWindow>
#include <cassert>
#include <cmath>

inline
QWindow *findWindowForWidget(const QWidget *widget)
{
for (;;) {
QWindow *wh = widget->window()->windowHandle();
if (wh != nullptr)
return wh;

widget = qobject_cast<const QWidget *>(widget->parent());
if (widget == nullptr)
return nullptr;
}
}

inline
QScreen * findScreenForWidget(const QWidget *widget)
{
for (;;) {
QWindow *wh = widget->window()->windowHandle();
if (wh != nullptr) {
QScreen *scr = wh->screen();
if (scr != nullptr)
return scr;
}

widget = qobject_cast<const QWidget *>(widget->parent());
if (widget == nullptr)
return nullptr;
}
}

CollapsibleGroupBox::CollapsibleGroupBox(QWidget *parent) :
QGroupBox(parent)
{
m_clExpButton = new CollapseExpandButton(this);

connect(m_clExpButton, &CollapseExpandButton::clicked, this, &CollapsibleGroupBox::onVisibilityChanged);

QTimer::singleShot(0, this, [this] {
auto wh = findWindowForWidget(this);
if (wh != nullptr)
connect(wh, &QWindow::screenChanged, this, &CollapsibleGroupBox::onScreenChanged);
});

QTimer::singleShot(0, this, &CollapsibleGroupBox::resizeCollapseButton);
}

void CollapsibleGroupBox::collapseLayout(QLayout *lay)
{
assert(!m_layoutMargins.contains(lay));

const int cnt = lay->count();
for (int idx = 0; idx < cnt; idx++) {
auto lit = lay->itemAt(idx);

if (lit->widget()) {
auto w = lit->widget();
if (w != m_clExpButton)
w->setVisible(false);
}
else if (lit->spacerItem())
collapseSpacer(lit->spacerItem());
else if (lit->layout())
collapseLayout(lit->layout());
}

m_layoutMargins[lay] = lay->contentsMargins();
lay->setContentsMargins(0, 0, 0, 0);
}

void CollapsibleGroupBox::collapseSpacer(QSpacerItem *spacer)
{
assert(!m_spacerSizes.contains(spacer));

m_spacerSizes[spacer] = {spacer->sizeHint(), spacer->sizePolicy()};
spacer->changeSize(0, 0);
}

void CollapsibleGroupBox::expandLayout(QLayout *lay)
{
assert(m_layoutMargins.contains(lay));

const int cnt = lay->count();
for (int idx = 0; idx < cnt; idx++) {
auto lit = lay->itemAt(idx);

if (lit->widget())
lit->widget()->setVisible(true);
else if (lit->spacerItem())
expandSpacer(lit->spacerItem());
else if (lit->layout())
expandLayout(lit->layout());
}

lay->setContentsMargins(m_layoutMargins[lay]);
}

void CollapsibleGroupBox::expandSpacer(QSpacerItem *spacer)
{
assert(m_spacerSizes.contains(spacer));

const auto &sz = m_spacerSizes[spacer].first;
const auto &pol = m_spacerSizes[spacer].second;

spacer->changeSize(sz.width(), sz.height(), pol.horizontalPolicy(), pol.verticalPolicy());
}

void CollapsibleGroupBox::onScreenChanged()
{
resizeCollapseButton();
}

void CollapsibleGroupBox::onVisibilityChanged()
{
assert(this->layout() != nullptr);

CollapseExpandButton::State s = m_clExpButton->state();

switch (s) {
case CollapseExpandButton::State::COLLAPSED:
m_layoutMargins.clear();
m_spacerSizes.clear();

collapseLayout(this->layout());
break;
case CollapseExpandButton::State::EXPANDED:
expandLayout(this->layout());
break;
}
}

void CollapsibleGroupBox::resizeCollapseButton()
{
const QScreen *scr = findScreenForWidget(this);

if (scr == nullptr)
return;

const auto &size = this->size();

#ifdef Q_OS_WIN
qreal baseSize = 15.0;
int yOffset = 5;
#else
qreal baseSize = 22.0;
int yOffset = 0;
#endif

if (scr == nullptr)
return;

if (QString::compare(QApplication::style()->objectName(), "fusion") == 0)
baseSize = 15.0;

const qreal dpi = scr->logicalDotsPerInchX();
const qreal btnSize = floor((baseSize * dpi / 96.0) + 0.5);

m_clExpButton->setGeometry(size.width() - btnSize, yOffset, btnSize, btnSize);
}

void CollapsibleGroupBox::resizeEvent(QResizeEvent *)
{
resizeCollapseButton();
}

This seems to collapse and restore the boxes exactly as I would expect.



Related Topics



Leave a reply



Submit