欢迎访问我的博客首页。
PyQt5 教程
1. 显示控件
显示一个 PyQt5 中定义的按键 QPushButton。
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
button = QtWidgets.QPushButton()
button.resize(450, 150)
button.setText('按键')
button.show()
sys.exit(app.exec_())
下面是显示窗口。每个显示窗口都有标题栏,所以标题栏下方才是按键。
2. 自定义控件
QWidget 是所有可视控件、鼠标事件处理控件的基类。它本身是一个不可见的透明矩形,也不能处理鼠标事件。它只能占个位置,因此我们称之为空控件。重写 paintEvent 函数可以使它可见,重写鼠标事件处理函数可以使它具备捕捉鼠标事件的功能。
自定义控件需要继承空控件 QWidget。现在我们定义一个控件,它的中心是一个红色的圆。这个控件不能处理鼠标事件,第 5 节在此基础上,重写了鼠标事件处理函数,因此可以处理鼠标事件。
class WidgetExample(QtWidgets.QWidget):
def __init__(self):
super(WidgetExample, self).__init__()
self.resize(450, 150)
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
radius = min(self.width(), self.height()) // 2
color = QtGui.QColor(255, 0, 0, 200)
pen = QtGui.QPen(color, 1)
brush = QtGui.QBrush(QtCore.Qt.BrushStyle.SolidPattern)
brush.setColor(color)
painter = QtGui.QPainter()
painter.begin(self)
painter.setPen(pen)
painter.setBrush(brush)
painter.drawEllipse(QtCore.QPoint(self.width() // 2, self.height() // 2), radius, radius)
painter.end()
上面定义的控件,其中心是一个红色的圆形,不透明度为 200/255,效果如下。
自定义控件时最好使用 resize 函数,否则可能会因宽或高为 0 而不可见。
3. 使用布局创建控件
下面的例子中,两个按键被放在一个竖直布局中,对齐方式是默认的分散对齐。然后该布局被包装成一个控件,这个包装的控件可以像其它控件一样使用。
class WidgetLayout(QtWidgets.QWidget):
def __init__(self):
super(WidgetLayout, self).__init__()
self.resize(450, 150)
button1 = QtWidgets.QPushButton()
button1.setText('按键1')
button2 = QtWidgets.QPushButton()
button2.setText('按键2')
layout = QtWidgets.QVBoxLayout()
layout.addWidget(button1)
layout.addWidget(button2)
self.setLayout(layout)
从上面的代码可以看出,定义一个包含多个控件的控件时,先继承 QWidget,然后把待添加的控件放到布局中,最后调用 setLayout 函数。
还可以使用下面的方法。即,先定义一个空控件,然后以该控件作为实参创建一个布局,向布局中添加其它控件,最后这个空控件上就会有其它控件。
def create_WidgetLayout():
widget = QtWidgets.QWidget()
widget.resize(450, 150)
button1 = QtWidgets.QPushButton()
button1.setText('按键1')
button2 = QtWidgets.QPushButton()
button2.setText('按键2')
layout = QtWidgets.QVBoxLayout(widget)
layout.addWidget(button1)
layout.addWidget(button2)
return widget
上面两种方法的效果完全相同。
4. 控件堆叠
QLabel 像一个空控件,我们可以在它上面添加其它控件。在 QLabel 上显示控件 b,只需要让 QLabel 作为控件 b 构造函数的实参,此时 QLabel 称为该控件的父控件。
在其它控件上添加控件,会涉及到鼠标穿透等问题,暂时不介绍。
4.1 在一个控件上堆叠一个控件
下面的例子是在 label 上显示一个 button。
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
label = QtWidgets.QLabel()
label.resize(450, 150)
label.setPixmap(QtGui.QPixmap('assets/0.jpg'))
label.setScaledContents(True)
button = QtWidgets.QPushButton(label)
button.setText('按键')
label.show()
sys.exit(app.exec_())
效果如下。
4.2 在一个控件上堆叠一个布局
布局也是控件,因此可以在一个控件上显示一个包含多个控件的布局。
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
label = QtWidgets.QLabel()
label.resize(450, 150)
label.setPixmap(QtGui.QPixmap('assets/0.jpg'))
label.setScaledContents(True)
button1 = QtWidgets.QPushButton()
button1.setText('按键1')
button2 = QtWidgets.QPushButton()
button2.setText('按键2')
layout = QtWidgets.QVBoxLayout(label)
layout.addWidget(button1)
layout.addWidget(button2)
label.show()
sys.exit(app.exec_())
竖直布局默认是分散对齐的。上面的代码在 label 上放置一个竖直布局,布局中有两个按键,效果如下。
4.3 思考
实际上,第三节的两种方法就是控件堆叠。底层的空控件不可见,所以只能看到两个按键。如果为类 WidgetLayout 添加第 2 节中的 paintEvent 函数,或在 create_WidgetLayout 函数体的第一行使用第 2 节中的 WidgetExample 类,就可以看到下面的堆叠效果。
可以看出,图 4.3 是把图 3 堆叠在图 2 上方的效果。
5. 鼠标事件
为类 WidgetExample 重写三个处理鼠标点击事件的函数。
class WidgetExample(QtWidgets.QWidget):
signal = QtCore.pyqtSignal(str)
def __init__(self):
super(WidgetExample, self).__init__()
self.resize(450, 150)
self.signal.connect(self.mouseSlot)
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
radius = min(self.width(), self.height()) // 2
color = QtGui.QColor(255, 0, 0, 200)
pen = QtGui.QPen(color, 1)
brush = QtGui.QBrush(QtCore.Qt.BrushStyle.SolidPattern)
brush.setColor(color)
painter = QtGui.QPainter()
painter.begin(self)
painter.setPen(pen)
painter.setBrush(brush)
painter.drawEllipse(QtCore.QPoint(self.width() // 2, self.height() // 2), radius, radius)
painter.end()
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
self.signal.emit('鼠标点击')
def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent) -> None:
self.signal.emit('鼠标双击')
def mouseReleaseEvent(self, a0: QtGui.QMouseEvent) -> None:
self.signal.emit('鼠标松开')
def mouseSlot(self, msg):
print(msg)
可以看出,定义的信号须是静态成员,定义信号时可以指定若干个参数类型,使用函数 connect 为其绑定槽函数 mouseSlot。槽函数可以是成员函数,也可以是非成员函数。
6. 编码规范
6.1 文件和类的命名与定义
设计复杂界面时,可以先把局部做成自定义控件,然后用这些控件做成更大区域的控件,最终做成整个界面。遵循下面的编码规范,可以使代码具备更高的可读性、可复用性、可扩展性,和更低的耦合性,且便于测试。
- 使用 WidgetXxx 格式命名自定义的控件,其中 Xxx 是首字母大写的名字,这个名字应能说明该控件的功能。存放该控件的文件名应为 widgetXxx.py。
- 调用基类的 setLayout 函数在该控件上添加包含其它控件的布局。
- 定义成员函数 set_plot 为控件绑定槽函数。把槽函数通过构造函数传进来,也可以实现绑定。相比而言,第一种方法降低了控件和槽函数的耦合,即使不绑定槽函数,也可以把控件显示出来,便于测试;而且当控件 a 是控件 b 的成员,控件 b 是控件 c 的成员时,控件 c 可以调用 set_plot 函数把槽函数传递给 b,控件 b 同样可以调用 set_plot 函数把槽函数传递给 a,使得各控件绑定自己需要的槽函数。
下面的例子中,槽函数定义为类 Slot 的成员函数。WidgetXxx 类的成员函数 set_plot 接收槽函数类,并为自己的信号绑定槽函数。如果 WidgetXxx 类还有其它控件,可以调用 set_slot 函数把槽函数传递给这些控件,让它们自己为自己绑定槽函数。
class Slot:
def __init__(self):
pass
@staticmethod
def button():
print('按键点击')
# widgetXxx.py
class WidgetXxx(QtWidgets.QWidget):
def __init__(self):
super(WidgetXxx, self).__init__()
self.button = QtWidgets.QPushButton()
self.button.setText('按键')
layout = QtWidgets.QHBoxLayout()
layout.addWidget(self.button)
self.setLayout(layout)
def set_plot(self, slot):
self.button.clicked.connect(slot.button)
6.2 选择基类
虽然上面例子使用的基类都是 QWidget,但使用 QLabel 和 QPushButton 等可能会更方便。比如 QWidget 是空控件,使用 setStyleSheel 函数设置背景是无效的,只能通过重写 paintEvent 函数改变背景。而且继承 QWidget 的控件默认宽度或高度可能为 0,如果没有指定宽高,你可能看不到它。由于 QLabel 和 QPushButton 都是 QWidget 的派生类,所以继承它们和继承 QWidget 一样,都可以重写 paintEvent 函数和鼠标事件处理函数等。
7. 线程
Qt 不允许子线程直接使用界面。如果直接在子线程中使用界面,会报错如下。
QObject: Cannot create children for a parent that is in a different thread.
(Parent is QApplication(0x1763530), parent's thread is QThread(0x15057f0), current thread is ThreadExample(0x17d4e50)
我们可以为子线程添加信号并绑定主线程中的槽函数,以此让主线程的槽函数修改或调用主线程中定义的界面。因为要在线程中使用槽函数,所以我们要借助 QThread 创建线程。
7.1 计数器
我们定义一个计数器。子线程负责计数,并通知主线程更新显示计数。子线程每计数到整百,还会通知主线程弹窗提示。
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
class ThreadExample(QtCore.QThread):
signal_counter = QtCore.pyqtSignal(int)
signal_message = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None):
super(ThreadExample, self).__init__(parent)
self.count = 0
def reset(self):
self.count = 0
def run(self):
while True:
self.msleep(100)
self.count += 1
print(self.count)
# 更新计数。
self.signal_counter.emit(self.count)
# 弹出消息框。
if self.count % 100 == 0:
self.signal_message.emit('消息', '当前计数为 %d.' % self.count)
def set_slot(self, view):
self.signal_counter.connect(view.lcd.display)
self.signal_message.connect(view.show_critical)
class WidgetExample(QtWidgets.QWidget):
def __init__(self, parent=None):
super(WidgetExample, self).__init__(parent)
# 1.创建界面。
self.lcd = QtWidgets.QLCDNumber()
self.pushButton = QtWidgets.QPushButton('开始')
self.pushButton.setMinimumHeight(60)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.lcd)
layout.addWidget(self.pushButton)
self.setLayout(layout)
self.setWindowTitle('子线程通过信号更新和调用主线程中的界面')
self.resize(450, 150)
# 2.添加子线程。
self.thread = ThreadExample()
# 3.状态。
self.status = False
def callback(self):
if not self.status:
# 重置线程的计数和LCD的计数。
self.thread.reset()
self.lcd.display(0)
# 开始计数。
self.thread.start()
self.pushButton.setText('停止')
else:
# 终止线程。
if self.thread.isRunning():
self.thread.terminate()
self.pushButton.setText('开始')
self.status = not self.status
def show_critical(self, title, text):
QtWidgets.QMessageBox.critical(self, title, text, QtWidgets.QMessageBox.Yes)
def closeEvent(self, a0: QtGui.QCloseEvent) -> None:
if self.thread.isRunning():
self.thread.terminate()
def set_slot(self):
self.pushButton.clicked.connect(self.callback)
self.thread.set_slot(self)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
win = WidgetExample()
win.set_slot()
win.show()
sys.exit(app.exec_())
ThreadExample 是借助 QThread 实现的线程。它以每隔 100 毫秒使 count 加 1 的方式实现计数,并通过自己的两个信号发送 count。
WidgetExample 负责定义主界面且创建子线程。在成员函数 set_slot 中,主界面的按键绑定到控制子线程开始和终止的函数,子线程的两个信号绑定到主线程的函数以更新和调用主线程中的界面。
7.2 消息弹窗与文件选框
把耗时任务安排给主线程会使界面卡顿,因此我们经常需要使用子线程处理耗时任务。子线程通过弹窗告诉用户自己的执行状态,是很有必要的。
7.1 节已经有子线程弹窗:子线程每计数 100 就让主线程弹窗提示。现在,我们给出一个更简单、更通用的例子。
import os
import sys
import time
from PyQt5 import QtCore, QtGui, QtWidgets
class ThreadExample(QtCore.QThread):
signal = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None):
super(ThreadExample, self).__init__(parent)
def run(self):
# 执行耗时任务。
for i in range(5):
time.sleep(1)
print('-- 耗时 %d.' % i)
# 弹出消息框。
self.signal.emit('消息', '来自子线程的消息')
def set_slot(self, view):
self.signal.connect(view.show_critical)
class Slot:
def __init__(self):
self.thread = ThreadExample()
def run(self):
self.thread.start()
self.thread.wait()
print('-- 线程结束。')
def set_slot(self, view):
self.thread.set_slot(view)
class WidgetExample(QtWidgets.QWidget):
def __init__(self, parent=None):
super(WidgetExample, self).__init__(parent)
# 创建界面。
self.pushButton = QtWidgets.QPushButton('按键')
self.pushButton.setMinimumHeight(60)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.pushButton)
self.setLayout(layout)
self.setWindowTitle('子线程通过信号调用主线程中的界面')
self.resize(450, 150)
def show_critical(self, title, text):
QtWidgets.QMessageBox.critical(self, title, text, QtWidgets.QMessageBox.Yes)
y = QtWidgets.QFileDialog.getOpenFileNames(self, '选择图像', os.getcwd(),
"All Files(*);;Text Files(*.txt *.png)")
print(y)
def set_slot(self, slot):
self.pushButton.clicked.connect(slot.run)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
win = WidgetExample()
slot = Slot()
win.set_slot(slot)
slot.set_slot(win)
win.show()
sys.exit(app.exec_())
子线程 ThreadExample 执行一个耗时为 5 秒的任务,执行完发送信号。信号槽类 Slot 定义的成员函数专门作为主界面 WidgetExample 的槽函数。与 7.1 不同的是,子线程由槽函数类创建并维护,这是更通用的做法:主界面的按键调用槽函数、槽函数创建子线程执行耗时任务、子线程发送信号调用或修改主界面。
本例的槽函数不但会弹出一个错误弹窗,还会弹出一个文件选框。
7.3 等待窗口
执行耗时任务时,弹出一个表示等待的动画是常用做法。下面,我们就为 7.2 的等待过程添加一个动画。
import sys
import time
from PyQt5 import QtCore, QtGui, QtWidgets
class ThreadExample(QtCore.QThread):
signal_waiting = QtCore.pyqtSignal(str)
signal_message = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None):
super(ThreadExample, self).__init__(parent)
def run(self):
# 1.显示等待界面。
self.signal_waiting.emit('正在加载...')
# 2.执行耗时任务。
for i in range(5):
time.sleep(1)
print('-- 线程结束。')
# 3.关闭等待界面。
self.signal_waiting.emit('')
# 4.弹出消息框。
self.signal_message.emit('消息', '来自子线程的消息')
def set_slot(self, view):
self.signal_waiting.connect(view.show_waiting)
self.signal_message.connect(view.show_message)
class Slot:
def __init__(self):
self.thread = ThreadExample()
def run(self):
self.thread.start()
# self.thread.wait()
print('-- 等待线程结束...')
def set_slot(self, view):
self.thread.set_slot(view)
class WidgetWaiting(QtWidgets.QDialog):
def __init__(self):
super().__init__()
movie = QtGui.QMovie('assets/movie.gif')
movie.start()
# 在当前界面上显示动画。
gif = QtWidgets.QLabel(self)
gif.setMovie(movie)
self.msg = QtWidgets.QLabel()
self.msg.setStyleSheet('QLabel{color:rgb(225,22,173);font-size:20px;font-weight:normal;font-family:Arial}')
self.msg.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.msg.setText('等待...')
# 在动画上显示标签。
layout = QtWidgets.QHBoxLayout(gif)
layout.addWidget(self.msg)
# 隐藏标题栏并冻结其它窗口。
self.setWindowFlag(QtCore.Qt.WindowType.FramelessWindowHint)
self.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
self.resize(200, 200) # 和 gif 尺寸相等。
def set_msg(self, msg):
self.msg.setText(msg)
class WidgetExample(QtWidgets.QWidget):
def __init__(self, parent=None):
super(WidgetExample, self).__init__(parent)
# 1.创建界面。
self.pushButton = QtWidgets.QPushButton('按键')
self.pushButton.setMinimumHeight(60)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.pushButton)
self.setLayout(layout)
self.setWindowTitle('子线程通过信号调用主线程中的界面')
self.resize(450, 150)
# 2.等待界面。
self.widgetWaiting = WidgetWaiting()
def show_waiting(self, msg):
if msg:
self.widgetWaiting.set_msg(msg)
self.widgetWaiting.show()
else:
self.widgetWaiting.close()
def show_message(self, title, text):
res = QtWidgets.QMessageBox.information(self, title, text,
QtWidgets.QMessageBox.StandardButton.Yes |
QtWidgets.QMessageBox.StandardButton.No)
if res == QtWidgets.QMessageBox.StandardButton.Yes:
print('-- Yes')
elif res == QtWidgets.QMessageBox.StandardButton.No:
print('-- No')
def set_slot(self, slot):
self.pushButton.clicked.connect(slot.run)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
win = WidgetExample()
slot = Slot()
win.set_slot(slot)
slot.set_slot(win)
win.show()
sys.exit(app.exec_())
与 7.2 的主要不同之处是,我们创建了一个等待窗口 WidgetWaiting。等待窗口受子线程两个信号的控制,在子现存开始执行时出现,在子线程结束时退出。
7.4 中止子线程
我们在 7.2 的基础上实现中止子线程的功能,且子线程运行时按键显示为 “停止”,子线程停止时按键显示为 “开始”。需要注意的时,子线程开始、中止或完成时状态都会改变。
import sys
import time
from PyQt5 import QtCore, QtGui, QtWidgets
class ThreadExample(QtCore.QThread):
signal = QtCore.pyqtSignal(str, str)
def __init__(self, parent=None):
super(ThreadExample, self).__init__(parent)
self.view = None
def run(self):
# 执行耗时任务。
for i in range(10):
time.sleep(1)
print('-- 耗时 %d.' % i)
self.view.pushButton.setText('开始')
self.view.pushButton.setStyleSheet('background-color:rgb(0,255,0)')
# 弹出消息框。
self.signal.emit('消息', '来自子线程的消息')
def set_slot(self, view):
self.signal.connect(view.show_critical)
self.view = view
class Slot:
def __init__(self):
self.thread = ThreadExample()
self.view = None
def run(self):
if self.thread.isRunning():
self.thread.terminate()
self.view.pushButton.setText('开始')
self.view.pushButton.setStyleSheet('background-color:rgb(0,255,0)')
else:
self.thread.start()
self.view.pushButton.setText('停止')
self.view.pushButton.setStyleSheet('background-color:rgb(255,0,0)')
# self.thread.wait()
# print('-- 线程结束。')
def set_slot(self, view):
self.thread.set_slot(view)
self.view = view
class WidgetExample(QtWidgets.QWidget):
def __init__(self, parent=None):
super(WidgetExample, self).__init__(parent)
# 创建界面。
self.pushButton = QtWidgets.QPushButton('开始')
self.pushButton.setStyleSheet('background-color:rgb(0,255,0)')
self.pushButton.setMinimumHeight(60)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.pushButton)
self.setLayout(layout)
self.setWindowTitle('子线程通过信号调用主线程中的界面')
self.resize(450, 150)
def show_critical(self, title, text):
QtWidgets.QMessageBox.critical(self, title, text, QtWidgets.QMessageBox.Yes)
def set_slot(self, slot):
self.pushButton.clicked.connect(slot.run)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
win = WidgetExample()
slot = Slot()
win.set_slot(slot)
slot.set_slot(win)
win.show()
sys.exit(app.exec_())
首先说一下传参问题。类 ThreadExample 和 Slot 通过 set_slot 函数获取主界面 view。如果没有调用 set_slot 而调用 run,会因 view 为 None 而出现运行错误。通过子线程的构造函数把主线程中的视图 view 传给子线程似乎是更合适的方法,但不建议这样做。如果这样做,子线程的成员 view 就包含了 Qt 界面,子线程就间接包含了 Qt 界面。
使用子线程 QThread 的原则是,子线程与主线程仅通过信号与槽函数相互调用。不要把主线程中的数据通过信号以外的其它手段传递给子线程。
类 Slot 的 run 函数中,子线程的开始和中止都会改变按键状态,但无法在子线程完成时改变按键状态。因此,类 ThreadExample 也需要获取 view,在 run 函数执行完成时改变按键状态。
需要注意的是,类 Slot 的 run 函数中不能调用子线程的 wait 方法。否则,在子线程执行完成之前,该函数都不会响应按键点击事件。
8. QScrollArea
QScrollArea 控件可以显示一个比自己尺寸更大的控件,用于实现记事本、图片查看器等功能。下面我们用它来实现一个滑动窗口:在 1 个 QScrollArea 中存放 3 个 QLabel。
class WidgetListMenu(QtWidgets.QWidget):
def __init__(self, spacing, parent=None):
super(WidgetListMenu, self).__init__(parent)
label_1 = QtWidgets.QLabel()
label_2 = QtWidgets.QLabel()
label_3 = QtWidgets.QLabel()
label_1.setStyleSheet('background-color:rgb(255,0,0)')
label_2.setStyleSheet('background-color:rgb(0,255,0)')
label_3.setStyleSheet('background-color:rgb(0,0,255)')
layout = QtWidgets.QHBoxLayout()
layout.setSpacing(spacing)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(label_1)
layout.addWidget(label_2)
layout.addWidget(label_3)
self.setLayout(layout)
class WidgetScrollMenu(QtWidgets.QScrollArea):
def __init__(self, parent=None):
super(WidgetScrollMenu, self).__init__(parent)
# 1.创建页面。
self.spacing = 5
list_menu = WidgetListMenu(self.spacing)
self.setWidget(list_menu)
# 2.设置。
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.resize(800, 400)
self.x_start = 0
def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
menu_width = (self.width() - self.spacing) // 2
list_width = menu_width * 3 + self.spacing * 2
self.widget().resize(list_width, self.height())
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
if a0.button() == QtCore.Qt.MouseButton.LeftButton:
self.x_start = a0.pos().x()
def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
x_delta = a0.pos().x() - self.x_start
offset = x_delta // 10
position = self.horizontalScrollBar().value()
self.horizontalScrollBar().setValue(position - offset)
def wheelEvent(self, a0: QtGui.QWheelEvent) -> None:
position = self.horizontalScrollBar().value()
direction = 1 if a0.angleDelta().y() < 0 else -1
offset = direction * self.width() // 2 // 4
self.horizontalScrollBar().setValue(position + offset)
上面的代码使用 2 个类自定义了 2 个控件。第一个控件包含 3 个横向排列的 QLabel。第二个控件自定义一个 QScrollArea 存放第一个自定义控件。第二个控件的尺寸小于第一个,它实现了鼠标拖动、滚轮滑动的功能。
下面我们改进上面的代码,添加以下功能:单击 QLabel 出现阴影效果;被双击的 QLabel 正好占满整个 QScrollArea,其它两个 QLabel 隐藏,再次双击恢复如初。
class WidgetMenuBase(QtWidgets.QLabel):
signal = QtCore.pyqtSignal()
def __init__(self, parent=None):
super(WidgetMenuBase, self).__init__(parent)
self.width_shadow = 3 # 阴影宽度。
self.is_down = False # 是否被按下。
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
self.is_down = True
self.repaint()
ev.ignore()
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
self.is_down = False
self.repaint()
ev.ignore()
def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent) -> None:
self.signal.emit()
a0.ignore()
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter()
painter.begin(self)
pen_1 = QtGui.QPen(QtGui.QColor(0, 0, 0), self.width_shadow)
pen_2 = QtGui.QPen(QtGui.QColor(110, 110, 110), self.width_shadow)
if self.is_down:
painter.setPen(pen_1)
painter.drawLine(0, 0, 0, self.height()) # 左。
painter.drawLine(0, 0, self.width(), 0) # 上。
painter.setPen(pen_2)
painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height()) # 右
painter.drawLine(0, self.height() - 1, self.width(), self.height() - 1) # 下。
else:
painter.setPen(pen_2)
painter.drawLine(0, 0, 0, self.height())
painter.drawLine(0, 0, self.width(), 0)
painter.setPen(pen_1)
painter.drawLine(self.width() - 1, 0, self.width() - 1, self.height())
painter.drawLine(0, self.height() - 1, self.width(), self.height() - 1)
class WidgetListMenu(QtWidgets.QWidget):
signal = QtCore.pyqtSignal(int)
def __init__(self, spacing, parent=None):
super(WidgetListMenu, self).__init__(parent)
label_0 = WidgetMenuBase()
label_1 = WidgetMenuBase()
label_2 = WidgetMenuBase()
label_0.setStyleSheet('background-color:rgb(255,0,0)')
label_1.setStyleSheet('background-color:rgb(0,255,0)')
label_2.setStyleSheet('background-color:rgb(0,0,255)')
label_0.signal.connect(lambda: self.callback(0))
label_1.signal.connect(lambda: self.callback(1))
label_2.signal.connect(lambda: self.callback(2))
self.layout = QtWidgets.QHBoxLayout()
self.layout.setObjectName('layout')
self.layout.setSpacing(spacing)
self.layout.setContentsMargins(0, 0, 0, 2)
self.layout.addWidget(label_0)
self.layout.addWidget(label_1)
self.layout.addWidget(label_2)
self.setLayout(self.layout)
self.hide = False # 是否有两个页面被隐藏。
def callback(self, idx):
for i in range(self.layout.count()):
if i == idx:
continue
label = self.layout.itemAt(i).widget()
if self.hide:
label.show()
else:
label.hide()
self.hide = not self.hide
class WidgetScrollMenu(QtWidgets.QScrollArea):
def __init__(self, parent=None):
super(WidgetScrollMenu, self).__init__(parent)
# 1.创建页面。
self.spacing = 5
list_menu = WidgetListMenu(self.spacing)
self.setWidget(list_menu)
# 2.设置。
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.resize(800, 400)
self.x_start = 0
def resizeEvent(self, a0: QtGui.QResizeEvent) -> None:
if self.widget().hide:
self.widget().resize(self.width(), self.height())
else:
menu_width = (self.width() - self.spacing) // 2
list_width = menu_width * 3 + self.spacing * 2
self.widget().resize(list_width, self.height())
def mousePressEvent(self, a0: QtGui.QMouseEvent) -> None:
if a0.button() == QtCore.Qt.MouseButton.LeftButton:
self.x_start = a0.pos().x()
def mouseMoveEvent(self, a0: QtGui.QMouseEvent) -> None:
x_delta = a0.pos().x() - self.x_start
offset = x_delta // 10
position = self.horizontalScrollBar().value()
self.horizontalScrollBar().setValue(position - offset)
def wheelEvent(self, a0: QtGui.QWheelEvent) -> None:
position = self.horizontalScrollBar().value()
direction = 1 if a0.angleDelta().y() < 0 else -1
offset = direction * self.width() // 2 // 4
self.horizontalScrollBar().setValue(position + offset)
def mouseDoubleClickEvent(self, a0: QtGui.QMouseEvent) -> None:
self.resizeEvent(None)
上面的代码使用 3 个类实现了 3 个控件。第一个类自定义了一个点击时出现阴影效果的 QLabel,继承这个 QLabel 即可自定义一个同样带有阴影效果的 QLabel。第二个和第三个类分别是对上一段代码中第一个和第二个类的改进。第二个类会放大被双击的 QLabel,隐藏其它两个 QLabel。
9. QSplitter
除了 QHBoxLayout 和 QVBoxLayout 布局外,还可以使用 QSplitter 布局。不同之处在于 QSplitter 中的控件尺寸是可以使用鼠标动态调整的。
class WidgetSplitter(QtWidgets.QSplitter):
def __init__(self, parent=None):
super(WidgetSplitter, self).__init__(parent)
# 1.创建页面。
label_1 = QtWidgets.QLabel()
label_2 = QtWidgets.QLabel()
label_3 = QtWidgets.QLabel()
label_1.setMinimumHeight(100)
label_2.setMinimumHeight(200)
label_3.setMinimumHeight(100)
label_1.setStyleSheet('background-color:rgb(255,0,0)')
label_2.setStyleSheet('background-color:rgb(0,255,0)')
label_3.setStyleSheet('background-color:rgb(0,0,255)')
self.addWidget(label_1)
self.addWidget(label_2)
self.addWidget(label_3)
# 2.设置。
self.setHandleWidth(0)
self.setContentsMargins(0, 0, 0, 0)
self.setChildrenCollapsible(False)
self.setOrientation(QtCore.Qt.Orientation.Vertical)
self.resize(500, 300)
10. 参考
- 文档,Qt 官方。