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);
…
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_())
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
Sorting Two Corresponding Arrays
How Can an Incomplete Type Be Used as a Template Parameter to Vector Here
Nonfree Module Is Missing in Opencv 3.0
Representing Big Numbers in Source Code for Readability
How to Reliably Get an Object's Address When Operator& Is Overloaded
Compile the Python Interpreter Statically
Compile Time Sizeof_Array Without Using a MACro
Building a 32-Bit Float Out of Its 4 Composite Bytes
How to Get a List of Video Capture Devices (Web Cameras) on Linux ( Ubuntu )? (C/C++)
Change Default Value of Cmake_Cxx_Flags_Debug and Friends in Cmake
Why Do We Need to Use Boost::Asio::Io_Service::Work
Are Multiple Mutations of the Same Variable Within Initializer Lists Undefined Behavior Pre C++11
C++ Qt Signal and Slot Not Firing
What Does Flushing the Buffer Mean