pyqt5和QToolBox中的动画效果python

Animation effect in a QToolBox in pyqt5 and python

QToolboxes可以设置动画效果吗?我有 1 个有 2 页的工具箱,我想为两者之间的切换设置动画。 (上下滑动) 使用 currentIndex 我只能在 0 和 1 之间切换,因此无法使用 QPropertyanimation 制作动画。 还有其他方法吗?

QToolBox 使用 QVBoxLayout 来显示其内容,添加一个新项目实际上会创建两个小部件并将它们添加到该布局:

  • 一个自定义的QAbstractButton,用于显示每个页面的标题并激活它;
  • 包含实际小部件的 QScrollArea;

一种可能性是创建一个动画来更新以前和新项目的 最大 大小,但是有一个问题:按钮连接到最终调用的内部函数setCurrentIndex(),但由于这不是一个 虚拟 函数,它不能被覆盖,所以理论上不可能获得鼠标点击和拦截。

幸运的是,QToolBox 提供了一个有用的函数可以帮助我们:每当添加新项目时调用itemInserted()。通过覆盖它,我们不仅可以获得对所有新小部件的引用,还可以断开按钮的 clicked 信号,并将其连接到我们自己实现的 setCurrentIndex.

为了简化动画过程,我创建了一个助手 class,它保留对按钮、滚动区域和实际小部件的引用,并提供控制滚动区域高度和正确配置的函数它基于他们的 current/future 状态。

现在,动画使用从 0.0 到 1.0 的插值 QVariantAnimation 工作,然后使用该比率根据 target 高度计算两个小部件之间的比例。该高度是从工具箱的当前高度开始计算的,然后我们减去第一个按钮的大小提示(和布局间距)乘以页数。通过这种方式,我们随时知道滚动区域的目标高度是多少,即使在动画期间调整 window 的大小时也是如此。
每当添加和删除项目时都会调用更新该高度的函数,但也会在调整工具箱大小时调用。

在动画开始时计算尺寸需要特别小心:默认情况下,布局基于其最小尺寸考虑其小部件的最小尺寸提示,并且滚动区域始终具有最小高度 提示 的 72 个像素。默认情况下,QToolBox 通过隐藏前一项并显示新项来立即在页面之间切换,因此大小提示未更改,但由于我们要同时显示 两个 滚动区域在动画期间,这将迫使工具箱增加其最小尺寸。解决方案是强制两个滚动区域的最小尺寸为 1,这样 minimumSizeHint 将被忽略。
此外,由于显示小部件会增加间距,因此我们需要将隐藏滚动区域的大小减小该数量,直到新的滚动区域高于间距。

这一切的唯一问题是为了防止计算过于复杂,在动画期间点击另一个页面必须忽略,但如果动画足够短,那应该不是什么大问题。

from PyQt5 import QtCore, QtWidgets
from functools import cached_property

class ToolBoxPage(QtCore.QObject):
    destroyed = QtCore.pyqtSignal()
    def __init__(self, button, scrollArea):
        super().__init__()
        self.button = button
        self.scrollArea = scrollArea
        self.widget = scrollArea.widget()
        self.widget.destroyed.connect(self.destroyed)

    def beginHide(self, spacing):
        self.scrollArea.setMinimumHeight(1)
        # remove the layout spacing as showing the new widget will increment
        # the layout size hint requirement
        self.scrollArea.setMaximumHeight(self.scrollArea.height() - spacing)
        # force the scroll bar off if it's not visible before hiding
        if not self.scrollArea.verticalScrollBar().isVisible():
            self.scrollArea.setVerticalScrollBarPolicy(
                QtCore.Qt.ScrollBarAlwaysOff)

    def beginShow(self, targetHeight):
        if self.scrollArea.widget().minimumSizeHint().height() <= targetHeight:
            # force the scroll bar off it will *probably* not required when the
            # widget will be shown
            self.scrollArea.setVerticalScrollBarPolicy(
                QtCore.Qt.ScrollBarAlwaysOff)
        else:
            # the widget will need a scroll bar, but we don't know when;
            # we will show it anyway, even if it's a bit ugly
            self.scrollArea.setVerticalScrollBarPolicy(
                QtCore.Qt.ScrollBarAsNeeded)
        self.scrollArea.setMaximumHeight(0)
        self.scrollArea.show()

    def setHeight(self, height):
        if height and not self.scrollArea.minimumHeight():
            # prevent the layout considering the minimumSizeHint
            self.scrollArea.setMinimumHeight(1)
        self.scrollArea.setMaximumHeight(height)

    def finalize(self):
        # reset the min/max height and the scroll bar policy
        self.scrollArea.setMinimumHeight(0)
        self.scrollArea.setMaximumHeight(16777215)
        self.scrollArea.setVerticalScrollBarPolicy(
            QtCore.Qt.ScrollBarAsNeeded)


