
簡介一份面向MFC界面開發者的CListCtrl自繪實現代碼包解決默認滾動條、表頭和列表項樣式生硬的問題。資源包含自定義滾動條類、自定義表頭類以及基于Owner-Draw的列表項繪制邏輯涉及CScrollBar/CHeaderCtrl繼承、OnPaint重繪、WM_HSCROLL/WM_VSCROLL及HDM_LAYOUT等消息處理。壓縮包共42個文件以h/cpp源碼為主另有14個bmp位圖資源、2個txt說明以及示例工程SkinList_demo整體僅87KB。已有325人學習適合需要提升MFC列表界面美觀度的初中級開發者參考。通過閱讀rm.txt和示例工程可快速掌握自繪控件銜接與消息分工并直接復用或改造到自己的項目中。 做MFC界面開發的朋友應該都有過這種經歷項目做到一半產品經理指著你提交的版本說“這個CListCtrl太素了表頭要有質感、選中行要變成藍色而且失焦不能變灰、滾動條也要跟整體風格統一”。一開始你可能覺得簡單調幾個顏色就行真正動手才發現系統默認繪制機制比想象中頑固得多。表頭HeaderCtrl、列表項Items、滾動條Scrollbar這三塊各有各的繪制路徑HeaderCtrl和Items還好說滾動條自繪更是能把人繞暈。這篇文章把我折騰過的一整套CListCtrl重繪代碼拆開講清楚從HeaderCtrl到Items再到Scrollbar每個部分怎么接管、攔截哪些消息、處理哪些狀態以及那些網上很少說透的坑一次性寫明白。適合正在做MFC界面美化、被CListCtrl各種默認行為折磨、又不想引入第三方皮膚庫的朋友參考。1. 重繪前的思路先搞清楚要動哪幾個部分1.1 三條自繪路線怎么選CListCtrl的自繪業內常見有三條路線第一條是Custom DrawNM_CUSTOMDRAW它適合列表項和整行的定制也是大多數人第一次接觸CListCtrl自繪時用的方式第二條是Owner DrawLVS_OWNERDRAWFIXED給列表項完全自繪的能力靈活但工作量大還要自己處理所有繪制細節非必要不推薦第三條是對子窗口做子類化比如把CHeaderCtrl攔截下來自己畫或者攔截ToolTip、滾動條之類的系統組件。實際工程里這三條路線往往是組合使用的。就拿我這個項目來說HeaderCtrl我用的OwnerDraw路線給表頭設置HDS_OWNERDRAWFIXED樣式后重載DrawItem因為表頭的視覺結構比較固定背景、文字、排序箭頭、分隔線四樣東西自己畫反而比跟系統默認繪制做斗爭更可控。Items用的Custom Draw消息處理鏈清晰能做交替行色、選中高亮、圖標混合這些常見效果而且不用像Owner Draw那樣自己處理鍵盤導航和編輯狀態。Scrollbar這條路最特殊后面單獨用一整章講因為它根本不是普通意義上的“子窗口”沒法直接子類化。1.2 三個目標的技術特點與坑點概覽動手之前先把三個部分的特點和主要難點列個表方便你對照自己的需求判斷優先級。實際項目里這三個部分不是必須同時改很多時候只改Items就能讓列表顏值提升一大截。繪制目標推薦方案關鍵消息/接口主要難點HeaderCtrl子類化 OwnerDrawHDS_OWNERDRAWFIXED、DrawItem排序箭頭狀態、分隔線像素ItemsNM_CUSTOMDRAWCDDS_PREPAINT/ITEMPREPAINT失焦變灰、選中態覆蓋ScrollbarWM_NCPAINT或替換控件WM_NCPAINT、GetScrollInfo非客戶區交互、滑塊坐標計算這個表是我整理項目筆記時畫的也是我后來做同類需求時最先翻出來看的東西。你可以把滾動條那行單獨圈出來它是三個部分里唯一需要同時處理繪制和交互的地方也是最容易在“畫出來了但拖不動”這個詭異狀態上卡住的部分。2. 表頭HeaderCtrl重繪從默認樣式到完全自繪2.1 子類化CHeaderCtrl并啟用OwnerDraw要接手表頭繪制不能直接在CListCtrl里發消息得先拿到CHeaderCtrl窗口并子類化它。最常見的寫法是在CMyListCtrl的PreSubclassWindow里做手腳。我建議把CMyHeaderCtrl單獨聲明成一個類別把所有邏輯堆在CListCtrl里否則后期維護會很難受。// MyListCtrl.h class CMyHeaderCtrl : public CHeaderCtrl { public: virtual void PreSubclassWindow() override; protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) override; }; class CMyListCtrl : public CListCtrl { public: virtual void PreSubclassWindow() override; protected: CMyHeaderCtrl m_header; }; // MyListCtrl.cpp void CMyListCtrl::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); CHeaderCtrl* pHeader GetHeaderCtrl(); if (pHeader ! NULL) { m_header.SubclassWindow(pHeader-GetSafeHwnd()); } } void CMyHeaderCtrl::PreSubclassWindow() { CHeaderCtrl::PreSubclassWindow(); // 關鍵啟用 OwnerDraw 樣式否則 DrawItem 不會被調用 ModifyStyle(0, HDS_OWNERDRAWFIXED); }這里有個細節值得注意PreSubclassWindow是在窗口創建時調用的理論上此時GetHeaderCtrl已經返回了有效的CHeaderCtrl指針但實際運行中還是會遇到拿到NULL的情況。比如在對話框模板里創建的ListCtrl如果模板的visible狀態或者創建順序有微妙差異表頭可能還沒初始化完。所以我習慣加一層空指針判斷寧可什么都不做也不能讓程序崩掉。如果你用的是Create動態創建建議在OnCreate里而不是PreSubclassWindow里做子類化。啟用HDS_OWNERDRAWFIXED之后DrawItem會被系統調用HDC就是表頭客戶區這時候就算完全進入自繪模式了。2.2 繪制排序箭頭和分隔線的細節在DrawItem里必須取HDITEM注意mask要包含HDI_TEXT和HDI_FORMAT兩樣。HDI_FORMAT里除了列對齊方式還帶有HDF_SORTUP和HDF_SORTDOWN標志這是排序箭頭狀態的唯一來源。很多新手不知道這一點以為箭頭需要自己維護一個變量來記排序狀態結果列表排序一變化箭頭狀態就錯亂。void CMyHeaderCtrl::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC CDC::FromHandle(lpDIS-hDC); if (!pDC) return; CRect rc(lpDIS-rcItem); CRect rcText rc; rcText.left 8; rcText.right - 16; // 右側預留箭頭空間 // 背景 pDC-FillSolidRect(rc, RGB(52, 55, 58)); // 底色分割線 CPen pen(PS_SOLID, 1, RGB(70, 72, 75)); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(rc.left, rc.bottom - 1); pDC-LineTo(rc.right, rc.bottom - 1); pDC-SelectObject(pOldPen); // 獲取列文本與格式 TCHAR szText[128] {0}; HDITEM hdi {0}; hdi.mask HDI_TEXT | HDI_FORMAT; hdi.pszText szText; hdi.cchTextMax 127; GetItem(lpDIS-itemID, hdi); // 文字 pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(RGB(230, 230, 230)); pDC-DrawText(szText, rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); // 排序箭頭 if (hdi.fmt HDF_SORTUP) { DrawSortArrow(pDC, rc, TRUE); } else if (hdi.fmt HDF_SORTDOWN) { DrawSortArrow(pDC, rc, FALSE); } }繪制箭頭時有個最容易踩的坑文字和箭頭搶空間。如果列寬比較小文字會把箭頭位置蓋住。我的做法是rcText.right - 16給箭頭留出固定的16像素空間箭頭畫在列右側10像素附近。另一個坑是分隔線位置畫在rcItem.right - 1還是rcItem.right效果差別很大畫到right上會被下一列的背景蓋住看起來像是分隔線斷續。按1像素內縮才能在視覺上形成連續分隔效果。箭頭三角形本身用Polygon畫就行注意坐標要跟文字垂直居中對齊。這個方案在XP到Win10的實測中表現穩定但有一點必須提醒如果系統開啟了視覺風格manifest里帶comctl32 v6默認分隔線的顏色和寬度會跟你畫的對不上視覺風格下CHeaderCtrl自身還有一層漸變背景需要你完全覆蓋所有像素否則會有毛邊。3. 列表項Items重繪選中態與焦點態才是重點3.1 NM_CUSTOMDRAW的完整處理鏈Items部分的核心是NM_CUSTOMDRAW消息。在CMyListCtrl里直接用ON_NOTIFY_REFLECT反射消息消息經ListCtrl自己處理一遍再傳給父窗口這樣代碼可以封裝在控件類內部外部調用方不用感知。BEGIN_MESSAGE_MAP(CMyListCtrl, CListCtrl) ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, CMyListCtrl::OnNMCustomDraw) END_MESSAGE_MAP() void CMyListCtrl::OnNMCustomDraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD (NMLVCUSTOMDRAW*)pNMHDR; DWORD dwStage pLVCD-nmcd.dwDrawStage; *pResult CDRF_DODEFAULT; if (dwStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return; } if (dwStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYSUBITEMDRAW; return; } if (dwStage CDDS_SUBITEMPREPAINT) { // 在這里按列設置文字顏色、背景色 } }這個處理鏈的順序是PREPAINT整控件準備繪制→ ITEMPREPAINT每一行準備繪制→ SUBITEMPREPAINT每行每一列準備繪制。每一層返回不同的CDRF標志決定下一層是否會收到通知。如果你只做整行顏色統一走到ITEMPREPAINT就夠了直接在pLVCD-clrText和pLVCD-clrTextBk里賦值返回CDRF_NEWFONT如果需要不同列不同顏色才必須繼續通知到SUBITEMPREPAINT。注意一點CDDS_PREPAINT里如果返回CDRF_SKIPDEFAULT整個列表都會變成空白這是新手最容易犯的錯誤。3.2 失焦不變灰的兩種實現思路先解釋為什么CListCtrl失焦后選中項會變灰。系統默認在失去焦點時會把選中項畫成灰色視覺風格下是淺灰藍而Custom Draw的uItemState里的CDIS_SELECTED標志在失焦時會被系統清掉所以你在Custom Draw里判斷CDIS_SELECTED會發現失靈選中項突然不生效了。思路一是繞開CDIS_SELECTED直接查詢項狀態。LVIS_SELECTED這個狀態位在失焦后依然保留選中狀態本身不會因為失焦而清除只是視覺上被系統強制畫成灰。所以你可以直接調用GetItemState去查不看uItemState里那個不可靠的標志。但只改clrTextBk還不夠系統對選中行背景有特殊處理視覺風格下是一層漸變藍失焦時這層漸變會變成灰色蓋過你設置的背景色。更徹底的做法是在ITEMPREPAINT階段自己FillSolidRect整行背景然后返回CDRF_NEWFONT讓系統繼續畫文字但文字顏色自己控制。if (dwStage CDDS_ITEMPREPAINT) { int iItem (int)pLVCD-nmcd.dwItemSpec; CDC* pDC CDC::FromHandle(pLVCD-nmcd.hdc); CRect rcItem; GetItemRect(iItem, rcItem, LVIR_BOUNDS); if (GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED) { // 無論焦點在不在都畫成同一種選中藍 pDC-FillSolidRect(rcItem, RGB(0, 120, 215)); pLVCD-clrText RGB(255, 255, 255); pLVCD-clrTextBk RGB(0, 120, 215); } else { pDC-FillSolidRect(rcItem, RGB(255, 255, 255)); pLVCD-clrText RGB(30, 30, 30); pLVCD-clrTextBk RGB(255, 255, 255); } *pResult CDRF_NEWFONT; return; }這段代碼最核心的一句就是GetItemState(iItem, LVIS_SELECTED) LVIS_SELECTED它不關心焦點狀態只關心這一行是否處于“選中”這個邏輯狀態。背景已經被你填成固定的亮藍色失焦時系統也沒有機會再把它畫灰了。我自己實測下來這個方案在Win7到Win10、XP加經典主題下都穩定。思路二是攔截WM_KILLFOCUS和WM_SETFOCUS收到消息后強制RedrawWindow在繪制期間臨時把焦點狀態“固定”成有焦點。這個方法在理論上也行但實際容易被模態對話框、菜單彈出、視圖切換打斷而且焦點消息和繪制消息之間有時序問題會出現偶發的“閃一下灰再變藍”的鬼畜效果。我不推薦除非你遇到了連GetItemState都不能用的特殊場景。3.3 取消所有選擇行與選中行顏色控制“取消所有選擇行”是網上問得很多的功能其實LVM_SETITEMSTATE一條消息就能解決。關鍵在于用-1作為item索引表示“所有項”。很多人第一次寫會把state參數傳錯導致一行都沒取消掉。void DeselectAll(CListCtrl list) { LVITEM lvItem {0}; lvItem.stateMask LVIS_SELECTED | LVIS_FOCUSED; lvItem.state 0; list.SendMessage(LVM_SETITEMSTATE, (WPARAM)-1, (LPARAM)lvItem); }注意stateMask必須同時包含LVIS_SELECTED和LVIS_FOCUSEDstate清零。如果你只清LVIS_SELECTED而保留了LVIS_FOCUSED焦點行會留下一個虛框看起來像是沒有完全取消選擇。這條代碼在需要全選/全不選切換的場景里非常實用。選中行顏色控制方面別把顏色值硬編碼在繪制函數里。我習慣在CMyListCtrl里維護兩個成員變量m_crSelectionBack和m_crSelectionText在OnSysColorChange里根據系統主題動態更新這樣換膚或者系統高對比度模式下選中行顏色不會顯得突兀。另外一個容易被忽略的點是選中行文本里如果有圖標DrawIcon的透明區域跟FillSolidRect的背景色要協調否則圖標邊緣會出現難看的白框。4. 滾動條Scrollbar重繪最硬核也最容易被問爆的地方4.1 為什么滾動條不能像表頭那樣直接子類化很多人一想到重繪滾動條第一反應是跟表頭一樣子類化結果發現GetScrollBarCtrl(SB_VERT)對CListCtrl返回是NULL。原因是CListCtrl的滾動條不是獨立窗口而是ListView窗口非客戶區的一部分你根本沒有一個獨立的HWND可以去Subclass。這是滾動條重繪第一道坎也是網上資料總是講一半的原因。正因為如此業內形成了兩條主流路線。路線A是攔截WM_NCPAINT在非客戶區自己繪制滾動條同時處理WM_NCHITTEST、WM_NCLBUTTONDOWN等命中消息這是真正意義上的滾動條重繪但交互邏輯全得自己寫路線B是去掉標準滾動條樣式在ListCtrl右側放一個自繪的CScrollBar子類控件通過消息同步滾動位置。工程上路線B更可控很多開源皮膚庫都在用調試也直觀。4.2 路線AWM_NCPAINT自繪非客戶區采用路線A的話核心思路是先調用Default()讓系統把邊框等非客戶區畫好然后在滾動條區域上疊畫。數據來源是GetScrollInfo滑塊位置和高度必須自己算。void CMyListCtrl::OnNcPaint() { Default(); // 先讓系統畫默認非客戶區 CWindowDC dc(this); CRect rcClient; GetClientRect(rcClient); ClientToScreen(rcClient); // 滾動條區域 CRect rcScroll; rcScroll.left rcClient.right - GetSystemMetrics(SM_CXVSCROLL); rcScroll.top rcClient.top; rcScroll.bottom rcClient.bottom; rcScroll.right rcClient.right; SCROLLINFO si; ZeroMemory(si, sizeof(si)); si.cbSize sizeof(si); si.fMask SIF_ALL; GetScrollInfo(SB_VERT, si); // 滑塊高度 int nTrackH rcScroll.Height(); int nThumbH (int)(nTrackH * (double)si.nPage / (si.nMax 1)); nThumbH max(nThumbH, 20); int nRange max(1, nTrackH - nThumbH); int nThumbPos (si.nPos - si.nMin) * nRange / max(1, si.nMax - si.nPage 1); // 畫軌道與滑塊 dc.FillSolidRect(rcScroll, RGB(240, 240, 240)); CRect rcThumb(rcScroll.left, rcScroll.top nThumbPos, rcScroll.right, rcScroll.top nThumbPos nThumbH); dc.FillSolidRect(rcThumb, RGB(150, 150, 150)); }這段代碼有幾個計算細節必須注意ListView的滾動范圍不是0到nMax而是0到nMax - nPage 1所以滑塊位置的分母要用si.nMax - si.nPage 1否則滾到底滑塊也到不了底部。滑塊高度用比例計算后還要做最小高度限制否則內容很多時滑塊會變成一條肉眼幾乎看不見的細線。系統沒有提供直接獲取非客戶區DPI本文還有配套的精品資源點擊獲取