How to make an expandable/collapsable section widget in Qt

C++QtWidgetCustom ControlsQwidget

C++ Problem Overview


I would like to create a custom widget in Qt with the following features:

  • It is a container
  • It may be populated with any Qt layout
  • It may be inside any Qt layout
  • A button allows to collapse/fold vertically the content, so only the button is visible, all the contained layout is invisible.
  • The previous button allows to expand/unfold it again to the size of the layout content.
  • The expanding/collapsing is based on sizes (not on show/hide) to allows animation.
  • Usable in QDesigner

To provide an idea, here is an image of a similar widget (not Qt): enter image description here

I already have a frame that work correctly and is exposed in QDesigner. I need now to make it to extend/collapse, which does not seem so simple.

I tried to play with resize(), sizePolicy(), sizeHint() but that does not work: When the frame is collapsed I got following values:

sizeHint: (500,20)
size    : (500,20)
closestAcceptableSize: (518,150)
Painted size: (518, 150)

QLayout::closestAcceptableSize is not part of the widget so I cannot change it.

Any hint or/and code snippet to achieve that?

EDITED: Here a simple example. I removed all except necessary.

main.cpp example

#include <QWidget>
#include <QPushButton>
#include <QVBoxLayout>

#include "section.hpp"


using namespace myWidgets;
int main(int argc, char *argv[])
{
	QApplication a(argc, argv);


	// Create the main Window
	QWidget window;
	window.resize(500,500);
	window.setStyleSheet("QPushButton:{background-color:rgba(128,128,128,192);}");

	// Create the main window layout
	QVBoxLayout topLayout(&window);
	QWidget *w1 = new QWidget();
	w1->setStyleSheet("background-color:rgba(128,128,128,192);");
	topLayout.addWidget(w1);

	Section section(&window);
	topLayout.addWidget(&section);

	QVBoxLayout inLayout(&section);
	QPushButton *button = new QPushButton();
	button->setMinimumHeight(100);
	inLayout.addWidget(button);

	QWidget *w2 = new QWidget();
	w2->setStyleSheet("background-color:rgba(128,128,128,192);");
	topLayout.addWidget(w2);



	window.show();

	return a.exec();
}

Section.hpp

#ifndef SECTION_HPP
#define SECTION_HPP

#include <QPushButton> //for the expand/collapse button
#include <QtDesigner/QDesignerExportWidget>
#include <QLayout>
#include <QPainter>
#include <QPaintEvent>
#include <QDebug>


// Compatibility for noexcept, not supported in vsc++
#ifdef _MSC_VER
#define noexcept throw()
#endif

#if defined SECTION_BUILD
	#define SECTION_BUILD_DLL_SPEC Q_DECL_EXPORT
#elif defined SECTION_EXEC
	#define SECTION_BUILD_DLL_SPEC
#else
	#define SECTION_BUILD_DLL_SPEC Q_DECL_IMPORT
#endif

namespace myWidgets
{

class SECTION_BUILD_DLL_SPEC Section : public QWidget
{
	Q_OBJECT

	Q_PROPERTY( bool is_expanded MEMBER isExpanded)

public:
	// Constructor, standard
	explicit Section( QWidget *parent=0 ): QWidget(parent),
		expandButton(this)
	{
		expandButton.resize(20,20);
		expandButton.move(0,0);
		expandButton.connect(&expandButton, &QPushButton::clicked,
							 this, &Section::expandCollapseEvent);

		QMargins m= contentsMargins();
		m.setTop(m.top()+25);
		setContentsMargins(m);
		//setSizePolicy(sizePolicy().horizontalPolicy(), QSizePolicy::Minimum);

	}

	virtual void expand( bool expanding ) noexcept
	{
		resize(sizeHint());
		isExpanded = expanding;
		updateGeometry();

qDebug() << (isExpanded? "expanded":"collapsed") << sizeHint() << QWidget::size() <<
			parentWidget()->layout()->closestAcceptableSize(this, size());
	}

	virtual QSize sizeHint() const noexcept override
	{
		if (isExpanded) return QSize(layout()->contentsRect().width(),
									 layout()->contentsRect().height());
		else return QSize(layout()->contentsRect().width(), 20);
	}

	// Implement custom appearance
	virtual void paintEvent(QPaintEvent *e) noexcept override
	{
		(void) e; //TODO: remove
		QPainter p(this);
		p.setClipRect(e->rect());
		p.setRenderHint(QPainter::Antialiasing );
		p.fillRect(e->rect(), QColor(0,0,255,128));
	}

protected:

