资讯动态

Qt 分页组件实战:复刻 Element UI 分页设计,解决表格卡顿

发布时间:2026/10/14 0:53:40 来源:尧图企业网站定制
简介一套参考前端界面库分页风格实现的桌面端分页控件源码面向具有C与Qt基础、希望在桌面应用中借鉴现代网页交互体验的开发者。资源压缩包内一共两个文件一个接口头文件和一个实现源文件整体体积仅三KB结构非常精简已有1080人浏览学习。组件围绕分页导航类封装了当前页、总页数、每页条目数等核心状态通过信号与槽机制提供上一页、下一页、直接跳转等操作页码变化时还能对外发出变化信号供业务方联动并支持动态修改总页数后自动调整显示。实现过程中通过样式表定制外观同时加入页码显示、每页条目数选择、首页末页快捷跳转以及非法输入回退等细节处理构造函数中也完成了按钮和标签的界面布局与默认值初始化。阅读完整源码可掌握自定义控件封装、对象间通信、样式表应用及常见交互事件处理等关键技能也能直接嵌入现有项目或二次扩展快速补齐桌面应用的分页导航能力。1. 后台表格卡出幻觉后我为什么要在 Qt 里复刻一个 Element 风格分页组件接手一个内部后台管理系统时表格从几千行涨到十几万行一次性setRowCount塞进QTableWidget后界面直接卡住数秒滚动起来像在拖动一张巨型图片。那时最常见的解法是「只查一页、加载一页」但 Qt Widgets 自带的控件里没有一个现成的分页组件于是我想到了网页端早就有的标准答案Element UI 的 Pagination。这个 Qt 分页组件就是把它的交互结构搬到桌面端总条数、页码、上一页/下一页、跳转输入框、每页条数选择器外加那套「页数多了就折叠成省略号」的窗口逻辑。它解决的痛点是数据量大时的按需渲染适合用 Qt Widgets 写后台、写数据管理界面、又被列表性能逼到墙角的开发者。做过一次之后你会发现麻烦不在按钮拼装而在页码折叠算法和信号时序这两个地方。2. 先把交互拆清楚Element 分页组件到 Qt 的类与信号映射2.1 组件拆成六个零件每一块都对应一个 Qt 控件Element UI 的 Pagination 设计上不是一个黑匣子它把交互切成六个可独立开关的区块再用一个layout字符串控制显示顺序和组合方式。这个思路在 Qt 里可以直接平移每个区块对应哪个控件用一张表就能说清Element 区块职责Qt 里对应的实现total显示「共 N 条」QLabel文本由setTotal驱动sizes每页条数下拉QComboBox内容来自 pageSizes 配置prev上一页按钮QPushButton到第一页时setEnabled(false)pager页码按钮组与省略号一个容器QWidget内部动态生成QPushButtonnext下一页按钮QPushButton到最后一页时禁用jumper跳转输入框QLabelQLineEdit回车触发跳页我一般会做一个setLayout(total,sizes,prev,pager,next,jumper)这样的配置入口让使用方自己决定要不要显示总条数、要不要显示下拉框。后台系统里不同页面需求差异很大有的只要「上一页/下一页」有的要完整的一套写死顺序反而是给自己挖坑。2.2 用 Q_PROPERTY 暴露状态信号只发「页码变化」组件内部要维护三个核心状态总条数total、当前页码currentPage、每页条数pageSize。这三个状态在 Qt 里最适合用Q_PROPERTY暴露出来好处是设计器里能读写、QSS 里能用动态属性选择器外部调用时语义也清楚。头文件骨架大概长这样#pragma once #include QWidget #include QVector class QPushButton; class QComboBox; class QLineEdit; class QLabel; class QHBoxLayout; class Pagination : public QWidget { Q_OBJECT Q_PROPERTY(int total READ total WRITE setTotal NOTIFY totalChanged) Q_PROPERTY(int currentPage READ currentPage WRITE setCurrentPage NOTIFY currentChanged) Q_PROPERTY(int pageSize READ pageSize WRITE setPageSize NOTIFY pageSizeChanged) public: explicit Pagination(QWidget *parent nullptr); int total() const { return total_; } int currentPage() const { return current_; } int pageSize() const { return pageSize_; } void setTotal(int total); void setCurrentPage(int page); void setPageSize(int size); void setPageSizes(const QVectorint sizes); void setPagerCount(int count); int pageCount() const; signals: void totalChanged(int total); void currentChanged(int page); void pageSizeChanged(int size); // 翻页或改每页条数后统一发出外部监听后去取对应分片数据 void pageRequested(int page, int size); private: void rebuildPages(); int total_ 0; int current_ 1; int pageSize_ 20; int pagerCount_ 7; };关键点在于信号设计currentChanged只负责报告「页码变了」pageRequested是取数信号同时带上 page 和 pageSize外部拿到后自己算 offset。有人会把「去数据库查数据」这件事也塞进组件里我一般不建议分页组件应该保持状态机加信号器的形态数据源、重试、竞态处理都放外面这样组件才能在十几个页面里复用。2.3 组件不碰数据源分页状态与取数逻辑的边界Element 的 Pagination 本身也不管数据它只告诉你当前页变了数据怎么来是你的业务。搬到 Qt 里这个边界更要守住有人图省事把QSqlQueryModel直接塞进分页控件翻页时控件内部发 SQL结果换一个数据库、加一个过滤条件就得改控件源码。正确做法是让外部监听pageRequested自己决定走网络请求、查 SQLite 还是从内存数组切片。有一个使用细节要注意这个类用了Q_OBJECT头文件必须进 mocCMake 里只要把pagination.h加进HEADERS就不会出问题。我见过有新人把pagination.cpp单独编过、头文件放 include 目录但没加进工程编译报一大堆未定义信号的错误十有八九都是 moc 没跑。3. 按钮怎么生成、页码怎么折叠核心布局与折叠算法3.1 用 layout 字符串让控件可配置而不是写死顺序上一步接口定义里留了setLayout的伏笔这一步把它落地。解析逻辑很简单按逗号切分字符串每个区块名对应一次显隐控制。之所以不用setVisible挨个写死是因为不同页面需求确实不一样void Pagination::setLayout(const QString layout) { const QStringList parts layout.split(,, Qt::SkipEmptyParts); for (const QString part : parts) { if (part total) { totalLabel_-show(); } else if (part sizes) { sizeCombo_-show(); } else if (part prev) { prevButton_-show(); } else if (part pager) { pagerContainer_-show(); } else if (part next) { nextButton_-show(); } else if (part jumper) { jumperLabel_-show(); jumperEdit_-show(); } } rebuildPages(); }这里有一个兼容点Qt::SkipEmptyParts是 Qt 5.14 之后的写法老项目里要写成QString::SkipEmptyParts不然编译直接报错。布局顺序由构造函数里的addWidget顺序决定setLayout只负责显隐这样实现最省事也够用。3.2 页码折叠算法pagerCount 控制下的窗口与省略号整个组件里最值得写清楚的逻辑就是页码折叠这也是参考 Element 分页组件时最容易抄错的地方。Element 的规则是页码不多时全部显示页数超过pagerCount时第一页和最后一页固定显示中间以当前页为中心开一个窗口窗口之外用省略号占位。这个算法在 Qt 里可以独立成函数void Pagination::rebuildPages() { // 每次重建前先清掉旧的按钮否则页码越多按钮越积越多 qDeleteAll(pagerButtons_); pagerButtons_.clear(); const int totalPages pageCount(); if (totalPages pagerCount_) { // 页数少全部页码平铺不显示省略号 for (int i 1; i totalPages; i) { addPageButton(i); } updateNavState(); return; } // 首末页固定占两个位置中间还剩 pagerCount_-2 个位置 addPageButton(1); // half 表示当前页两侧各保留几页pagerCount-3 要减去当前页自身和首末页 const int half (pagerCount_ - 3) / 2; int start qBound(2, current_ - half, totalPages - 1); int end qBound(2, current_ half, totalPages - 1); // 窗口若不足 pagerCount_-2 页向缺失的一侧补齐 if (end - start 1 pagerCount_ - 2) { if (start 2) { end qMin(totalPages - 1, start pagerCount_ - 3); } else { start qMax(2, end - pagerCount_ 3); } } if (start 2) { addEllipsisButton(/* 向前跳转 */); } for (int i start; i end; i) { addPageButton(i); } if (end totalPages - 1) { addEllipsisButton(/* 向后跳转 */); } addPageButton(totalPages); highlightCurrentPage(); }pagerCount默认取 7也就是最多显示 7 个按钮位。举例总共 50 页、当前第 8 页时显示结果是1 … 6 7 8 9 10 … 50。窗口的补齐逻辑要重点看当当前页靠近头部时end - start 1会小于pagerCount_ - 2这时要把窗口往尾部扩靠近尾部时反过来。不补这一步翻到第 1 页时中间会出现只有 2 个页码的难堪局面。3.3 按钮生成与点击绑定重点在清掉旧按钮addPageButton和addEllipsisButton我建议这样实现页码按钮文本就是页码本身点击后直接setCurrentPage(page)省略号按钮不能只做显示占位它要能点击跳一屏否则就违背了 Element 的交互习惯。按钮生成时要给一个最小宽度和统一高度中文环境下「上一页」三个字比较宽我一般用setMinimumWidth(34)配合setMinimumHeight(30)而不是写死按钮总宽度QPushButton *Pagination::addPageButton(int page) { auto *btn new QPushButton(QString::number(page), pagerContainer_); btn-setProperty(page, page); btn-setMinimumWidth(34); btn-setMinimumHeight(30); connect(btn, QPushButton::clicked, this, [this, page]() { setCurrentPage(page); }); pagerLayout_-addWidget(btn); pagerButtons_.append(btn); return btn; } QPushButton *Pagination::addEllipsisButton(int jumpTarget) { auto *btn new QPushButton(…, pagerContainer_); // 把目标页码存在属性里点击时只读属性不依赖重建前捕获的局部变量 btn-setProperty(jumpTarget, jumpTarget); btn-setMinimumWidth(34); btn-setToolTip(jumpTarget current_ ? 向前一屏 : 向后一屏); connect(btn, QPushButton::clicked, this, [this, btn]() { setCurrentPage(btn-property(jumpTarget).toInt()); }); pagerLayout_-addWidget(btn); pagerButtons_.append(btn); return btn; }清旧按钮这步很多人会忘直接导致每次翻页都 append 一组按钮翻 10 次多了 70 个控件界面越来越重。qDeleteAll之后要立刻clear()同时pagerLayout_不需要手动 removeWidget因为delete按钮后布局会自动处理。4. 接进真实列表最小集成与 5 行 QSS 换肤4.1 最小集成QTableWidget 分页组件一页一页取数组件本身不碰数据集成时最标准的姿势是监听pageRequested算出 offset 后去取一页数据。这里有个容易踩的误区有人先查出全部数据然后手动切片填表数据量一大就退回老问题。正确做法是把分页条件直接下沉到查询语句或接口参数里void MainWindow::initPager() { pager_-setPageSizes({10, 20, 50, 100}); pager_-setPageSize(20); pager_-setTotal(0); connect(pager_, Pagination::pageRequested, this, [this](int page, int pageSize) { const int offset (page - 1) * pageSize; // 同步直接执行 SQL 或查询接口 // 异步请求时带上页码返回后校验是否过期 loadPage(offset, pageSize); }); } void MainWindow::loadPage(int offset, int pageSize) { // 假设 queryRows 返回 QListQStringList且带 totalCount auto result queryRows(offset, pageSize); table_-setRowCount(result.rows.size()); for (int row 0; row result.rows.size(); row) { for (int col 0; col result.rows[row].size(); col) { table_-setItem(row, col, new QTableWidgetItem(result.rows[row][col])); } } pager_-setTotal(result.totalCount); }offset 为什么是(page - 1) * pageSize第一页 page1 时 offset 是 0正好从第一条开始第二页从第 21 条开始pageSize20 时。如果接口是 1-based 的记得在连接处把 page 转成内部的 0-based 页码不要在生产代码里临时page - 1 1这种写法后患无穷。4.2 换肤把 Element 的视觉用 QSS 还原Element 的视觉特征很明显白底、细边框、圆角、hover 变蓝、当前页蓝底白字。Qt 的 QSS 基本能一比一还原而且和前端 CSS 的写法很像。放一份我常用的基础样式Pagination QPushButton { min-width: 34px; min-height: 30px; padding: 0 6px; border: 1px solid #d9d9d9; border-radius: 4px; background-color: #ffffff; color: #333333; } Pagination QPushButton:hover { border-color: #409eff; color: #409eff; } Pagination QPushButton:disabled { border-color: #d9d9d9; background-color: #fafafa; color: #c0c4cc; } Pagination QPushButton[currenttrue] { border-color: #409eff; background-color: #409eff; color: #ffffff; }需要注意currenttrue这个属性选择器对应的动态属性要在代码里设置btn-setProperty(current, page current_)。动态属性变化后 QSS 不会自动重绘我一般会在highlightCurrentPage()里对每个按钮手动刷新一次样式否则当前页高亮死活不生效玄学现场。4.3 与 QAbstractTableModel 结合时先别急着 beginResetModel用 QTableWidget 是最快的集成方式但如果数据量大且后面要做排序、过滤我更推荐把分页组件和QAbstractTableModel配合。常见的做法是每次翻页都调beginResetModel()再endResetModel()简单粗暴但会丢失滚动位置和选中状态。数据量在几百条以内这么干问题不大超过一千条时建议用增量更新void MainWindow::applyPage(const QVectorQStringList rows) { // 行数直接对齐到这次返回的数据量避免旧数据残留在界面上 beginResetModel(); currentRows_ rows; endResetModel(); }beginResetModel会让所有视图完全重建表格滚动条会跳回顶部用户翻到第 8 页时滚动位置被重置是常见吐槽点。如果你的场景要保留滚动位置就得改成beginRemoveRows/beginInsertRows的组合代价是代码复杂度明显上升不建议第一次做分页组件时就上。5. 翻车记录分页组件的 5 个高频坑与排查顺序5.1 省略号按钮点了没反应或跳转页数不对现象点击「…」按钮像点了块木头完全没反应或者跳转后落点不是预期的那一屏。 原因最常见的是省略号只做成了纯显示用的QLabel压根没接点击信号。另一个隐蔽原因是 lambda 里捕获了 rebuild 前的局部变量start/end按钮在多次重建里被复用或者被重新 connect点击时用的还是旧窗口的页码。 解决省略号按钮用上面addEllipsisButton的方案把目标页码直接存成jumpTarget属性。每次 rebuild 重新算目标值点击只读属性不依赖任何捕获变量。这样一改翻页再多也不会跳错。5.2 快速翻页后表格数据错乱迟到的旧响应覆盖了新数据现象快速连点第 3、4、5 页页码高亮停在 5表格却显示了第 3 页的内容。 原因异步取数时第 3 页的请求慢先发出但后回来回来时直接把表格填充了没有校验这个响应是不是「当前最新的请求」。 解决在取数入口维护一个自增的请求序号异步返回时比对序号不一致直接丢弃。用QtConcurrent::run的场景可以这样写const uint seq fetchSeq_; QtConcurrent::run([this, offset, size, seq]() { auto rows queryRows(offset, size); QMetaObject::invokeMethod(this, [this, rows, seq]() { if (seq ! fetchSeq_) return; // 过期响应直接丢弃 pager_-setTotal(rows.totalCount); table_-setRowCount(rows.items.size()); // 填充表格 }); });这个问题的本质是竞态不是慢查询本身。即使接口只需要 10 毫秒用户手速快一点也会触发。Qt 里用QFutureWatcher也能达到同样效果关键是在回调入口统一做一次序号校验。5.3 QSS 里写 background-color 不生效现象background-color: #ffffff写上去按钮还是系统默认的灰底渐变或者只有边框变了、背景没变。 原因QPushButton 在 Windows 和多数 Linux 主题下默认走原生绘制QSS 里只写 background-color 而没有同时声明border时Qt 会把它当作「未完全样式化」的控件部分绘制路径仍然交给原生风格。 解决把border、background-color一起写上上面第 4.2 节的写法就是完整声明。另一个连带坑是动态属性currenttrue不会自动触发重绘必须在highlightCurrentPage()里对按钮执行一次style()-unpolish(btn)再style()-polish(btn)这一步不做当前页高亮偶尔不刷新被用户截图吐槽过不止一次。5.4 total 为 0 或超出总页数时的除零与越界现象数据为空时setTotal(0)下一页按钮还可以点点完页码变成 1 之外的值或者外部拿到总条数后直接setCurrentPage(999)组件毫无反应。 原因pageCount()在total0时算出 0「最后一页」变成一个非法状态setCurrentPage没有做越界 clamp。 解决pageCount()至少返回 1并且setCurrentPage内部做一次qBoundint Pagination::pageCount() const { if (pageSize_ 0) return 1; if (total_ 0) return 1; return (total_ pageSize_ - 1) / pageSize_; } void Pagination::setCurrentPage(int page) { const int target qBound(1, page, pageCount()); if (target current_) return; current_ target; rebuildPages(); emit currentChanged(current_); emit pageRequested(current_, pageSize_); }total0时显示一页空态、禁用下一页这个行为要在一开始就定好不然后续所有页面都要额外补判断。5.5 中文文案把按钮撑变形DPI 下更明显现象固定了整体宽度后切换到中文环境「上一页」「下一页」文字把按钮挤得比页码按钮宽出一大截高分屏缩放 125% 时文字直接截断成省略号。 原因QPushButton 的 sizeHint 依赖文字宽度中文一字双宽硬编码整行宽度或者只设setFixedWidth(24)的按钮在 DPI 缩放后必然不够用。 解决按钮统一用min-widthmin-height不要写死整行宽度分页组件所在容器用QHBoxLayout让它自然撑开。跳转输入框的标签如果是「跳至」 输入框 「页」建议用setText(跳至 %1 页)动态拼接。中英文切换时把文案单独收敛到一个静态函数里不要散落在各个按钮构造处。6. 进阶把页码折叠抽成纯函数再用 QSignalSpy 做冒烟测试6.1 留一手页码折叠算法与 UI 解耦把折叠算法抽成纯静态函数是我后来最受益的一个决定直接让测试脱离 QWidget 的构造开销。输入四个参数输出一个纯数据的页码序列正数代表页码负数代表省略号// 返回如 {1, -2, 6, 7, 8, 9, 10, -1, 50} // 负数统一表示省略号-1 表示左侧省略-2 表示右侧省略 static QVectorint visiblePages(int totalPages, int current, int pagerCount) { QVectorint result; if (totalPages pagerCount) { for (int i 1; i totalPages; i) result.append(i); return result; } result.append(1); const int half (pagerCount - 3) / 2; int start qBound(2, current - half, totalPages - 1); int end qBound(2, current half, totalPages - 1); if (end - start 1 pagerCount - 2) { if (start 2) end qMin(totalPages - 1, start pagerCount - 3); else start qMax(2, end - pagerCount 3); } if (start 2) result.append(-1); for (int i start; i end; i) result.append(i); if (end totalPages - 1) result.append(-2); result.append(totalPages); return result; }这样写的好处是 UI 层只负责遍历结果、生成按钮算法层专注于边界第 1 页、最后一页、靠近头尾的窗口补齐、总页数小于pagerCount的全展开。这些边界如果没有纯函数兜底每次改样式表都可能误伤折叠逻辑。6.2 一个冒烟测试模板防止手滑改坏翻页逻辑有纯函数之后加上 Qt Test 的QSignalSpy就能做最小的翻页冒烟测试验证点击下一页后信号只发一次且页码正确递增。这一步不需要真的弹出一个窗口#include QtTest/QtTest void tst_Pagination::nextEmitsCurrentChanged() { Pagination pager; pager.setTotal(50); pager.setPageSize(10); pager.setCurrentPage(1); QSignalSpy spy(pager, Pagination::currentChanged); pager.next(); // 组件里公开一个 next()/prev() 槽便于测试 QCOMPARE(spy.count(), 1); QCOMPARE(pager.currentPage(), 2); QCOMPARE(visiblePages(50, 2, 7), QVectorint({1, 2, 3, 4, 5, 6, -2, 50})); }我自己的习惯是凡是涉及列表的页面pageRequested里第一句就打印页码和 pageSize翻页有没有触发、触发了几次一目了然。后来做这类组件的固定流程是先把visiblePages这张表测好再谈界面。分页组件看起来小真正容易翻车的恰恰是那些「看起来不用测」的部分多留一套冒烟测试省下的是上线后被用户用鼠标点出来的尴尬。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