使用 PyQt 实现音乐歌单卡片交互效果:从布局到动画的完整解析

在音乐类应用中,歌单卡片的视觉效果和交互体验至关重要。本文将基于 PyQt 框架,详细讲解如何实现一个带有悬停动画、圆角裁剪、遮罩效果的音乐歌单组件,涵盖布局设计、图形绘制、动画实现等核心技术点。

一、效果演示

最终实现的歌单卡片具备以下交互效果:

  1. 基础布局:由图片区域和描述区域组成,图片区域顶部圆角,描述区域底部圆角,整体形成「胶囊」状视觉效果。
  2. 悬停交互:
    • 卡片向上轻微跳动,伴随弹性缓动动画。
    • 半透明遮罩层渐显,覆盖整个卡片,中央出现播放按钮。
    • 描述区域背景色根据图片主题色动态变化,增强视觉关联性。

具体效果如下:

二、布局构造:分层设计实现视觉结构

核心组件 playlistCover 采用 三层嵌套布局,结构如下:

1. 根容器(QWidget)

  • 设置 WA_TranslucentBackground 属性实现透明背景,配合 FramelessWindowHint 去除边框。
  • 使用 QVBoxLayout 垂直排列子组件,边距和间距均为 0,确保紧密贴合。

2. 内容层

  • 图片区域(RoundedLabel):继承自 QLabel,实现顶部圆角裁剪(后文详细解析),固定尺寸 200x200,开启 scaledContents 自动缩放图片。
  • 描述区域(QLabel):固定高度 80px,设置 wordWrap 自动换行,通过样式表配置字体、颜色和底部圆角(border-bottom-left-radius/border-bottom-right-radius)。

3. 遮罩层(QWidget)

  • 初始隐藏,尺寸与内容层一致,覆盖图片和描述区域。
  • 内部使用 QVBoxLayout 居中放置播放按钮(RoundToolButton),按钮图标为 Fluent Design 风格的播放图标。
  • 通过 QGraphicsOpacityEffect 控制透明度,实现渐显 / 渐隐动画。
1
2
3
4
5
6
7
8
# 关键布局代码
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.coverLabel)
self.layout.addWidget(self.despLabel)

self.mask_layer = QWidget(self)
self.mask_layout = QVBoxLayout(self.mask_layer)
self.mask_layout.addWidget(self.playButton)

三、圆角构造:图片与描述的视觉衔接

1. 图片区域:顶部圆角裁剪(自定义绘制)

通过子类化 QLabel 实现 RoundedLabel,重写 setPixmap 方法,使用 QPainterPath 裁剪图片顶部两角:

  • 路径绘制逻辑

    • 从底部左侧开始,绘制左侧边到顶部圆角起点。
    • 使用 arcTo 绘制左上和右上圆角(半径可配置)。
    • 连接右侧边到底部,形成仅顶部圆角的闭合路径。
  • 抗锯齿优化:开启 QPainter.Antialiasing,确保边缘平滑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 圆角裁剪核心代码
def _rounded_top_corners(self, pixmap, radius):
result = QPixmap(size)
result.fill(Qt.transparent)
painter = QPainter(result)
painter.setRenderHint(QPainter.Antialiasing)

path = QPainterPath()
w, h = size.width(), size.height()
path.moveTo(0, h)
path.lineTo(0, radius)
path.arcTo(0, 0, 2*radius, 2*radius, 180, -90) # 左上圆角
path.lineTo(w - radius, 0)
path.arcTo(w - 2*radius, 0, 2*radius, 2*radius, 90, -90) # 右上圆角
path.lineTo(w, h)
path.closeSubpath()

painter.setClipPath(path)
painter.drawPixmap(0, 0, pixmap)
return result

2. 描述区域:底部圆角(样式表实现)

通过样式表直接配置底部两角圆角,与图片区域的顶部圆角形成对称:

1
2
3
4
5
QLabel#despLabel {
background-color: rgb(r, g, b); /* 动态主题色 */
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
}

四、遮罩原理:半透明层与视觉聚焦

遮罩层本质是一个 半透明黑色背景的容器,作用是:

  1. 视觉分层:通过 rgba(0, 0, 0, 0.6) 背景色模糊下方内容,突出播放按钮。
  2. 交互区域:覆盖整个卡片,确保鼠标事件触发时响应区域足够大。
  3. 圆角一致性:通过样式表设置全角圆角(与描述区域底部圆角呼应),实现整体视觉统一:
1
2
3
4
QWidget#maskLayer {
background-color: rgba(0, 0, 0, 0.6);
border-radius: 15px; /* 与描述区域底部圆角半径一致 建议初始化radius变量,方便维护*/
}

