第 16 章

动画

本章共 5 个小节 · PySide6 Basic Tutorial
本章要点:
  1. 基于属性的动画;
  2. 关键帧动画;
  3. 动画分组。
16.1

与动画有关的类型

在某段时间内,可视化引擎与计时器协同工作,通过一些特定的算法,在两个值(如两个坐标点)之间插入过渡值,由于视觉延迟,人们会感觉到物体在运动,于是就形成了动画。在持续时间不变的情况下,计时器触发的频数越高(如每秒24次,每秒30次等),动画看起来就越流畅。

在Qt中,与动画有关的类,可以使用官方文档提供的继承关系图(如图16-1所示)来大致了解。

图 16-1
图16-1 动画相关类型的继承关系
图16-1 动画相关类型的继承关系
  1. QAbstractAnimation:所有动画类的抽象基类,仅实现了一些公共的方法,如放播和暂停动画。
  1. QVariantAnimation:内插值动画的基类。
  1. QPropertyAnimation:很常用的动画类,可让 Qt对象的属性产生动画。它是 QVariantAnimation的派生类。
  1. QAnimationGroup:抽象基类,它是一个容器类,用于将动画对象分组。并且分组可以嵌套,即动画分组中可以包含另一个分组,毕竟QAnimationGroup类也是从QAbstractAnimation类派生的。
  1. QSequentialAnimationGroup:动画分组类,该分组内的动画将按次序播放。例如,先启动动画A,动画A结束后B才能启动。
  1. QParallelAnimationGroup:动画分组类,分组内的动画列表可以同时启动,例如A、B、C动画可以同时启动。
16.2

基于属性的动画

属性动画只能应用于 Qt 对象—QObject 类或 QObject 的派生类。在实例化 QPropertyAnimation类时,可以使用以下构造函数:

def __init__( target: QObject, propertyName: QByteArray | bytes, parent: QObject | None = ... )

target参数指定要产生动画的 Qt 对象,propertyName指定要产生动画的属性名称,parent 参数引用父级 Qt 对象。

如果在调用构造函数时未指定目标对象和属性名称,还可以通过 setTargetObject和setPropertyName方法进行设置。

16.2.1 示例:简单的位移动画

本示例实现让标签组件的位置坐标产生动画—从A点移动到B点。具体步骤如下。

  1. 从 QWidget类派生出 DemoWindow类,作为示例程序的窗口。
class DemoWindow(QWidget): ......
  1. 实例化 QFrame 组件和 QLabel 组件,QLabel 组件位于 QFrame 组件内。
#面板 panel = QFrame(self) panel.setFrameShape(QFrame.Shape.Panel) #标签 lb = QLabel("Hello", panel) #设置标签组件的坐标 lb.move(15, 15)
  1. 实例化 QPropertyAnimation类(动画类)。
self.animat = QPropertyAnimation(lb, b"pos", self)

要让QLabel组件的坐标产生动画,要使用的属性应是pos。

  1. 设置动画的持续时间、初始值和终值。
#设置动画时长(毫秒) self.animat.setDuration(6 * 1000) #设置动画初始值 self.animat.setStartValue(QPoint(15, 15)) #设置动画终值 self.animat.setEndValue(QPoint(200, 200))

动画持续时间为6秒(单位是毫秒),坐标从点(15,15)移动到点(200,200)。

  1. 创建两个按钮,用于操作动画。
self.btnStart = QPushButton("开始", self) self.btnStop = QPushButton("停止", self) #连接按钮的clicked信号 self.btnStart.clicked.connect(self.onStart) self.btnStop.clicked.connect(self.onStop)
  1. 单击“开始”按钮后,开始运行动画(调用start方法启动动画)。
def onStart(self): #启动动画 self.animat.start() #禁用"开始"按钮 self.btnStart.setEnabled(False) #启用"停止"按钮 self.btnStop.setEnabled(True)
  1. 单击“停止”按钮停止正在运行的动画(调用 stop方法)。
def onStop(self): # 停止动画 self.animat.stop() # 启用"开始"按钮 self.btnStart.setEnabled(True) # 禁用"停止"按钮 self.btnStop.setEnabled(False)
  1. 动画结束后会发出finished信号。应用程序需要连接此信号,以便在动画结束后恢复两个按钮的状态。