class AnimatedToolBox(QtWidgets.QToolBox):
    _oldPage = _newPage = None
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._pages = []

    @cached_property
    def animation(self):
        animation = QtCore.QVariantAnimation(self)
        animation.setDuration(250)
        animation.setStartValue(0.)
        animation.setEndValue(1.)
        animation.valueChanged.connect(self._updateSizes)
        return animation

    @QtCore.pyqtProperty(int)
    def animationDuration(self):
        return self.animation.duration()

    @animationDuration.setter
    def animationDuration(self, duration):
        self.animation.setDuration(max(50, min(duration, 500)))

    @QtCore.pyqtSlot(int)
    @QtCore.pyqtSlot(int, bool)
    def setCurrentIndex(self, index, now=False):
        if self.currentIndex() == index:
            return
        if now:
            if self.animation.state():
                self.animation.stop()
                self._pages[index].finalize()
            super().setCurrentIndex(index)
            return
        elif self.animation.state():
            return
        self._oldPage = self._pages[self.currentIndex()]
        self._oldPage.beginHide(self.layout().spacing())
        self._newPage = self._pages[index]
        self._newPage.beginShow(self._targetSize)
        self.animation.start()

    @QtCore.pyqtSlot(QtWidgets.QWidget)
    @QtCore.pyqtSlot(QtWidgets.QWidget, bool)
    def setCurrentWidget(self, widget):
        for i, page in enumerate(self._pages):
            if page.widget == widget:
                self.setCurrentIndex(i)
                return

    def _index(self, page):
        return self._pages.index(page)

    def _updateSizes(self, ratio):
        if self.animation.currentTime() < self.animation.duration():
            newSize = round(self._targetSize * ratio)
            oldSize = self._targetSize - newSize
            if newSize < self.layout().spacing():
                oldSize -= self.layout().spacing()
            self._oldPage.setHeight(max(0, oldSize))
            self._newPage.setHeight(newSize)
        else:
            super().setCurrentIndex(self._index(self._newPage))
            self._oldPage.finalize()
            self._newPage.finalize()

    def _computeTargetSize(self):
        if not self.count():
            self._targetSize = 0
            return
        l, t, r, b = self.getContentsMargins()
        baseHeight = (self._pages[0].button.sizeHint().height()
            + self.layout().spacing())
        self._targetSize = self.height() - t - b - baseHeight * self.count()

    def _buttonClicked(self):
        button = self.sender()
        for i, page in enumerate(self._pages):
            if page.button == button:
                self.setCurrentIndex(i)
                return

    def _widgetDestroyed(self):
        self._pages.remove(self.sender())

    def itemInserted(self, index):
        button = self.layout().itemAt(index * 2).widget()
        button.clicked.disconnect()
        button.clicked.connect(self._buttonClicked)
        scrollArea = self.layout().itemAt(index * 2 + 1).widget()
        page = ToolBoxPage(button, scrollArea)
        self._pages.insert(index, page)
        page.destroyed.connect(self._widgetDestroyed)
        self._computeTargetSize()

    def itemRemoved(self, index):
        if self.animation.state() and self._index(self._newPage) == index:
            self.animation.stop()
        page = self._pages.pop(index)
        page.destroyed.disconnect(self._widgetDestroyed)
        self._computeTargetSize()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        self._computeTargetSize()


if __name__ == '__main__':
    import sys
    app = QtWidgets.QApplication(sys.argv)
    toolBox = AnimatedToolBox()
    for i in range(8):
        container = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout(container)
        for b in range((i + 1) * 2):
            layout.addWidget(QtWidgets.QPushButton('Button {}'.format(b + 1)))
        layout.addStretch()
        toolBox.addItem(container, 'Box {}'.format(i + 1))
    toolBox.show()
    sys.exit(app.exec())