
简介基于微软基础类库的自绘标签页控件示例面向需要进行界面定制的Windows客户端开发人员用来解决系统默认标签页外观简陋、可交互形式单一的问题。整个资源打包为一份压缩包内有两个文件分别是一个源程序文件和一个头文件压缩包体积只有六KB非常适合快速通读与移植。自绘部分重点通过对绘制消息的处理调用图形设备接口完成标签颜色、形状、字体、边框、渐变背景和图标等元素的自定义绘制同时还可加入滑动切换与淡入淡出等动画过渡。这一示例演示了从消息响应到逐像素绘制的完整流程已有三百五十四人学习下载对于想要提升界面表现力或者钻研控件自绘技巧的开发者具有简明而直接的参考价值。 做Windows客户端开发的尤其是从MFC、WTL这批老框架一路摸爬滚打过来的几乎都有过跟CTabCtrl较劲的经历。系统默认的那个Tab控件灰底、黑字、蓝色实心选中项放到现在随便一个产品界面里都显得格格不入。我这次收拾一个老项目界面要往深色风格走其它控件都已经重绘得七七八八了唯独那个Tab一露出来就瞬间破功。没办法只能自己动手画一个自绘的CTabCtrl顶上。这篇文章不是扔一段代码让你复制粘贴就完事而是把自绘Tab这件事从思路、样式设置、绘制流程、踩坑指南一条线讲透。不管你是刚接触MFC自绘的新手还是被一堆GDI操作整得头大、想找一套现成方案的老人只要你需要在MFC或者Win32窗口体系下画一个看起来不那么原生的Tab这篇东西应该都能给你省不少时间。1. 自绘CTabCtrl动手之前先想清楚的事1.1 系统默认Tab到底差在哪产品想从中拿回来什么说句公道话默认Tab不是“丑”而是“不搭”。默认Tab控件的绘制依赖系统主题主题开启时它是蓝白渐变的高光风格主题关闭时就变成灰突突的平面样式。不管哪一种都很难和自定义界面的主色调统一。更麻烦的是Tab控件标签下方有一大片空白客户区如果你的对话框背景是深色或者带纹理那片空白就会像一块膏药一样贴在界面上无论怎么调整对话框背景色都对不上。产品经理或者UI设计师通常不会直接说“Tab要自绘”他们会说“这个Tab能不能做得跟整体风格融合一点”“选中项能不能换个颜色”“标签文字能不能大一点粗一点”。这些诉求翻译成技术语言就是三件事第一标签区域的颜色、形状、边框、文字样式要完全可控第二标签区和页面区要视觉上融为整体不能留出生硬的分界第三交互反馈要能自定义比如鼠标悬停变色、按下时有轻微偏移感。明白了这三点自绘的方向就不会跑偏。自绘不是炫技而是把“系统说了算”变成“你说了算”。1.2 自绘方案怎么选Owner Draw是主流且不折腾的路线实现自绘Tab业界基本两条路。第一路子类化加完全接管绘制自己处理WM_PAINT把所有标签都画出来同时自己处理鼠标消息和命中测试。灵活性确实最高想画成什么样都行但工作量大得惊人键盘导航、焦点状态、窗口尺寸变化、DPI缩放全都要额外处理。说实话除非你要做的是类似Chrome那种带拖拽动画的标签页否则完全没必要。第二路是设置TCS_OWNERDRAWFIXED样式重写DrawItem。这是Windows原生提供的自绘接口系统依然负责标签布局、点击命中、选中状态维护你只需要在回调里把每个标签“画出来”。相当于系统已经把框架搭好了你只管往里填颜色和文字。我实际选择的就是第二路。它把“控件行为”和“外观绘制”解耦了系统处理交互逻辑我只处理视觉表达。这套思路不仅适用Tab也适用于按钮、列表项、组合框等一大票MFC控件搞懂一次后面很多自绘都能复用。2. 自绘核心机制先把这些关键点吃透2.1 TCS_OWNERDRAWFIXED和DrawItem是怎么串起来的自绘的入口本质上是一个Windows消息WM_DRAWITEM。这个消息会携带一个DRAWITEMSTRUCT结构体里面装着itemID项目索引、itemState项目状态、itemData关联数据、rcItem项目矩形等关键字段。MFC把这个消息封装成了一个虚函数DrawItem(LPDRAWITEMSTRUCT)所以自绘Tab在MFC里最标准的做法是写一个CTabCtrl的派生类重写DrawItem然后在DrawItem里写你的绘画逻辑。每条Tab需要绘制时系统都会调用一次这个函数你只需要根据状态和矩形把颜色和文字画上去。这里有个容易翻车的点必须确保控件样式里有TCS_OWNERDRAWFIXED否则DrawItem根本不会被调用。我见过不少人在Resource Editor里拖了一个Tab控件然后在代码里重写了DrawItem死活不出来效果最后发现样式没加白忙一场。2.2 GetItemRect拿到的矩形其实是个“半成品”在DrawItem的DRAWITEMSTRUCT里rcItem已经帮你算好了当前项的位置和大小直接用就行。但如果你想在其它地方获取某个Tab项的位置比如实现鼠标悬停高亮就需要调用GetItemRect。这里有个细节要注意如果Tab是多行模式TCS_MULTILINEGetItemRect返回的坐标在不同行之间会有偏移而且拉出的矩形可能会把整行都包进去。稳妥做法是绘制时直接以rcItem为准不要在绘图过程里再做二次坐标换算否则会受到运行时缩放和边框宽度的影响出现错位。另外提醒一下在DrawItem里绘制文字时很多新手直接调用DrawText结果文字背景留了一个白色方块。原因是没有设置背景透明。正确做法是先调用SetBkMode(hDC, TRANSPARENT)把文字背景设为透明再DrawText字体周围就会干净地融入Tab背景。2.3 状态判断选中态、悬停态、普通态一个都不能少DRAWITEMSTRUCT的itemState字段里ODS_SELECTED代表选中ODS_FOCUS代表焦点这两个状态系统会帮你维护。但鼠标悬停Hover状态不在这里面需要自己实现。实现方式不复杂维护一个成员变量hoverIndex处理WM_MOUSEMOVE判断鼠标坐标落在哪个Tab项区域内记录下来并调用Invalidate触发重绘。同时配合TrackMouseEvent接收WM_MOUSELEAVE在鼠标离开控件时把hoverIndex重置为-1再重绘一次。这套逻辑写完界面才会有“活”的感觉鼠标划过去的时候标签会有反馈而不是一块死板的色块。很多自绘控件给人“一眼假”的感觉就是因为缺少了这些状态交互。3. 实操落地从零写一个能直接用的自绘Tab3.1 类骨架和样式初始化先建一个CTabCtrl的派生类我这里叫CMyTabCtrl。在PreSubclassWindow里把自绘样式加上。注意这里要保留原有的TCS_FIXEDWIDTH等布局样式只是追加TCS_OWNERDRAWFIXED。// MyTabCtrl.h class CMyTabCtrl : public CTabCtrl { public: CMyTabCtrl(); virtual ~CMyTabCtrl(); protected: afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg void OnSelchange(NMHDR* pNMHDR, LRESULT* pResult); DECLARE_MESSAGE_MAP() virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); virtual BOOL OnEraseBkgnd(CDC* pDC); private: int m_hoverIndex; BOOL m_bTracking; };// MyTabCtrl.cpp void CMyTabCtrl::PreSubclassWindow() { CTabCtrl::PreSubclassWindow(); // 加上自绘样式注意保留原有的对齐/宽度样式 ModifyStyle(0, TCS_OWNERDRAWFIXED); m_hoverIndex -1; m_bTracking FALSE; }3.2 DrawItem的完整实现这是整个自绘的核心。以深色主题为例选中标签用亮色填充未选中标签用深色填充文字颜色也做区分。同时绘制一条底部边框让Tab和页面区之间形成一条清晰的分隔线。void CMyTabCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rcItem(lpDrawItemStruct-rcItem); BOOL bSelected (lpDrawItemStruct-itemState ODS_SELECTED); BOOL bHover (lpDrawItemStruct-itemID (UINT)m_hoverIndex) !bSelected; // 1. 填充底色 if (bSelected) pDC-FillSolidRect(rcItem, RGB(34, 34, 34)); else if (bHover) pDC-FillSolidRect(rcItem, RGB(60, 60, 60)); else pDC-FillSolidRect(rcItem, RGB(48, 48, 48)); // 2. 顶部画一条高光线增加一点细节 if (bSelected) { CRect rcTop(rcItem.left, rcItem.top, rcItem.right, rcItem.top 2); pDC-FillSolidRect(rcTop, RGB(0, 150, 255)); } // 3. 设置文字样式 CFont* pFont GetFont(); if (!pFont) { // 空指针保护理论上不会走到这但防御一下总没错 pFont CFont::FromHandle((HFONT)GetStockObject(DEFAULT_GUI_FONT)); } CFont* pOldFont pDC-SelectObject(pFont); pDC-SetBkMode(TRANSPARENT); CString strText; GetItemText(lpDrawItemStruct-itemID, strText); // 4. 文字颜色区分 COLORREF clrText bSelected ? RGB(255, 255, 255) : RGB(180, 180, 180); pDC-SetTextColor(clrText); if (bSelected) { // 选中时让文字向下偏移1像素模拟轻微按下的手感 CRect rcText rcItem; rcText.OffsetRect(0, 1); pDC-DrawText(strText, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } else { pDC-DrawText(strText, rcItem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } pDC-SelectObject(pOldFont); }3.3 背景融合让Tab“长”在对话框上而不是一块补丁自绘Tab最容易出现的违和感是背景不对。Tab控件的客户区标签下面大片空白也需要处理否则它和对话框背景就是两套颜色。我的处理方式是让Tab控件的背景直接跟随父窗口背景。在OnEraseBkgnd里不再调用默认实现而是填充与父窗口一致的背景色。更彻底的做法是直接把Tab控件的WS_EX_TRANSPARENT扩展样式加上然后在OnPaint里只画标签客户区完全留给父窗口的对话框背景透过来。但透明窗口会带来重绘顺序问题实现成本不低工程上我更推荐直接填充背景色。BOOL CMyTabCtrl::OnEraseBkgnd(CDC* pDC) { // 不调用默认的灰色填充改为填充父窗口背景色 CRect rcClient; GetClientRect(rcClient); // 如果你的父窗口也是深色背景这里就填相同的颜色 pDC-FillSolidRect(rcClient, RGB(34, 34, 34)); return TRUE; }3.4 切换重绘别把闪烁留给用户Tab切换时控件需要重绘。如果直接在OnPaint里画所有内容选中状态变化会导致标签区域闪白尤其在高DPI下更明显。解决办法是通知消息触发轻量重绘。在对话框里给Tab控件添加TCN_SELCHANGE消息处理切换时调用Invalidate(FALSE)。注意参数是FALSE不是TRUE。FALSE表示不擦除背景直接重绘能减少闪烁TRUE会先擦除背景再重画在自绘控件里几乎必然闪。void CMyTabCtrl::OnSelchange(NMHDR* pNMHDR, LRESULT* pResult) { // 选中页变化触发自绘刷新 Invalidate(FALSE); *pResult 0; }如果界面复杂、绘制开销大可以用双缓冲先创建内存DC把所有内容画到内存DC里再一次BitBlt到窗口DC。但Tab控件绘制量不大只要背景处理得当双缓冲一般用不上加了反而增加代码复杂度。3.5 封装一点实用接口为了让自绘Tab真正好用通常还要封装几个接口获取当前选中页、设置选中页、注册页面切换回调。这几个接口服务于业务逻辑让外部代码不直接操作控件状态统一走封装入口。int CMyTabCtrl::GetCurrentPage() const { return GetCurSel(); } void CMyTabCtrl::SetCurrentPage(int nPage) { if (nPage 0 nPage GetItemCount()) { SetCurSel(nPage); Invalidate(FALSE); } }这样外部调用就是一行代码的事不会散落一堆GetCurSel和Invalidate的组合。我在项目里习惯把Tab控件子类化之后再挂一个回调委托页面切换时通知主窗口做对应的界面更新。4. 常见问题与排查技巧实录4.1 文字不显示或者变成黑块这个我早期踩过不少次。最常见的两个原因一是没有SetBkMode(TRANSPARENT)文本背景会带底色二是字体句柄无效GetFont返回了空指针没有兜底。文字变黑块一般是因为SelectObject时选入了空字体GDI无法绘制。对策DrawText之前必须SetBkMode同时要对GetFont的结果做空指针保护拉一个DEFAULT_GUI_FONT或者系统字体作为后备。4.2 选中项和页面背景之间出现白边这个问题的根源在于WM_ERASEBKGND和DrawItem绘制顺序不一致。如果OnEraseBkgnd填充的是背景色但DrawItem里的rcItem区域和背景色有细微偏差就会露出一条白边。另一种情况是控件在对话框资源里原本带WS_EX_CLIENTEDGE风格边框自带凹陷效果自绘之后那条边框就特别突兀。对策使用ModifyStyleEx(WS_EX_CLIENTEDGE, 0)把边框去掉确保OnEraseBkgnd和DrawItem用的是同一套背景色。如果还有白边检查GetItemRect返回的矩形与客户区边缘之间是否有间距在FillSolidRect时多扩几个像素覆盖掉。4.3 自绘之后点击没反应点击失效不一定是自绘导致但自绘过程中确实容易误伤。最常见的是在DrawItem里改动了DC状态没有恢复比如SelectObject了临时字体或画刷没有把旧对象选回来后续命中测试和系统绘制就会出问题。另一个情况是重写OnEraseBkgnd时返回了TRUE但同时没有处理ON_WM_PAINT导致控件整个不刷新看起来像是“点了没反应”。对策DrawItem里所有SelectObject操作最后必须用pOldFont这类指针恢复现场。同时确认OnPaint或者默认的WM_PAINT处理没有被错误拦截。4.4 高DPI下文字模糊或者位置错乱这个问题在自绘控件里几乎躲不掉尤其是从普通DPI环境切换到125%、150%缩放时。MFC的D2D或者GDI绘制时如果直接用GetItemRect返回的逻辑坐标再配合未缩放的字体就会出现文字发虚、标签高度不够、文字被裁切的状况。对策在创建字体时使用CreateFontIndirect并把lfHeight设置为合适的像素值不能写死。标准做法是利用GetDpiForWindow获取当前DPI然后用物理像素乘以缩放系数。另一个方案是走MFC的高DPI兼容逻辑设置进程级Per-Monitor V2 DPI Awareness然后重写OnDpiChanged。4.5 Tab项宽度完全不受控制默认情况下Tab宽度是系统根据文字长度自动计算的自绘之后你可能会希望所有标签等宽。这时不要试图在DrawItem里强行拉伸而是设置TCS_FIXEDWIDTH样式同时用SetItemSize设置固定宽度和高度。ModifyStyle(0, TCS_FIXEDWIDTH); SetItemSize(CSize(120, 32));注意TCS_FIXEDWIDTH和TCS_OWNERDRAWFIXED不冲突可以同时存在。设置完固定宽度后GetItemRect返回的矩形就是等宽的DrawItem里的绘制逻辑不需要再处理宽度自适应问题。下面把上面提到的问题整理成一个速查表方便排查时快速定位。现象常见原因对策文字不显示/黑块SetBkMode未透明、字体句柄无效SetBkMode(TRANSPARENT)字体做空指针保护选中项有白边边框扩展样式、绘制区域不完整去掉WS_EX_CLIENTEDGE绘制时适当扩展区域点击没反应GDI对象未复原、重绘流程被拦截DrawItem里SelectObject要成对恢复高DPI文字模糊字体像素写死、未缩放用CreateFontIndirect配合GetDpiForWindow标签宽度失控未设置固定宽度设置TCS_FIXEDWIDTH调用SetItemSize这套自绘思路沉淀下来之后不只是CTabCtrl能用。后来我又用它处理了自绘Button、ListControl表头核心都是同一个套路保留系统行为接管视觉绘制。如果你也要在MFC项目里做自绘我的建议是第一版先别追求圆角、渐变、动画这些花活把背景融合、状态区分、DPI适配这三个基础问题解决就已经能吊打系统默认样式了。等这些稳定了再慢慢加细节。本文还有配套的精品资源点击获取