#连接finished信号,在动画结束时发出 self.animat.finished.connect(self.onFinished) ...... def onFinished(self): #动画结束后,恢复按钮状态 self.btnStart.setEnabled(True) self.btnStop.setEnabled(False)

运行示例程序后,单击“开始”按钮激活动画,Hello标签开始移动,如图16-2所示。

Hello标签移动到(200,200)处会停止,动画结束,如图16-3所示。

图 16-2、图 16-3
图 16-2 Hello 标签开始移动 图 16-3 Hello标签已停止移动
图 16-2 Hello 标签开始移动
图 16-3 Hello标签已停止移动

16.2.2 示例:stateChanged 信号

当动画对象的状态发生改变后,会发出stateChanged信号。该信号包含两个参数——新状态和旧状态。动画对象的状态由QAbstractAnimation.State枚举表示,该枚举定义了3个值:Running表示动画正在运行,Paused表示动画已暂停,Stopped表示动画已停止。

本示例的代码将连接 stateChanged 信号,然后在 QLabel 组件中显示状态的变化。具体实现步骤如下。

  1. 从 QWidget 类派生出自定义窗口 MyWindow。
class MyWindow(QWidget): ......
  1. 本示例产生动画的目标是QFrame对象,先将其初始化。
def __init__(self): super().__init__() #初始化 QFrame 组件 self.frame = QFrame(self) #修改QFrame组件的背景色 self.frame.setFrameShape(QFrame.Shape.Box) palette = self.frame.palette() palette.setColor(QPalette.ColorRole.Window, QColor("Red")) self.frame.setPalette(palette) self.frame.setAutoFillBackground(True)
  1. 初始化4个 QPushButton 组件,用来控制动画的状态。
self.btnStart = QPushButton("开始", self) self.btnPause = QPushButton("暂停", self) self.btnResume = QPushButton("继续", self) self.btnStop = QPushButton("停止", self)
  1. 初始化 QLabel组件,用于显示状态信息。
self.lbmsg = QLabel(self) self.lbmsg.setText("准备就绪")
  1. 创建动画对象的实例。
#初始化 QPropertyAnimation 对象 self.animat = QPropertyAnimation(self) #设置目标对象 self.animat.setTargetObject(self.frame) #设置目标属性 self.animat.setPropertyName(b'size') #设置动画持续时间 self.animat.setDuration(5000) #设置初值 self.animat.setStartValue(QSize(60, 60)) #设置终值 self.animat.setEndValue(QSize(245, 180))

本示例的动画目标是QFrame组件,目标属性是size,因此初始值和最终值的类型是QSize。

  1. 分别连接4个按钮的clicked信号,用于改变动画对象的状态。
self.btnStart.clicked.connect(self.onStart) self.btnPause.clicked.connect(self.onPause) self.btnResume.clicked.connect(self.onResume) self.btnStop.clicked.connect(self.onStop) ...... def onStart(self): self.animat.start() def onPause(self): self.animat.pause() def onResume(self): self.animat.resume() def onStop(self): self.animat.stop()
  1. 连接动画对象的 stateChanged信号,当状态改变后显示文本提示。
self.animat.stateChanged.connect(self.onAnimatStateChanged) ...... def onAnimatStateChanged(self, newState: QAbstractAnimation.State, oldState: QAbstractAnimation.State): # 旧状态 _from = "正在运行" if oldState == QAbstractAnimation.State.Running else "已停止" if oldState == QAbstractAnimation.State.Stopped else "已暂停" if oldState == QAbstractAnimation.State.Paused else "未知" # 新状态 _to = "正在运行" if newState == QAbstractAnimation.State.Running else "已停止" if newState == QAbstractAnimation.State.Stopped else "已 暂停" if newState == QAbstractAnimation.State.Paused else "未知" # 组织消息文本 msg = f"状态变化:{_from} -> {_to}" # 更新 QLabel组件 self.lbmsg.setText(msg)

运行示例程序后,单击窗口上的控制按钮,窗口底部的标签会显示状态的变化,如图16-4所示。

图 16-4
图16-4 显示动画状态的变化
图16-4 显示动画状态的变化
16.3

自定义属性

