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