参考:Events and signals in PyQt5
所有的应用都是事件驱动的。事件大部分都是由用户的行为产生的,当然也有其他的事件产生方式,比如网络的连接,窗口管理器或者定时器等。调用应用的 exec_()
方法时,应用会进入主循环,主循环会监听和分发事件。
在事件模型中,有三个角色:
- 事件源(event source):发生了状态改变的对象,用于生成事件。
- 事件对象(event object):将状态更改封装在事件源中。
- 事件目标(event target):即要通知的对象。事件源对象将处理事件的任务委托给事件目标。
1 事件和信号及槽的区别
信号与槽可以说是对事件处理机制的高级封装,如果说事件是用来创建窗口控件的,那么信号与槽就是用来对这个窗口控件进行使用的。比如一个按钮,当我们使用这个按钮时,只关心 clicked 信号,至于这个按钮如何接收并处理鼠标点击事件,然后再发射这个信号,则不用关心。但是如果要重载一个按钮,这时就要关心这个问题了。比如可以改变它的行为:在鼠标按键按下时触发 clicked 信号,而不是在释放时。
PyQt5/PySide2 是对 Qt 的封装,Qt 程序是事件驱动的,它的每个动作都由幕后某个事件所触发。Qt 事件的类型有很多,常见的 Qt 事件如下:
- 键盘事件:按键按下和松开。
- 鼠标事件:鼠标指针移动、鼠标按键按下和松开。
- 拖放事件:用鼠标进行拖放。
- 滚轮事件:鼠标滚轮滚动。
- 绘屏事件:重绘屏幕的某些部分。
- 定时事件:定时器到时。
- 焦点事件:键盘焦点移动。
- 进入和离开事件:鼠标指针移入 Widget 内,或者移出。
- 移动事件:Widget 的位置改变。
- 大小改变事件:Widget 的大小改变。
- 显示和隐藏事件:Widget 显示和隐藏。
- 窗口事件:窗口是否为当前窗口。
还有一些常见的 Qt 事件,比如 Socket 事件、剪贴板事件、字体改变事件、布局改变事件等。
下面看一个 LCD 的例子:
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
class LCDNumber(QtWidgets.QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initUI()
lcd = QtWidgets.QLCDNumber(self)
sld = QtWidgets.QSlider(QtCore.Qt.Horizontal) # 横向滑块
vbox = QtWidgets.QVBoxLayout()
vbox.addWidget(lcd)
vbox.addWidget(sld)
self.setLayout(vbox)
sld.valueChanged.connect(lcd.display)
def initUI(self):
self.setGeometry(300, 300, 250, 150)
self.setWindowTitle('Signal and slot')
if __name__ == "__main__":
run(LCDNumber)
效果:
其中 sld.valueChanged
是滑块的值改变的信号,lcd.display
是 LCD 数字的槽函数。即 sld
发送被改变的值给 lcd
并显示出来。
2 使用事件处理的方法
PyQt5/PySide2 提供了如下 5 种事件处理和过滤方法(由弱到强),其中只有前两种方法使用最频繁。
- 重新实现事件函数:比如
mousePressEvent()
、keyPressEvent()
、paintEvent()
。这是最常规的事件处理方法。 - 重新实现
QObject.event()
:一般用在 PyQt5/PySide2 没有提供该事件的处理函数的情况下,即增加新事件时。 - 安装事件过滤器 :如果对
QObject
调用installEventFilter
,则相当于为这个QObject
安装了一个事件过滤器,对于QObject
的全部事件来说,它们都会先传递到事件过滤函数eventFilter
中,在这个函数中我们可以抛弃或者修改这些事件,比如可以对自己感兴趣的事件使用自定义的事件处理机制,对其他事件使用默认的事件处理机制。由于这种方法会对调用installEventFilter
的所有QObject
的事件进行过滤,因此如果要过滤的事件比较多,则会降低程序的性能。 - 在
QApplication
中安装事件过滤器 :这种方法比上一种方法更强大:QApplication
的事件过滤器将捕获所有QObject
的所有事件,而且第一个获得该事件。也就是说,在将事件发送给其他任何一个事件过滤器之前(就是在第三种方法之前),都会先发送给QApplication
的事件过滤器。 - 重新实现
QApplication
的notify()
方法 :使用notify()
来分发事件。要想在任何事件处理器之前捕获事件,唯一的方法就是重新实现QApplication
的notify()
。在实践中,在调试时才会使用这种方法。
3 重新实现事件函数
重载 keyPressEvent
函数,实现按下 Esc 键程序就会退出:
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
class EscWin(QtWidgets.QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initUI()
def initUI(self):
self.setGeometry(300, 300, 250, 150)
self.setWindowTitle('Event handler')
def keyPressEvent(self, e):
if e.key() == QtCore.Qt.Key_Escape:
self.close()
if __name__ == "__main__":
run(EscWin)
4 事件对象
事件对象是用来描述一系列的事件自身属性的对象。
这个示例中,我们在一个组件里显示鼠标的 X 和 Y 坐标。
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
class Window(QtWidgets.QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initUI()
def initUI(self):
grid = QtWidgets.QGridLayout()
x = 0
y = 0
self.text = f'x: {x}, y: {y}'
self.label = QtWidgets.QLabel(self.text, self)
grid.addWidget(self.label, 0, 0, QtCore.Qt.AlignTop)
# 事件追踪默认没有开启,当开启后才会追踪鼠标的点击事件
self.setMouseTracking(True)
self.setLayout(grid)
self.setGeometry(300, 300, 450, 300)
self.setWindowTitle('Event object')
def mouseMoveEvent(self, e):
x = e.x()
y = e.y()
text = f'x: {x}, y: {y}'
self.label.setText(text)
if __name__ == "__main__":
run(Window)
效果:
5 事件发送
有时候我们会想知道是哪个组件发出了一个信号,sender()
方法能搞定这件事。
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
class Window(QtWidgets.QMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.init_Ui()
def init_Ui(self):
btn1 = QtWidgets.QPushButton("Button 1", self)
btn1.move(30, 50)
btn2 = QtWidgets.QPushButton("Button 2", self)
btn2.move(150, 50)
btn1.clicked.connect(self.buttonClicked)
btn2.clicked.connect(self.buttonClicked)
self.statusBar()
self.setGeometry(300, 300, 450, 350)
self.setWindowTitle('Event sender')
def buttonClicked(self):
sender = self.sender()
self.statusBar().showMessage(sender.text() + ' was pressed')
if __name__ == "__main__":
run(Window)
这个例子里有两个按钮,buttonClicked()
方法决定了是哪个按钮能调用 sender()
方法。我们用调用 sender()
方法的方式决定了事件源。状态栏显示了被点击的按钮。
程序展示:
如果在信号激活的插槽中调用,则PySide2.QtCore.QObject.sender()
返回指向发送信号的对象的指针;否则返回None
。指针仅在执行从该对象的线程上下文调用此函数的插槽期间有效。
如果发送方被销毁或插槽与发送方的信号断开连接,则此函数返回的指针将变为无效。
警告:此功能违反了模块化的面向对象原则。但是,当多个信号连接到单个插槽时,访问发送者可能会很有用。
警告:如上所述,当通过DirectConnection
从不同于该对象线程的线程中调用插槽时,此函数的返回值无效。不要在这种情况下使用此功能。
6 发送自定义信号
QObject
实例能发送事件信号。下面的例子是发送自定义的信号。
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
class Communicate(QtCore.QObject):
closeApp = Signal()
class Window(QtWidgets.QMainWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.init_Ui()
def init_Ui(self):
self.c = Communicate()
self.c.closeApp.connect(self.close)
self.setGeometry(300, 300, 450, 350)
self.setWindowTitle('Emit signal')
def mousePressEvent(self, event):
self.c.closeApp.emit()
if __name__ == "__main__":
run(Window)
创建了一个叫 closeApp
的信号,这个信号会在鼠标按下的时候触发,事件与 QMainWindow
的 槽函数 close
绑定 。
点击窗口的时候,发送 closeApp
信号,程序终止。
7 经典案例
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
QPainter = QtGui.QPainter
QMenu = QtWidgets.QMenu
QEvent, QTimer, Qt = QtCore.QEvent, QtCore.QTimer, QtCore.Qt
class Window(QtWidgets.QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.init_Ui()
# 避免窗口大小重绘事件的影响,可以把参数0改变成3000(3秒),然后在运行,就可以明白这行代码的意思。
QTimer.singleShot(0, self.give_help)
self.justDoubleClicked = False
self.key = ""
self.text = ""
self.message = ""
def init_Ui(self):
self.resize(400, 300)
self.move(100, 100)
self.setWindowTitle("Events")
def give_help(self):
self.text = "请点击这里触发追踪鼠标功能"
self.update() # 重绘事件,也就是触发paintEvent函数。
def closeEvent(self, event):
'''重新实现关闭事件'''
print("Closed")
def one(self):
'''上下文菜单槽函数'''
self.message = "Menu option One"
self.update()
def two(self):
self.message = "Menu option Two"
self.update()
def three(self):
self.message = "Menu option Three"
self.update()
def contextMenuEvent(self, event):
'''重新实现上下文菜单事件'''
menu = QMenu(self)
oneAction = menu.addAction("&One")
twoAction = menu.addAction("&Two")
oneAction.triggered.connect(self.one)
twoAction.triggered.connect(self.two)
if not self.message:
menu.addSeparator()
threeAction = menu.addAction("Thre&e")
threeAction.triggered.connect(self.three)
menu.exec_(event.globalPos())
def clearMessage(self):
'''清空消息文本的槽函数'''
self.message = ""
def paintEvent(self, event):
'''重新实现绘制事件'''
text = self.text
i = text.find("\n\n")
if i >= 0:
text = text[0:i]
if self.key: # 若触发了键盘按钮,则在文本信息中记录这个按钮信息。
text += "\n\n你按下了: {0}".format(self.key)
painter = QPainter(self)
painter.setRenderHint(QPainter.TextAntialiasing)
painter.drawText(self.rect(), Qt.AlignCenter, text) # 绘制信息文本的内容
if self.message: # 若消息文本存在则在底部居中绘制消息,5秒钟后清空消息文本并重绘。
painter.drawText(self.rect(), Qt.AlignBottom | Qt.AlignHCenter,
self.message)
QTimer.singleShot(5000, self.clearMessage)
QTimer.singleShot(5000, self.update)
def resizeEvent(self, event):
'''重新实现调整窗口大小事件'''
self.text = "调整窗口大小为: QSize({0}, {1})".format(
event.size().width(), event.size().height())
self.update()
def mouseReleaseEvent(self, event):
'''重新实现鼠标释放事件'''
# 若鼠标释放为双击释放,则不跟踪鼠标移动
# 若鼠标释放为单击释放,则需要改变跟踪功能的状态,如果开启跟踪功能的话就跟踪,不开启跟踪功能就不跟踪
if self.justDoubleClicked:
self.justDoubleClicked = False
else:
self.setMouseTracking(not self.hasMouseTracking()) # 单击鼠标
if self.hasMouseTracking():
self.text = "开启鼠标跟踪功能.\n" + \
"请移动一下鼠标!\n" + \
"单击鼠标可以关闭这个功能"
else:
self.text = "关闭鼠标跟踪功能.\n" + \
"单击鼠标可以开启这个功能"
self.update()
def mouseMoveEvent(self, event):
'''重新实现鼠标移动事件'''
if not self.justDoubleClicked:
globalPos = self.mapToGlobal(event.pos()) # 窗口坐标转换为屏幕坐标
self.text = """鼠标位置:
窗口坐标为:QPoint({0}, {1})
屏幕坐标为:QPoint({2}, {3}) """.format(event.pos().x(), event.pos().y(), globalPos.x(), globalPos.y())
self.update()
def mouseDoubleClickEvent(self, event):
'''重新实现鼠标双击事件'''
self.justDoubleClicked = True
self.text = "你双击了鼠标"
self.update()
def keyPressEvent(self, event):
'''重新实现键盘按下事件'''
self.key = ""
if event.key() == Qt.Key_Home:
self.key = "Home"
elif event.key() == Qt.Key_End:
self.key = "End"
elif event.key() == Qt.Key_PageUp:
if event.modifiers() & Qt.ControlModifier:
self.key = "Ctrl+PageUp"
else:
self.key = "PageUp"
elif event.key() == Qt.Key_PageDown:
if event.modifiers() & Qt.ControlModifier:
self.key = "Ctrl+PageDown"
else:
self.key = "PageDown"
elif Qt.Key_A <= event.key() <= Qt.Key_Z:
if event.modifiers() & Qt.ShiftModifier:
self.key = "Shift+"
self.key += event.text()
if self.key:
self.key = self.key
self.update()
else:
super().keyPressEvent(event)
def event(self, event):
'''重新实现其他事件,适用于PyQt没有提供该事件的处理函数的情况,
Tab键由于涉及焦点切换,不会传递给keyPressEvent,因此,需要在这里重新定义。
'''
if (event.type() == QEvent.KeyPress and
event.key() == Qt.Key_Tab):
self.key = "在event()中捕获Tab键"
self.update()
return True
return super().event(event)
if __name__ == "__main__":
run(Window)
update()
函数的作用是更新窗口。由于在窗口更新过程中会触发一次 paintEvent
函数(paintEvent
是窗口基类 QWidget
的内部函数),因此在本例中 update
函数的作用等同于 paintEvent
函数。
对于上下文菜单事件,主要影响 message
变量的结果,paintEvent
负责把这个变量在窗口底部输出。
绘制事件是代码的核心事件,它的主要作用是时刻跟踪 text
与 message
这两个变量的信息,并把 text
的内容绘制到窗口的中部,把 message
的内容绘制到窗口的底部(保持 5 秒后就会被清空)。
8 安装事件过滤器
from xinet import QtWidgets, QtCore, QtGui, Signal
from xinet.run_qt import run
QPainter = QtGui.QPainter
QMenu = QtWidgets.QMenu
QLabel = QtWidgets.QLabel
QImage = QtGui.QImage
QEvent, QTimer, Qt = QtCore.QEvent, QtCore.QTimer, QtCore.Qt
class EventFilter(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("事件过滤器")
self.label1 = QLabel("请点击")
self.label2 = QLabel("请点击")
self.label3 = QLabel("请点击")
self.LabelState = QLabel("test")
self.image1 = QImage("images/cartoon1.ico")
self.image2 = QImage("images/cartoon1.ico")
self.image3 = QImage("images/cartoon1.ico")
self.width = 600
self.height = 300
self.resize(self.width, self.height)
self.label1.installEventFilter(self)
self.label2.installEventFilter(self)
self.label3.installEventFilter(self)
mainLayout = QtWidgets.QGridLayout(self)
mainLayout.addWidget(self.label1, 500, 0)
mainLayout.addWidget(self.label2, 500, 1)
mainLayout.addWidget(self.label3, 500, 2)
mainLayout.addWidget(self.LabelState, 600, 1)
self.setLayout(mainLayout)
def eventFilter(self, watched, event):
if watched == self.label1: # 只对label1的点击事件进行过滤,重写其行为,其他的事件会被忽略
if event.type() == QEvent.MouseButtonPress: # 这里对鼠标按下事件进行过滤,重写其行为
mouseEvent = QtGui.QMouseEvent(event)
if mouseEvent.buttons() == Qt.LeftButton:
self.LabelState.setText("按下鼠标左键")
elif mouseEvent.buttons() == Qt.MidButton:
self.LabelState.setText("按下鼠标中间键")
elif mouseEvent.buttons() == Qt.RightButton:
self.LabelState.setText("按下鼠标右键")
'''转换图片大小'''
transform = QtCore.QTransform()
transform.scale(0.5, 0.5)
tmp = self.image1.transformed(transform)
self.label1.setPixmap(QPixmap.fromImage(tmp))
if event.type() == QEvent.MouseButtonRelease: # 这里对鼠标释放事件进行过滤,重写其行为
self.LabelState.setText("释放鼠标按钮")
self.label1.setPixmap(QtGui.QPixmap.fromImage(self.image1))
return super().eventFilter(watched, event) # 其他情况会返回系统默认的事件处理方法。
if __name__ == "__main__":
run(EventFilter)
效果:
对于使用事件过滤器,关键是要做好两步。
对要过滤的控件设置 installEventFilter
,这些控件的所有事件都会被 eventFilter
函数接收并处理。
installEventFilter的使用方法如下:
self.label1.installEventFilter(self)
self.label2.installEventFilter(self)
self.label3.installEventFilter(self)
在 QApplication 中安装事件过滤器)的使用也非常简单,与第三种事件处理方法相比,只需要简单地修改两处代码即可。
屏蔽三个 label 标签控件的 installEventFilter 代码:
# self.label1.installEventFilter(self)
# self.label2.installEventFilter(self)
# self.label3.installEventFilter(self)
对于在 QApplication 中安装 installEventFilter,下面代码的意思是 dialog 的所有事件都要经过 eventFilter 函数处理,而不仅仅是三个标签控件的事件。
if __name__=='__main__':
app=QApplication(sys.argv)
dialog=EventFilter()
app.installEventFilter(dialog)
dialog.show()
app.exec_()
完整代码:
# -*- coding: utf-8 -*-
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class EventFilter(QDialog):
def __init__(self, parent=None):
super(EventFilter, self).__init__(parent)
self.setWindowTitle("事件过滤器")
self.label1 = QLabel("请点击")
self.label2 = QLabel("请点击")
self.label3 = QLabel("请点击")
self.LabelState = QLabel("test")
self.image1 = QImage("images/cartoon1.ico")
self.image2 = QImage("images/cartoon1.ico")
self.image3 = QImage("images/cartoon1.ico")
self.width = 600
self.height = 300
self.resize(self.width, self.height)
# self.label1.installEventFilter(self)
# self.label2.installEventFilter(self)
# self.label3.installEventFilter(self)
mainLayout = QGridLayout(self)
mainLayout.addWidget(self.label1, 500, 0)
mainLayout.addWidget(self.label2, 500, 1)
mainLayout.addWidget(self.label3, 500, 2)
mainLayout.addWidget(self.LabelState, 600, 1)
self.setLayout(mainLayout)
def eventFilter(self, watched, event):
print(type(watched))
if watched == self.label1: # 只对label1的点击事件进行过滤,重写其行为,其他的事件会被忽略
if event.type() == QEvent.MouseButtonPress: # 这里对鼠标按下事件进行过滤,重写其行为
mouseEvent = QMouseEvent(event)
if mouseEvent.buttons() == Qt.LeftButton:
self.LabelState.setText("按下鼠标左键")
elif mouseEvent.buttons() == Qt.MidButton:
self.LabelState.setText("按下鼠标中间键")
elif mouseEvent.buttons() == Qt.RightButton:
self.LabelState.setText("按下鼠标右键")
'''转换图片大小'''
transform = QTransform()
transform.scale(0.5, 0.5)
tmp = self.image1.transformed(transform)
self.label1.setPixmap(QPixmap.fromImage(tmp))
if event.type() == QEvent.MouseButtonRelease: # 这里对鼠标释放事件进行过滤,重写其行为
self.LabelState.setText("释放鼠标按钮")
self.label1.setPixmap(QPixmap.fromImage(self.image1))
return QDialog.eventFilter(self, watched, event) # 其他情况会返回系统默认的事件处理方法。
if __name__ == '__main__':
app = QApplication(sys.argv)
dialog = EventFilter()
app.installEventFilter(dialog)
dialog.show()
sys.exit(app.exec_())
可见。第四种事件处理方法确实过滤了所有事件,而不像第三种方法那样只过滤三个标签控件的事件。