简介:面向Python开发者的PyQt5界面布局学习案例,展示如何在主窗口中实现左侧导航栏的抽屉/折叠效果,同时将界面与业务逻辑解耦,适合希望提升桌面应用界面组织能力的PyQt5初学者或中级开发者。资源包共15个文件,以.py源代码、.ui界面定义、.xml配置文件及.pyc编译文件为主,另含一张效果预览图与项目配置文件,压缩包仅43KB,便于快速下载研究。已有8218人学习浏览,受到同类开发者认可。通过该案例可掌握左侧菜单的展开收起动画、自定义控件组织、界面与业务分离的模块化设计思路,并可在现有代码基础上扩展菜单项与业务页面,快速套用到个人项目中。该案例适合用于快速搭建带侧边栏的工具软件、管理后台等桌面应用,整体麻雀虽小但五脏俱全。代码结构清晰,内置UI与业务文件独立划分,适合边读边改,作为PyQt5实战入门参考。
1. 这个抽屉导航项目到底解决了什么问题
先交代一下背景。前一阵子我接了一个内部工具的需求,要求做一个带左侧导航栏的桌面客户端,功能模块有五个左右:数据看板、订单查询、配置管理、日志查看、用户权限。这类界面形态在管理后台里太常见了,但需求方提了一个附加要求:左侧导航栏要能折叠,折叠之后只保留一排图标,鼠标点开某一项的时候能自动展开对应的功能页,整个操作流要顺畅,不能有卡顿感。
当时顺手搜了一圈现成的开源方案,发现不少代码是把侧栏折叠和业务逻辑死死绑在同一个类里。窗口里既负责按钮点击,又负责页面跳转,还直接把数据库查询、文件读写全写在界面类里面。小项目还好,模块一多,改起来很痛苦:动了界面布局可能导致业务代码跟着崩,改了业务逻辑又要小心翼翼绕开界面对象。所以这次我决定动手写一个界面和业务分离的Demo,把抽屉导航拆出来做成一个可复用组件,业务逻辑通过控制器去调度。这就是项目标题里“界面和业务分离案例01”的由来。
这个项目适合谁来参考?如果你正在用Python3.x + PyQt5写桌面工具,希望导航侧栏有折叠效果,又想把界面代码和业务代码理清楚,那这套代码可以直接抄。文章里我会把抽屉效果的核心原理、界面分离的架构思路、完整代码拆解和踩坑记录全部过一遍,尽量做到拿过去就能跑。
先上一张效果描述:窗口分为左右两栏,左侧是导航栏,右侧是QStackedWidget管理的页面栈;导航栏顶部有一个折叠按钮,点击之后侧栏宽度从200像素平滑收缩到60像素,只保留图标和一个小箭头;再次点击展开恢复完整宽度,图标旁边显示文字菜单。点击菜单项时,右侧页面栈切换对应页面。整个过程全部通过QPropertyAnimation完成,没有用任何第三方动画库。
2. 抽屉折叠的核心:QPropertyAnimation与状态管理
2.1 为什么用QPropertyAnimation而不是手动改宽度
很多人实现折叠效果的第一反应是循环修改setMinimumWidth,比如起一个QTimer每隔几毫秒把宽度减一点。这个思路不是不行,但有几个问题:一是动画过程中界面容易闪烁,二是你还要自己处理加速减速效果,三是代码里到处穿插sleep或者定时器,逻辑很散。
PyQt5自带的QPropertyAnimation是更专业的做法。它的本质是给某个对象的某个数值型属性做插值动画,你只需要指定起始值、结束值、持续时间和缓动曲线,动画引擎会自动计算中间帧,而且动画过程中窗口的绘制和事件循环不会被阻塞。实测下来,在普通Windows办公电脑上做200像素到60像素的宽度折叠,120毫秒到200毫秒的时长最舒服,太快了显得生硬,太慢了用户会不耐烦。
这里有一个关键点:QPropertyAnimation操作的是属性,不是任意方法。所以你要保证折叠的侧栏对象有可以被动画化的属性。QWidget的minimumWidth和maximumWidth都是可读写的int型属性,天然适合做这种宽度动画。
2.2 折叠状态的数据结构设计
我在SideBar里用一个枚举或者常量维护状态:
class SideBar(QFrame): EXPAND_WIDTH = 200 COLLAPSE_WIDTH = 60 STATE_EXPANDED = "expanded" STATE_COLLAPSED = "collapsed"初始化时设置固定宽度区间:
self.setMinimumWidth(self.EXPAND_WIDTH) self.setMaximumWidth(self.EXPAND_WIDTH)这样侧栏在初始状态下是固定宽度,不会因为内容变大而撑开。折叠的时候,动画目标是把minimumWidth和maximumWidth都变成COLLAPSE_WIDTH,展开时则反之。宽度区间的两端必须同步修改,否则会出现一端的值先到了、另一端的值还在慢慢追,窗口布局被强行撑开的诡异现象。
2.3 折叠动画的完整实现
核心代码其实只有十几行:
from PyQt5.QtCore import QPropertyAnimation, QEasingCurve, pyqtSignal from PyQt5.QtWidgets import QFrame, QVBoxLayout, QPushButton class SideBar(QFrame): toggle_requested = pyqtSignal(bool) def __init__(self, parent=None): super().__init__(parent) self.is_expanded = True self._init_ui() self._anim = None def toggle(self): if self._anim and self._anim.state() == QPropertyAnimation.Running: return target_width = ( self.COLLAPSE_WIDTH if self.is_expanded else self.EXPAND_WIDTH ) self._anim = QPropertyAnimation(self, b"minimumWidth", self) self._anim.setDuration(160) self._anim.setStartValue(self.width()) self._anim.setEndValue(target_width) self._anim.setEasingCurve(QEasingCurve.InOutCubic) self._anim.finished.connect(lambda: self._on_anim_done(target_width)) self._anim.start() self._anim_2 = QPropertyAnimation(self, b"maximumWidth", self) self._anim_2.setDuration(160) self._anim_2.setStartValue(self.width()) self._anim_2.setEndValue(target_width) self._anim_2.setEasingCurve(QEasingCurve.InOutCubic) self._anim_2.start() def _on_anim_done(self, target_width): self.is_expanded = target_width == self.EXPAND_WIDTH self.toggle_requested.emit(not self.is_expanded)注意我写了两段动画,分别是minimumWidth和maximumWidth,时长和缓动曲线保持一致。这样做的好处是动画过程中两个边界始终同步,布局引擎不会因为边界值不一致而对侧栏做意外的拉伸或压缩。
还有一个小细节:如果用户连续点击折叠按钮,需要判断动画是否还在运行。我在toggle开头加了一个Running判断,直接return,这样可以避免动画被反复打断导致宽度卡在一个奇怪的值上。
2.4 折叠过程中文字和图标的联动
宽度从200收缩到60之后,文字如果还在,要么被裁掉,要么换行,非常难看。我采用的办法是:每个菜单按钮在构造时同时存放图标和文字,折叠完成后把文字隐藏,展开完成后再恢复。
严格来说,更平滑的做法是对文字的透明度做QGraphicsOpacityEffect+QPropertyAnimation,但实测下来在按钮数量不超过六个时,直接隐藏/显示文字的视觉跳跃感并不明显,而且代码简单很多。所以我推荐这种“折叠后隐藏文字”的方案,不把复杂度和收益成比例的地方搞得太花哨。
每个菜单按钮的结构大致长这样:
class NavButton(QPushButton): def __init__(self, text, icon_path, parent=None): super().__init__(text, parent) self.setText(text) self.setIcon(QIcon(icon_path)) self.setIconSize(QSize(24, 24)) self.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.setFixedHeight(44)折叠完成后遍历所有NavButton,把ToolButtonStyle改成只显示图标:
btn.setToolButtonStyle(Qt.ToolButtonIconOnly)展开则恢复为:
btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)这里有一个容易忽略的点:如果你用的是QAction+QToolButton的组合,ToolButtonStyle的设置方式一样,但要注意QToolButton的setToolButtonStyle同时要配合setText才有显示效果。如果按钮上没有设置文字,仅仅设置了图标,style改成什么都不会有文字。
3. 界面和业务分离:主窗口只做组装,控制器管业务
3.1 为什么一定要把界面和业务拆开
很多PyQt5的教程Demo都把UI和业务写在一个类里,窗口自己建按钮,自己处理点击事件,自己访问数据库。代码量在五百行以内的时候,这种写法确实“方便”,但你一旦开始维护就会发现:界面微调布局要小心翼翼不碰业务;业务逻辑加个判断要翻遍整个类找合适位置;等测试反馈一个bug时,根本分不清是界面事件的时序问题还是业务处理的问题。
界面和业务分离的核心不是“多建几个文件”,而是明确谁负责什么。我的分层方式是:
- 界面层(view):只负责展示组件、接收用户操作、把操作翻译成语义化信号。
- 控制器层(controller):负责响应界面信号,调度业务对象,把结果回填到界面。
- 业务对象(service/model):负责具体的计算、存储、网络请求等纯逻辑。
这样的好处是界面层可以独立测试,业务层也可以脱离界面用脚本测试,两者通过信号槽的约定连接,互不依赖实现细节。
3.2 主窗口的职责界限
主窗口MainWindow在这个架构里只做三件事:
- 创建左侧SideBar。
- 创建右侧页面容器QStackedWidget,并添加各个业务页面。
- 创建控制器Controller,把SideBar和StackedWidget都交给它。
主窗口不直接写任何“点击某个菜单后该怎么处理”的逻辑。所有界面交互都通过信号发出去,由控制器统一接收和调度。这样你以后替换左侧导航的样式、调整页面布局,控制器一行都不用改。
3.3 控制器如何通过信号槽把两边接起来
控制器持有SideBar实例、页面容器实例和业务对象实例。它做的事情是“监听界面信号,调用业务方法,再更新界面”。举个例子,点击“订单查询”菜单时:
- NavButton发出clicked信号,SideBar内部把这个信号转成
menu_selected(str)语义化信号,参数是菜单标识符。 - 控制器连接
menu_selected信号,在自己的槽函数里根据标识符切换页面栈到对应页面。 - 页面显示之后,页面自身会发出
page_shown信号,控制器通知业务对象去准备数据,数据准备好了再通过一个更新信号回传给页面。
界面上的按钮不直接去调业务函数,业务函数也不持有控件指针。所有数据流向都是单向的:界面 -> 控制器 -> 业务 -> 控制器 -> 界面。
4. 关键代码逐段拆解:从侧栏到页面栈
4.1 项目目录结构
一个可维护的PyQt5项目,文件夹结构建议按照用途划分,而不是按照文件类型划分。我这份项目的结构如下:
project/ ├── main.py # 程序入口,创建App、MainWindow和Controller ├── views/ │ ├── __init__.py │ ├── main_window.py # 主窗口 │ ├── side_bar.py # 左侧导航栏 │ └── pages/ │ ├── __init__.py │ ├── dashboard_page.py │ ├── order_page.py │ ├── config_page.py │ └── log_page.py ├── controller/ │ ├── __init__.py │ └── main_controller.py ├── services/ │ ├── __init__.py │ └── data_service.py └── resources/ ├── icons/ # 菜单图标 └── style.qss # 全局QSS样式4.2 main.py 程序入口
入口文件要做的事情很纯粹:创建QApplication,加载QSS样式,实例化主窗口和控制器,显示窗口,进入事件循环。代码是这样写的:
import sys from PyQt5.QtWidgets import QApplication from views.main_window import MainWindow from controller.main_controller import MainController def main(): app = QApplication(sys.argv) with open("resources/style.qss", "r", encoding="utf-8") as f: app.setStyleSheet(f.read()) window = MainWindow() controller = MainController(window) window.set_controller(controller) window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()有两点容易被新手忽略。第一,QSS样式文件如果包含中文资源路径,读取时一定要加encoding="utf-8",否则在某些Windows中文环境下会乱码导致样式加载失败。第二,我习惯让主窗口提供一个set_controller方法,而不是在MainController的构造器里直接塞一堆控件引用,这样主窗口和控制器之间的依赖方向更清晰:MainWindow不知道Controller的内部结构,只知道有人会把一个控制器设置进来。
4.3 MainWindow:只做布局组装
主窗口的代码量其实很少,因为所有复杂的交互逻辑都下沉到了SideBar和Controller。
class MainWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("侧边抽屉导航 Demo") self.resize(1000, 700) self.side_bar = SideBar(self) self.page_stack = QStackedWidget(self) self.dashboard_page = DashboardPage(self) self.order_page = OrderPage(self) self.config_page = ConfigPage(self) self.log_page = LogPage(self) self.page_stack.addWidget(self.dashboard_page) self.page_stack.addWidget(self.order_page) self.page_stack.addWidget(self.config_page) self.page_stack.addWidget(self.log_page) main_layout = QHBoxLayout() main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) main_layout.addWidget(self.side_bar) main_layout.addWidget(self.page_stack, stretch=1) container = QWidget() container.setLayout(main_layout) self.setCentralWidget(container) self.side_bar.toggle_requested.connect(self._on_side_bar_toggled) def _on_side_bar_toggled(self, collapsed): # 折叠状态变化时,主窗口可以做窗口级联调,比如调整状态栏、重排按钮等。 # 真正切换页面的事不在这里做,交给Controller。 pass这里我把QStackedWidget放在主窗口里直接构建,而不是放在某个专门的PageContainer类里,是为了减少一层不必要的嵌套。项目的页面数量如果超过八个,可以考虑把page_stack再封装成一个独立类,但四个页面完全没必要过度设计。
4.4 Controller:界面和业务之间的“快递员”
MainController是整个分离架构的关键。要理解它,先看一个基本流程:页面初始化时需要加载一批配置数据,但页面本身不应该直接操作数据源,那么Controller怎么协调?
class MainController: def __init__(self, window): self.window = window self.data_service = DataService() self._init_signals() def _init_signals(self): self.window.side_bar.menu_selected.connect(self.on_menu_selected) # 各个页面通过自身的信号向Controller要数据 self.window.dashboard_page.data_requested.connect( self.on_dashboard_data_requested ) self.window.order_page.data_requested.connect( self.on_order_data_requested ) def on_menu_selected(self, menu_key): page_map = { "dashboard": 0, "order": 1, "config": 2, "log": 3, } index = page_map.get(menu_key) if index is not None: self.window.page_stack.setCurrentIndex(index) def on_dashboard_data_requested(self): data = self.data_service.get_dashboard_data() self.window.dashboard_page.update_data(data) def on_order_data_requested(self, query_params): rows = self.data_service.query_orders(query_params) self.window.order_page.show_result(rows)信号槽在这里的价值非常大:页面发起请求时,并不需要知道到底是谁在响应;Controller响应时,也不需要知道页面内部的控件细节,只需要调用页面暴露的公共方法去更新界面。这样就实现了“界面不依赖业务,业务不感知界面”。
5. 实测中容易踩的坑和我的处理方式
5.1 QSS样式在折叠动画中的表现不一致
第一次跑通折叠动画后,我发现一个奇怪的现象:侧栏背景色在动画过程中出现了细微的“断裂”,像是被分割成了几段。排查了一圈发现,问题出在QSS背景渐变上。侧栏设置的QSS是background: qlineargradient(...),但动画过程中QWidget不断改变宽度,渐变背景的重绘产生了严重的锯齿和错位。
解决方案是把渐变背景改成了纯色,或者在动画期间临时把样式表替换掉,动画结束后再恢复。前者简单稳妥,我最终选的就是纯色。如果你实在需要渐变,建议把渐变绘制在父窗口的paintEvent里,而不是直接作为侧栏的背景样式。
5.2 QToolButton在收缩状态下的文字残留
有几次折叠后,按钮文字并没有立刻消失,而是以白色色块的形式短暂闪了一下。这是因为QToolButton的IconOnly模式需要重新触发一次样式绘制。
处理办法是在_on_anim_done里对每个按钮调用一次style()->unpolish(btn)和polish(btn):
for btn in self.nav_buttons: self.style().unpolish(btn) btn.setToolButtonStyle(Qt.ToolButtonIconOnly) self.style().polish(btn)unpolish和polish的套路本质是让Qt强制重新计算样式,比单纯调用update()可靠得多。
5.3 QStackedWidget切页时页面刷新时序
另外还有一个容易出问题的点:QStackedWidget切页时只是把当前页设为可见,并不会自动触发某种“加载数据”的信号。所以前面我在每个Page里自定义了data_requested信号,页面在showEvent里发出这个信号,Controller收到后异步去加载数据,加载完成后调用页面的更新方法。这样可以保证每次切到某个页面时都能看到最新数据,而且不需要在MainWindow里写一堆if-else。
5.4 动画运行中快速点击折叠按钮
前面代码里已经加了Running判断,这里再补充一点实战经验:如果折叠和展开共用同一个toggle方法,Running状态判断需要同时作用于minimumWidth动画和maximumWidth动画。不然可能出现minimumWidth动画被拦住,但maximumWidth动画重新起了一个,两个动画节奏不一致,侧栏宽度瞬间跳变。我最终把两个QPropertyAnimation都存成成员变量,在toggle开头同时检查两者的state,确保安全。
6. 这个项目的扩展方向
写完这个Demo之后,我实际又基于它加了不少功能。比如侧栏支持了分组菜单,每个分组可以展开收缩,顶部增加了用户头像区域,折叠时头像区域也跟着变窄。QStackedWidget加了页面缓存,页面切换后保留之前的滚动位置和用户输入。
如果要把这套东西真正用到生产级工具里,建议在此基础上再做两件事:一是把SideBar里的动画代码再抽出一个独立的CollapsiblePanel基类,这样不只是左侧导航,其他需要折叠的面板也能复用;二是增加一个Navigator接口,把菜单注册逻辑和Controller进一步解耦,菜单项支持动态增删,而不需要改Controller代码。
我在实际编码过程中的体验是:如果一开始就沿着“界面只负责展示,业务只在Controller里调度”的思路走,后面加需求会轻松很多。界面上加一个按钮,只要发一个信号出去就行;业务里换一个数据源,只要改动Service层,界面的所有调用方都感知不到。很多国产软件的功能迭代周期长,很大程度就是因为界面和业务搅在一起,改一行动全身。这个项目虽然小,但分层思想是完全可复制的。
本文还有配套的精品资源,点击获取