PyQt5实现平滑移动侧边菜单栏与悬停触发动画

蓝鲸
蓝鲸
发布时间:2026-10-11 | 1人浏览过
原创

本文提供基于PyQt5实现平滑移动侧边菜单栏的完整代码方案。通过自定义组件结合QPropertyAnimation与QEasingCurve曲线,原生支持左、上、右、下四方向滑动展开。代码包含鼠标悬停触发机制、宽度占比动态配置以及防界面卡死优化,适配常规桌面端开发需求,直接复制即可运行验证。

核心目标与运行效果

该方案旨在为桌面应用添加类似移动端或现代浏览器的侧边抽屉菜单。利用Qt自带的属性动画系统,在不使用外部第三方库的前提下,实现控件位置变化的平滑插值。运行后,当鼠标移入窗口特定区域时,菜单面板会沿指定边界流畅滑入;点击面板外的半透明遮罩层可自动触发收回动画。

第1步:封装支持平滑过渡的抽屉组件

核心逻辑集中在自定义控件中。该类继承自QWidget,通过设置无框与悬浮窗口标志,配合半透明子控件实现遮罩效果。动画对象绑定至控件的位置属性(pos),并使用OutCubic缓动曲线模拟自然阻尼效果。

from PyQt5.QtCore import Qt, QPropertyAnimation, QEasingCurve, QPoint
from PyQt5.QtGui import QMouseEvent
from PyQt5.QtWidgets import QWidget, QApplication

class CDrawer(QWidget):
    LEFT, TOP, RIGHT, BOTTOM = range(4)

    def __init__(self, *args, stretch=1 / 3, direction=0, widget=None, **kwargs):
        super(CDrawer, self).__init__(*args, **kwargs)
        self.setWindowFlags(self.windowFlags()
                            | Qt.FramelessWindowHint | Qt.Popup | Qt.NoDropShadowWindowHint)
        self.setAttribute(Qt.WA_StyledBackground, True)
        self.setAttribute(Qt.WA_TranslucentBackground, True)
        
        # 进入动画配置
        self.animIn = QPropertyAnimation(self, b'pos', duration=500)
        self.animIn.setEasingCurve(QEasingCurve.OutCubic)
        # 离开动画配置
        self.animOut = QPropertyAnimation(self, b'pos', duration=500)
        self.animOut.setEasingCurve(QEasingCurve.OutCubic)
        self.animOut.finished.connect(self.onAnimOutEnd)
        
        self.setStretch(stretch)
        self.direction = direction
        
        # 半透明背景遮罩
        self.alphaWidget = QWidget(self, objectName='CDrawer_alphaWidget')
        self.alphaWidget.setStyleSheet('background:rgba(55,55,55,100);')
        self.alphaWidget.setAttribute(Qt.WA_TransparentForMouseEvents, True)
        self.setWidget(widget)

    def resizeEvent(self, event):
        self.alphaWidget.resize(self.size())
        super(CDrawer, self).resizeEvent(event)

    def mousePressEvent(self, event):
        pos = event.pos()
        if pos.x() >= 0 and pos.y() >= 0 and self.childAt(pos) == None and self.widget:
            if not self.widget.geometry().contains(pos):
                self.animationOut()
                return
        super(CDrawer, self).mousePressEvent(event)

    def show(self):
        super(CDrawer, self).show()
        parent = self.parent().window() if self.parent() else self.window()
        if not parent or not self.widget:
            return
        self.setGeometry(parent.geometry())
        self.animationIn(self.geometry())

    def animationIn(self, geometry):
        width = int(geometry.width() * self.stretch)
        height = int(geometry.height() * self.stretch)
        self.widget.hide()
        
        if self.direction == self.LEFT:
            self.widget.setGeometry(0, 0, width, geometry.height())
            self.animIn.setStartValue(QPoint(-width, 0))
            self.animIn.setEndValue(QPoint(0, 0))
        elif self.direction == self.RIGHT:
            self.widget.setGeometry(geometry.width() - width, 0, width, geometry.height())
            self.animIn.setStartValue(QPoint(self.width(), 0))
            self.animIn.setEndValue(QPoint(self.width() - width, 0))
        elif self.direction == self.TOP:
            self.widget.setGeometry(0, 0, geometry.width(), height)
            self.animIn.setStartValue(QPoint(0, -height))
            self.animIn.setEndValue(QPoint(0, 0))
        elif self.direction == self.BOTTOM:
            self.widget.setGeometry(0, geometry.height() - height, geometry.width(), height)
            self.animIn.setStartValue(QPoint(0, self.height()))
            self.animIn.setEndValue(QPoint(0, self.height() - height))
            
        self.animIn.start()
        self.widget.show()

    def animationOut(self):
        self.animIn.stop()
        geo = self.widget.geometry()
        width = self.widget.width()
        height = self.widget.height()
        
        if self.direction == self.LEFT:
            self.animOut.setStartValue(geo.topLeft())
            self.animOut.setEndValue(QPoint(-width, 0))
        elif self.direction == self.RIGHT:
            self.animOut.setStartValue(geo.topLeft())
            self.animOut.setEndValue(QPoint(self.width(), 0))
        elif self.direction == self.TOP:
            self.animOut.setStartValue(geo.topLeft())
            self.animOut.setEndValue(QPoint(0, -height))
        elif self.direction == self.BOTTOM:
            self.animOut.setStartValue(geo.topLeft())
            self.animOut.setEndValue(QPoint(0, self.height()))
            
        self.animOut.start()

    def onAnimOutEnd(self):
        QApplication.sendEvent(self, QMouseEvent(
            QMouseEvent.MouseButtonPress, QPoint(-1, -1), Qt.LeftButton, Qt.NoButton, Qt.NoModifier))

    def setWidget(self, widget):
        self.widget = widget
        if widget:
            widget.setParent(self)
            self.animIn.setTargetObject(widget)
            self.animOut.setTargetObject(widget)

    def setStretch(self, stretch):
        self.stretch = max(0.1, min(stretch, 0.9))

    def getDirection(self):
        return self.direction

    def setDirection(self, direction):
        d = int(direction)
        self.direction = d if 0 <= d <= 3 else self.LEFT

