1. 先把机制讲清楚:QLabel 的“悬浮”到底指什么
很多人第一次在 PyQt 里想做“鼠标移到某个 QLabel 上就变色 / 换图 / 弹提示”,第一反应是去找hovered信号,然后发现 QLabel 根本没有这个东西,翻文档翻半天,最后在QWidget的虚函数列表里看到enterEvent和leaveEvent才恍然大悟。这个坑我踩过不止一次,而且每次带新人都会遇到同样的问题,所以值得把它彻底讲透。
“鼠标悬浮事件”这个说法本身就有点模糊,在 Qt 的事件体系里,它至少对应三套不同的东西:一套是QEvent.Enter/QEvent.Leave,属于控件级别的进入和离开通知;一套是QEvent.HoverEnter/QEvent.HoverLeave/QEvent.HoverMove,需要显式开启Qt.WA_Hover属性才会派发;还有一套是样式表里的:hover伪状态,由QStyleSheetStyle在内部消化,属于绘制层面的事情。这三套机制并不是替代关系,而是分层关系,搞清楚它们各自负责什么,后面所有的选型和排错都会顺理成章。
这个内容适合谁看?如果你正在用 PyQt5 或 PyQt6 写桌面工具、做内部管理界面、做图片浏览或卡片列表,并且需要在 QLabel 这种“本来就不是交互控件”的东西上加悬浮反馈,那么下面的内容基本可以照着抄。如果你只是想让文字变个颜色,第 4 章的样式表方案三行代码就够了;如果你要做状态联动、动画、和业务逻辑挂钩,那就老老实实看第 2 章和第 3 章。
1.1 三套 hover 机制的分层关系
先说最底层的Enter/Leave。这两个事件由 Qt 的窗口系统在鼠标指针跨过控件边界时自动派发,不需要任何额外设置。QWidget收到之后会调用虚函数enterEvent()和leaveEvent(),默认实现是空的,所以你在源码里看不到任何反应。这一层的可靠度最高,因为它跟控件的几何区域直接绑定,不依赖任何属性开关。
第二层的HoverEnter/HoverLeave/HoverMove就要挑剔得多。它们只有在控件设置了Qt.WA_Hover属性之后才会被派发,HoverMove更是只有在鼠标未按下时也会持续产生移动通知,这是它和mouseMoveEvent最大的区别 —— 后者默认要按住鼠标键才触发,除非你调了setMouseTracking(True)。这两者的区别经常被混淆,我见过不少人为了让 hover 生效去开mouseTracking,结果没有任何效果,因为方向从一开始就错了。
第三层是 QSS 的:hover。写出QLabel:hover { ... }之后,QStyleSheetStyle会在内部给这个控件设置WA_Hover并接管相关事件,你不需要手动做任何事情,但代价是你对事件流的控制权被样式表拿走了一部分。这一层最省事,也最容易出现“明明写了却不生效”的情况,原因通常出在选择器写错、控件没有可绘制的背景、或者父级的样式表把子控件覆盖掉了。
| 机制 | 触发条件 | 是否需要额外设置 | 适用场景 |
|---|---|---|---|
enterEvent/leaveEvent | 鼠标跨越控件边界 | 无 | 状态联动、业务逻辑、动画 |
HoverEnter/HoverLeave/HoverMove | 鼠标在控件内移动 | 需Qt.WA_Hover | 需要跟随鼠标位置的高亮 |
QSS:hover | 由样式引擎内部派发 | 无(引擎自动处理) | 纯视觉反馈、快速原型 |
三者的执行顺序也值得记一下:鼠标进入控件时,Enter事件先到达,接着才是HoverEnter;离开时顺序反过来。如果你在同一时刻既重写了enterEvent又写了 QSS 的:hover,那么两边都会生效,但状态更新的时机可能差一帧,肉眼一般看不出来,做动画的时候就会暴露。
1.2 QLabel 继承链上默认开启/关闭了哪些能力
QLabel 的继承链是QLabel → QFrame → QWidget,所以它天然拥有QWidget的全部事件虚函数,包括enterEvent和leaveEvent。这一点经常被误解成“QLabel 不支持悬浮事件”,准确的说法应该是:QLabel 不支持悬浮信号,但支持悬浮事件。信号和事件在 Qt 里是两套东西,信号要靠你自己定义,事件是框架白送的。
WA_Hover属性在 QWidget 上默认是关闭的,QLabel 也没有开。所以你在 QLabel 上直接重写event()去判断QEvent.HoverEnter,会发现永远收不到。这个问题我在早期排查了很久,最后是看到源码里QWidget::event()对Enter事件的处理 —— 只有当WA_Hover被设置时,才会顺带派发对应的HoverEnter。理解这一点之后,“为什么开了 mouseTracking 也没用”这个问题也就有了答案。
另外还有几个 QLabel 自身的特点会影响悬浮行为。第一,QLabel 的sizeHint是根据内容算出来的,如果它既没有文字也没有图片,尺寸可能是 0,那就不存在可悬浮的区域,鼠标永远不会进入。第二,QLabel 默认不接受焦点,也不处理鼠标按下,所以拿它当按钮用的时候需要自己补上mousePressEvent。第三,QLabel 有个自己的信号linkHovered,专门用于富文本里的超链接悬浮,这个信号经常被忽略,但在做帮助文档、协议链接这类场景时非常好用。
1.3 三条实现路线的横向选型
选型其实不复杂,我一般按“要不要参与业务逻辑”来分。纯视觉反馈,比如鼠标划过卡片背景变浅、边框变蓝,直接上 QSS,代码量最小,维护成本最低,后期改颜色不用动 Python。需要跟业务联动,比如悬浮时在右侧面板同步显示这张图的详细信息、悬浮时暂停某个定时器,那就重写enterEvent/leaveEvent,状态可控,调试也直观。
至于事件过滤器这条路,适合的场景是“控件不是我自己创建的”。比如界面上有一批 QLabel 是从.ui文件加载的,或者来自某个第三方组件库,你不方便给每一个都换成自定义子类,这时候给它们统一装一个事件过滤器,在外部集中处理,改动面最小。这个方案我在维护老项目时用得最多,因为它完全不用改动原有代码结构。
还有第四个选项,严格来说不算 hover 事件,但经常被拿来当替代方案:用QToolTip配合setToolTip(),鼠标停留一会儿自动弹提示框。这个不是即时响应,有延迟,而且样式受全局控制,不适合做交互动效,但在“补充说明文字”这种场景下足够用,一行代码解决,性价比很高。
2. 重写 enterEvent / leaveEvent:最稳的一条路
重写虚函数是所有方案里最可控的,因为事件从产生到处理的全过程都在你手里,出了问题也容易定位。这一章我把完整代码、版本差异和状态对外暴露的写法都过一遍。
2.1 最小可跑通的代码
先给一个能直接跑的最小例子。继承 QLabel,重写两个虚函数,在函数里改写内部状态并触发重绘,同时发一个自定义信号出去备业务使用。注意super().enterEvent(event)这行不要省,父类实现虽然看起来是空的,但 Qt 内部有些状态维护依赖它,省略之后在复杂布局里偶尔会出现父容器收不到正确通知的情况。
import sys from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout class HoverLabel(QLabel): hoverIn = pyqtSignal() hoverOut = pyqtSignal() def __init__(self, text="", parent=None): super().__init__(text, parent) self._hovered = False self.setMinimumSize(160, 48) self.setAlignment(Qt.AlignCenter) @property def hovered(self): return self._hovered def enterEvent(self, event): self._hovered = True self.hoverIn.emit() self.update() super().enterEvent(event) def leaveEvent(self, event): self._hovered = False self.hoverOut.emit() self.update() super().leaveEvent(event) def paintEvent(self, event): super().paintEvent(event) if self._hovered: from PyQt5.QtGui import QPainter, QPen painter = QPainter(self) painter.setPen(QPen(Qt.blue, 2)) painter.drawRect(self.rect().adjusted(1, 1, -1, -1))这段代码里我特意保留了setMinimumSize,原因在 1.2 里提过 —— 如果 QLabel 的内容为空,尺寸算出来是 0,鼠标进不来。加一个最小尺寸是最省事的兜底方式。另外self.update()不是必须的,因为改状态通常伴随样式变化会自然重绘,但如果你的绘制逻辑完全依赖_hovered这个内部变量,就得手动触发。
跑起来你会看到鼠标移上去边框变蓝,移开恢复。这就是最基础的版本,接下来所有复杂场景都是在这个骨架上加东西。
2.2 PyQt5 和 PyQt6 的签名差异
这里有一个必须提前说清楚的版本坑:enterEvent的参数类型在 Qt5 和 Qt6 里不一样。Qt5 里是enterEvent(QEvent *event),Qt6 里改成了enterEvent(QEnterEvent *event),QEnterEvent是从QEvent派生出来的新类型,额外带了鼠标进入时的坐标信息。leaveEvent倒是没变,还是QEvent。
Python 是动态类型,你不写类型注解,参数名随便叫,照样能跑,所以这个差异在运行时不会报错。但有两个后果:一是如果你的代码里需要用到进入点的坐标(比如做一个从鼠标位置展开的效果),在 PyQt5 里拿不到,得自己用QCursor.pos()转;二是在做类型标注和静态检查的时候,写错类型会报警告,团队协作里很难看。
# PyQt6 的正确写法 from PyQt6.QtGui import QEnterEvent from PyQt6.QtCore import QEvent class HoverLabel6(QLabel): def enterEvent(self, event: QEnterEvent) -> None: print("进入位置:", event.position()) super().enterEvent(event) def leaveEvent(self, event: QEvent) -> None: super().leaveEvent(event)QEnterEvent提供的position()返回的是QPointF,相对控件左上角。这个信息在做“从鼠标进入点扩散”的动画时很有用,比QCursor.pos()减去mapToGlobal(QPoint(0,0))要干净得多 —— 后者在高 DPI 缩放下偶尔会有半像素误差,我吃过这个亏,做缩放动画的时候边缘会抖一下。
另外提醒一句,如果你在同一个项目里同时要兼容 PyQt5 和 PyQt6,最简单的做法是用try/except导入:
try: from PyQt6.QtGui import QEnterEvent QT6 = True except ImportError: from PyQt5.QtGui import QEvent as QEnterEvent QT6 = False然后参数注解统一写QEnterEvent,不写死具体类型,运行时也不做类型判断。这种兼容写法在小工具类库里很常见,成本低,收益明显。
2.3 把 hover 状态做成信号对外暴露
直接重写事件有个隐患:状态只存在于控件内部,外部想同步就得持有控件引用去读属性。这在简单界面里没问题,但一旦界面复杂起来,比如左侧列表悬浮时要联动右侧详情、或者悬浮时要暂停某个自动轮播的定时器,靠外部轮询或者硬编码连接会很别扭。
我一般的做法是在自定义类里定义两个信号hoverIn和hoverOut,然后在构造函数或界面初始化阶段连到槽函数上。这样控件本身不关心外面要做什么,只负责老实上报状态,耦合度低,也方便做单元测试 —— 你可以手动调用hoverIn.emit()来验证下游逻辑,不需要真的去模拟鼠标。
def build_ui(self): for i in range(6): label = HoverLabel(f"卡片 {i + 1}") label.hoverIn.connect(lambda idx=i: self.detail_panel.show_item(idx)) label.hoverOut.connect(self.detail_panel.clear) self.list_layout.addWidget(label)这里有个 Python 闭包的经典坑:循环里用 lambda 连信号,如果不把循环变量作为默认参数绑进去,所有 lambda 捕获的都是最后一个值。我第一次写这段代码的时候六个卡片悬浮全显示最后一项,排查了半小时才反应过来不是 Qt 的问题,是 Python 的作用域问题。写成lambda idx=i:就没问题了,或者用functools.partial。
再补充一个实践心得:hoverIn和hoverOut这种成对的信号,建议在hoverOut里不要做太重的事情,比如重新构建整个布局。因为鼠标快速划过一排控件时会连续触发 enter/leave,重操作会明显卡顿。我一般的做法是在hoverOut里只重置状态和样式,真正的数据加载放在hoverIn里并加一个短延迟防抖,200 毫秒左右,肉眼感知不到,但能砍掉大量无效触发。
3. 事件过滤器:不改造 QLabel 也能监听
事件过滤器是 Qt 里一个非常实用的机制,允许一个对象去“旁观”另一个对象收到的所有事件。对于 hover 这种需求,它最大的价值是:不用新建子类,可以对已经存在的 QLabel 生效,甚至可以批量对某个容器下的所有子控件生效。
3.1 装过滤器的正确姿势
用法分三步:定义一个继承自QObject的类并重写eventFilter,创建实例,调用目标控件的installEventFilter()把实例装上去。装上之后,目标控件收到的每一个事件都会先经过过滤器的eventFilter方法,你可以在这里判断类型、做处理,然后决定是放行还是拦下。
from PyQt5.QtCore import QObject, QEvent, Qt class HoverWatcher(QObject): def __init__(self, on_enter=None, on_leave=None, parent=None): super().__init__(parent) self._on_enter = on_enter self._on_leave = on_leave def eventFilter(self, obj, event): if event.type() == QEvent.Enter: if self._on_enter: self._on_enter(obj) elif event.type() == QEvent.Leave: if self._on_leave: self._on_leave(obj) return False用的时候:
watcher = HoverWatcher( on_enter=lambda w: w.setStyleSheet("color: #1a73e8;"), on_leave=lambda w: w.setStyleSheet(""), parent=self, ) for label in self.findChildren(QLabel): label.installEventFilter(watcher)findChildren(QLabel)这一行会把整棵子控件树里所有的 QLabel 都找出来,一次性全部装上。这个写法在改造老界面时特别爽,原本一堆静态 QLabel 瞬间就有了悬浮反馈,一行 Python 都不用改到原有代码。
要注意父对象的问题。HoverWatcher(parent=self)里的 parent 最好传一个有生命周期的对象,比如当前窗口。如果不传,Python 的垃圾回收可能会在你不知情的时候把 watcher 回收掉,过滤器就失效了,而且不会报任何错,表现得像是“偶尔生效偶尔不生效”,极难排查。这个问题我在一个长时间运行的工具里遇到过,最后是加了个成员变量持有引用才解决。
3.2 一个可复用的 HoverWatcher
上面那个版本已经够用了,但真实项目里往往还要区分“哪个控件进来了”。因为eventFilter的第一个参数obj就是事件的目标控件,所以你可以直接拿到它做判断,或者用obj.property("itemId")取出业务标识。
def eventFilter(self, obj, event): et = event.type() if et == QEvent.Enter: obj.setProperty("hovered", True) obj.style().unpolish(obj) obj.style().polish(obj) elif et == QEvent.Leave: obj.setProperty("hovered", False) obj.style().unpolish(obj) obj.style().polish(obj) return False这里用动态属性配合unpolish/polish代替直接setStyleSheet,是因为频繁调用setStyleSheet会触发整个样式树重新解析,控件一多就明显卡。改成属性驱动之后,样式表只需要在窗口初始化时设一次,之后只是切换属性值,开销小得多。这个技巧值得记下来,凡是“状态切换导致样式变化”的场景都适用。
还有一个细节:polish和unpolish这一对调用看起来有点反直觉,顺序是先unpolish再polish,作用是丢弃旧样式缓存然后重新计算。漏掉任何一步都会出现“属性变了但界面没变”的情况,尤其是嵌套布局里的控件,我第一次写的时候只调了polish,调了半天没反应。
3.3 返回值写错会出什么事
eventFilter的返回值非常关键,False表示“我看过了,继续往下传”,True表示“我处理了,到此为止不再传递”。写True相当于把事件吞掉了,目标控件自己就收不到了。
对于Enter/Leave这种事件,吞掉的后果是:控件内部用于维护 hover 状态的那套逻辑看不到事件。如果这个控件同时用了 QSS 的:hover,样式就不会更新,出现“代码里明明执行了但界面没反应”的诡异现象。更麻烦的是,有些控件依赖Leave事件来复位内部状态,比如 QPushButton 的按下状态、QScrollBar 的滚动条显示,吞掉之后行为会变得很奇怪。
我的经验是:除非你确定要拦截,否则永远返回False。如果真的需要拦截特定事件,也只拦截你关心的那几种类型,其他一律放行,不要图省事在方法末尾统一return True。这个坑我在一个自定义下拉框里踩过,当时把Leave吞了,结果下拉框点开之后鼠标移出,弹出层不消失,用户点哪儿都是失效的,排查了半天才定位到是过滤器返回值的问题。
4. QSS 的 :hover:最省事,但前提条件不少
如果你的需求只是视觉变化,QSS 是性价比最高的方案,没有之一。写几行样式,不用新建类,不用装过滤器,改颜色的时候产品经理来了直接改字符串就行。
4.1 样式表 hover 的生效链路
基本写法很直白:
QLabel#thumbCard:hover { background-color: #eaf2fb; border: 1px solid #1a73e8; border-radius: 6px; }要让这段样式真的生效,有几个前置条件常被忽略。第一,控件必须有对象名或者能匹配到选择器,#thumbCard对应setObjectName("thumbCard"),如果没设置对象名,就得用类型选择器QLabel,但那样会波及所有 QLabel。第二,控件要有可绘制的区域,QLabel 如果设置了autoFillBackground(True)并且调色板被别的代码改过,可能会盖掉样式表的背景,这种情况我遇到过,最后是靠关掉autoFillBackground解决的。第三,如果父控件设置了样式表,子控件会继承一部分规则,写选择器的时候最好带上前缀,比如#mainPanel QLabel:hover,避免误伤。
还有一个更隐蔽的问题:QLabel 显示的是富文本或者图片时,样式表设置的背景会被内容覆盖。设置setPixmap()之后,背景色在图片区域是完全看不到的,只有边框还在。这时候要么给外层套一个 QFrame 做背景,要么改用绘制事件自己画背景,别在样式表上死磕。
4.2 和重写事件混用时最容易踩的冲突
这是我最想强调的一点。当你在同一个控件上既重写了enterEvent,又写了 QSS 的:hover,两者会互相干扰,表现是“样式偶尔生效偶尔不生效”。
根本原因在于 1.1 里说的:QSS 的 hover 依赖QStyleSheetStyle内部去处理HoverEnter/HoverLeave事件来更新状态。如果你的enterEvent里做了某些操作,比如调用setStyleSheet()重新设置样式、或者调用setAttribute改动属性、或者在事件里return掉了不让父类处理,样式引擎收不到事件,:hover的状态就卡住了。
具体到代码上,最常见的错误是在enterEvent里重新设置样式表:
# 不推荐的写法 def enterEvent(self, event): self.setStyleSheet("QLabel { background: #eaf2fb; }") # 覆盖了整个样式表 super().enterEvent(event)这样写会把之前设置的所有样式全部替换掉,包括那些带:hover的规则,等于自己把样式引擎的配置给清了。正确做法是用动态属性,样式表只在初始化时设一次,hover 的时候改属性值。
如果确实需要同时用两套,我的建议是分工明确:QSS 只负责静态外观和简单的:hover反馈,enterEvent里只做业务逻辑,不碰样式。两边各管一摊,互不干扰。这个约定在团队里推广之后,相关的问题基本就绝迹了。
4.3 动态属性驱动的状态样式
动态属性配合样式表是 Qt 里非常优雅的一套做法,值得单独展开讲讲。
def set_hovered(self, on: bool): self.setProperty("hovered", on) self.style().unpolish(self) self.style().polish(self)配套的样式表:
QLabel[hovered="true"] { background-color: #eaf2fb; border: 1px solid #1a73e8; } QLabel[hovered="false"] { background-color: #ffffff; border: 1px solid #dfe3e8; }注意属性值在样式表里要写成字符串"true"/"false",Python 里传的是布尔值,Qt 会做转换,但选择器匹配的是字符串形式,写错大小写就不生效。这一点文档里写得不明显,我第一次写的时候用了True首字母大写,样式死活不生效。
动态属性的好处是把“状态”和“外观”解耦了。同一个属性可以驱动多种控件的外观,甚至可以在样式表里用[hovered="true"][disabled="true"]这样的组合选择器表达复杂状态。而且属性值可以通过property()读出来,业务代码判断状态的时候不用再维护一堆布尔成员变量,代码干净很多。
5. 落进真实界面:四个典型用法拆解
前面讲的是机制,这一章讲怎么用。四个场景都是我在实际项目里做过的,代码结构可以直接参考。
5.1 卡片列表的悬停高亮
这是最常见的需求:一列卡片,鼠标划过哪张哪张高亮,同时右侧或者下方展示对应的详情。实现上用HoverLabel加动态属性最合适,因为要和业务联动,必须能拿到 hover 信号。
class CardLabel(QLabel): activated = pyqtSignal(object) def __init__(self, item, parent=None): super().__init__(parent) self.item = item self.setText(item.title) self.setObjectName("cardLabel") self.setProperty("hovered", False) self.setMinimumHeight(56) self.setContentsMargins(12, 8, 12, 8) self.setCursor(Qt.PointingHandCursor) def enterEvent(self, event): self._set_hover(True) super().enterEvent(event) def leaveEvent(self, event): self._set_hover(False) super().leaveEvent(event) def _set_hover(self, on): self.setProperty("hovered", on) self.style().unpolish(self) self.style().polish(self)两个细节值得说。setCursor(Qt.PointingHandCursor)这行很重要,它给了用户一个“这里可以点”的视觉暗示,成本极低但体验提升明显,很多内部工具就是缺这种小细节显得粗糙。contentsMargins是为了给背景色留出内边距,不然文字贴着边框很难看。
还有一个容易忽略的点:卡片列表通常放在QScrollArea里,如果卡片之间没有间隔,鼠标从一个卡片直接滑到另一个,Qt 会正常派发 leave 和 enter,顺序是先 leave 旧的再 enter 新的。但如果布局里卡片有重叠(比如用了负间距做叠层效果),事件顺序会变得不可预测,可能先 enter 新的再 leave 旧的,导致最终状态错误。解决办法是在_set_hover(True)里检查一下当前是不是已经有别的卡片处于 hover 状态,先把它复位。
5.2 缩略图悬停换图与放大
图片类需求有两个层次:换图和缩放。换图最简单,准备两张QPixmap,在 enter/leave 里切换:
def enterEvent(self, event): self.setPixmap(self.hover_pixmap) super().enterEvent(event) def leaveEvent(self, event): self.setPixmap(self.normal_pixmap) super().leaveEvent(event)这里需要注意的是图片尺寸。如果两张图尺寸不一致,setPixmap之后 QLabel 的sizeHint会变,触发父布局重新计算,整个界面会跳一下。所以要么预先统一尺寸,要么固定 QLabel 的setFixedSize(),让图片去适应控件,而不是让控件去适应图片。我一般会在初始化时把两张图都scaled()到同一尺寸,用Qt.SmoothTransformation保证缩放质量。
缩放效果就要复杂一些,因为直接改geometry会触发布局重算,卡顿明显。我的做法是用QGraphicsOpacityEffect做透明度渐变,或者改变QPixmap本身的大小再重设,前者性能好,后者视觉效果更强。
from PyQt5.QtWidgets import QGraphicsOpacityEffect from PyQt5.QtCore import QPropertyAnimation, QEasingCurve effect = QGraphicsOpacityEffect(label) label.setGraphicsEffect(effect) anim = QPropertyAnimation(effect, b"opacity", label) anim.setDuration(150) anim.setStartValue(0.85) anim.setEndValue(1.0) anim.setEasingCurve(QEasingCurve.OutCubic)有个坑要提醒:一个控件同时只能挂一个 graphics effect,如果你已经用 effect 做了别的效果,就没法再叠加。而且 graphics effect 会让控件走离屏渲染,和某些样式表规则会冲突,表现为圆角失效或者阴影丢失。做视觉要求高的界面时,我更倾向于用自绘paintEvent配合QPainter的setOpacity,控制力更强,副作用更少。
5.3 富文本链接的 linkHovered
QLabel 自带一个linkHovered信号,专门用于富文本里的超链接悬浮,参数是被悬浮的链接地址。这个信号知道的人不多,但在做帮助中心、协议条款、版本更新说明这类内容时特别有用。
label = QLabel() label.setTextFormat(Qt.RichText) label.setText('<a href="detail">查看详情</a> | <a href="help">帮助</a>') label.setTextInteractionFlags(Qt.LinksAccessibleByMouse) label.linkHovered.connect(lambda url: self.status_bar.showMessage(url))关键在setTextInteractionFlags(Qt.LinksAccessibleByMouse),不设这个,链接根本不可点,linkHovered也不会触发。这个标志位还决定了文字能不能被选中,如果只想让链接可点但不想让用户选中文字,用Qt.LinksAccessibleByMouse就够了;如果还要支持键盘操作,再加LinksAccessibleByKeyboard。
实测下来这个信号的触发范围比想象中精确,鼠标只有真正在链接文字上才会触发,悬浮在普通文字上不会有反应。所以拿它做“鼠标悬停在超链接上时改变整个控件样式”的效果是可行的,比自己去算文字坐标要靠谱得多。
5.4 悬停动画与性能取舍
hover 动画最容易犯的错是“动画属性选错导致布局抖动”。任何会改变控件尺寸的属性,比如geometry、minimumHeight、maximumWidth,动画起来都会触发父布局的重新计算,一帧几毫秒看着不多,但十个卡片同时动起来就掉帧了。
安全的动画属性有三类:opacity(通过 graphics effect 或自绘)、color(通过QPalette或者自绘)、以及不参与布局的pos(如果控件是绝对定位的话)。其中 opacity 的兼容性最好,效果也最温和。
# 推荐的写法:只动 opacity,不动尺寸 anim = QPropertyAnimation(effect, b"opacity", self) anim.setDuration(120) anim.setEasingCurve(QEasingCurve.OutQuad)还有一点:动画对象一定要有父对象或者在成员变量里持有,否则 Python 回收之后动画就停了,表现为“第一次有效果,之后没了”。这个坑非常典型,我至少见过三次不同的项目里出现同样的问题。
时长方面,hover 这类高频交互,120 到 180 毫秒比较合适,超过 250 毫秒用户会觉得迟钝,低于 80 毫秒又看不出动画过程。缓动曲线用OutQuad或者OutCubic,起步快收尾慢,感觉比较跟手。
6. 排查实录:hover 不生效/状态错乱的速查表
这一章全是踩坑经验,症状和原因一一对应,遇到问题直接对照着查。
6.1 症状对照表
| 症状 | 最可能的原因 | 处理方式 |
|---|---|---|
| 完全不触发 | 重写了event()但没调父类实现 | 确认super().event(event)被调用 |
| 完全不触发 | 控件尺寸为 0 | 设置setMinimumSize或填充内容 |
| 完全不触发 | 控件被WA_TransparentForMouseEvents穿透 | 检查并取消该属性 |
| 只触发一次 | 事件过滤器对象被回收 | 用成员变量持有 watcher 引用 |
| 样式变了但事件没触发 | QSS:hover在起作用,事件被吞 | 检查eventFilter是否返回True |
| enter 后立刻 leave | 控件内部有子控件,父子切换抖动 | leaveEvent里判断光标是否真的在外 |
| 滚动后状态错乱 | 滚动不触发 enter/leave | 滚动时手动清理 hover 状态 |
| 弹窗后状态卡住 | 弹出窗口导致 leave 丢失 | 定时器兜底或QApplication.activeWindow判断 |
6.2 三个必须记住的坑
第一个坑,父子控件之间的 enter/leave 抖动。这个是 Qt 的既有行为,不是 bug。鼠标从父控件移到它内部的子控件上时,父控件会收到Leave,子控件收到Enter。如果你的 QLabel 里面放了一个小图标 QLabel,鼠标划过图标区域,外层就会不断地 leave 和 enter,视觉效果是背景一闪一闪。
def leaveEvent(self, event): pos = self.mapFromGlobal(QCursor.pos()) if self.rect().contains(pos): return # 光标还在自己范围内,只是移到了子控件上 super().leaveEvent(event)这段判断的核心逻辑是:leave 事件触发之后,用光标的全局坐标反查它是否还在自己的矩形区域内。如果在,说明是父子切换引起的假离开,直接忽略。这个技巧我在做复杂卡片布局时用了很多次,非常有效。
第二个坑,滚动区域里的状态错位。鼠标停在某个卡片上不动,用户滚动滚轮,卡片位置变了,但鼠标没有跨越边界,Qt 不会派发新的 enter/leave,结果就是 hover 状态还挂在已经滚走的卡片上。解决思路是在滚动条的valueChanged里遍历可见控件,重新判断一次光标位置,把状态刷新一遍。这个逻辑写起来不难,但很容易被忘记,测试的时候不滚动根本发现不了。
第三个坑,鼠标快速移动时丢事件。这个在低配机器和远程桌面环境下比较明显,鼠标甩得飞快,中间经过的控件可能一个 enter 都没收到。属于系统层面的限制,没法完全根治。缓解办法有两种:一是用QTimer以 100 毫秒左右的间隔主动检查光标位置,做一次状态校正;二是用HoverEnter事件作为补充,实测它比Enter在高频移动下稍微可靠一点。前者更稳,代价是多了一个定时器。
最后分享一个我在排查这类问题时的习惯:在enterEvent和leaveEvent的第一行打日志,带上控件对象名和事件时间戳。很多“偶发”的问题,看一眼日志时间戳就能判断是事件没来、还是来了但处理逻辑被短路了。这个习惯帮我省下的时间是按小时算的,比漫无目的地读文档高效得多。