	// on click of the expandButton, collapse/expand this widget
	virtual void expandCollapseEvent() noexcept
	{
		expand(!isExpanded);
	}


	bool isExpanded = true; //whenever the section is collapsed(false) or expanded(true)
	QPushButton expandButton; //the expanding/collapsing button
};

}


#endif // SECTION_HPP

C++ Solutions


Solution 1 - C++

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

Solution 2 - C++

Even though this is old I found this thread helpful. However, I'm working in python, so I had to convert the C++ code. Just in case anyone is looking for a python version of x squared's solution. Here is my port:

from PyQt4 import QtCore, QtGui


class Spoiler(QtGui.QWidget):
    def __init__(self, parent=None, title='', animationDuration=300):
        """
        References:
            # Adapted from c++ version
            http://stackoverflow.com/questions/32476006/how-to-make-an-expandable-collapsable-section-widget-in-qt
        """
        super(Spoiler, self).__init__(parent=parent)

        self.animationDuration = animationDuration
        self.toggleAnimation = QtCore.QParallelAnimationGroup()
        self.contentArea = QtGui.QScrollArea()
        self.headerLine = QtGui.QFrame()
        self.toggleButton = QtGui.QToolButton()
        self.mainLayout = QtGui.QGridLayout()

        toggleButton = self.toggleButton
        toggleButton.setStyleSheet("QToolButton { border: none; }")
        toggleButton.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        toggleButton.setArrowType(QtCore.Qt.RightArrow)
        toggleButton.setText(str(title))
        toggleButton.setCheckable(True)
        toggleButton.setChecked(False)

        headerLine = self.headerLine
        headerLine.setFrameShape(QtGui.QFrame.HLine)
        headerLine.setFrameShadow(QtGui.QFrame.Sunken)
        headerLine.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Maximum)

        self.contentArea.setStyleSheet("QScrollArea { background-color: white; border: none; }")
        self.contentArea.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Fixed)
        # start out collapsed
        self.contentArea.setMaximumHeight(0)
        self.contentArea.setMinimumHeight(0)
        # let the entire widget grow and shrink with its content
        toggleAnimation = self.toggleAnimation
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"minimumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"maximumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self.contentArea, b"maximumHeight"))
        # don't waste space
        mainLayout = self.mainLayout
        mainLayout.setVerticalSpacing(0)
        mainLayout.setContentsMargins(0, 0, 0, 0)
        row = 0
        mainLayout.addWidget(self.toggleButton, row, 0, 1, 1, QtCore.Qt.AlignLeft)
        mainLayout.addWidget(self.headerLine, row, 2, 1, 1)
        row += 1
        mainLayout.addWidget(self.contentArea, row, 0, 1, 3)
        self.setLayout(self.mainLayout)

        def start_animation(checked):
            arrow_type = QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
            direction = QtCore.QAbstractAnimation.Forward if checked else QtCore.QAbstractAnimation.Backward
            toggleButton.setArrowType(arrow_type)
            self.toggleAnimation.setDirection(direction)
            self.toggleAnimation.start()

        self.toggleButton.clicked.connect(start_animation)

    def setContentLayout(self, contentLayout):
        # Not sure if this is equivalent to self.contentArea.destroy()
        self.contentArea.destroy()
        self.contentArea.setLayout(contentLayout)
        collapsedHeight = self.sizeHint().height() - self.contentArea.maximumHeight()
        contentHeight = contentLayout.sizeHint().height()
        for i in range(self.toggleAnimation.animationCount()-1):
            spoilerAnimation = self.toggleAnimation.animationAt(i)
            spoilerAnimation.setDuration(self.animationDuration)
            spoilerAnimation.setStartValue(collapsedHeight)
            spoilerAnimation.setEndValue(collapsedHeight + contentHeight)
        contentAnimation = self.toggleAnimation.animationAt(self.toggleAnimation.animationCount() - 1)
        contentAnimation.setDuration(self.animationDuration)
        contentAnimation.setStartValue(0)
        contentAnimation.setEndValue(contentHeight)

Solution 3 - C++

I have dug through the excellent pointer provided by @LoPiTal and converted it to PyQt5 (Python3). I think it's very elegant.

In case anyone is looking for a PyQt-solution, here's my code:

