简介:基于Element UI设计理念打造的Qt分页组件源码,面向需要在桌面应用中实现清晰翻页交互的Qt/C++开发者。资源压缩包共2个文件,含pagenavigator.cpp与pagenavigator.h,分别承载组件逻辑实现与接口声明,整体仅3KB,轻量易读、可直接嵌入项目。组件以Qt Widget为基础,定义PageNavigator类并继承自QWidget,封装当前页、总页数等核心属性,通过nextPage、prevPage、jumpToPage等槽函数处理翻页逻辑,同时对外发出pageChanged信号,便于父窗口或业务模块实时响应。界面采用Qt样式表(QSS)模拟Element UI外观,支持跳转输入框、首页/末页快捷操作,并可扩展每页显示条数选择,满足常见列表分页需求。已有1080人学习使用,适合希望将前端分页设计迁移到Qt环境、快速获得成熟交互范式的开发者直接复用或二次改造。 以前我在做Qt客户端的时候,最怕遇到那种“数据涨得飞快”的表格页面。QTableWidget一次性塞几千行,滚动起来已经有点吃力,再多一点界面直接没脾气。更麻烦的是,产品经理只会问“翻页能不能做得好看一点”“每页条数能不能让用户自己选”“当前页能不能高亮”,而我打开浏览器看着ElementUI的分页组件,心里只有一个想法:要是Qt里也能有一个这样的分页组件就好了。
后来我真的动手做完了这件事:参考ElementUI的el-pagination,基于Qt Widgets实现了一个纯QWidget分页组件。没有引入任何第三方库,全部使用QWidget、QHBoxLayout、QSS这些基础能力写出来,可以在QTableView、QTableWidget、QListView等任何控件下方直接加。它能显示总条数、页码按钮、快速跳转框、每页条数下拉框,也可以只显示最基础的上一页/下一页。这篇文章就把它拆开讲一遍,从接口设计、页码算法、样式还原到实际项目里踩过的坑,适合正在给Qt项目找分页方案的人参考。
1. 为什么我在Qt里复刻ElementUI分页组件
桌面端的翻页方案其实不少。最原始的是自己摆四个按钮:首页、上一页、下一页、尾页,旁边再加一个QLabel显示“第X页/共Y页”。功能上完全能用,但交互体验和ElementUI一比就差了不少。ElementUI的el-pagination有几个点确实做得好,值得QWidget复刻。
第一是功能整合度高。一条分页条里可以同时容纳总条数、页码按钮、快速跳转、每页条数选择,使用者按需开启或关闭,不会逼用户到处找入口。第二是页码数量可控,默认只显示7个页码按钮,超出部分用省略号过渡。这个设计非常聪明,哪怕总共有一万页,分页条宽度也基本不会变化。第三是视觉状态明确,当前页是纯色高亮,hover有主题色反馈,禁用按钮也有自己的一套灰化样式,用户不需要思考就能知道当前位置。
参考ElementUI并不是为了“抄个样子”,而是要把它背后的交互设计逻辑搬到Qt里。这中间有一个很关键的思维转换:Web组件的props需要翻译成Qt属性与方法,Vue的事件需要翻译成Qt信号槽,样式系统则需要用QSS来模拟。等这些都想明白之后,实现本身就不复杂了。
做之前我给自己定了三个目标:API尽量贴近el-pagination,了解Web前端的人拿到手能直接上手;外观尽可能还原,不做原创瞎设计;内部结构保持简单,方便以后按业务需求改。事实证明,这三个目标在整个开发过程里省了非常多纠结,遇到问题时照着ElementUI的行为去对齐就行。
2. 接口与事件:从el-pagination的props到Qt信号槽
动手写代码前,先把接口契约定清楚。ElementUI的el-pagination核心props有current-page、page-size、page-sizes、total、pager-count、layout、disabled。我按Qt的习惯把它们翻译成了setter方法,并对外只暴露两个信号:当前页变化和每页条数变化。
| el-pagination属性 | Qt侧对应设计 |
|---|---|
| current-page | setCurrentPage(int page) |
| page-size | setPageSize(int size) |
| page-sizes | setPageSizes(QList<int>) |
| total | setTotal(int total) |
| pager-count | setPagerCount(int count) |
| disabled | setEnabled(bool) |
| layout | setShowTotal / setShowPageSize / setShowJumper |
| current-change事件 | currentChanged(int currentPage) |
| size-change事件 | pageSizeChanged(int pageSize) |
头文件里的核心接口我设计成这样:
class Pagination : public QWidget { Q_OBJECT public: explicit Pagination(QWidget *parent = nullptr); void setTotal(int total); void setPageSize(int pageSize); void setCurrentPage(int page); void setPageSizes(const QList<int>& sizes); void setPagerCount(int count); void setShowTotal(bool show); void setShowPageSize(bool show); void setShowJumper(bool show); int total() const; int pageSize() const; int currentPage() const; int pageCount() const; signals: void currentChanged(int page); void pageSizeChanged(int size); private: void refresh(); void renderButtons(); QList<int> calcVisibleItems() const; };有几个设计决策值得说明一下。第一个是currentChanged信号的触发时机。我采用的是“只有用户点击页码、输入跳转、切换每页条数时才发信号,外部代码调用setCurrentPage时不发信号”。为什么这样设计?因为在构造函数里初始化组件时,如果没有这个约定,setTotal、setPageSize这些方法都会连带触发一次currentChanged,外部再接到信号去查数据,会出现一打开页面就连查好几次的尴尬情况。
第二个是pageSize变化后的越界处理。比如当前在第5页,每页20条,用户把每页条数改成10条,总页数变大了,当前页如果还停在第5页可能已经超出新的最大页数。所以在setPageSize里必须对当前页做clamp处理:
void Pagination::setPageSize(int size) { if (m_pageSize == size) return; m_pageSize = size; const int maxPage = pageCount(); if (m_currentPage > maxPage) { m_currentPage = maxPage; } refresh(); }第三是每页条数下拉框的信号处理。QComboBox有currentIndexChanged,但直接把选中的索引映射成pageSize,会分不清到底是用户主动改的,还是代码初始化时误触发的。稳妥的做法是等布局全部构建完成后再连接信号,或者在当前索引变化时再判断是否真的是items内容发生变化。
connect(m_pageSizeBox, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) { int size = m_pageSizes.value(index, 20); if (size == m_pageSize) return; setPageSize(size); emit pageSizeChanged(m_pageSize); });3. 页码序列计算:省略号是怎么生成和收放的
分页组件的核心不在控件拼接,而在页码序列的生成。ElementUI的精髓是“在最多pagerCount个页码按钮里,同时展示首尾页码和当前页附近页码,中间用省略号兜底”。这个逻辑我单独抽成了calcVisibleItems(),和UI完全解耦。
先明确规则。总页数小于等于pagerCount时,直接把所有页码显示出来,没有省略号。总页数大于pagerCount时,按当前页位置分成三种情况。
第一种,当前页靠前,只显示右侧省略号,例如“1 2 3 4 5 ... 20”。第二种,当前页靠后,只显示左侧省略号,例如“1 ... 16 17 18 19 20”。第三种,当前页在中间,两侧都有省略号,例如“1 ... 9 10 11 ... 20”。
这里我用-1表示省略号占位。这样返回的QList<int>里,正数就是页码,-1就是省略按钮,渲染层拿到这个列表后逐个生成按钮即可。
QList<int> Pagination::calcVisibleItems() const { QList<int> items; const int totalPage = pageCount(); if (totalPage <= m_pagerCount) { for (int i = 1; i <= totalPage; ++i) { items.append(i); } return items; } const int half = (m_pagerCount - 2) / 2; const bool showLeftEllipsis = (m_currentPage - 1) > half + 1; const bool showRightEllipsis = (totalPage - m_currentPage) > half + 1; if (showLeftEllipsis && showRightEllipsis) { items << 1 << -1; for (int i = m_currentPage - 1; i <= m_currentPage + 1; ++i) { items.append(i); } items << -1 << totalPage; } else if (showLeftEllipsis) { items << 1 << -1; for (int i = totalPage - (m_pagerCount - 3); i <= totalPage; ++i) { items.append(i); } } else if (showRightEllipsis) { for (int i = 1; i <= m_pagerCount - 2; ++i) { items.append(i); } items << -1 << totalPage; } else { for (int i = 1; i <= totalPage; ++i) { items.append(i); } } return items; }这个算法的一个关键点在于:无论哪种情况,生成的元素数量都等于pagerCount。确保这一点非常重要,因为分页条在切换页码时不希望宽度乱跳。ElementUI默认pagerCount是7,我实测下来7个按钮也是观感最舒适的,太少了显示不全,太多了会挤压后面跳转框的位置。
拿到页码序列之后就是渲染。这里有一个性能上的坑:不要每次刷新都delete掉旧按钮再new一批新按钮。正确的做法是在构造函数里一次性创建pagerCount个QPushButton,之后只更新文本、显隐和属性。
void Pagination::renderButtons() { const QList<int> items = calcVisibleItems(); for (int i = 0; i < m_pageButtons.size(); ++i) { QPushButton *btn = m_pageButtons.at(i); if (i >= items.size()) { btn->hide(); continue; } const int value = items.at(i); btn->show(); btn->setProperty("pageValue", value); if (value == -1) { btn->setText("..."); btn->setEnabled(false); btn->setProperty("ellipsis", true); btn->setProperty("active", false); } else { btn->setText(QString::number(value)); btn->setEnabled(true); btn->setProperty("ellipsis", false); btn->setProperty("active", value == m_currentPage); } } }按钮的点击事件在构造函数里统一连接,点击后从动态属性pageValue里读取页码。如果是正数就直接跳转,如果是-1,还可以做成ElementUI那种“点击省略号快进一段”的效果,后面我会单独说。
connect(btn, &QPushButton::clicked, this, [this, btn]() { const int value = btn->property("pageValue").toInt(); if (value > 0 && value != m_currentPage) { jumpToPage(value); } });4. 样式还原:用QSS给QWidget穿上一件ElementUI皮囊
ElementUI分页条的视觉核心元素是:白底、淡灰色边框、4px圆角、hover时变主题蓝、当前页深蓝底白字。这些用QSS还原基本没有难度,关键在于选择器怎么写。
我给所有页码按钮统一设置了pageButton="true"这个动态属性,不管是上一页、下一页还是数字按钮,都打上同样的标记。这样一组QSS规则就能覆盖全部按钮。
Pagination QPushButton[pageButton="true"] { min-width: 32px; height: 32px; margin-right: 4px; border: 1px solid #DCDFE6; border-radius: 4px; background: #FFFFFF; color: #606266; font-size: 13px; font-family: "Microsoft YaHei", "PingFang SC", sans-serif; padding: 0 4px; } Pagination QPushButton[pageButton="true"]:hover { color: #409EFF; border-color: #409EFF; } Pagination QPushButton[pageButton="true"][active="true"] { background: #409EFF; border-color: #409EFF; color: #FFFFFF; } Pagination QPushButton[pageButton="true"][active="true"]:hover { background: #66B1FF; border-color: #66B1FF; } Pagination QPushButton[pageButton="true"]:disabled { color: #C0C4CC; background: #FFFFFF; border-color: #E4E7ED; }这里有个容易踩的坑必须提醒:QSS对动态属性的识别不是“实时”的。你给按钮setProperty之后,样式表不会自动刷新,直接肉眼看到的结果可能是按钮样式没变。解决办法是对控件手动触发polish:
btn->style()->unpolish(btn); btn->style()->polish(btn);每次点击页码切换active属性时都需要这样处理,否则会出现当前页高亮还停在老按钮上的诡异现象。如果整个分页组件用到的样式比较多,也可以对整个Pagination调用unpolish/polish,但粒度太粗,没必要。
上一页和下一页按钮我直接用文本箭头实现。上一页显示“<”,下一页显示“>”。这样不需要贴图,也不依赖字体图标库。边界情况下,当前页是第一页时上一页按钮禁用,当前页是最后一页时下一页按钮禁用。QSS里的:disabled状态自动接管灰色样式。
快速跳转框用QSpinBox做,两侧自带的上下箭头需要隐藏,否则很难看:
Pagination QSpinBox { border: 1px solid #DCDFE6; border-radius: 4px; min-height: 30px; min-width: 50px; background: #FFFFFF; padding: 0 6px; color: #606266; font-size: 13px; } Pagination QSpinBox::up-button, Pagination QSpinBox::down-button { width: 0; height: 0; border: none; }每页条数下拉框QComboBox也需要单独定制,尤其是下拉面板,默认样式和ElementUI差别很大。列表里的item加了hover高亮之后,交互体验会贴近很多:
Pagination QComboBox { border: 1px solid #DCDFE6; border-radius: 4px; min-height: 30px; padding: 0 8px; background: #FFFFFF; color: #606266; font-size: 13px; } Pagination QComboBox::drop-down { border: none; width: 20px; } Pagination QComboBox QAbstractItemView { border: 1px solid #E4E7ED; outline: none; background: #FFFFFF; } Pagination QComboBox QAbstractItemView::item { height: 30px; padding-left: 8px; } Pagination QComboBox QAbstractItemView::item:hover { background: #F5F7FA; color: #409EFF; }到这里,组件的外观基本可以达到“一眼看去就是ElementUI”的程度。剩下一些细节,比如总条数Label用灰色文字,字体统一为13px,按钮之间的间距用margin控制而不是layout spacing,都是让观感更贴近原版的小技巧。
5. 接入真实项目后踩过的坑
代码写完之后,接入真实项目的过程中又遇到了一堆问题。有些问题不大,但不处理就是很难受,这里挑几个典型的记录一下。
第一个坑是焦点虚线。Qt里的QPushButton点击后默认会获得焦点,在很多样式下会显示一圈虚线边框,非常破坏观感。ElementUI里不存在这个问题,但在QWidget里必须手动处理。最省事的办法是在构造函数里给所有按钮统一设置:
setFocusPolicy(Qt::NoFocus);每个子控件都要设,包括页码按钮、上一页下一页按钮、跳转输入框。跳转输入框如果不设NoFocus也能用,但点击后同样会出现输入框焦点样式。根据实测,我建议除了真正需要文字输入的QSpinBox,其他控件全部设置NoFocus。
第二个坑是跳转输入框的信号时机。一开始我用QSpinBox的valueChanged信号来做跳转,结果发现用户在输入框里按上下键、滚动鼠标滚轮、或者输入过程中值变化时都会触发跳转,完全不符合“输完再跳”的预期。ElementUI的行为是用户输入页码后按回车才跳转。我最后改成监听输入框内部QLineEdit的returnPressed信号:
connect(m_jumperEdit->lineEdit(), &QLineEdit::returnPressed, this, [this]() { int page = m_jumperEdit->value(); if (page >= 1 && page <= pageCount() && page != m_currentPage) { jumpToPage(page); } m_jumperEdit->blockSignals(true); m_jumperEdit->setValue(m_currentPage); m_jumperEdit->blockSignals(false); });注意跳转成功之后把输入框的值重置回当前页码,不然输入框里会保留上一次输入的数字,下次打开会误以为当前页已经跳过去了。
第三个坑是页码按钮的动态创建与回收。早期版本我图省事,每次刷新页面都重新创建QPushButton再addWidget,结果在快速翻页的时候明显感觉闪烁,还伴随内存碎片。改成预创建pagerCount个按钮缓存起来、只更新文本和属性之后,肉眼可见顺滑了很多。如果你要在超大数据量下高频刷新,这个经验能直接省掉一次性能排查。
第四个坑是省略号的处理。按照前面的算法,省略号的value是-1,如果直接把按钮setEnabled(false)并禁用,它的文字会变成灰色。但ElementUI其实是支持点击省略号快速跳页的:点击左侧省略号向前跳pagerCount-2页,点击右侧省略号向后跳pagerCount-2页。如果想还原这个交互,不能禁用按钮,而是要在点击时单独判断:
if (value == -1) { const int step = m_pagerCount - 2; if (btn == m_leftEllipsisBtn) { jumpToPage(qMax(1, m_currentPage - step)); } else if (btn == m_rightEllipsisBtn) { jumpToPage(qMin(pageCount(), m_currentPage + step)); } return; }这里还需要在renderButtons时记录当前渲染的是左省略还是右省略,用两个专门的成员变量保存对应按钮即可。
第五个坑是每页条数变化后表格顶部的对齐问题。组件本身只管发pageSizeChanged信号,但实际业务中表格翻到第5页后用户把pageSize改成50,这时候往往需要同时把currentPage重置回第一页。我的建议是在业务侧统一处理,而不是让组件内部自动重置,否则用户改完条数还想翻回去就变得很别扭。
我把这些常见问题的排错顺序整理成了一个表,方便后续排查:
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 点击页码高亮不动 | 动态属性变化后样式未刷新 | unpolish/polish对应按钮 |
| 点击按钮出现虚线环 | 按钮获得焦点 | 统一setFocusPolicy(Qt::NoFocus) |
| 跳转框输入值就跳页 | 用了valueChanged信号 | 改用lineEdit的returnPressed |
| 快速翻页闪烁 | 每次刷新删除重建按钮 | 预创建按钮,只更新属性 |
| 省略号不可点击 | 直接setEnabled(false) | 保留按钮状态,点击时按步长跳页 |
接入业务代码的基本姿势是这样的:
auto *pagination = new Pagination(this); pagination->setTotal(model->rowCount()); pagination->setPageSize(20); pagination->setPageSizes({10, 20, 50, 100}); connect(pagination, &Pagination::currentChanged, this, [this](int page) { loadPageData(page); table->scrollToTop(); });所有数据查询都在currentChanged信号里进行,组件内部不关心数据从哪来,只负责把页码事件发出来。这和el-pagination在Vue里的角色完全一致。
最后分享一个我在这个项目里收获最大的经验:先把页码算法calcVisibleItems当成纯函数写好,用几组边界数据测一遍,再开始写界面。不要觉得分页组件简单就直接写UI,像“总页数正好等于pagerCount”“当前页在临界点差一页”“总页数只有一页”这些情况,靠手点界面很难完整覆盖。我是把QList<int>直接打到控制台里对比预期的,确认无误后才放心连按钮。这样写出来的组件,后面再改样式、加功能都会轻松很多。
本文还有配套的精品资源,点击获取