刻Element UI分頁組件:Qt桌面應(yīng)用的組件化實踐)
簡介一份參考Element UI分頁交互風(fēng)格實現(xiàn)的Qt分頁組件源碼面向熟悉C/Qt、需要在桌面應(yīng)用中集成翻頁控件的開發(fā)者適合數(shù)據(jù)表格、列表瀏覽及后臺管理端的桌面端適配。資源共2個文件分別對應(yīng)組件實現(xiàn)與頭文件壓縮包僅3KB代碼精簡無冗余依賴。組件封裝了PageNavigator類包含當(dāng)前頁、總頁數(shù)、每頁條目數(shù)等核心屬性提供上一頁/下一頁、跳轉(zhuǎn)至指定頁、首尾頁快捷操作等槽函數(shù)并通過pageChanged信號與外部界面解耦同時預(yù)留QSS樣式入口可輕松復(fù)現(xiàn)Element UI的簡潔觀感。已有1080人學(xué)習(xí)下載。借助這份小體積樣例可快速掌握自定義QWidget封裝、信號槽通信、QSpinBox與QComboBox聯(lián)動等Qt實用技巧稍作擴(kuò)展即可應(yīng)用到真實項目。1. 這個Qt分頁組件能干什么把Element UI的分頁體驗搬進(jìn)桌面應(yīng)用如果你的 Qt 桌面應(yīng)用需要一套分頁組件又正好看著 Element UI 的 el-pagination 流口水那這份資源就是答案。它用 QML JavaScript開發(fā)語言層面是 Qt Quick 的聲明式語法復(fù)刻了 Element UI 分頁組件的主要能力layout 任意排列、帶省略號的頁碼、快速跳轉(zhuǎn)、每頁條數(shù)下拉前端組件的屬性、事件、樣式都可用類似方式調(diào)整。適合兩類人一類是 Qt 桌面開發(fā)想在項目里引入組件化思路另一類是熟悉前端 Vue/Element UI、需要把既有原型平移到 QML 場景的開發(fā)者。讀完這篇筆記你能拿到可以直接編譯運(yùn)行的分頁組件源碼并知道怎么改造成自己業(yè)務(wù)里的黑匣子。2. 技術(shù)棧與組件設(shè)計用 QML JavaScript 復(fù)刻 el-pagination 的 API 層2.1 為什么選 QML 而不是 Widgets選 QMLQt Quick而不是傳統(tǒng) Widgets核心原因是組件化表達(dá)能力和 JavaScript 生態(tài)。Widgets 里做分頁控件通常是在 QWidget 里手動 layout、連接 clicked 信號每個按鈕都是獨立對象狀態(tài)管理依賴 C 撐著。而 QML 的聲明式 UI 寫法本身就接近 Vue 模板一個 Item 包含屬性、信號、嵌套子組件界面狀態(tài)通過屬性綁定自動刷新。前端背景的人看這套組件會非常親切改樣式也基本不用碰 C。另一個原因是 JavaScript 邏輯跑在 QML 內(nèi)部不需要為分頁這種輕邏輯跨 QObject 通信。頁碼窗口裁剪、layout 渲染順序、跳頁輸入校驗這些在 QML 里用幾十行 JS 就能寫完。雖然也可以把分頁算法寫在 C 里再暴露給 QML但從組件復(fù)用角度看純 QML JS 的實現(xiàn)更貼近“下載一個 .qml 文件就能用”的前端組件習(xí)慣如果你所在的是大規(guī)模 C 工程后續(xù)也可以把算法層整體遷到 C接口保持不變。這個項目把重心放在“組件庫化、可復(fù)用”這個目標(biāo)上。再說說為什么要參考 Element UI 而不是自己拍腦袋設(shè)計。el-pagination 在 Vue 生態(tài)里被大量項目驗證過它的兩個設(shè)計很值錢layout 字符串驅(qū)動布局以及 pager 省略號算法。這兩種能力恰好都能在 QML 里自然表達(dá)——layout 對應(yīng) Repeater 按順序加載子組件省略號對應(yīng)數(shù)組裁剪。把別人驗證過的交互邏輯平移過來能少踩很多不知道從哪里冒出來的交互坑。2.2 對齊 el-pagination 的屬性與事件組件對外 API 盡量照搬 Element UI 的命名讓熟悉前端的同事接手時不用重新學(xué)一遍。屬性和原型的對應(yīng)關(guān)系如下Element UI 屬性本項目 QML 屬性類型說明current-pagecurrentPageint當(dāng)前頁從 1 開始page-sizepageSizeint每頁條數(shù)totaltotalint數(shù)據(jù)總量page-countpageCountint只讀由 total 與 pageSize 計算pager-countpagerCountint頁碼按鈕數(shù)量默認(rèn) 7layoutlayoutArray控制項顯示順序如 total、prev、pagerpage-sizespageSizesArray每頁條數(shù)下拉選項事件上current-change 對應(yīng)change(currentPage)size-change 對應(yīng)sizeChange(newPageSize, currentPage)。設(shè)計時我把“狀態(tài)變化”和“用戶操作事件”分開currentPage 屬性變化只負(fù)責(zé)刷新組件內(nèi)部外觀change 信號只在用戶真正點擊、跳轉(zhuǎn)時發(fā)射一次。這個區(qū)分在后面第 5 章的信號重復(fù)觸發(fā)坑里會重點展開也是整個組件不容易踩坑的關(guān)鍵。2.3 目錄結(jié)構(gòu)pagination-qt/ └── Pagination.qml # 組件主體屬性、信號、算法、渲染 └── pager-utils.js # 純函數(shù)頁碼序列生成與邊界裁剪 └── demo/ └── main.qml # 演示頁模擬列表 分頁聯(lián)動 └── main.cpp # Qt Quick 入口 └── pagination.pro # qmake 工程文件把 pager-utils.js 獨立出來是為了讓頁碼算法可以在 Node 里單獨驗證第 6 章會用斷言向量鎖住它防止后續(xù)改樣式把算法改壞。Pagination.qml 只負(fù)責(zé)組件邊界和布局內(nèi)部調(diào)用 utils 的函數(shù)demo 是一個模擬 86 條數(shù)據(jù)的列表切頁時重新填充 ListModel方便直接觀察分頁效果。main.cpp 是標(biāo)準(zhǔn) Qt Quick 啟動入口不做額外邏輯保證跨平臺編譯簡單。3. 核心模塊拆解分頁請求從點擊到刷新的完整鏈路3.1 對外接口屬性、信號與跳轉(zhuǎn)函數(shù)組件最外層是 Item直接暴露與 el-pagination 對齊的屬性并定義兩個信號。所有頁碼跳轉(zhuǎn)都收斂到j(luò)ump()函數(shù)不在按鈕 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]再判斷目標(biāo)頁沒變時直接 return避免無意義的信號。change 信號只在 jump 里發(fā)射onCurrentPageChanged只負(fù)責(zé)重建 pagerItems。readonly pageCount 用total 0判斷total 為 0 時兜底為 1防止出現(xiàn) 0 頁或 NaN 頁。這里故意讓外部拿到 total 后只需賦值不用關(guān)心 pageCount 怎么算。3.2 頁碼序列算法省略號是怎么算出來的這是分頁組件最有技術(shù)含量的部分。el-pagination 在頁碼總數(shù)超過 pager-count 時會保留第 1 頁和最后一頁把當(dāng)前頁附近的頁碼作為窗口顯示窗口兩邊用省略號占位。下面這個函數(shù)實現(xiàn)了同樣的策略并且不依賴任何 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 頁把以當(dāng)前頁為中心的窗口頁渲染在中間窗口不足時貼住邊界。leftMore 和 rightMore 分別決定左右是否出現(xiàn)省略號。參數(shù) pageCount、currentPage、pagerCount 都由組件屬性傳入函數(shù)本身沒有 QML 依賴所以能在 Node 里直接跑斷言。這個窗口算法在極端貼近邊界時按鈕數(shù)量可能會少于 pagerCount常見前端分頁庫也有類似表現(xiàn)對業(yè)務(wù)沒什么影響后續(xù)要嚴(yán)格固定按鈕數(shù)量基于這個函數(shù)再裁剪 width 即可。3.3 layout 渲染像寫 Vue 模板一樣編排控制項layout 值是字符串?dāng)?shù)組順序決定控件從左到右的排列。實現(xiàn)方式是外層 Row 套一個 RepeaterRepeater 的 delegate 是一個 Loader根據(jù) switch 匹配結(jié)果加載對應(yīng)的組件。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數(shù)據(jù)源是 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) } } } } } }這里的參數(shù)說明checkable 只對數(shù)字頁碼生效省略號項是 disabled 狀態(tài)視覺上是置灰的避免用戶點“...”后沒反應(yīng)以為是按鈕壞了。最終點擊都走 root.jump所以 currentPage 的越界保護(hù)、信號發(fā)射都集中在 3.1 節(jié)里delegate 不需要知道跳轉(zhuǎn)細(xì)節(jié)。Button 用的是 QtQuick.Controls 2自帶 hover 和 focus 外觀省去手寫鼠標(biāo)事件。3.4 事件處理prev、next、jumper 與 sizes上一頁和下一頁按鈕只是把 currentPage 加減 1 再走 jump唯一要注意的是在邊界時按鈕應(yīng)該禁用而不是點了后沒反應(yīng)。跳頁輸入框用 TextField IntValidator按下回車時解析輸入并調(diào)用 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: 頁 } } }編輯結(jié)束后做兩件事合法數(shù)字走 jump然后無論是否合法都把輸入框重置成當(dāng)前頁這樣用戶輸錯可以看到輸入被糾正而不是留下一個錯誤數(shù)字等下一次提交。每頁條數(shù)選擇用 ComboBoxComponent { id: sizesComponent ComboBox { model: root.pageSizes onActivated: { var newPageSize parseInt(currentText, 10) root.pageSize newPageSize root.sizeChange(newPageSize, root.currentPage) } } }換 pageSize 后 currentPage 是保持還是重置為 1不同業(yè)務(wù)有不同習(xí)慣。這個組件保持當(dāng)前頁把決定權(quán)交給外部監(jiān)聽 sizeChange 的代碼。如果后端分頁接口不支持從非第 1 頁開始外部直接pager.currentPage 1再請求即可。4. 運(yùn)行與驗證從命令行到 Qt Creator 的啟動實錄4.1 用 qmlscene 快速啟動最快驗證組件的方式是 qmlscene不需要編譯。進(jìn)入項目目錄后執(zhí)行cd pagination-qt qmlscene -I . demo/main.qml參數(shù)-I .把當(dāng)前目錄加入 QML import 路徑這樣 demo/main.qml 里可以通過相對路徑找到 Pagination.qml。能彈出窗口并且看到“共 86 條”的 total 文本就說明組件加載成功。如果提示module QtQuick version 2.15 is not installed說明本機(jī) Qt 版本低于 5.15把兩個 import 里的版本號改成 2.12 或 2.6例如import QtQuick 2.6。大部分老版本 Qt 5 都帶這些模塊不需要額外安裝。4.2 用 qmake 工程跑Qt Creator 的完整路徑qmlscene 適合快速預(yù)覽真正在業(yè)務(wù)工程里還是要放到 Qt Creator 里編譯調(diào)試。qmake 工程文件很簡潔QT quick qml CONFIG c11 SOURCES main.cpp RESOURCES qml.qrcmain.cpp 就是標(biāo)準(zhǔn)的 QGuiApplication QQmlApplicationEngine加載qrc:/demo/main.qml。這里有個容易翻車的地方qml.qrc 必須把 Pagination.qml、pager-utils.js、demo/main.qml 全部登記進(jìn)去Qt Creator 部署時只認(rèn) qrc 里列出的文件漏掉 pager-utils.js 會導(dǎo)致組件運(yùn)行時報“無法加載腳本”但界面還能彈出來特別難發(fā)現(xiàn)。如果只做本地調(diào)試也可以不建 qrc直接把 qml 文件放工程目錄用相對路徑加載省掉資源編譯環(huán)節(jié)。4.3 驗證矩陣啟動成功后按下面這份驗證矩陣快速過一遍功能確保改動沒有破壞核心行為操作預(yù)期結(jié)果關(guān)注點啟動 demo列表顯示第 1 頁 10 條total 顯示 86初始 currentPage 為 1點擊第 3 頁頁碼 3 高亮列表內(nèi)容切換pagerItems 是否正確重建jumper 輸入 5 回車跳到第 5 頁頁碼 5 高亮輸入校驗和 jump clamp每頁條數(shù)改為 20sizeChange 觸發(fā)列表刷新pageCount 變?yōu)?5ComboBox 的 currentText 解析total 改為 0分頁器只剩 1 個頁碼prev/next 置灰pageCount 兜底邏輯生效驗證時把 Qt Creator 的調(diào)試輸出打開因為沒有在組件里埋日志的話信號發(fā)沒發(fā)出去只能靠界面表現(xiàn)猜。我在組件里臨時加過console.log(change, page)等所有驗證通過后再刪掉。5. 常見坑與排查QML 分頁組件的五個翻車現(xiàn)場5.1 total 為 0 時所有按鈕全部失效現(xiàn)象接口返回空列表total 0分頁器一片灰連頁碼都沒了。原因pageCount Math.ceil(0 / pageSize)算出 0buildPager 里 for 循環(huán)執(zhí)行 0 次pagerItems 是空數(shù)組。解決在 pageCount 計算時做兜底total 0 ? Math.ceil(total / pageSize) : 1讓空數(shù)據(jù)也顯示一個不可跳轉(zhuǎn)的頁碼視覺上更完整。buildPager 里也加了pageCount 1的直接返回雙保險。5.2 currentPage 越界后被反復(fù)拉回現(xiàn)象外部把 currentPage 賦值為 100界面先跳到 100又立刻被拉回最大值像在抽搐。原因有些封裝會在onCurrentPageChanged里做越界修正賦值一次后觸發(fā)回調(diào)回調(diào)又賦值形成二次變更。解決所有越界修正集中在 jump 和屬性綁定里外部不要監(jiān)聽 currentPage 變化去改它如果必須監(jiān)聽用標(biāo)志位擋住重入property bool syncing: false onCurrentPageChanged: { if (syncing) return syncing true if (currentPage pageCount) currentPage pageCount syncing false }5.3 省略號按鈕渲染成可點擊狀態(tài)現(xiàn)象鼠標(biāo)移到“...”上有 hover 效果點擊卻沒反應(yīng)看起來像組件壞了。原因delegate 里把所有 item 都當(dāng)成 Button省略號也拿到 enabled true。解決pager 的 delegate 里給“...”設(shè)置enabled: false同時checkable: false讓它從視覺到交互都明確“不可點”。如果業(yè)務(wù)要求點擊省略號跳半頁那是另一個功能在第 6 章會給改造方案。5.4 layout 字符串匹配翻車空格和大小寫現(xiàn)象layout 配置成[total, pager , next]pager 沒渲染其他控件正常。原因switch 是嚴(yán)格字符串比較 pager 不等于pager。解決在 Loader 里先String(modelData).trim()再 switch大小寫問題也在同一層統(tǒng)一toLowerCase()。從 JSON 或配置文件讀 layout 時常帶隱藏?fù)Q行trim 能一并處理。這個坑很難發(fā)現(xiàn)因為布局少一個控件并不會報錯。5.5 點擊頁碼導(dǎo)致列表刷兩次現(xiàn)象點一下第 3 頁網(wǎng)絡(luò)請求發(fā)了兩次列表閃兩下。原因jump 里改了 currentPage 后發(fā)射 change而onCurrentPageChanged里又發(fā)射了一次 change。信號職責(zé)沒有分開。解決change 只在 jump 里發(fā)射一次onCurrentPageChanged只負(fù)責(zé)更新 pagerItems 和按鈕高亮不做事件上報。這個約定寫進(jìn)代碼注釋后續(xù)接手的人再往 onCurrentPageChanged 里塞邏輯時能看到提示。6. 二次開發(fā)把分頁組件塞進(jìn)你的業(yè)務(wù)工程6.1 讓省略號具備前后半頁快速跳躍默認(rèn)省略號是置灰的Element UI 里點擊“...”會前后跳半屏。改造方法很簡單給省略號項設(shè)置 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) }左右兩個省略號可以共用一個函數(shù)根據(jù)當(dāng)前相對于中間頁的位置判斷跳前還是跳后或者直接在數(shù)據(jù)模型里給 more 項加一個step字段左邊為負(fù)、右邊為正渲染層無腦累加。6.2 用純函數(shù) 斷言向量鎖住頁碼算法pager-utils.js 抽出獨立函數(shù)后我在業(yè)務(wù)里每次改版都會先跑一遍斷言向量用 Node 直接執(zhí)行node tests/pager.test.js測試文件里不依賴 Qt只需要常規(guī)斷言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 頁、所有數(shù)字都在有效范圍內(nèi)。從那以后我每次新增分頁功能都強(qiáng)制先跑一遍斷言再往界面上綁定屬性順序反了就容易把排期燒在玄學(xué)上。6.3 對接真實后端接口把 demo 里的假數(shù)據(jù)換成網(wǎng)絡(luò)請求只需要在外部監(jiān)聽組件的兩個信號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 頁是大多數(shù)后端分頁接口的硬性要求否則當(dāng)前頁大于新 pageCount 時請求會拿不到數(shù)據(jù)。Pagination.qml 本身不關(guān)心數(shù)據(jù)來源你傳 total它負(fù)責(zé)算頁碼你監(jiān)聽 change它把用戶意圖交出來。組件和業(yè)務(wù)邏輯之間就這一層契約后續(xù)把整個分頁器換成 C 實現(xiàn)外部代碼也不用動。希望幫到你。本文還有配套的精品資源點擊獲取