import sys
from PyQt5.QtWidgets import (QPushButton, QDialog, QTreeWidget,
                             QTreeWidgetItem, QVBoxLayout,
                             QHBoxLayout, QFrame, QLabel,
                             QApplication)

class SectionExpandButton(QPushButton):
    """a QPushbutton that can expand or collapse its section
    """
    def __init__(self, item, text = "", parent = None):
        super().__init__(text, parent)
        self.section = item
        self.clicked.connect(self.on_clicked)
        
    def on_clicked(self):
        """toggle expand/collapse of section by clicking
        """
        if self.section.isExpanded():
            self.section.setExpanded(False)
        else:
            self.section.setExpanded(True)
            

class CollapsibleDialog(QDialog):
    """a dialog to which collapsible sections can be added;
    subclass and reimplement define_sections() to define sections and
        add them as (title, widget) tuples to self.sections
    """
    def __init__(self):
        super().__init__()
        self.tree = QTreeWidget()
        self.tree.setHeaderHidden(True)
        layout = QVBoxLayout()
        layout.addWidget(self.tree)
        self.setLayout(layout)
        self.tree.setIndentation(0)
        
        self.sections = []
        self.define_sections()
        self.add_sections()

    def add_sections(self):
        """adds a collapsible sections for every 
        (title, widget) tuple in self.sections
        """
        for (title, widget) in self.sections:
            button1 = self.add_button(title)
            section1 = self.add_widget(button1, widget)
            button1.addChild(section1)

    def define_sections(self):
        """reimplement this to define all your sections
        and add them as (title, widget) tuples to self.sections
        """
        widget = QFrame(self.tree)
        layout = QHBoxLayout(widget)
        layout.addWidget(QLabel("Bla"))
        layout.addWidget(QLabel("Blubb"))
        title = "Section 1"
        self.sections.append((title, widget))

    def add_button(self, title):
        """creates a QTreeWidgetItem containing a button 
        to expand or collapse its section
        """
        item = QTreeWidgetItem()
        self.tree.addTopLevelItem(item)
        self.tree.setItemWidget(item, 0, SectionExpandButton(item, text = title))
        return item

    def add_widget(self, button, widget):
        """creates a QWidgetItem containing the widget,
        as child of the button-QWidgetItem
        """
        section = QTreeWidgetItem(button)
        section.setDisabled(True)
        self.tree.setItemWidget(section, 0, widget)
        return section
        

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = CollapsibleDialog()
    window.show()
    sys.exit(app.exec_())

Solution 4 - C++

I know this is not a good way to answer a question, just with a link, but I think this blog post is quite relevant:

http://www.fancyaddress.com/blog/qt-2/create-something-like-the-widget-box-as-in-the-qt-designer/

It is based on QTreeWidget, and uses its expand / collapse features, which are already implemented. It explains how widgets can be added to the tree widget items, and how to add a button to use for collapse / expand them.

Of course, all the credit goes for the post author.

Solution 5 - C++

Contributing a version using PySide2 (official Qt5 bindings for python3)

from PySide2 import QtCore, QtGui, QtWidgets

