十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyQt5抽屉导航组件:界面与业务分离的折叠侧栏实现

PyQt5抽屉导航组件:界面与业务分离的折叠侧栏实现 简介面向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, parentNone): 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, bminimumWidth, 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, bmaximumWidth, 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之后文字如果还在要么被裁掉要么换行非常难看。我采用的办法是每个菜单按钮在构造时同时存放图标和文字折叠完成后把文字隐藏展开完成后再恢复。严格来说更平滑的做法是对文字的透明度做QGraphicsOpacityEffectQPropertyAnimation但实测下来在按钮数量不超过六个时直接隐藏/显示文字的视觉跳跃感并不明显而且代码简单很多。所以我推荐这种“折叠后隐藏文字”的方案不把复杂度和收益成比例的地方搞得太花哨。每个菜单按钮的结构大致长这样class NavButton(QPushButton): def __init__(self, text, icon_path, parentNone): 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)这里有一个容易忽略的点如果你用的是QActionQToolButton的组合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, encodingutf-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样式文件如果包含中文资源路径读取时一定要加encodingutf-8否则在某些Windows中文环境下会乱码导致样式加载失败。第二我习惯让主窗口提供一个set_controller方法而不是在MainController的构造器里直接塞一堆控件引用这样主窗口和控制器之间的依赖方向更清晰MainWindow不知道Controller的内部结构只知道有人会把一个控制器设置进来。4.3 MainWindow只做布局组装主窗口的代码量其实很少因为所有复杂的交互逻辑都下沉到了SideBar和Controller。class MainWindow(QMainWindow): def __init__(self, parentNone): 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, stretch1) 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层界面的所有调用方都感知不到。很多国产软件的功能迭代周期长很大程度就是因为界面和业务搅在一起改一行动全身。这个项目虽然小但分层思想是完全可复制的。本文还有配套的精品资源点击获取
返回列表