在 Python中,可以通过property类来自定义属性成员,例如:

class Demo: def __init__(self): self._x = 0 self._y = 0 #读写X属性的方法 def getX(self): return self._x def setX(self, val): self._x = val #读写Y属性的方法 def getY(self): return self._y def setY(self, val): self._y = val #定义属性 X = property(getX, setX) Y = property(getY, setY)

上述代码中,Demo类具有X、Y属性。property类在定义属性时会绑定一些成员方法,最常用的是指定获取和设置属性值的方法。如上述代码中,读取X属性值的方法是getX,设置X属性值的方法是 setX。方法成员只要与property类的实例关联,在读写属性时会自动调用。

下面的代码演示了属性的使用。

obj = Demo() obj.x = 150 obj.Y = 230

当向X属性赋值150时,会调用 setX方法,并将150传递给val参数。同理,设置Y属性时也如此,setY方法被调用。当属性被读取时,getX、getY方法被调用并返回适当的值。

但是,在Qt对象中,只有通过Q_PROPERTY(C++宏)定义的属性才能产生动画。因此,如果自定义属性需要动画功能,就不能使用 Python的property 类来定义属性了,必须使用 QtCore模块公开的Property 类。该类的使用方法与 Python 中的 property 类相似,它可以等效于 Q_PROPERTY 宏。

于是,上文中提到的Demo类可以进行以下修改。

class Demo(QObject): def __init__(self, parent: QObject = None): super().__init__(parent) self._x = 0 self._y = 0 #读写X属性的方法 def getX(self): return self._x def setX(self, val): self._x = val #读写Y属性的方法 def getY(self): return self._y def setY(self, val): self._y = val #定义属性 X = Property(int, getX, setX) Y = Property(int, getY, setY)

Property构造函数的第一个参数是type,用于指定属性值的数据类型,上述代码中是int。type参数之后,还可以使用以下参数。

  1. fget:获取属性值的方法成员。
  1. fset:用于设置属性值的方法成员。
  1. freset:用于重置属性值的方法成员。主要用来将属性值还原为默认值。
  1. fdel:从Python的属性字典中删除属性时调用的方法成员。

下面的示例将演示对QColor类型的自定义属性进行动画处理。Rectangle类派生自QWidget,其中包含名为backgroundColor的属性,属性值存储在__bg字段中。完整的代码如下:

class Rectangle(QWidget): def __init__(self, parent: QWidget = None): super().__init__(parent) #存储属性值的字段 self.__bg = QColor("black") def getBgColor(self): return self.__bg def setBgColor(self, color: QColor): self.__bg = color self.update() def resetBgColor(self): self.__bg = QColor("black") self.update() #属性 backgroundColor = Property( type=QColor, fget=getBgColor, fset=setBgColor, freset=resetBgColor) def paintEvent(self, event: QPaintEvent): #获取矩形区域 rect = event.rect() #获取背景颜色 color = self.__bg #填充矩形区域 painter = QPainter() painter.begin(self) painter.fillRect(rect, color) painter.end()

由于 setBgColor 和 resetBgColor方法会修改属性的值,为了能及时触发paint事件以重新绘制界面,在向__bg 字段赋值后需要立即调用 update 方法。Rectangle 类的 paintEvent 方法实现了使用 backgroundColor属性设置的颜色填充矩形区域。

随后,可以在另一个窗口组件中使用Rectangle类,并对 backgroundColor属性进行动画处理。代码如下:

class MyWindow(QWidget): def __init__(self): super().__init__() #自定义组件 self.custWidg = Rectangle(self) #设置背景颜色 self.custWidg.backgroundColor = QColor("yellow") ...... #动画对象 self.animat = QPropertyAnimation(self) #动画时长 self.animat.setDuration(5600) #初值 self.animat.setStartValue(self.custWidg.backgroundColor) #终值 self.animat.setEndValue(QColor("red")) #目标属性 self.animat.setPropertyName(b"backgroundColor") #目标对象 self.animat.setTargetObject(self.custWidg)

动画过程是将Rectangle对象的背景颜色从黄色变为红色,如图16-5和图16-6所示。

图 16-5、图 16-6
图 16-5 动画正在过渡 图 16-6 动画已结束
图 16-5 动画正在过渡
图 16-6 动画已结束
16.4