class Expander(QtWidgets.QWidget):
    def __init__(self, parent=None, title='', animationDuration=300):
        """
        References:
            # Adapted from PyQt4 version
            https://stackoverflow.com/a/37927256/386398
            # Adapted from c++ version
            https://stackoverflow.com/a/37119983/386398
        """
        super(Expander, self).__init__(parent=parent)

        self.animationDuration = animationDuration
        self.toggleAnimation = QtCore.QParallelAnimationGroup()
        self.contentArea =  QtWidgets.QScrollArea()
        self.headerLine =   QtWidgets.QFrame()
        self.toggleButton = QtWidgets.QToolButton()
        self.mainLayout =   QtWidgets.QGridLayout()

        toggleButton = self.toggleButton
        toggleButton.setStyleSheet("QToolButton { border: none; }")
        toggleButton.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        toggleButton.setArrowType(QtCore.Qt.RightArrow)
        toggleButton.setText(str(title))
        toggleButton.setCheckable(True)
        toggleButton.setChecked(False)

        headerLine = self.headerLine
        headerLine.setFrameShape(QtWidgets.QFrame.HLine)
        headerLine.setFrameShadow(QtWidgets.QFrame.Sunken)
        headerLine.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Maximum)

        self.contentArea.setStyleSheet("QScrollArea { background-color: white; border: none; }")
        self.contentArea.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
        # start out collapsed
        self.contentArea.setMaximumHeight(0)
        self.contentArea.setMinimumHeight(0)
        # let the entire widget grow and shrink with its content
        toggleAnimation = self.toggleAnimation
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"minimumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"maximumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self.contentArea, b"maximumHeight"))
        # don't waste space
        mainLayout = self.mainLayout
        mainLayout.setVerticalSpacing(0)
        mainLayout.setContentsMargins(0, 0, 0, 0)
        row = 0
        mainLayout.addWidget(self.toggleButton, row, 0, 1, 1, QtCore.Qt.AlignLeft)
        mainLayout.addWidget(self.headerLine, row, 2, 1, 1)
        row += 1
        mainLayout.addWidget(self.contentArea, row, 0, 1, 3)
        self.setLayout(self.mainLayout)

        def start_animation(checked):
            arrow_type = QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
            direction = QtCore.QAbstractAnimation.Forward if checked else QtCore.QAbstractAnimation.Backward
            toggleButton.setArrowType(arrow_type)
            self.toggleAnimation.setDirection(direction)
            self.toggleAnimation.start()

        self.toggleButton.clicked.connect(start_animation)

    def setContentLayout(self, contentLayout):
        # Not sure if this is equivalent to self.contentArea.destroy()
        self.contentArea.destroy()
        self.contentArea.setLayout(contentLayout)
        collapsedHeight = self.sizeHint().height() - self.contentArea.maximumHeight()
        contentHeight = contentLayout.sizeHint().height()
        for i in range(self.toggleAnimation.animationCount()-1):
            expandAnimation = self.toggleAnimation.animationAt(i)
            expandAnimation.setDuration(self.animationDuration)
            expandAnimation.setStartValue(collapsedHeight)
            expandAnimation.setEndValue(collapsedHeight + contentHeight)
        contentAnimation = self.toggleAnimation.animationAt(self.toggleAnimation.animationCount() - 1)
        contentAnimation.setDuration(self.animationDuration)
        contentAnimation.setStartValue(0)
        contentAnimation.setEndValue(contentHeight)

Solution 6 - C++

The solution I applied is to use the MaximumSize property of the widget to limit the height while folded.

The biggest problem is to know the the unfolded height while folded to allow a correct animation step. This has not been solved and I currently make an animation with a fix height step (which I set to an appropriate value in relation to the expected height of the window).

if (toBeFolded)
{
    unfoldedMaxHeight = maximumHeight();
    previousUnfoldedHeight = height();
    setMaximumHeight(25);
}
else
{
    // animate maximumHeight from 25 up to where the height do not change
    // A hint of the final maximumHeight is the previousUnfoldedHeight.
    // After animation, set maximumHeight back to unfoldedMaxHeight.
}

Solution 7 - C++

I built Python3/Qt5 styled example to test out a StyleSheet class I'm writing. I also fixed an issue with size calculations that didn't consider size changes to the expander button.

I also changed the method to setLayout() to be consistent with Qt.

Expander Closed

Expander Open

import sys
import inspect
import textwrap
from collections import OrderedDict, UserString
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QIcon
from PyQt5.QtWidgets import *