五、遮罩动画:渐显渐隐的平滑过渡

使用 QPropertyAnimation 控制遮罩层的透明度,实现「鼠标进入时渐显,离开时渐隐」的效果:

  • 进入动画:透明度从 0 到 1,持续 300ms,缓动曲线为 OutQuad(快进慢出)。
  • 离开动画:透明度从 1 到 0,持续 300ms,缓动曲线为 InQuad(慢进快出)。
  • 事件触发:在 enterEventleaveEvent 中启动动画,并控制遮罩层的显隐状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 动画初始化
self.enter_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
self.enter_animation.setDuration(300)
self.enter_animation.setStartValue(0.0)
self.enter_animation.setEndValue(1)

self.leave_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
self.leave_animation.setDuration(300)
self.leave_animation.setStartValue(1)
self.leave_animation.setEndValue(0.0)

# 事件触发
def enterEvent(self, event):
self.mask_layer.show()
self.enter_animation.start()
super().enterEvent(event)

def leaveEvent(self, event):
self.leave_animation.start()
super().leaveEvent(event)

六、跳动动画:弹性位移增强交互反馈

卡片悬停时的上下跳动通过 位置动画 实现,核心逻辑:

  1. 位移控制:定义 animation_offset 为 20px,鼠标进入时向上移动(pos - QPoint(0, offset)),离开时返回原始位置。
  2. 缓动曲线:使用 OutCubic 曲线(流畅的弹性效果),区别于线性动画的生硬感。
  3. 动画方向:通过 QAbstractAnimation.ForwardBackward 控制正向 / 反向动画,避免重复创建动画对象。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 跳动动画关键代码
self.hover_animation = QPropertyAnimation(self, b"pos")
self.hover_animation.setDuration(300)
self.hover_animation.setEasingCurve(QEasingCurve.OutCubic) # 推荐曲线

def enterEvent(self, event):
start_pos = self.pos()
end_pos = start_pos - QPoint(0, self.animation_offset)
self.hover_animation.setStartValue(start_pos)
self.hover_animation.setEndValue(end_pos)
self.hover_animation.start()
...

def leaveEvent(self, event):
current_pos = self.pos()
self.hover_animation.setStartValue(current_pos)
self.hover_animation.setEndValue(self.original_pos)
self.hover_animation.start()
...

七、细节优化:播放按钮与主题色适配

1. 播放按钮设置

使用 qfluentwidgetspro 中的 RoundToolButton,具备天然圆角和点击涟漪效果:

  • 图标设置为 FluentIcon.PLAY,图标尺寸 20x20,按钮最小尺寸 60x60,确保触控友好。
  • 布局上通过 QVBoxLayout 居中,实现遮罩层内的垂直水平居中。

2. 动态主题色

通过 Theme_Color.getThemeColor 方法提取图片主色调,应用于描述区域背景色,增强视觉一致性:

1
2
3
self._themecolor = Theme_Color.getThemeColor(self._imagepath)
r, g, b = self._themecolor
self.despLabel.setStyleSheet(f"background-color: rgb({r},{g},{b});")

主题色的代码请参考:如何使用python提取图片主题色 | 怪兽马尔克

八、总结与扩展

本文通过 PyQt 的自定义绘制、动画系统和样式表,实现了一个具备完整交互效果的音乐歌单卡片。核心技术点包括:

  • 图形裁剪:使用 QPainterPath 实现非矩形区域绘制。
  • 动画设计:组合透明度动画和位置动画,配合缓动曲线提升体验。
  • 分层架构:通过多层容器分离视觉和交互逻辑,便于维护和扩展。

可扩展方向:

  1. 响应式布局:支持卡片尺寸动态调整,适配不同屏幕分辨率。
  2. 触摸交互:添加点击事件处理(clicked 信号),跳转歌单详情页。
  3. 性能优化:使用缓存机制避免重复图片裁剪,或引入异步加载处理大图片。

通过合理运用 PyQt 的图形和动画模块,开发者可以轻松实现复杂的交互效果,为桌面应用赋予现代 UI 体验。完整代码已在文末提供,欢迎下载调试!