关键帧动画

在动画过程中的关键位置插入的特定值,称为关键帧。而关键帧与关键帧之间的过渡动画就成了关键帧动画。简单动画有两个关键帧,即初始值和最终值,两者之间则通过专门的算法来产生过渡值。但对于较复杂的动画来说,可能要求在特定的时间点插入特定的值。

例如,对某组件T的pos属性进行动画处理时,假设初值为(0,0),终值为(200,240),持续时间为10秒。那么,对于简单动画而言,在整个动画周期内,只是把组件T从起点移动到终点,走的是直线路径。如果在时长的一半处(进度为0.5)插入一个值(90,45),那么,动画的前5秒是把组件T从(0,0)移动到(90,45)处;剩下的5秒则是把组件T从(90,45)移动到(200,240)处。如此一来,T的行进路线不再是单一的直线了。

QVariantAnimation类公开了两个可以设置关键帧的方法成员。

def setKeyValueAt( step: float, value: Any ) def setKeyValues( values: Sequence[Tuple[float, Any]] )

setKeyValueAt方法单次调用只插入一个关键帧,step参数的取值范围是[0.0,1.0],0.0是动画的开始时间,1.0则表示动画的结束时间。若 step参数的值为0.5,就表示动画时长的中间值。因此,step参数指定的是动画的相对进度。value参数则是要插入的值。

setKeyValues方法是一次性插入多个关键帧,每个关键帧由元组(Tuple)对象封装,其中包含两个元素—动画进度和要插入的值。

以下示例将让标签组件(QLabel)的pos属性(表示组件在父容器中的坐标)产生动画。代码如下:

#窗口 window = QWidget() window.setWindowTitle("关键帧动画") window.resize(350, 330) #标签组件 lb = QLabel(window) #设置图标 icon = QPixmap("truck.png") lb.setPixmap(icon) #默认坐标 lb.move(5, 5) #动画 animation = QPropertyAnimation(window) #持续时间 animation.setDuration(6000) #动画目标 animation.setTargetObject(lb) #目标属性 animation.setPropertyName(b"pos") #设置关键帧 animation.setKeyValueAt(0.0, QPoint(0, 0)) animation.setKeyValueAt(0.25, QPoint(70, 280)) animation.setKeyValueAt(0.5, QPoint(140, 60)) animation.setKeyValueAt(0.75, QPoint(210, 280)) animation.setKeyValueAt(1.0, QPoint(280, 0)) #循环次数 animation.setLoopCount(4) #显示窗口 window.show() #启动动画 animation.start()

在使用关键帧动画时,可以不设置初值和终值(不需要调用setStartValue、setEndValue方法),直接插入关键帧即可。上述代码依次在整个动画进度的起点、1/4处、1/2处、3/4处以及终点插入关键帧,将动画的总时长平均划分为4段,每个关键帧对应着一个坐标值。最终使QLabel组件沿着类似W的路径移动。

上述代码是在显示窗口后再调用start方法启动动画的。在此之前使用setLoopCount方法设置了动画的循环次数(示例中循环4次),动画进行到终点时会自动回到起点并重新启动。达到设定的循环次数后自动停止。

16.5

动画分组

在复杂应用场景下,将若干动画对象放进一个容器中,可以进行统一控制(开始、暂停、停止)。QAnimationGroup是动画分组的抽象基类,定义了一些通用成员。

  1. addAnimation:将动画对象添加到分组中。
  1. insertAnimation:将动画对象插入指定索引处。
  1. removeAnimation:从分组中删除指定的动画对象。
  1. takeAnimation:删除指定索引处的动画对象,并返回被删除的动画对象。
  1. clear:清空分组,即删除所有动画对象。
  1. animationCount:返回分组中的动画对象数量。
  1. animationAt:获取指定索引处的动画对象。

不要直接使用QAnimationGroup 类,应使用它的派生类:QSequentialAnimationGroup类或者QParallelAnimationGroup 类。

通过QSequentialAnimationGroup类分组的动画是按顺序运行的。假设分组中有A、B、C动画,在动画启动后,先运行A,A结束后才能运行B;同理,B结束后才能运行C。而 QParallelAnimationGroup分组中的动画可以并列运行,即A、B、C可以同时启动。