class QStyleSheet(UserString):
    """
    Represent stylesheets as dictionary key value pairs.
    Update complex stylesheets easily modifying only the attributes you need
    Allow for attribute inheritance or defaulting of stylesheets.
    
    # TODO support [readOnly="true"] attribute-selectors
            QTextEdit, QListView  <-- you can have multiple classes.
            QCheckBox::indicator  <-- some psuedo classes have double colons
    """
    def __init__(self, cls=None, name=None, psuedo=None, **styles):
        """
        Arguments to the constructor allow you to default different properties of the CSS Class.
        Any argument defined here will be global to this StyleSheet and cannot be overidden later.
        
        :param cls: Default style prefix class to ``cls``
        :param name: Default object name to ``name`` (hashtag) is not needed.
        :param psuedo: Default psuedo class to ``psuedo``, example: ``:hover``
        """
        self.cls_scope = cls
        self.psuedo_scope = psuedo
        self.name_scope = name
        self._styles = OrderedDict() # we'll preserve the order of attributes given - python 3.6+
        if styles:
            self.setStylesDict(OrderedDict(styles))
        
    def _ident(self, cls=None, name=None, psuedo=None):
    
        # -- ensure value is of correct type ----------------------------------------
        if cls is not None and not inspect.isclass(cls):
            raise ValueError(f'cls must be None or a class object, got: {type(cls)}')
            
        if name is not None and not isinstance(name, str):
            raise ValueError(f'name must be None or a str, got: {type(name)}')
            
        if psuedo is not None and not isinstance(psuedo, str):
            raise ValueError(f'psuedo must be None or a str, got: {type(psuedo)}')
            
        # -- ensure not overiding defaults -------------------------------------------
        if cls is not None and self.cls_scope is not None:
            raise ValueError(f'cls was set in __init__, you cannot override it')
            
        if name is not None and self.name_scope is not None:
            raise ValueError(f'name was set in __init__, you cannot override it')
            
        if psuedo is not None and self.psuedo_scope is not None:
            raise ValueError(f'psuedo was set in __init__, you cannot override it')
            
        # -- apply defaults if set ---------------------------------------------------
        if cls is None and self.cls_scope is not None:
            cls = self.cls_scope
            
        if name is None and self.name_scope is not None:
            name = self.name_scope
            
        if psuedo is None and self.psuedo_scope is not None:
            psuedo = self.psuedo_scope
            
        # return a tuple that can be used as a dictionary key.
        ident = tuple([getattr(cls, '__name__', None), name or None, psuedo or None])
        return ident
        
    def _class_definition(self, ident):
        """Get the class definition string"""
        cls, name, psuedo = ident
        return '%s%s%s' % (cls or '', name or '', psuedo or '')
        
    def _fix_underscores(self, styles):
        return OrderedDict([(k.replace('_', '-'), v) for k,v in styles.items()])
        
    def setStylesStr(self, styles):
        """
        Parse styles from a string and set them on this object.
        """
        raise NotImplementedError()
        self._update()
        
    def setStylesDict(self, styles, cls=None, name=None, psuedo=None):
        """
        Set styles using a dictionary instead of keyword arguments
        """
        styles = self._fix_underscores(styles)
        if not isinstance(styles, dict):
            raise ValueError(f'`styles` must be dict, got: {type(styles)}')
        if not styles:
            raise ValueError('`styles` cannot be empty')
            
        ident = self._ident(cls, name, psuedo)
        stored = self._styles.get(ident, OrderedDict())
        stored.update(styles)
        self._styles[ident] = stored
        
        self._update()
        
    def setStyles(self, cls=None, name=None, psuedo=None, **styles):
        """
        Set or update styles according to the CSS Class definition provided by (cls, name, psuedo) using keyword-arguments.
        
        Any css attribute with a hyphen ``-`` character should be changed to an underscore ``_`` when passed as a keyword argument.
        
        Example::
        
            Lets suppose we want to create the css class:
            
                QFrame#BorderTest { background-color: white; margin:4px; border:1px solid #a5a5a5; border-radius: 10px;}
                
            >>> stylesheet.setStyle(cls=QFrameBorderTest, background_color='white', margin='4px', border_radius='10px')
            
            >>> print(stylesheet)
            
            QFrame#BorderTest { background-color: white; margin:4px; border:1px solid #a5a5a5; border-radius: 10px;}
        """
        styles = OrderedDict(styles)
        self.setStylesDict(styles=styles, cls=cls, name=name, psuedo=psuedo)
        
    def getStyles(self, cls=None, name=None, psuedo=None):
        """
        Return the dictionary representations of styles for the CSS Class definition provided by (cls, name, psuedo)
        
        :returns: styles dict (keys with hyphens)
        """
        ident = self._ident(cls, name, psuedo)
        return self._styles.get(ident)
        
    def getClassIdents(self):
        """Get all class identifier tuples"""
        return list(self._styles.keys())
        
    def getClassDefinitions(self):
        """Get all css class definitions, but not the css attributes/body"""
        return [self._class_definition(ident) for ident in self.getClassIdents()]
    
    def validate(self):
        """
        Validate all the styles and attributes on this class
        """
        raise NotImplementedError()
        
    def merge(self, stylesheet, overwrite=True):
        """
        Merge another QStyleSheet with this QStyleSheet.
        The QStyleSheet passed as an argument will be left un-modified.
        
        :param overwrite: if set to True the matching class definitions will be overwritten
                          with attributes and values from ``stylesheet``.
                          Otherwise, the css attributes will be updated from ``stylesheet``
        :type overwrite: QStyleSheet
        """
        for ident in stylesheet.getClassIdents():
            styles = stylesheet.getStyles(ident)
            cls, name, psuedo = ident
            self.setStylesDict(styles, cls=cls, name=name, psuedo=psuedo)
            
        self._update()
        
    def clear(self, cls=None, name=None, psuedo=None):
        """
        Clear styles matching the Class definition
        
        The style dictionary cleared will be returned
        
        None will be returned if nothing was cleared.
        """
        ident = self._ident(cls, name, psuedo)
        return self._styles.pop(ident, None)
        
    def _update(self):
        """Update the internal string representation"""
        stylesheet = []
        for ident, styles in self._styles.items():
            if not styles:
                continue
            css_cls = self._class_definition(ident)
            css_cls = css_cls + ' ' if css_cls else ''
            styles_str = '\n'.join([f'{k}: {v};' for k, v in styles.items()])
                
            styles_str = textwrap.indent(styles_str, ''.ljust(4))
            stylesheet.append('%s{\n%s\n}' % (css_cls, styles_str))
            
        self.data = '\n\n'.join(stylesheet)


