如何在Qt中制作可展开/收缩的部件小部件

76

我想在Qt中创建一个自定义窗口小部件,具有以下特点:

  • 它是一个容器
  • 它可以填充任何Qt布局
  • 它可以放置在任何Qt布局内
  • 一个按钮允许垂直折叠/收起内容,只显示按钮,所有包含的布局都不可见。
  • 上一个按钮允许再次展开/展开到布局内容的大小。
  • 扩展/折叠基于尺寸(而不是显示/隐藏),以允许动画效果。
  • 可在QDesigner中使用

为了提供一个思路,这是一个类似的小部件的图片(不是Qt): enter image description here

我已经有一个可以在QDesigner中正常工作且已公开的框架。现在我需要使它可以展开/折叠,这似乎并不简单。

我尝试了resize()、sizePolicy()和sizeHint(),但没有成功: 当框架被折叠时,我得到以下值:

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

QLayout::closestAcceptableSize不属于小部件,因此我无法更改它。

有任何提示或/和代码片段来实现这一点吗?

编辑: 这里有一个简单的例子。我删除了所有除必要部分外的内容。

main.cpp示例

#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
9个回答

80
我遇到了同样的问题,并通过将可折叠小部件实现为 QScrollArea,该小部件的最大高度由 QPropertyAnimation 动态动画化来解决它。
但由于我不使用 QDesigner,所以我不能告诉您它是否在那里正常工作。
我仍然有一个问题:可折叠小部件不仅可以向下扩展,还可以向上和向下扩展。如果位于其上方的小部件尚未达到其最小高度,则可能会导致它们缩小。但与我们必须自己构建这个东西相比,这真的是一个细节... 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);
}

如何使用它:

…
auto * anyLayout = new QVBoxLayout();
anyLayout->addWidget(…);
…
Spoiler spoiler;
spoiler.setContentLayout(*anyLayout);
…

Spoiler example


1
当然,继续吧 :) - x squared
2
@IceFire 使用这段代码,当动画执行时,强制父部件更新其大小的最佳方法是什么? - manatttta
2
对于仍在参考此答案的任何人。我已将点击信号切换为切换信号,以允许外部切换展开/折叠按钮。这对我很有用,因此我可以实现全部展开/全部折叠功能。 - ricekab
1
不确定为什么评论消失了,但这是IceFire存储库的链接: https://github.com/Elypson/qt-collapsible-section - x squared
1
我已经编辑了这个项目以支持pyside6和setup.py https://github.com/eyalk11/qt-collapsible-section 。有一些bug需要我忽略border:none。 - user2679290
显示剩余14条评论

37

即使这是老帖子,但我发现这个帖子很有用。不过,我正在使用Python,所以我必须将C++代码转换为Python代码。 如果有人在寻找x squared解决方案的Python版本,这是我的移植:

from PyQt4 import QtCore, QtGui


class Spoiler(QtGui.QWidget):
    def __init__(self, parent=None, title='', animationDuration=300):
        """
        References:
            # Adapted from c++ version
            https://dev59.com/TVwY5IYBdhLWcg3wkIci
        """
        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)

2
就像我之前评论的那样:将点击信号切换到切换信号可以使展开/折叠在按钮外部进行切换。这对我很有用,因为我可以实现全部展开/全部折叠的功能。 - ricekab

14

我已经仔细研究了@LoPiTal提供的指针,并将其转换为PyQt5(Python3)。我认为它非常优雅。

如果有人正在寻找PyQt解决方案,这是我的代码:

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

这个解决方案相当复杂,根据人们的要求,我并不真正推荐它。 - JokerMartini

8

2
非常优雅,非常感谢指针!我已经将它转换为PyQt5,并将代码作为单独的答案添加。希望最终能帮助到其他人。 - CodingCat
我谷歌搜索了“qt-designer widget-box source”,并找到了这个链接。假设这是真正的源代码,它显示Widget Box实际上是作为QTreeView实现的,类似于您提供的示例。 - Boon
@Boon 是的,根据您提供的链接,您可以在 widget-box 的背后找到树形小部件的实际实现 此处。结合自定义的 item delegate,您可以隐藏默认的树形小部件外观。这就是 Qt 的方式 :D - LoPiTaL
网站已经崩溃了,这是一个存档链接到代码:https://web.archive.org/web/20230604043944/https://blog.fancyaddress.com/qt-2/create-something-like-the-widget-box-as-in-the-qt-designer/这是一个在互联网存档中的截图:https://web.archive.org/web/20190707122905/http://www.fancyaddress.com/blog/wp-content/uploads/2013/10/25u5yzr.png - undefined

5

受 @x squared 的启发,此版本为 Qt Designer 设计,可用于折叠内容。使用时,将 QToolButton 提升并指定一个 QFrame 以折叠其内容。

预览

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_;
};

在mainwindow.cpp中:

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

谢谢,对我的项目有帮助。 - The_Average_Engineer

4
使用 PySide2(Python3 的官方 Qt5 绑定库)来贡献一个版本。
from PySide2 import QtCore, QtGui, QtWidgets

class Expander(QtWidgets.QWidget):
    def __init__(self, parent=None, title='', animationDuration=300):
        """
        References:
            # Adapted from PyQt4 version
            https://dev59.com/TVwY5IYBdhLWcg3wkIci#37927256
            # Adapted from c++ version
            https://dev59.com/TVwY5IYBdhLWcg3wkIci#37119983
        """
        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)

3
我建立了一个使用Python3/Qt5风格的示例来测试我正在编写的StyleSheet类。我还修复了一个大小计算问题,该问题没有考虑到扩展器按钮的大小更改。
我还将方法更改为setLayout(),以与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_())

1

原始问题想要在Qt Designer中使用小部件,因此这是我制作的@x squared存储库的分支:https://github.com/seanngpack/qt-collapsible-section

它适用于QT5,要使其与Qt Designer配合使用,您只需构建和安装存储库即可。

关于如何做到这一点没有太多文档,因此我可能会稍后发布一篇描述该过程的文章。


1
我采用的解决方案是使用小部件的MaximumSize属性来限制折叠时的高度。
最大的问题是要知道折叠时未展开的高度,以允许正确的动画步骤。这个问题还没有得到解决,目前我使用固定高度步长进行动画(我将其设置为与窗口预期高度相关的适当值)。
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.
}

不确定这是否符合您的上下文,我最终使用了 widget.setVisible(not widget.isVisible()) 而不是搞弄大小计算。 - Jason

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接