由于QAnimationGroup类派生自QAbstractAnimation类,所以一个分组可以添加到另一个分组中,可以通过 start、stop等方法来控制整个分组的动画。

16.5.1 示例:按顺序运行的动画分组

本示例将演示QSequentialAnimationGroup类的使用。示例分别对按钮组件的大小和位置进行动画处理。在QSequentialAnimationGroup分组中,当改变大小的动画结束后,改变位置的动画才能开始。

具体的实现步骤如下。

  1. 创建QWidget实例,作为应用程序的主窗口。
win = QWidget() win.setWindowTitle("Demo") win.resize(500, 500)
  1. 初始化 QPushButton 组件。
btn = QPushButton("单击这里开始", win) btn.move(10, 15)
  1. 初始化第一个动画对象,改变按钮的大小(属性类型为QSize)。
anim1 = QPropertyAnimation() #目标对象 anim1.setTargetObject(btn) #目标属性 anim1.setPropertyName(b"size") #时长 anim1.setDuration(5000) #初值 anim1.setStartValue(QSize(100, 36)) #终值 anim1.setEndValue(QSize(240, 100))
  1. 初始化第二个动画对象,改变按钮组件的位置(属性类型为QPoint)。
anim2 = QPropertyAnimation() #总时长 anim2.setDuration(6000) #目标属性 anim2.setPropertyName(b"pos") #目标对象 anim2.setTargetObject(btn) #初值 anim2.setStartValue(QPoint(10, 10)) #终值 anim2.setEndValue(QPoint(250, 370))
  1. 实例化 QSequentialAnimationGroup分组对象,然后将两个动画添加到分组中。
group = QSequentialAnimationGroup(win) #将动画对象添加到分组中 group.addAnimation(anim1) group.addAnimation(anim2)
  1. 将按钮的 clicked信号与动画分组的 start方法连接,实现单击按钮启动动画的功能。
btn.clicked.connect(group.start)

运行示例程序后,单击按钮。动画分组启动后,先对按钮的大小进行动画处理,如图16-7所示。随后处理按钮的位置变化,如图16-8所示。

图 16-7、图 16-8
图16-7 动画改变按钮的大小 图16-8 动画改变按钮的位置
图16-7 动画改变按钮的大小
图16-8 动画改变按钮的位置

16.5.2 示例:并行的动画分组

本示例将通过QParallelAnimationGroup类实现同时改变按钮组件(QPushButton)的大小和位置。具体实现步骤如下。

  1. 创建QWidget实例,作为示例程序的主窗口。
wind = QWidget() wind.setWindowTitle("Demo") wind.resize(500, 500)
  1. 实例化 QPushButton 组件。
btn = QPushButton("单击这里开始动画", wind)
  1. 创建第一个动画,改变按钮组件的大小。
animat1 = QPropertyAnimation(wind) #目标对象 animat1.setTargetObject(btn) #目标属性 animat1.setPropertyName(b"size") #总时长 animat1.setDuration(3500) #初值和终值 animat1.setStartValue(QSize(100, 36)) animat1.setEndValue(QSize(230, 100))
  1. 创建第二个动画,用于改变按钮组件的位置。
animat2 = QPropertyAnimation(wind) #总时长 animat2.setDuration(5000) #目标对象 animat2.setTargetObject(btn) #目标属性 animat2.setPropertyName(b"pos") #初值和终值 animat2.setStartValue(QPoint(10, 10)) animat2.setEndValue(QPoint(275, 400))
  1. 实例化QParallelAnimationGroup类,作为动画对象的容器。
group = QParallelAnimationGroup(wind)
  1. 将两个动画对象添加到动画分组中。
group.addAnimation(animat1) group.addAnimation(animat2)
  1. 将按钮组件的 clicked信号与动画分组的start方法连接。只要按钮被单击就会启动动画。
btn.clicked.connect(group.start)

运行示例程序后,单击按钮开始播放动画。此时按钮的大小和位置是同时改变的,如图16-9与图 16-10 所示。

图 16-9、图 16-10
图 16-9 动画启动前 图16-10 动画开始后按钮的大小和位置同步改变
图 16-9 动画启动前
图16-10 动画开始后按钮的大小和位置同步改变