class Expander(QWidget):
    def __init__(self, parent=None, title=None, animationDuration=200):
        super().__init__(parent=parent)

        self.animationDuration = animationDuration
        self.toggleAnimation = QtCore.QParallelAnimationGroup()
        self.contentArea = QScrollArea()
        self.headerLine = QFrame()
        self.toggleButton = QToolButton()
        self.mainLayout = QGridLayout()

        toggleButton = self.toggleButton
        self.toggleButtonQStyle = QStyleSheet(QToolButton, border='none')
        toggleButton.setStyleSheet(str(self.toggleButtonQStyle))
        toggleButton.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
        toggleButton.setArrowType(QtCore.Qt.RightArrow)
        toggleButton.setText(title or '')
        toggleButton.setCheckable(True)
        toggleButton.setChecked(False)
        toggleButton.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)

        headerLine = self.headerLine
        self.headerLineQStyle = QStyleSheet(QFrame)
        headerLine.setFrameShape(QFrame.NoFrame)  # see: https://doc.qt.io/archives/qt-4.8/qframe.html#Shape-enum
        headerLine.setFrameShadow(QFrame.Plain)   # see: https://doc.qt.io/archives/qt-4.8/qframe.html#Shape-enum
        headerLine.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)

        self.contentAreaQStyle = QStyleSheet(QScrollArea, border='none')
        self.contentArea.setStyleSheet(str(self.contentAreaQStyle))
        self.contentArea.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        # start out collapsed
        self.contentArea.setMaximumHeight(0)
        self.contentArea.setMinimumHeight(0)
        # let the entire widget grow and shrink with its content
        toggleAnimation = self.toggleAnimation
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"minimumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self, b"maximumHeight"))
        toggleAnimation.addAnimation(QtCore.QPropertyAnimation(self.contentArea, b"maximumHeight"))
        # don't waste space
        mainLayout = self.mainLayout
        mainLayout.setVerticalSpacing(0)
        mainLayout.setContentsMargins(0, 0, 0, 0)
        row = 0
        mainLayout.addWidget(self.toggleButton, row, 0, 1, 1, QtCore.Qt.AlignLeft)
        mainLayout.addWidget(self.headerLine, row, 2, 1, 1)
        row += 1
        mainLayout.addWidget(self.contentArea, row, 0, 1, 3)
        super().setLayout(self.mainLayout)

        def start_animation(checked):
            arrow_type = QtCore.Qt.DownArrow if checked else QtCore.Qt.RightArrow
            direction = QtCore.QAbstractAnimation.Forward if checked else QtCore.QAbstractAnimation.Backward
            toggleButton.setArrowType(arrow_type)
            self.toggleAnimation.setDirection(direction)
            self.toggleAnimation.start()

        self.toggleButton.clicked.connect(start_animation)
        
    def setHeaderFrameStyles(self, styles):
        self._setWidgetStyles(self.headerLine, self.headerLineQStyle, styles)
    
    def setToggleButtonStyles(self, styles):
        self._setWidgetStyles(self.toggleButton, self.toggleButtonQStyle, styles)
        
    def setContentAreaStyles(self, styles):
        self._setWidgetStyles(self.contentArea, self.contentAreaQStyle, styles)
            
    def _setWidgetStyles(self, widget, qstylesheet, var):
        if isinstance(var, QStyleSheet):
            qstylesheet.merge(var)
            widget.setStyleSheet(str(qstylesheet))
        elif isinstance(var, dict):
            qstylesheet.setStylesDict(var)
            widget.setStyleSheet(str(qstylesheet))
        elif isinstance(var, str):
            widget.setStyleSheet(var)
        else:
            raise ValueError('invalid argument type: {type(var)}')
    
        

    def setLayout(self, contentLayout):
        """
        Set the layout container that you would like to expand/collapse.
        
        This should be called after all styles are set.
        """
        # Not sure if this is equivalent to self.contentArea.destroy()
        self.contentArea.destroy()
        self.contentArea.setLayout(contentLayout)
        collapsedHeight = self.toggleButton.sizeHint().height()
        contentHeight = contentLayout.sizeHint().height()
        for i in range(self.toggleAnimation.animationCount()-1):
            spoilerAnimation = self.toggleAnimation.animationAt(i)
            spoilerAnimation.setDuration(self.animationDuration)
            spoilerAnimation.setStartValue(collapsedHeight)
            spoilerAnimation.setEndValue(collapsedHeight + contentHeight)
        contentAnimation = self.toggleAnimation.animationAt(self.toggleAnimation.animationCount() - 1)
        contentAnimation.setDuration(self.animationDuration)
        contentAnimation.setStartValue(0)
        contentAnimation.setEndValue(contentHeight)
        
        
