效果预览

代码
from functools import wraps
from PyQt5.QtWidgets import QWidget, QMessageBox
from PyQt5.QtCore import QTimer, Qt, QRectF, QThread, QEvent, pyqtSignal
from PyQt5.QtGui import QPainter, QColor, QPen
class WaitingSpinner(QWidget):
# 定义显示模式
STYLE_DOTS = "dots" # 走点模式(iOS 风格圆点)
STYLE_LINE = "line" # 线条模式(经典 Spinner)
STYLE_ARC = "arc" # 弧形模式(Material Design 风格)
def __init__(self, parent=None, style="dots", color=QColor("#007AFF")):
super().__init__(parent) # 必须传入 parent
self.style_mode = style
self.color = color
self.angle = 0
self.timer = QTimer(self)
self.timer.timeout.connect(self._update_animation)
# 初始状态
self.hide()
# 监听父控件的事件,确保大小同步
if parent:
parent.installEventFilter(self)
def eventFilter(self, obj, event):
# 当父控件大小改变时,自动跟随
if obj == self.parent() and event.type() == QEvent.Resize:
self.resize(obj.size())
return super().eventFilter(obj, event)
def start(self):
if self.parent():
self.resize(self.parent().size()) # 覆盖整个父控件
self.show()
self.raise_() # 确保在最顶层
self.timer.start(60)
def stop(self):
self.timer.stop()
self.hide()
def _update_animation(self):
self.angle = (self.angle + 30) % 360 if self.style_mode != self.STYLE_ARC else (self.angle + 10) % 360
self.update()
def set_style(self, style):
self.style_mode = style
self.update()
def paintEvent(self, event):
"""绘制事件"""
super().paintEvent(event)
# 创建 QPainter 对象
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
# 1. 绘制半透明遮罩背景 (可选,增加磨砂感)
painter.fillRect(self.rect(), QColor(255, 255, 255, 150))
# 2. 绘制动画 (移至几何中心)
painter.translate(self.width() / 2, self.height() / 2)
# 调用指定的绘制逻辑
if self.style_mode == self.STYLE_DOTS:
self._draw_dots(painter)
elif self.style_mode == self.STYLE_LINE:
self._draw_lines(painter)
elif self.style_mode == self.STYLE_ARC:
self._draw_arc(painter)
def _draw_dots(self, painter):
"""绘制 iOS 风格的圆点旋转"""
for i in range(8):
painter.save()
painter.rotate(self.angle + i * 45)
# 根据索引设置透明度,形成旋转感
opacity = int(255 * (i / 8))
color = QColor(self.color)
color.setAlpha(opacity)
painter.setBrush(color)
painter.setPen(Qt.NoPen)
painter.drawEllipse(10, -3, 6, 6) # 在半径 10 的位置画圆
painter.restore()
def _draw_lines(self, painter):
"""绘制线条旋转"""
pen = QPen(self.color)
pen.setWidth(3)
pen.setCapStyle(Qt.RoundCap)
for i in range(12):
painter.save()
painter.rotate(self.angle + i * 30)
opacity = int(255 * (i / 12))
pen.setColor(QColor(self.color.red(), self.color.green(), self.color.blue(), opacity))
painter.setPen(pen)
painter.drawLine(10, 0, 18, 0)
painter.restore()
def _draw_arc(self, painter):
"""绘制弧形旋转"""
pen = QPen(self.color)
pen.setWidth(4)
pen.setCapStyle(Qt.RoundCap)
painter.setPen(pen)
# 绘制一个 270 度的弧线
rect = QRectF(-15, -15, 30, 30)
painter.rotate(self.angle * 2) # 弧形旋转快一点
painter.drawArc(rect, 0, 270 * 16)
# 为静态方法的装饰器
@staticmethod
def with_loading(spinner_attr_name="spinner"):
"""
静态装饰器:执行耗时任务时显示 Spinner
:param spinner_attr_name: 类实例中 spinner 对象的变量名
"""
class __Worker(QThread):
"""通用的工作线程,用于执行装饰的函数"""
finished_signal = pyqtSignal(object)
error_signal = pyqtSignal(Exception) # 专门用于传递异常
def __init__(self, func, *args, **kwargs):
super().__init__()
self.func = func
self.args = args
self.kwargs = kwargs
def run(self):
try:
# 执行业务逻辑
result = self.func(*self.args, **self.kwargs)
self.finished_signal.emit(result)
except Exception as e:
# 捕获所有异常并发送错误信号
self.error_signal.emit(e)
def decorator(func):
@wraps(func)
def wrapper(instance, *args, **kwargs):
# 运行时通过instance(即self)获取spinner
# 获取类实例中的 spinner 对象
spinner = getattr(instance, spinner_attr_name, None)
if spinner:
spinner.start()
# 加载时禁用窗口,防止二次点击
instance.setEnabled(False)
# 创建并启动线程
# 这里的__Worker类需要能在模块作用域找到,或者定义在内部
instance._worker = __Worker(func, instance, *args, **kwargs)
# 直接传递 instance, *args, **kwargs
# 此时 args 里面已经包含了除了 self(instance) 以外的所有位置参数
# 我们只需要确保 Worker 启动时能够正确调用它们
# 定义统一的清理逻辑
def cleanup():
if spinner:
spinner.stop()
instance.setEnabled(True) # 恢复窗口可点击
instance._worker = None # 显式释放引用
# 正常完成的回调
def handle_success(result):
cleanup()
# 这里可以根据需要处理返回值
print(f"任务完成,结果: {result}")
# 发生错误的回调
def handle_error(e):
cleanup()
# 可以在这里弹出错误提示框
QMessageBox.critical(instance, "错误", f"任务执行过程中发生异常:\n{str(e)}")
instance._worker.finished_signal.connect(handle_success)
instance._worker.error_signal.connect(handle_error)
instance._worker.start()
return wrapper
return decorator
class __MyApp(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("装饰器加载演示")
# self.setFixedSize(300, 200)
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QVBoxLayout, QPushButton
layout = QVBoxLayout(self)
# 1. 准备 Spinner
# 创建浮层 Spinner,但不放进 layout
# 它会自动附着在 self (Demo 窗口) 上
self.loading_overlay = WaitingSpinner(self, style="dots")
self.btn = QPushButton("开始执行耗时任务")
self.btn.clicked.connect(lambda: self.heavy_task(3))
layout.addWidget(self.btn)
# 2. 使用装饰器 (指向 self.loading_overlay)
@WaitingSpinner.with_loading(spinner_attr_name="loading_overlay")
def heavy_task(self, count: int):
import time
# 模拟耗时操作,如从服务器下载数据
print("工作线程启动,执行次数: ", count)
for i in range(count):
time.sleep(3)
print(f"工作线程进度: {i + 1}/5")
if i == 1:
raise Exception("模拟任务失败")
return "Data Downloaded"
if __name__ == "__main__":
import sys
from PyQt5.QtWidgets import QApplication, QWidget
app = QApplication(sys.argv)
window = __MyApp()
window.show()
sys.exit(app.exec_())