
简介这是一份MFC框架下深度定制的自绘CTabCtrl标签控件源码示例面向Windows界面开发者用于解决标准标签页控件外观单一、交互不够灵活的问题。通过手动接管绘制流程可实现标签颜色、形状、字体、图标及动画过渡等丰富视觉效果在工业软件、工具面板或个性客户端中均有实用价值。压缩包内共2个文件包含一个C实现文件与一个头文件分别承载具体绘制逻辑和类接口声明整个资源仅6KB结构精简、便于快速研读。目前已有354人学习。源码重点展示了OnPaint消息处理、GDI绘图函数调用、自定义状态绘制等核心技巧同时涉及WM_NCPAINT、WM_ERASEBKGND及定时器驱动动画等进阶处理思路对希望提升MFC界面表现力、深入理解Windows自绘控件机制的开发者来说是一份小而精的参考范例。1. 为什么非要自己画Tab控件先说结论CTabCtrl默认长那样根本不是我审美不行而是它真的没法直接用。MFC自带的Tab控件在XP时代还算凑合放到Win10/Win11上那个格格不入的边框、生硬的选中态、毫无质感的标签底色直接拉低整个软件的档次。尤其是做上位机、做工业软件、做工具类客户端的朋友产品经理拿着设计稿过来说这个Tab要圆角、要渐变、选中项要有个指示条你打开VS拖一个CTabCtrl上去不用试就知道——完蛋这玩意儿根本做不出效果。自绘CTabCtrl解决的就是这个问题让Tab控件彻底摆脱系统默认外观所有颜色、圆角、间距、字体、选中指示器全部由自己的代码控制。适合谁适合用MFC做Windows桌面客户端、被UI需求折磨过的C开发者以及想搞清楚OwnerDraw机制到底怎么用的朋友。这篇文章我把从设置样式、重写DrawItem、处理鼠标交互到解决闪烁的完整方案写出来照抄即可。2. 自绘之前必须先想清楚的几件事2.1 自绘不是从WM_PAINT开始是从OwnerDraw开始很多第一次做自绘的兄弟上来就在OnPaint里画画完发现子项根本控制不了鼠标点上去也不会反馈。正确做法是给CTabCtrl设置TCS_OWNERDRAWFIXED样式让控件把每个标签页的绘制权交出来。这个样式的作用是系统不再负责画每个Tab项的内部外观而是通过WM_DRAWITEM消息把绘制请求发给你你在响应函数里自己画。代码上就一行DWORD dwStyle GetWindowLong(m_tab.GetSafeHwnd(), GWL_STYLE); SetWindowLong(m_tab.GetSafeHwnd(), GWL_STYLE, dwStyle | TCS_OWNERDRAWFIXED);注意TCS_OWNERDRAWFIXED和TCS_OWNERDRAWVARIABLE的区别前者所有Tab项高度一致适合常规场景后者允许每个项高度不同一般用不到还麻烦。2.2 绘制消息走的是DrawItem虚函数不是OnPaint设好OwnerDraw样式后接下来说说绘制入口。你可能会想那我重写OnPaint总行吧不行。OnPaint负责的是整个控件的背景和边框而每个Tab项的绘制是通过DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct)虚函数回调的。也就是说你要把CTabCtrl派生一个子类重写DrawItem在那里画每一个标签页。如果你是动态创建控件注意必须在Create之后立刻设置OwnerDraw样式否则控件在创建初期会先按默认样式画一遍屏幕上一闪而过特别难看。如果是对话框模板里拖入的控件可以在OnInitDialog里设置。2.3 父窗口插手会让事情变复杂还有一个常见的坑WM_DRAWITEM默认是发给父窗口的也就是说你可以在对话框类里直接处理OnDrawItem不去派生控件类。小项目用这种方式倒也快但一旦涉及多个自绘控件父窗口的OnDrawItem就得写一堆switch-case来区分控件ID代码迅速膨胀。我建议直接做控件子类化绘制逻辑内聚在控件类内部到别的项目里直接拖走复用。3. 从零实现一个自绘CTabCtrl3.1 创建控件并设置扩展样式先看一个完整的派生类声明class CMyTabCtrl : public CTabCtrl { public: CMyTabCtrl(); virtual ~CMyTabCtrl(); protected: afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnPaint(); virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); DECLARE_MESSAGE_MAP() private: bool m_bHover; // 鼠标是否悬停在控件上 int m_nHoverIndex; // 悬停的Tab索引 CFont m_font; // 自绘字体 CRect m_rcCloseBtn; // 如果有关闭按钮记录热区 };创建时建议用Create动态创建方便统一管理样式m_tab.Create(WS_CHILD | WS_VISIBLE | TCS_OWNERDRAWFIXED | TCS_FIXEDWIDTH, rcTab, this, IDC_MYTAB);这里我加了TCS_FIXEDWIDTH让所有Tab项宽度统一。自绘模式下项宽的控制在自己的代码里但加上这个可以避免系统在部分场景下重新计算宽度带来的抖动。3.2 DrawItem里面的绘制代码逐行拆解DrawItem是核心。先拿DRAWITEMSTRUCT里的rcItem——这就是当前要绘制的那个Tab项的矩形区域。为什么用系统给的这个矩形而不是自己算因为系统已经根据索引、宽度、排列方式计算好了自己算很容易在边界条件下差几个像素出现文字被裁切或者重叠。void CMyTabCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rcItem lpDrawItemStruct-rcItem; int nIndex lpDrawItemStruct-itemID; BOOL bSelected (lpDrawItemStruct-itemState ODS_SELECTED); // 文字内容 TCHAR szText[256] {0}; TCITEM tci {0}; tci.mask TCIF_TEXT; tci.pszText szText; tci.cchTextMax 255; GetItem(nIndex, tci); // 背景 pDC-FillSolidRect(rcItem, bSelected ? RGB(255,255,255) : RGB(245,246,248)); // 选中项顶部加一条高亮指示线 if (bSelected) { CRect rcLine rcItem; rcLine.bottom rcLine.top 3; pDC-FillSolidRect(rcLine, RGB(0, 120, 215)); } // 文字 rcItem.DeflateRect(10, 6, 10, 6); pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(bSelected ? RGB(0, 120, 215) : RGB(80, 80, 80)); pDC-DrawText(szText, -1, rcItem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }这段代码做了四件事画背景、画选中指示线、画文字、设置文字颜色。注意几个细节ODS_SELECTED是判断选中态的标志不是ODS_FOCUS。ODS_FOCUS表示键盘焦点通常选中态和焦点态要区分开否则会出现鼠标点过去没选中但焦点还在导致颜色状态错乱。GetItem获取文字时要填充TCITEM.mask忘记设TCIF_TEXT直接调用返回的是空字符串排查半天找不到原因。DeflateRect之前先备份原始矩形。如果后面要画下边框用原始矩形不要用Deflate之后的否则右边距和下边距不对称。3.3 选中项与被选中项的层次关系看起来上面代码已经把选中和非选中状态画出来了但实际运行时会发现非选中项会把选中项盖住一部分或者选中项的右边框出不来。原因在于Tab控件的绘制顺序。OwnerDraw模式下系统按从右到左逐个调用DrawItem最后绘制第一个Tab项。如果你选中了第一个Tab而第二个Tab的绘制矩形和第一个有重叠第二个就会盖住第一个的右边。解决办法是画非选中项时手动把右侧留出1个像素的缝隙或者画完所有项之后在OnPaint里补画一层选中项边框。我更推荐后者逻辑清晰也不影响鼠标热区判断。void CMyTabCtrl::OnPaint() { CPaintDC dc(this); // 先让系统做默认绘制触发每个Tab项的DrawItem CTabCtrl::OnPaint(); // 再画一次选中项的外边框盖住接缝 int nSel GetCurSel(); if (nSel 0) { CRect rcItem; GetItemRect(nSel, rcItem); dc.Draw3dRect(rcItem, RGB(0,120,215), RGB(0,120,215)); } }3.4 背景擦除消除闪屏的根源自绘控件闪烁的根源十有八九是OnEraseBkgnd和绘制代码重复刷背景。系统默认的EraseBkgnd会用背景色刷一遍整个控件然后DrawItem又刷一遍Tab项区域一擦一画一秒钟几十次屏幕不闪才怪。方案很粗暴重写OnEraseBkgnd直接返回TRUE禁止擦除背景。所有背景绘制全部交给OnPaint统一完成。BOOL CMyTabCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; }注意这里只解决控件自身的闪烁。如果整个对话框在调整大小时闪烁那是对话框双缓冲的问题得在对话框里用MemDC整体双缓冲跟Tab控件无关别混为一谈。4. 细节打磨让自绘控件真正好用4.1 鼠标悬停效果提升交互质感悬停效果是现代控件的基本操作让用户知道这个区域可点。实现思路是OnMouseMove里判断鼠标所在的Tab索引和上一次记录的不同就触发Invalidate同时调用TrackMouseEvent捕获WM_MOUSELEAVE。void CMyTabCtrl::OnMouseMove(UINT nFlags, CPoint point) { int nHover -1; for (int i 0; i GetItemCount(); i) { CRect rcItem; GetItemRect(i, rcItem); if (rcItem.PtInRect(point)) { nHover i; break; } } if (nHover ! m_nHoverIndex) { m_nHoverIndex nHover; Invalidate(FALSE); } // 触发WM_MOUSELEAVE TRACKMOUSEEVENT tme { sizeof(tme), TME_LEAVE, m_hWnd, 0 }; TrackMouseEvent(tme); CTabCtrl::OnMouseMove(nFlags, point); }在DrawItem里用m_nHoverIndex nIndex来判断悬停态背景色可以稍微调整比如RGB(240,245,250)比普通态亮一点比选中态淡一点。4.2 字体管理别用系统默认字体硬扛系统默认的Segoe UI在Tab控件里显示标签还行但如果你需要更大字号或者加粗标题直接用GetStockObject(DEFAULT_GUI_FONT)是不行的中文字体渲染会发虚。建议创建CFont指定字体名称和大小并在WM_SETFONT或者初始化时选入控件的CDC。m_font.CreateFont(-16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH, _T(Microsoft YaHei UI));绘制文字之前CFont* pOldFont pDC-SelectObject(m_font); pDC-DrawText(...); pDC-SelectObject(pOldFont);字体大小建议用负值表示字符高度别用正值正负值在DPI缩放时行为不一样。高分屏下不做这个处理Tab文字会模糊得像没戴眼镜。4.3 关闭按钮、图标等扩展功能如果要做类似浏览器的带关闭按钮的Tab在DrawItem里除了画文字还要画一个右上角的叉号。这时候需要维护一个CRect数组记录每个关闭按钮的热区在鼠标点击时判断是否落在热区内然后发通知或者直接调用DeleteItem。画叉号不要用DrawText写×字符字体渲染在不同DPI下位置会偏。用MoveTo和LineTo画两条线最稳void DrawCloseButton(CDC* pDC, CRect rcBtn) { int x1 rcBtn.left 5; int y1 rcBtn.top 5; CPen pen(PS_SOLID, 1, RGB(80,80,80)); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(x1, y1); pDC-LineTo(x1 6, y1 6); pDC-MoveTo(x1 6, y1); pDC-LineTo(x1, y1 6); pDC-SelectObject(pOldPen); }4.4 自适应选项卡宽度固定宽度的Tab在文字短时显得空旷文字长时又放不下。一个简单的自适应算法遍历所有Tab的文字用CDC::GetTextExtent计算像素宽度取最大值加上左右Padding再限制最小宽度和最大宽度。int nMaxWidth 80; for (int i 0; i GetItemCount(); i) { CString strText; TCITEM tci {0}; tci.mask TCIF_TEXT; tci.pszText strText.GetBuffer(256); tci.cchTextMax 255; GetItem(i, tci); strText.ReleaseBuffer(); CClientDC dc(this); CFont* pOldFont dc.SelectObject(m_font); CSize sz dc.GetTextExtent(strText); dc.SelectObject(pOldFont); nMaxWidth max(nMaxWidth, sz.cx 24); }然后调用SetItemSize(CSize(nMaxWidth, 32))。这个方法在OnInitDialog或者插入Tab项之后调用一次即可不要放在DrawItem里反复计算。5. 高频问题挨个排查问题现象原因解决办法自绘后Tab项文字消失TCITEM.mask没设置TCIF_TEXT或pszText缓冲区未初始化检查mask赋值pszText用局部数组填充控件背景闪烁严重系统默认的EraseBkgnd在刷背景重写OnEraseBkgnd返回TRUE背景统一在OnPaint画选中项被相邻项盖住边框绘制顺序先右后左相邻项重叠在OnPaint末尾补画选中项外边框点击Tab没反应忘了设置TCS_OWNERDRAWFIXED系统认为你在用默认绘制检查GWL_STYLE是否包含该样式文字在DPI变化后模糊字体未按DPI重新创建响应WM_DPICHANGED重新创建字体并刷新布局自绘后高度异常Win7和Win10对GetItemRect返回值不同固定SetItemSize设定项高不要依赖系统默认再说一个隐蔽的坑DrawItem里如果你调用了GetItemRect在某些Windows版本上lpDrawItemStruct-rcItem和GetItemRect返回的结果会有1像素差异。原因是系统在分配矩形时把边框计算进去了。以lpDrawItemStruct-rcItem为准GetItemRect只用在鼠标命中检测时获取近似区域误差1像素不影响点击。还有做完自绘之后如果你发现文字显示时背景还是系统默认的白色记得在DrawItem里调用SetBkMode(TRANSPARENT)。我见过多个同事在这里漏了结果是文字背后一块白底怎么调颜色都盖不住。原因就是GDI默认的OPAQUE背景模式。6. 一点个人经验碰过很多次Tab自绘的活儿之后最大的体会是自绘这事儿看起来是画图实际上大部分工作量在状态管理和矩形计算上。绘制代码本身顶多100行但为了处理悬停、选中、焦点、DPI、边框接缝这些问题往往要反复调试很久。所以动手之前先把状态模型理清楚——控件有几种状态状态之间怎么切换每种状态对应什么外观——后面写代码就是填表了。另外建议把绘制相关的颜色、字体、边距全部定义成控件类的成员变量而不是散落在DrawItem里的魔鬼数字。这样后续改主题、做深色模式只需要改几个成员变量不用在几百行绘制代码里翻找。先把这套基础框架搭好后续要加圆角背景、渐变填充、下划线指示器都是在DrawItem里替换画法的事。框架稳了改样式就是半小时以内的事情。本文还有配套的精品资源点击获取