class MainWindow(QMainWindow):
    LIGHT_BLUE = '#148cc1'
    MED_BLUE = '#0c6a94'
    DARK_BLUE = '#0a3a6b'
    PALE_SALMON = '#fd756d'
    LIGHT_GREY = '#d2d5da'
    SLATE = '#525863'
    
    def __init__(self):
        super().__init__()
        
        self.WINDOW_STYLE = QStyleSheet(QMainWindow, background_color=self.SLATE)
        self.WINDOW_STYLE = str(self.WINDOW_STYLE)
        
        self.LABEL_STYLE = QStyleSheet(QLabel, color=self.DARK_BLUE, font_weight=400, font_size='9pt')
        self.LABEL_STYLE = str(self.LABEL_STYLE)
        
        # -- QPushButton stylesheet ---------------------
        self.BUTTON_STYLE = s1 = QStyleSheet()
        
        s1.setStyles(cls=QPushButton, 
                    color='white', 
                    font_weight=400,
                    border_style='solid',
                    padding='4px',
                    background_color=self.LIGHT_BLUE)
                
        s1.setStyles(cls=QPushButton, psuedo=':pressed',
                    background_color=self.PALE_SALMON)
                    
        s1.setStyles(cls=QPushButton, psuedo=':focus-pressed',
                    background_color=self.PALE_SALMON)
                    
        s1.setStyles(cls=QPushButton, psuedo=':disabled',
                    background_color=self.LIGHT_GREY)
                    
        s1.setStyles(cls=QPushButton, psuedo=':checked',
                    background_color=self.PALE_SALMON)
                    
        s1.setStyles(cls=QPushButton, psuedo=':hover:!pressed:!checked',
                    background_color=self.MED_BLUE)
        self.BUTTON_STYLE = str(self.BUTTON_STYLE)

        self.BUTTON_GROUPBOX_STYLE = QStyleSheet(QGroupBox, border='none', font_weight='bold', color='white')
        self.BUTTON_GROUPBOX_STYLE = str(self.BUTTON_GROUPBOX_STYLE)

        self.TEXT_EDIT_STYLE = QStyleSheet(QTextEdit, color='white', border=f'1px solid {self.LIGHT_BLUE}', background_color=self.MED_BLUE)
        self.TEXT_EDIT_STYLE = str(self.TEXT_EDIT_STYLE)
        
        self.initUI()
        
    def initUI(self):
        contents_vbox = QVBoxLayout()
        label_box = QHBoxLayout()
        for text in ('hello', 'goodbye', 'adios'):
            lbl = QLabel(text)
            lbl.setStyleSheet(self.LABEL_STYLE)
            lbl.setAlignment(Qt.AlignCenter)
            label_box.addWidget(lbl)
        
        button_group = QButtonGroup()
        button_group.setExclusive(True)
        button_group.buttonClicked.connect(self._button_clicked)
        self.button_group = button_group 
        
        button_hbox = QHBoxLayout()
        
        
        for _id, text in enumerate(('small', 'medium', 'large')):
            btn = QPushButton(text)
            btn.setCheckable(True)
            btn.setStyleSheet(self.BUTTON_STYLE)
            button_group.addButton(btn)
            button_group.setId(btn, _id)
            button_hbox.addWidget(btn)
            
        button_group.buttons()[0].toggle()
            
        text_area = QTextEdit()
        text_area.setPlaceholderText('Type a greeting here')
        text_area.setStyleSheet(self.TEXT_EDIT_STYLE)
            
        contents_vbox.addLayout(label_box)
        contents_vbox.addLayout(button_hbox)
        contents_vbox.addWidget(text_area)
        
        collapsible = Expander(self, 'Expander')
        collapsible.setToggleButtonStyles({'padding': '4px', 'background-color': 'white'})
        collapsible.setContentAreaStyles({'background-color': 'white'})
        collapsible.setLayout(contents_vbox)
        
        vbox = QVBoxLayout()
        vbox.addWidget(collapsible)
        vbox.setAlignment(Qt.AlignTop)
        widget = QWidget()
        widget.setLayout(vbox)
        
        self.setCentralWidget(widget)
        
        
        self.setGeometry(200, 200, 500, 400)
        self.setWindowTitle('Expander')
        self.setStyleSheet(self.WINDOW_STYLE)
        self.show()
        
    def _button_clicked(self, button):
        """
        For the toggle behavior of a QButtonGroup to work you must 
        connect the clicked signal!
        """
        print('button-active', self.button_group.id(button))
        
        
