ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用QML复刻Element UI分页组件:Qt桌面应用的组件化实践

用QML复刻Element UI分页组件:Qt桌面应用的组件化实践 简介一份参考Element UI分页交互风格实现的Qt分页组件源码面向熟悉C/Qt、需要在桌面应用中集成翻页控件的开发者适合数据表格、列表浏览及后台管理端的桌面端适配。资源共2个文件分别对应组件实现与头文件压缩包仅3KB代码精简无冗余依赖。组件封装了PageNavigator类包含当前页、总页数、每页条目数等核心属性提供上一页/下一页、跳转至指定页、首尾页快捷操作等槽函数并通过pageChanged信号与外部界面解耦同时预留QSS样式入口可轻松复现Element UI的简洁观感。已有1080人学习下载。借助这份小体积样例可快速掌握自定义QWidget封装、信号槽通信、QSpinBox与QComboBox联动等Qt实用技巧稍作扩展即可应用到真实项目。1. 这个Qt分页组件能干什么把Element UI的分页体验搬进桌面应用如果你的 Qt 桌面应用需要一套分页组件又正好看着 Element UI 的 el-pagination 流口水那这份资源就是答案。它用 QML JavaScript开发语言层面是 Qt Quick 的声明式语法复刻了 Element UI 分页组件的主要能力layout 任意排列、带省略号的页码、快速跳转、每页条数下拉前端组件的属性、事件、样式都可用类似方式调整。适合两类人一类是 Qt 桌面开发想在项目里引入组件化思路另一类是熟悉前端 Vue/Element UI、需要把既有原型平移到 QML 场景的开发者。读完这篇笔记你能拿到可以直接编译运行的分页组件源码并知道怎么改造成自己业务里的黑匣子。2. 技术栈与组件设计用 QML JavaScript 复刻 el-pagination 的 API 层2.1 为什么选 QML 而不是 Widgets选 QMLQt Quick而不是传统 Widgets核心原因是组件化表达能力和 JavaScript 生态。Widgets 里做分页控件通常是在 QWidget 里手动 layout、连接 clicked 信号每个按钮都是独立对象状态管理依赖 C 撑着。而 QML 的声明式 UI 写法本身就接近 Vue 模板一个 Item 包含属性、信号、嵌套子组件界面状态通过属性绑定自动刷新。前端背景的人看这套组件会非常亲切改样式也基本不用碰 C。另一个原因是 JavaScript 逻辑跑在 QML 内部不需要为分页这种轻逻辑跨 QObject 通信。页码窗口裁剪、layout 渲染顺序、跳页输入校验这些在 QML 里用几十行 JS 就能写完。虽然也可以把分页算法写在 C 里再暴露给 QML但从组件复用角度看纯 QML JS 的实现更贴近“下载一个 .qml 文件就能用”的前端组件习惯如果你所在的是大规模 C 工程后续也可以把算法层整体迁到 C接口保持不变。这个项目把重心放在“组件库化、可复用”这个目标上。再说说为什么要参考 Element UI 而不是自己拍脑袋设计。el-pagination 在 Vue 生态里被大量项目验证过它的两个设计很值钱layout 字符串驱动布局以及 pager 省略号算法。这两种能力恰好都能在 QML 里自然表达——layout 对应 Repeater 按顺序加载子组件省略号对应数组裁剪。把别人验证过的交互逻辑平移过来能少踩很多不知道从哪里冒出来的交互坑。2.2 对齐 el-pagination 的属性与事件组件对外 API 尽量照搬 Element UI 的命名让熟悉前端的同事接手时不用重新学一遍。属性和原型的对应关系如下Element UI 属性本项目 QML 属性类型说明current-pagecurrentPageint当前页从 1 开始page-sizepageSizeint每页条数totaltotalint数据总量page-countpageCountint只读由 total 与 pageSize 计算pager-countpagerCountint页码按钮数量默认 7layoutlayoutArray控制项显示顺序如 total、prev、pagerpage-sizespageSizesArray每页条数下拉选项事件上current-change 对应change(currentPage)size-change 对应sizeChange(newPageSize, currentPage)。设计时我把“状态变化”和“用户操作事件”分开currentPage 属性变化只负责刷新组件内部外观change 信号只在用户真正点击、跳转时发射一次。这个区分在后面第 5 章的信号重复触发坑里会重点展开也是整个组件不容易踩坑的关键。2.3 目录结构pagination-qt/ └── Pagination.qml # 组件主体属性、信号、算法、渲染 └── pager-utils.js # 纯函数页码序列生成与边界裁剪 └── demo/ └── main.qml # 演示页模拟列表 分页联动 └── main.cpp # Qt Quick 入口 └── pagination.pro # qmake 工程文件把 pager-utils.js 独立出来是为了让页码算法可以在 Node 里单独验证第 6 章会用断言向量锁住它防止后续改样式把算法改坏。Pagination.qml 只负责组件边界和布局内部调用 utils 的函数demo 是一个模拟 86 条数据的列表切页时重新填充 ListModel方便直接观察分页效果。main.cpp 是标准 Qt Quick 启动入口不做额外逻辑保证跨平台编译简单。3. 核心模块拆解分页请求从点击到刷新的完整链路3.1 对外接口属性、信号与跳转函数组件最外层是 Item直接暴露与 el-pagination 对齐的属性并定义两个信号。所有页码跳转都收敛到jump()函数不在按钮 onClicked 里直接改 currentPage这是整个组件行为可控的起点。import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { id: root property int currentPage: 1 property int pageSize: 10 property int total: 0 property int pagerCount: 7 property var pageSizes: [10, 20, 50, 100] property var layout: [total, prev, pager, next, jumper] readonly property int pageCount: total 0 ? Math.ceil(total / pageSize) : 1 signal change(int page) signal sizeChange(int newPageSize, int page) function jump(page) { var target Math.max(1, Math.min(page, root.pageCount)) if (root.currentPage target) { return } root.currentPage target root.change(root.currentPage) } property var pagerItems: [] function updatePagerItems() { root.pagerItems buildPager(root.pageCount, root.currentPage, root.pagerCount) } onCurrentPageChanged: updatePagerItems() onPageCountChanged: updatePagerItems() Component.onCompleted: updatePagerItems() }jump 先做 clamp 到[1, pageCount]再判断目标页没变时直接 return避免无意义的信号。change 信号只在 jump 里发射onCurrentPageChanged只负责重建 pagerItems。readonly pageCount 用total 0判断total 为 0 时兜底为 1防止出现 0 页或 NaN 页。这里故意让外部拿到 total 后只需赋值不用关心 pageCount 怎么算。3.2 页码序列算法省略号是怎么算出来的这是分页组件最有技术含量的部分。el-pagination 在页码总数超过 pager-count 时会保留第 1 页和最后一页把当前页附近的页码作为窗口显示窗口两边用省略号占位。下面这个函数实现了同样的策略并且不依赖任何 QML 对象方便单独测试。// pager-utils.js function buildPager(pageCount, currentPage, pagerCount) { var count Math.max(5, pagerCount) if (!pageCount || pageCount 1) { return [{ type: page, label: 1 }] } if (pageCount count) { var all [] for (var i 1; i pageCount; i) { all.push({ type: page, label: i }) } return all } var half Math.floor(count / 2) var left currentPage - half var right currentPage half if (left 1) left 1 if (right pageCount) right pageCount var items [{ type: page, label: 1 }] var leftMore left 2 var rightMore right pageCount - 1 if (leftMore) { items.push({ type: more, label: ... }) } for (var j left; j right; j) { if (j 2 j pageCount - 1) { items.push({ type: page, label: j }) } } if (rightMore) { items.push({ type: more, label: ... }) } items.push({ type: page, label: pageCount }) return items } if (typeof module ! undefined) { module.exports { buildPager } }逻辑说明pageCount 小于等于 pagerCount 时干脆全部渲染不做省略号否则保留第 1 页和最后 1 页把以当前页为中心的窗口页渲染在中间窗口不足时贴住边界。leftMore 和 rightMore 分别决定左右是否出现省略号。参数 pageCount、currentPage、pagerCount 都由组件属性传入函数本身没有 QML 依赖所以能在 Node 里直接跑断言。这个窗口算法在极端贴近边界时按钮数量可能会少于 pagerCount常见前端分页库也有类似表现对业务没什么影响后续要严格固定按钮数量基于这个函数再裁剪 width 即可。3.3 layout 渲染像写 Vue 模板一样编排控制项layout 值是字符串数组顺序决定控件从左到右的排列。实现方式是外层 Row 套一个 RepeaterRepeater 的 delegate 是一个 Loader根据 switch 匹配结果加载对应的组件。Row { id: layoutRow spacing: 12 layoutDirection: Qt.LeftToRight Repeater { model: root.layout delegate: Loader { property string controlType: String(modelData).trim() width: item ? item.implicitWidth : 0 height: item ? item.implicitHeight : 0 sourceComponent: { switch (controlType) { case total: return totalComponent case prev: return prevComponent case pager: return pagerComponent case next: return nextComponent case jumper: return jumperComponent case sizes: return sizesComponent default: return undefined } } } } }把String(modelData).trim()放在 switch 之前是为了处理从配置文件或外部 JSON 传入带空格、换行的字符串。如果不做这一步“ pager ”和“pager”会匹配不上控件直接不渲染这种问题在视觉上很难排查。Loader 的 width 和 height 取实际子对象的 implicitWidth / implicitHeight确保 Row 能计算出正确布局宽度。页码列表本身也是一个 Row 套 Repeater数据源是 pagerItemsComponent { id: pagerComponent Row { spacing: 4 Repeater { model: root.pagerItems delegate: Button { text: model.label checkable: model.type page checked: model.type page model.label root.currentPage enabled: model.type page onClicked: { if (model.type page) { root.jump(model.label) } } } } } }这里的参数说明checkable 只对数字页码生效省略号项是 disabled 状态视觉上是置灰的避免用户点“...”后没反应以为是按钮坏了。最终点击都走 root.jump所以 currentPage 的越界保护、信号发射都集中在 3.1 节里delegate 不需要知道跳转细节。Button 用的是 QtQuick.Controls 2自带 hover 和 focus 外观省去手写鼠标事件。3.4 事件处理prev、next、jumper 与 sizes上一页和下一页按钮只是把 currentPage 加减 1 再走 jump唯一要注意的是在边界时按钮应该禁用而不是点了后没反应。跳页输入框用 TextField IntValidator按下回车时解析输入并调用 jump。Component { id: jumperComponent Row { spacing: 4 Text { text: 跳至 } TextField { width: 48 validator: IntValidator { bottom: 1; top: root.pageCount } onEditingFinished: { var value parseInt(text, 10) if (value 1 value root.pageCount) { root.jump(value) } text root.currentPage } } Text { text: 页 } } }编辑结束后做两件事合法数字走 jump然后无论是否合法都把输入框重置成当前页这样用户输错可以看到输入被纠正而不是留下一个错误数字等下一次提交。每页条数选择用 ComboBoxComponent { id: sizesComponent ComboBox { model: root.pageSizes onActivated: { var newPageSize parseInt(currentText, 10) root.pageSize newPageSize root.sizeChange(newPageSize, root.currentPage) } } }换 pageSize 后 currentPage 是保持还是重置为 1不同业务有不同习惯。这个组件保持当前页把决定权交给外部监听 sizeChange 的代码。如果后端分页接口不支持从非第 1 页开始外部直接pager.currentPage 1再请求即可。4. 运行与验证从命令行到 Qt Creator 的启动实录4.1 用 qmlscene 快速启动最快验证组件的方式是 qmlscene不需要编译。进入项目目录后执行cd pagination-qt qmlscene -I . demo/main.qml参数-I .把当前目录加入 QML import 路径这样 demo/main.qml 里可以通过相对路径找到 Pagination.qml。能弹出窗口并且看到“共 86 条”的 total 文本就说明组件加载成功。如果提示module QtQuick version 2.15 is not installed说明本机 Qt 版本低于 5.15把两个 import 里的版本号改成 2.12 或 2.6例如import QtQuick 2.6。大部分老版本 Qt 5 都带这些模块不需要额外安装。4.2 用 qmake 工程跑Qt Creator 的完整路径qmlscene 适合快速预览真正在业务工程里还是要放到 Qt Creator 里编译调试。qmake 工程文件很简洁QT quick qml CONFIG c11 SOURCES main.cpp RESOURCES qml.qrcmain.cpp 就是标准的 QGuiApplication QQmlApplicationEngine加载qrc:/demo/main.qml。这里有个容易翻车的地方qml.qrc 必须把 Pagination.qml、pager-utils.js、demo/main.qml 全部登记进去Qt Creator 部署时只认 qrc 里列出的文件漏掉 pager-utils.js 会导致组件运行时报“无法加载脚本”但界面还能弹出来特别难发现。如果只做本地调试也可以不建 qrc直接把 qml 文件放工程目录用相对路径加载省掉资源编译环节。4.3 验证矩阵启动成功后按下面这份验证矩阵快速过一遍功能确保改动没有破坏核心行为操作预期结果关注点启动 demo列表显示第 1 页 10 条total 显示 86初始 currentPage 为 1点击第 3 页页码 3 高亮列表内容切换pagerItems 是否正确重建jumper 输入 5 回车跳到第 5 页页码 5 高亮输入校验和 jump clamp每页条数改为 20sizeChange 触发列表刷新pageCount 变为 5ComboBox 的 currentText 解析total 改为 0分页器只剩 1 个页码prev/next 置灰pageCount 兜底逻辑生效验证时把 Qt Creator 的调试输出打开因为没有在组件里埋日志的话信号发没发出去只能靠界面表现猜。我在组件里临时加过console.log(change, page)等所有验证通过后再删掉。5. 常见坑与排查QML 分页组件的五个翻车现场5.1 total 为 0 时所有按钮全部失效现象接口返回空列表total 0分页器一片灰连页码都没了。原因pageCount Math.ceil(0 / pageSize)算出 0buildPager 里 for 循环执行 0 次pagerItems 是空数组。解决在 pageCount 计算时做兜底total 0 ? Math.ceil(total / pageSize) : 1让空数据也显示一个不可跳转的页码视觉上更完整。buildPager 里也加了pageCount 1的直接返回双保险。5.2 currentPage 越界后被反复拉回现象外部把 currentPage 赋值为 100界面先跳到 100又立刻被拉回最大值像在抽搐。原因有些封装会在onCurrentPageChanged里做越界修正赋值一次后触发回调回调又赋值形成二次变更。解决所有越界修正集中在 jump 和属性绑定里外部不要监听 currentPage 变化去改它如果必须监听用标志位挡住重入property bool syncing: false onCurrentPageChanged: { if (syncing) return syncing true if (currentPage pageCount) currentPage pageCount syncing false }5.3 省略号按钮渲染成可点击状态现象鼠标移到“...”上有 hover 效果点击却没反应看起来像组件坏了。原因delegate 里把所有 item 都当成 Button省略号也拿到 enabled true。解决pager 的 delegate 里给“...”设置enabled: false同时checkable: false让它从视觉到交互都明确“不可点”。如果业务要求点击省略号跳半页那是另一个功能在第 6 章会给改造方案。5.4 layout 字符串匹配翻车空格和大小写现象layout 配置成[total, pager , next]pager 没渲染其他控件正常。原因switch 是严格字符串比较 pager 不等于pager。解决在 Loader 里先String(modelData).trim()再 switch大小写问题也在同一层统一toLowerCase()。从 JSON 或配置文件读 layout 时常带隐藏换行trim 能一并处理。这个坑很难发现因为布局少一个控件并不会报错。5.5 点击页码导致列表刷两次现象点一下第 3 页网络请求发了两次列表闪两下。原因jump 里改了 currentPage 后发射 change而onCurrentPageChanged里又发射了一次 change。信号职责没有分开。解决change 只在 jump 里发射一次onCurrentPageChanged只负责更新 pagerItems 和按钮高亮不做事件上报。这个约定写进代码注释后续接手的人再往 onCurrentPageChanged 里塞逻辑时能看到提示。6. 二次开发把分页组件塞进你的业务工程6.1 让省略号具备前后半页快速跳跃默认省略号是置灰的Element UI 里点击“...”会前后跳半屏。改造方法很简单给省略号项设置 enabled true但点击时不走 jump 而是跳pagerCount - 2页onClicked: { if (model.type more) { forwardJump() } else if (model.type page) { root.jump(model.label) } } function forwardJump() { root.jump(root.currentPage root.pagerCount - 2) }左右两个省略号可以共用一个函数根据当前相对于中间页的位置判断跳前还是跳后或者直接在数据模型里给 more 项加一个step字段左边为负、右边为正渲染层无脑累加。6.2 用纯函数 断言向量锁住页码算法pager-utils.js 抽出独立函数后我在业务里每次改版都会先跑一遍断言向量用 Node 直接执行node tests/pager.test.js测试文件里不依赖 Qt只需要常规断言const assert require(assert) const { buildPager } require(../pager-utils.js) var labels buildPager(10, 5, 7).map(function (x) { return x.label }) assert.ok(labels.indexOf(1) 0, must contain first page) assert.ok(labels.indexOf(10) 0, must contain last page) assert.ok(labels.indexOf(0) -1, must not contain page 0) labels.forEach(function (label) { assert.ok(label ... || (label 1 label 10), invalid page: label) }) console.log(pager ok)这套向量不锁具体样式只锁合法性包含首尾、没有 0 页、所有数字都在有效范围内。从那以后我每次新增分页功能都强制先跑一遍断言再往界面上绑定属性顺序反了就容易把排期烧在玄学上。6.3 对接真实后端接口把 demo 里的假数据换成网络请求只需要在外部监听组件的两个信号Pagination { id: pager total: fakeApi.totalCount onChange: function (page) { fakeApi.fetchPage(page, pager.pageSize).then(function (rows) { listModel.clear() rows.forEach(function (row) { listModel.append(row) }) }) } onSizeChange: function (newPageSize, page) { pager.currentPage 1 fakeApi.fetchPage(1, newPageSize).then(function (rows) { listModel.clear() rows.forEach(function (row) { listModel.append(row) }) }) } }换 pageSize 时先重置到第 1 页是大多数后端分页接口的硬性要求否则当前页大于新 pageCount 时请求会拿不到数据。Pagination.qml 本身不关心数据来源你传 total它负责算页码你监听 change它把用户意图交出来。组件和业务逻辑之间就这一层契约后续把整个分页器换成 C 实现外部代码也不用动。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表