(注:本文代码基于 PyQt 5 和 qfluentwidgets 库,需提前安装依赖:pip install pyqt5 qfluentwidgets

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import os
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QWidget, QVBoxLayout, QHBoxLayout
from PyQt5.QtGui import QPixmap, QPainterPath, QPainter, QMouseEvent, QIcon
from PyQt5.QtCore import Qt, QPropertyAnimation, QEasingCurve, pyqtSignal, QSize, QAbstractAnimation, QPoint

from PyQt5.QtWidgets import QGraphicsOpacityEffect

from qfluentwidgets import FluentIcon, ToolButton

from NetEase.common.ThemeColor import Theme_Color


class RoundedLabel(QLabel):
"""
QLabel subclass that rounds the top-left and top-right corners of the pixmap.
"""

def __init__(self, parent=None, radius=10):
super().__init__(parent)
self._radius = radius
self._original_pixmap = None

@property
def radius(self):
return self._radius

@radius.setter
def radius(self, value):
self._radius = value
# reapply rounding if a pixmap is already set
if self._original_pixmap:
self.setPixmap(self._original_pixmap)

def setPixmap(self, pixmap):
"""
Overrides QLabel.setPixmap to apply rounding on the top corners.
"""
if not isinstance(pixmap, QPixmap):
super().setPixmap(pixmap)
return

self._original_pixmap = pixmap
rounded = self._rounded_top_corners(pixmap, self._radius)
super().setPixmap(rounded)

def _rounded_top_corners(self, pixmap, radius):
"""
Returns a new QPixmap with only the top-left and top-right
corners rounded by the given radius.
"""
size = pixmap.size()
result = QPixmap(size)
result.fill(Qt.transparent)

painter = QPainter(result)
painter.setRenderHint(QPainter.Antialiasing)

# Build a path rounding only the top corners
path = QPainterPath()
w, h = size.width(), size.height()
r = radius

# Start from bottom-left, go counter-clockwise
path.moveTo(0, h)
path.lineTo(0, r)
path.arcTo(0, 0, 2 * r, 2 * r, 180, -90)
path.lineTo(w - r, 0)
path.arcTo(w - 2 * r, 0, 2 * r, 2 * r, 90, -90)
path.lineTo(w, h)
path.closeSubpath()

painter.setClipPath(path)
painter.drawPixmap(0, 0, pixmap)
painter.end()

return result


class RoundedToolButton(ToolButton):

def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("""
ToolButton {
color: black;
border-radius: 30px;
background: transparent;
outline: none;
}

ToolButton:disabled {
color: rgba(0, 0, 0, 0.36);
background: rgba(249, 249, 249, 0.3);
border: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
""")
self.normal_icon = QIcon()
self.hover_icon = QIcon()

def setIcon(self, icon):
if isinstance(icon, str):
# 处理图标路径字符串
self.normal_icon = QIcon(icon)
base, ext = os.path.splitext(icon)
hover_path = f"{base}-hover{ext}"
if os.path.exists(hover_path):
self.hover_icon = QIcon(hover_path)
else:
self.hover_icon = self.normal_icon
else:
self.normal_icon = icon
self.hover_icon = icon

super().setIcon(self.normal_icon)

def setHoverIcon(self, icon):
self.hover_icon = icon

def enterEvent(self, event):
super().enterEvent(event)
if not self.hover_icon.isNull():
super().setIcon(self.hover_icon)

def leaveEvent(self, event):
super().leaveEvent(event)
super().setIcon(self.normal_icon)


class playlistCover(QWidget):
played = pyqtSignal()
clicked = pyqtSignal()

def __init__(self, image_path, parent=None):
super().__init__(parent=parent)

self._imagepath = image_path
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowFlag(Qt.FramelessWindowHint)
self.setCursor(Qt.PointingHandCursor)

self.layout = QVBoxLayout(self)

self.coverLabel = RoundedLabel(self)
self.coverLabel.setObjectName("coverLabel")
self.coverLabel.setMinimumSize(180, 180)
self.coverLabel.setScaledContents(True)
self.coverLabel.setPixmap(
QPixmap(self._imagepath).scaled(180, 180, Qt.IgnoreAspectRatio, Qt.SmoothTransformation))

self.despLabel = QLabel(self)
self.despLabel.setObjectName("despLabel")
self.despLabel.setText("你是我的单曲循环 我是你的随机播放")
self.despLabel.setWordWrap(True)
self.despLabel.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.despLabel.setFixedHeight(60)

self.layout.addWidget(self.coverLabel)
self.layout.addWidget(self.despLabel)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.setSpacing(0)
self.setLayout(self.layout)

self.mask_layer = QWidget(self)
self.mask_layer.setObjectName("maskLayer")
self.mask_layer.resize(self.coverLabel.width(), self.coverLabel.height() + self.despLabel.height())
self.mask_layer.setVisible(False)

self.listenCountLabel = QLabel(self)
self.listenCountLabel.setObjectName("listenCountLabel")
self.listenCountLabel.setText("🎧️ 167.6万")
self.listenCountLabel.move(self.coverLabel.width() - 90, self.coverLabel.y() + 10)

self.mask_layout = QVBoxLayout(self.mask_layer)
self.mask_layout.setAlignment(Qt.AlignCenter)
self.playButton = RoundedToolButton(self.mask_layer)
self.playButton.setIcon("./resource/play.svg")
self.playButton.setMinimumSize(60, 60)
self.playButton.setIconSize(QSize(60, 60))
self.mask_layout.addWidget(self.playButton)

self.opacity_effect = QGraphicsOpacityEffect(self.mask_layer)
self.mask_layer.setGraphicsEffect(self.opacity_effect)
self.mask_layer.move(self.x(), self.y())

self._themecolor = Theme_Color.getThemeColor(self._imagepath)
r, g, b = self._themecolor
self.setStyleSheet(f"""
QWidget#maskLayer {{
background-color: rgba(0, 0, 0, 0.4);
border-radius: 15px;
}}
QLabel#despLabel {{
background-color: rgb({r},{g},{b});
font-family: Microsoft YaHei;
font-size: 15px;
color: #ffffff;
padding-top: 5px;
padding-left: 5px;
padding-right: 5px;
border-bottom-left-radius: 15px; /* 左下角圆角 */
border-bottom-right-radius: 15px; /* 右下角圆角 */
}}
QLabel#listenCountLabel {{
background-color: transparent;
font-family: Microsoft YaHei;
font-size: 15px;
font-weight: bold;
color: #ffffff;
}}
""")

self.enter_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
self.enter_animation.setDuration(300)
self.enter_animation.setStartValue(0.0)
self.enter_animation.setEndValue(1)
self.enter_animation.setEasingCurve(QEasingCurve.OutQuad)

self.leave_animation = QPropertyAnimation(self.opacity_effect, b"opacity")
self.leave_animation.setDuration(300)
self.leave_animation.setStartValue(1)
self.leave_animation.setEndValue(0.0)
self.leave_animation.setEasingCurve(QEasingCurve.InQuad)

self.animation_offset = 20
self.original_pos = self.pos()

self.pos_animation = QPropertyAnimation(self, b"pos")
self.pos_animation.setDuration(300)
# self.pos_animation.setEasingCurve(QEasingCurve.OutBack) # 回弹效果
self.pos_animation.setEasingCurve(QEasingCurve.OutBounce) # 弹跳效果
self.__connectSignalToSlot()

def __connectSignalToSlot(self):
self.playButton.clicked.connect(lambda: self.played.emit())

def resizeEvent(self, event):
super().resizeEvent(event)
self.mask_layer.setGeometry(
self.coverLabel.x(),
self.coverLabel.y(),
self.coverLabel.width(),
self.coverLabel.height() + self.despLabel.height()
)
self.listenCountLabel.move(self.coverLabel.width() - 90, self.coverLabel.y() + 10)

def mousePressEvent(self, event: QMouseEvent):
self.clicked.emit()
super().mousePressEvent(event)

def enterEvent(self, event):
self.original_pos = self.pos()

# 上升动画
self.pos_animation.stop()
start = self.original_pos
end = start - QPoint(0, self.animation_offset)
self.pos_animation.setStartValue(start)
self.pos_animation.setEndValue(end)
self.pos_animation.start()

self.enter_animation.start()
self.mask_layer.show()
self.listenCountLabel.hide()
super().enterEvent(event)

def leaveEvent(self, event):
current = self.pos()
self.pos_animation.stop()
self.pos_animation.setStartValue(current)
self.pos_animation.setEndValue(self.original_pos)
self.pos_animation.start()

self.leave_animation.start()
self.listenCountLabel.show()
super().leaveEvent(event)

def moveEvent(self, event):
# 当组件被移动时更新原始位置
if self.pos_animation.state() == QAbstractAnimation.Stopped:
self.original_pos = self.pos()
super().moveEvent(event)


class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.init_ui()

def init_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QHBoxLayout(central_widget)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(20)

# 创建带图片的标签
image_label = playlistCover("./resource/test.jpg")
image_label_2 = playlistCover("./resource/test2.jpg")
image_label_3 = playlistCover("./resource/test3.jpg")
image_label_4 = playlistCover("./resource/test4.jpg")

layout.addWidget(image_label)
layout.addWidget(image_label_2)
layout.addWidget(image_label_3)
layout.addWidget(image_label_4)
layout.setAlignment(Qt.AlignCenter)


if __name__ == "__main__":
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())