第2步:构建主窗口与交互触发逻辑

主窗口负责管理抽屉实例的生命周期,并通过重写鼠标事件捕获悬停行为。为避免重复创建控件,采用惰性初始化模式。鼠标移入左侧边界范围即触发滑入,移出则重置状态。

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPainter, QCursor
from PyQt5.QtWidgets import *
from CDrawer import CDrawer
import sys

# 内部面板样式定义
class DrawerWidget(QWidget):
    def __init__(self, *args, **kwargs):
        super(DrawerWidget, self).__init__(*args, **kwargs)
        self.setStyleSheet('background:white;')
        layout = QVBoxLayout(self)
        layout.addWidget(QLineEdit(self))
        layout.addWidget(QPushButton('确认操作', clicked=self._click))
        layout.addWidget(QPushButton('取消', clicked=self._click2))

    def _click(self):
        txt = self.findChild(QLineEdit).text()
        print(f"输入内容: {txt}")

    def _click2(self):
        pass

class Window(QWidget):
    def __init__(self, *args, **kwargs):
        super(Window, self).__init__(*args, **kwargs)
        self.resize(480, 960)
        self.setStyleSheet('background:#f0f0f0;')
        self.hover_active = False
        self.cursor_pos = (0, 0)

    def enterEvent(self, event):
        self.hover_active = True
        super().enterEvent(event)

    def leaveEvent(self, event):
        self.hover_active = False
        super().leaveEvent(event)

    def mouseMoveEvent(self, event):
        self.cursor_pos = (event.x(), event.y())
        # 左侧悬停触发打开
        if self.hover_active and event.x() < 100:
            self.do_open_drawer()
        super().mouseMoveEvent(event)

    def do_open_drawer(self):
        if not hasattr(self, 'left_drawer') or not self.left_drawer.isVisible():
            container = DrawerWidget(self)
            self.left_drawer = CDrawer(self, direction=CDrawer.LEFT)
            self.left_drawer.setStretch(0.25)
            self.left_drawer.setWidget(container)
        self.left_drawer.show()
        # 恢复光标为默认箭头
        self.setCursor(Qt.ArrowCursor)

    def paintEvent(self, event):
        # 仅执行基础绘制,避免调用 update() 导致重绘死循环
        super().paintEvent(event)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = Window()
    win.show()
    sys.exit(app.exec_())

第3步:调节动画参数与显示比例

组件暴露了关键的配置入口。调用 setStretch(value) 可控制菜单面板占据主窗口的宽高比例,有效范围被限制在 0.1 到 0.9 之间以防越界。通过 setDirection() 切换整型参数(0左/1上/2右/3下)即可无缝切换锚定边缘。所有坐标计算均基于父窗口的几何信息动态适配高DPI缩放。

运行验证与排错指南

  • 界面卡顿或CPU飙升:原代码主窗口的 paintEvent 内存在频繁调用 self.update() 的情况,这会递归触发绘制事件。已替换为基础绘制逻辑,若需自定义UI元素,请在事件中调用 super().paintEvent(event) 后直接返回,切勿再次刷新窗口。
  • 动画未触发或位置偏移:检查是否遗漏了 setWidget() 绑定。若控件未正确挂载为父窗口的子项,动画将作用于不可见区域。确保主窗口未设置固定布局锁死大小。
  • 点击收起无效:alphaWidget 设置了 WA_TransparentForMouseEvents 属性以透传点击,实际判断逻辑位于 mousePressEvent 中校验 childAt()。若添加了复杂嵌套控件导致误判,可适当调整 hit-testing 掩码。

总结

以上代码完整覆盖了平滑移动的侧边栏核心实现路径。利用Qt底层动画管线替代手动定时器刷新,不仅降低了资源消耗,还保证了多操作系统下的渲染一致性。开发者可直接将模块嵌入现有GUI框架中,并根据业务形态微调缓动节奏与响应阈值。以上就是PyQt5实现平滑移动侧边菜单栏的详细流程,更多关于Python GUI开发与界面动画优化的资料请关注本站其它相关文章!

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表