度條:paintEvent實(shí)現(xiàn)圓角漸變與充電動(dòng)畫)
簡(jiǎn)介本資源是一份面向Qt初學(xué)者與界面開發(fā)者的自定義控件實(shí)戰(zhàn)示例圍繞paintEvent繪制電池進(jìn)度條展開幫助讀者理解QPainter繪圖、電量狀態(tài)反饋與動(dòng)畫更新的完整實(shí)現(xiàn)思路。包內(nèi)共8個(gè)文件以3個(gè)cpp源文件與2個(gè)h頭文件為核心配合1個(gè)pro工程文件、1個(gè)ui界面文件和1個(gè)user配置壓縮包約7KB結(jié)構(gòu)精簡(jiǎn)便于直接編譯運(yùn)行與二次修改。項(xiàng)目通過繼承QWidget并重寫paintEvent使用fillRect、drawRect等方法繪制電池外殼與電量區(qū)域并依據(jù)電量比例切換正常色、過渡色與警示色同時(shí)借助QPropertyAnimation驅(qū)動(dòng)電量值變化每次更新觸發(fā)重繪形成平滑動(dòng)畫效果。代碼將繪制邏輯、動(dòng)畫邏輯與顏色判斷分離封裝兼顧可讀性與性能適合作為Qt圖形界面編程的入門練習(xí)與課程設(shè)計(jì)參考。目前已有501人學(xué)習(xí)下載。1. 用 paintEvent 手搓電池進(jìn)度條為什么我不推薦 QProgressBar去年幫一個(gè)做嵌入式上位機(jī)的朋友改界面他那個(gè)電池電量顯示用的是 QProgressBar 套了個(gè)樣式表結(jié)果在 4K 屏上圓角糊成一團(tuán)低電量紅色閾值還得靠定時(shí)器硬刷 palette。我接手后直接把整個(gè)控件刪了用 QWidget 加 paintEvent 重寫代碼不到 120 行圓角、漸變、電量分段配色、充電動(dòng)畫全齊了。Qt 里畫電池進(jìn)度條這件事QProgressBar 能應(yīng)付 80% 的常規(guī)場(chǎng)景但一旦涉及異形邊框、多段閾值配色、動(dòng)態(tài)充電指示還是得回到 paintEvent 里自己拿 QPainter 一筆一筆畫。這篇筆記就拆一個(gè)我實(shí)際在用的電池進(jìn)度條控件從坐標(biāo)計(jì)算到抗鋸齒參數(shù)把每個(gè)坑都擺出來(lái)。適合已經(jīng)會(huì)寫 Qt Widgets、但一碰到自繪就發(fā)怵的開發(fā)者也適合想把手頭進(jìn)度條從“能用”拉到“能看”的熟手。2. 拆解 paintEvent 繪制流程從 QWidget 到電池輪廓2.1 為什么選 QWidget 自繪而不是 QProgressBarQProgressBar 的本質(zhì)是一個(gè)帶樣式的矩形填充條它的繪制邏輯被 Qt 樣式系統(tǒng)鎖死了。你可以在 QSS 里寫border-radius、background-color但一旦電池需要“正極凸起”這種非矩形輪廓QSS 就無(wú)能為力了。更麻煩的是QProgressBar 的 chunk 寬度是按百分比算的低電量時(shí)你想讓填充部分變成紅色、并且?guī)б粋€(gè)閃爍的充電圖標(biāo)就得子類化 QProgressBar 再重寫 paintEvent繞了一圈還是回到自繪。直接繼承 QWidget 的好處是整個(gè)繪制區(qū)域完全由你控制paintEvent里拿到的 QPainter 沒有任何預(yù)設(shè)樣式干擾。我一般會(huì)在構(gòu)造函數(shù)里做三件事設(shè)置setMinimumSize防止控件被壓扁、開啟setAttribute(Qt::WA_OpaquePaintEvent)減少重繪閃爍、把電量值用Q_PROPERTY暴露出去方便 QSS 或動(dòng)畫系統(tǒng)調(diào)用。class BatteryBar : public QWidget { Q_OBJECT Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) public: explicit BatteryBar(QWidget *parent nullptr) : QWidget(parent) { setMinimumSize(120, 48); setAttribute(Qt::WA_OpaquePaintEvent); } int value() const { return m_value; } void setValue(int v) { v qBound(0, v, 100); if (v m_value) return; m_value v; update(); // 只觸發(fā)重繪不重建控件 emit valueChanged(v); } signals: void valueChanged(int); private: int m_value 0; };這段代碼里qBound把電量鉗在 0 到 100避免外部傳入異常值導(dǎo)致繪制越界。update()是異步的Qt 會(huì)合并多次調(diào)用所以即使你每秒刷 60 次也不會(huì)把事件循環(huán)壓垮。WA_OpaquePaintEvent告訴 Qt 這個(gè)控件不透明省掉背景擦除那一步在嵌入式設(shè)備上能明顯降低 CPU 占用。2.2 電池輪廓的坐標(biāo)計(jì)算留白、圓角與正極凸起電池輪廓不是簡(jiǎn)單一個(gè)圓角矩形。標(biāo)準(zhǔn)畫法是主體占控件寬度的 85%右側(cè)留出 15% 給正極凸起。主體圓角半徑取高度的 20%正極凸起高度取主體的 40%垂直居中。這些比例不是拍腦袋定的是我在 1080P 和 4K 屏上反復(fù)調(diào)出來(lái)的——圓角太小顯得廉價(jià)太大在低分辨率下會(huì)糊。void BatteryBar::paintEvent(QPaintEvent *) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); const int w width(), h height(); const int bodyW w * 0.85; const int capW w - bodyW; const int capH h * 0.4; const int radius h * 0.2; // 電池主體路徑 QPainterPath body; body.addRoundedRect(QRectF(0, 0, bodyW, h), radius, radius); // 正極凸起路徑 QPainterPath cap; cap.addRoundedRect(QRectF(bodyW, (h - capH) / 2.0, capW, capH), capW * 0.3, capW * 0.3); // 先畫外框描邊 p.setPen(QPen(QColor(80, 80, 80), 2)); p.setBrush(Qt::NoBrush); p.drawPath(body); p.drawPath(cap); }QPainterPath的好處是能把多個(gè)形狀合并成一個(gè)邏輯整體后面做裁剪填充時(shí)直接p.setClipPath(body)就行不用擔(dān)心填充溢出到正極外面。addRoundedRect的半徑參數(shù)如果傳負(fù)數(shù)或超過半寬Qt 會(huì)自動(dòng)鉗制但為了可預(yù)期我習(xí)慣手動(dòng)算好再傳。正極凸起的圓角用capW * 0.3是為了讓它看起來(lái)像個(gè)金屬帽而不是一個(gè)方塊。2.3 電量填充與閾值配色三色分段的實(shí)現(xiàn)電量填充我分三檔0% 到 20% 紅色20% 到 50% 橙色50% 以上綠色。這個(gè)閾值不是固定的實(shí)際項(xiàng)目里我會(huì)把它做成QVectorQPairint, QColor成員方便從配置文件讀。填充區(qū)域的高度要比主體略小留出 2px 內(nèi)邊距否則填充色會(huì)蓋住描邊視覺上像溢出來(lái)。// 填充區(qū)域主體內(nèi)縮 2px QRectF fillRect QRectF(2, 2, (bodyW - 4) * m_value / 100.0, h - 4); QPainterPath fillPath; fillPath.addRoundedRect(fillRect, radius - 2, radius - 2); QColor fillColor; if (m_value 20) fillColor QColor(220, 60, 60); else if (m_value 50) fillColor QColor(230, 150, 40); else fillColor QColor(60, 180, 90); p.setClipPath(body); // 裁剪到主體內(nèi) p.fillPath(fillPath, fillColor); p.setClipping(false);這里有個(gè)細(xì)節(jié)fillRect的寬度是按m_value / 100.0算的但圓角半徑不能直接用radius否則電量很低時(shí)圓角會(huì)把填充區(qū)域“吃掉”看起來(lái)像沒電。我一般把填充圓角設(shè)為radius - 2并且當(dāng)m_value小于 5 時(shí)直接不畫填充只留一個(gè)空框避免出現(xiàn)一個(gè)奇怪的小圓點(diǎn)。3. 把控件接進(jìn)真實(shí)項(xiàng)目信號(hào)、動(dòng)畫與高 DPI 適配3.1 用 Q_PROPERTY 驅(qū)動(dòng)平滑動(dòng)畫直接setValue會(huì)讓電量條跳變體驗(yàn)很硬。我通常在外面套一個(gè)QPropertyAnimation把value屬性作為動(dòng)畫目標(biāo)持續(xù)時(shí)間 300ms緩動(dòng)曲線用QEasingCurve::OutCubic。這樣電量從 80 掉到 15 的時(shí)候填充條是滑下去的不是閃一下。QPropertyAnimation *anim new QPropertyAnimation(battery, value); anim-setDuration(300); anim-setEasingCurve(QEasingCurve::OutCubic); anim-setStartValue(battery-value()); anim-setEndValue(newValue); anim-start(QAbstractAnimation::DeleteWhenStopped);Q_PROPERTY里必須帶NOTIFY信號(hào)否則動(dòng)畫系統(tǒng)無(wú)法感知屬性變化。DeleteWhenStopped讓動(dòng)畫對(duì)象在結(jié)束后自動(dòng)釋放避免每次更新都 new 一個(gè)導(dǎo)致內(nèi)存緩慢增長(zhǎng)。如果電量變化頻繁比如每秒一次我會(huì)把 duration 降到 150ms否則動(dòng)畫會(huì)排隊(duì)看起來(lái)反而卡頓。3.2 高 DPI 下的坐標(biāo)縮放與 devicePixelRatio在 4K 屏上Qt 默認(rèn)會(huì)做邏輯像素到物理像素的映射width()返回的是邏輯寬度。如果你直接用width()算坐標(biāo)在devicePixelRatio為 2 的屏幕上繪制結(jié)果會(huì)被放大兩倍圓角看起來(lái)就糊了。解決辦法是在paintEvent里用QPainter的device()拿實(shí)際縮放比或者更簡(jiǎn)單所有坐標(biāo)都用qreal算最后交給 QPainter 去處理。qreal dpr devicePixelRatioF(); p.scale(1.0 / dpr, 1.0 / dpr); // 僅在手動(dòng)管理像素時(shí)使用不過大多數(shù)情況下不需要手動(dòng) scaleQt 的QPainter已經(jīng)幫你做了。真正要注意的是setMinimumSize傳的是邏輯像素在 4K 屏上控件實(shí)際會(huì)占更多物理像素所以圓角半徑要用qreal而不是int否則h * 0.2在 h 為奇數(shù)時(shí)會(huì)截?cái)鄬?dǎo)致左右圓角不對(duì)稱。3.3 充電狀態(tài)的動(dòng)態(tài)指示畫一個(gè)閃電符號(hào)充電時(shí)我一般在電池主體中間畫一個(gè)閃電符號(hào)用QPainterPath三個(gè)點(diǎn)連成折線填充黃色。閃電的坐標(biāo)要隨控件大小縮放不能寫死。if (m_charging) { QPainterPath bolt; qreal cx bodyW / 2.0, cy h / 2.0; qreal s h * 0.3; bolt.moveTo(cx s * 0.2, cy - s); bolt.lineTo(cx - s * 0.3, cy s * 0.1); bolt.lineTo(cx s * 0.1, cy s * 0.1); bolt.lineTo(cx - s * 0.2, cy s); bolt.lineTo(cx s * 0.3, cy - s * 0.1); bolt.lineTo(cx - s * 0.1, cy - s * 0.1); bolt.closeSubpath(); p.fillPath(bolt, QColor(255, 210, 60)); }閃電的六個(gè)點(diǎn)是我用坐標(biāo)紙畫出來(lái)的比例s h * 0.3保證在不同尺寸下都不會(huì)超出電池主體。closeSubpath讓路徑閉合填充時(shí)不會(huì)出現(xiàn)缺口。如果充電狀態(tài)需要閃爍我會(huì)在外面掛一個(gè)QTimer每 500ms 切換一次m_charging并update()而不是在paintEvent里做定時(shí)邏輯——繪制函數(shù)只負(fù)責(zé)畫不負(fù)責(zé)狀態(tài)調(diào)度。4. 避坑排查自繪電池條最容易翻車的五個(gè)地方4.1 現(xiàn)象圓角邊緣有鋸齒開了抗鋸齒也沒用原因QPainter::Antialiasing只對(duì)路徑描邊和填充生效但如果你的paintEvent里先畫了一個(gè)不透明的背景矩形再畫圓角背景矩形的直角邊緣會(huì)露出來(lái)。解決要么不畫背景要么把背景也做成圓角路徑并且確保WA_OpaquePaintEvent沒有和樣式表沖突。我一般直接在paintEvent開頭p.fillRect(rect(), Qt::transparent)清一次但前提是控件本身不設(shè)背景色。4.2 現(xiàn)象電量 0% 時(shí)填充區(qū)域還殘留一條細(xì)線原因fillRect寬度算出來(lái)是 0但QPainterPath::addRoundedRect在寬度為 0 時(shí)仍會(huì)畫出一條線。解決在填充前加判斷if (m_value 0)或者把填充寬度鉗到最小 1px 再配合裁剪。我習(xí)慣直接跳過 0% 的填充繪制只畫外框。4.3 現(xiàn)象在嵌入式 ARM 板上跑界面刷新明顯卡頓原因每幀都在paintEvent里重新構(gòu)建QPainterPath路徑三角化開銷在低端 CPU 上很可觀。解決把電池主體和正極的路徑緩存為成員變量只在resizeEvent里重建paintEvent直接復(fù)用。填充路徑因?yàn)殡S電量變化沒法緩存但它的形狀簡(jiǎn)單開銷可以接受。4.4 現(xiàn)象QSS 設(shè)置的border和自繪描邊打架原因QWidget 子類默認(rèn)會(huì)走樣式系統(tǒng)如果你在 QSS 里寫了BatteryBar { border: 1px solid red; }Qt 會(huì)在paintEvent之前先畫一遍樣式邊框和你的描邊重疊。解決在構(gòu)造函數(shù)里setStyleSheet(background: transparent;)或者重寫paintEvent時(shí)不要調(diào)用QWidget::paintEvent。我一般直接不寫 QSS所有視覺都在paintEvent里控制。4.5 現(xiàn)象動(dòng)畫過程中電量值跳變填充條閃爍原因QPropertyAnimation每幀都會(huì)調(diào)setValue而setValue里調(diào)了update()如果動(dòng)畫幀率和屏幕刷新率不同步就會(huì)出現(xiàn)撕裂。解決把setValue里的update()改成update(rect())限定重繪區(qū)域或者用QTimer手動(dòng)插值把動(dòng)畫步進(jìn)和paintEvent解耦。我通常用前者簡(jiǎn)單且有效。5. 進(jìn)階技巧用 QPainter 的合成模式做發(fā)光效果電池低電量時(shí)我想讓紅色填充帶一點(diǎn)“呼吸”發(fā)光但又不想引入 QGraphicsEffect——那東西在嵌入式上性能很差。后來(lái)發(fā)現(xiàn)直接用QPainter::CompositionMode_Plus疊一層半透明紅色就能模擬。if (m_value 20) { p.setCompositionMode(QPainter::CompositionMode_Plus); QColor glow(120, 0, 0, 80); p.fillPath(fillPath, glow); p.setCompositionMode(QPainter::CompositionMode_SourceOver); }CompositionMode_Plus會(huì)把源顏色和目標(biāo)顏色相加紅色疊加后亮度提升看起來(lái)像在發(fā)光。alpha 值我設(shè) 80太高會(huì)過曝太低沒效果。這個(gè)技巧在深色背景上尤其明顯淺色背景上反而會(huì)發(fā)灰所以我會(huì)根據(jù)背景色動(dòng)態(tài)調(diào)整 alpha。另一個(gè)我常用的驗(yàn)證方法是把paintEvent里的繪制結(jié)果渲染到一個(gè)QImage上然后保存成 PNG用腳本對(duì)比不同電量下的像素顏色。這樣能在不啟動(dòng)完整界面的情況下回歸測(cè)試配色閾值。QImage img(size(), QImage::Format_ARGB32); img.fill(Qt::transparent); QPainter p(img); // ... 同樣的繪制邏輯 ... p.end(); img.save(battery_15.png);從那以后我每次改電池控件的繪制代碼都強(qiáng)制走一遍這個(gè)離屏渲染流程把 0%、20%、50%、100% 四個(gè)關(guān)鍵幀存下來(lái)肉眼比對(duì)。血淚經(jīng)驗(yàn)是在 4K 屏上看著完美的圓角到了 720P 的嵌入式屏上可能糊成一片只有離屏渲染才能提前發(fā)現(xiàn)。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取