if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = MainWindow()
    sys.exit(app.exec_())

Solution 8 - C++

A version for Qt Designer inspired from @x squared. To use it, promote a QToolButton and designate a QFrame to it for collapsing contents.

Preview

CollapseButton.h:

#include <QToolButton>
#include <QApplication>
#include <QDebug>

#pragma once

class CollapseButton : public QToolButton {
public:
  CollapseButton(QWidget *parent) : QToolButton(parent), content_(nullptr) {
    setCheckable(true);
    setStyleSheet("background:none");
    setIconSize(QSize(8, 8));
    setFont(QApplication::font());
    connect(this, &QToolButton::toggled, [=](bool checked) {
      setArrowType(checked ? Qt::ArrowType::DownArrow : Qt::ArrowType::RightArrow);
      content_ != nullptr && checked ? showContent() : hideContent();
    });
  }

  void setText(const QString &text) {
    QToolButton::setText(" " + text);
  }

  void setContent(QWidget *content) {
    assert(content != nullptr);
    content_ = content;
    auto animation_ = new QPropertyAnimation(content_, "maximumHeight"); // QObject with auto delete
    animation_->setStartValue(0);
    animation_->setEasingCurve(QEasingCurve::InOutQuad);
    animation_->setDuration(300);
    animation_->setEndValue(content->geometry().height() + 10);
    animator_.addAnimation(animation_);
    if (!isChecked()) {
      content->setMaximumHeight(0);
    }
  }

  void hideContent() {
    animator_.setDirection(QAbstractAnimation::Backward);
    animator_.start();
  }

  void showContent() {
    animator_.setDirection(QAbstractAnimation::Forward);
    animator_.start();
  }

private:
  QWidget *content_;
  QParallelAnimationGroup animator_;
};

In mainwindow.cpp:

MainWindow::MainWindow(QWidget *parent) :
     QMainWindow(parent),
     ui(new Ui::MainWindow) {
  ui->setupUi(this);
  ui->toolButton->setContent(ui->contentFrame);
}

Solution 9 - C++

The original question wanted to use the widget in Qt Designer, so here is a fork of @x squared's repo I made: https://github.com/seanngpack/qt-collapsible-section

It works with QT5, to make it work with Qt designer you just have to build and install the repo.

There's not much documentation on how to do this, so I may make a post later describing the process.

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionAdrian MaireView Question on Stackoverflow
Solution 1 - C++x squaredView Answer on Stackoverflow
Solution 2 - C++ErotemicView Answer on Stackoverflow
Solution 3 - C++CodingCatView Answer on Stackoverflow
Solution 4 - C++LoPiTaLView Answer on Stackoverflow
Solution 5 - C++santaView Answer on Stackoverflow
Solution 6 - C++Adrian MaireView Answer on Stackoverflow
Solution 7 - C++Ben DeMottView Answer on Stackoverflow
Solution 8 - C++simonxekoView Answer on Stackoverflow
Solution 9 - C++sean ng packView Answer on Stackoverflow