简介:这份资源是一套自绘 CTabCtrl 控件的完整实现代码,面向具备 MFC 基础、希望突破系统默认控件外观限制的 Windows 开发者。它解决的是标准标签页控件样式单一、难以适配个性化界面需求的问题,通过手动接管绘制流程,让开发者能够自由定制标签的颜色、形状、字体与边框,并加入滑动、淡入淡出等过渡动画以及图标支持,从而提升界面的辨识度与交互质感。压缩包共 2 个文件,包含 1 个 cpp 实现文件与 1 个 h 头文件,整体约 6KB,其中头文件声明类结构与成员,实现文件则集中处理 OnPaint 等消息,借助 GDI/GDI+ 完成线条、矩形与文本绘制,并可能结合定时器驱动动画帧计算。目前已有 360 人学习,适合想深入理解自定义控件绘制机制、研究非客户区处理与动画逻辑的开发者参考借鉴。1. 自绘 CTabCtrl:从默认灰控件到可换肤标签页的那一步MFC 里做界面,绕不开CTabCtrl。默认样式就是 Win95 味道的灰色凸起标签,放在今天任何一套 UI 里都显得突兀。很多做工业软件、上位机、医疗设备界面的同行,产品功能都做完了,最后卡在标签页太丑这一关。自绘CTabCtrl就是解决这个问题的:不改动 MFC 框架,不引入第三方库,通过接管WM_DRAWITEM或重写DrawItem,把标签的绘制权拿到自己手里,想画成什么样就画成什么样。这份资源适合两类人:一是正在用 MFC 做桌面端、被默认控件外观拖后腿的开发者;二是想理解 Windows 自绘机制、但不想啃大部头框架源码的进阶新手。它不涉及复杂算法,核心就是消息映射和 GDI 绘制,但坑不少,下面按实际落地顺序拆开讲。2. 自绘 CTabCtrl 的三种技术路线:选错了后面全是返工2.1 为什么默认 CTabCtrl 不能直接改颜色CTabCtrl封装的是 Win32 的SysTabControl32窗口类。这个系统控件在WM_PAINT里自己画标签,外部拿不到绘制上下文。你调SetBkColor、SetTextColor基本无效,因为系统控件不响应这些消息。想改外观,只有两条路:要么让系统控件把绘制任务交出来,要么完全自己画一个。常见做法是重写DrawItem虚函数。CTabCtrl继承自CWnd,而CWnd提供了DrawItem虚函数,当控件收到WM_DRAWITEM时会调用它。但这里有个前提:控件必须设置TCS_OWNERDRAWFIXED样式,否则系统不会发WM_DRAWITEM。很多人第一步就翻车,样式没加,重写了DrawItem死活不进来。// 在对话框的 OnInitDialog 里,给 Tab 控件加自绘样式 m_tabCtrl.ModifyStyle(0, TCS_OWNERDRAWFIXED); // 注意:ModifyStyle 之后需要重新设置控件大小或调用 SetWindowPos // 否则样式变更可能不触发重绘 m_tabCtrl.SetWindowPos(NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);ModifyStyle的第二个参数是新增样式,TCS_OWNERDRAWFIXED告诉系统“标签绘制我自己来”。SWP_FRAMECHANGED强制窗口重新计算非客户区,确保样式生效。这一步不做,后面所有绘制代码都是白写。2.2 方案对比:DrawItem 重写 vs 完全自绘 vs 子类化三条路线各有适用场景,选之前先看清楚代价。方案改动量可控粒度适用场景主要风险重写 DrawItem小标签整体只换标签外观,保留系统行为样式未生效、坐标算错完全自绘 CWnd大像素级需要异形标签、动画要自己处理所有鼠标消息子类化 + NM_CUSTOMDRAW中部分绘制阶段微调颜色、字体阶段理解错导致画两遍我一般推荐先走DrawItem路线。它能保留CTabCtrl的页管理、键盘导航、TCN_SELCHANGE通知这些现成逻辑,只把绘制换掉。完全自绘适合标签形状完全不像传统 Tab 的场景,比如弧形、斜切、带图标的卡片式标签,但代价是InsertItem、DeleteItem、SetCurSel这些都要自己实现,工作量翻倍。子类化加NM_CUSTOMDRAW是另一条路,但CTabCtrl对NM_CUSTOMDRAW的支持不如ListCtrl完整,很多绘制阶段拿不到,实际用起来反而更绕。除非你只想改文字颜色,否则不建议。2.3 消息映射与 DrawItem 的触发条件DrawItem的签名是virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct)。DRAWITEMSTRUCT里关键字段有四个:hDC是绘制设备上下文,rcItem是当前标签的矩形区域,itemID是标签索引,itemState是状态位(选中、焦点、禁用)。void CMyTabCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC dc; dc.Attach(lpDrawItemStruct->hDC); int nIndex = lpDrawItemStruct->itemID; CRect rcItem = lpDrawItemStruct->rcItem; bool bSelected = (lpDrawItemStruct->itemState & ODS_SELECTED) != 0; // 根据选中状态选不同背景色 COLORREF clrBg = bSelected ? RGB(255, 255, 255) : RGB(230, 230, 230); dc.FillSolidRect(&rcItem, clrBg); // 取标签文字 CString strText; TCHAR szBuf[256] = { 0 }; TCITEM tci = { 0 }; tci.mask = TCIF_TEXT; tci.pszText = szBuf; tci.cchTextMax = 255; GetItem(nIndex, &tci); strText = szBuf; // 文字颜色随选中状态变化 dc.SetTextColor(bSelected ? RGB(0, 120, 215) : RGB(80, 80, 80)); dc.SetBkMode(TRANSPARENT); dc.DrawText(strText, &rcItem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); dc.Detach(); }dc.Attach把系统传进来的 HDC 绑到 MFC 的 CDC 对象上,用完必须Detach,否则 CDC 析构时会释放这个 HDC,导致系统后续绘制崩溃。这是血泪经验,很多人第一次写自绘就栽在这里,程序跑起来看着正常,一切换标签就花屏。GetItem取文字时,TCITEM的pszText必须指向有效缓冲区,cchTextMax要设对。如果标签文字超过 255 字符,这里会截断,需要动态分配。一般标签不会那么长,但心里要有数。itemState里ODS_SELECTED表示当前选中标签。注意,CTabCtrl的选中标签和焦点标签可能不是同一个,键盘操作时焦点在某个标签上但选中项没变,这时候ODS_FOCUS会置位。如果你要画焦点虚线框,得单独处理。3. 把标签画好看:颜色、字体、图标与圆角的参数化实现3.1 选中态与未选中态的视觉分层默认控件最丑的地方是选中标签和未选中标签几乎没区别,就靠一个凸起边框区分。自绘时要拉开层次:选中标签背景用亮色、文字用主色、底部加一条高亮线;未选中标签背景用浅灰、文字用次级灰。这样一眼就能看出当前在哪一页。// 选中标签底部画一条 2px 高亮线 if (bSelected) { CRect rcLine = rcItem; rcLine.top = rcLine.bottom - 2; dc.FillSolidRect(&rcLine, RGB(0, 120, 215)); } // 未选中标签右侧画一条细分隔线 else { CRect rcSep = rcItem; rcSep.left = rcSep.right - 1; rcSep.top += 6; rcSep.bottom -= 6; dc.FillSolidRect(&rcSep, RGB(200, 200, 200)); }高亮线宽度一般 2 到 3 像素,太细看不清,太粗显得笨重。分隔线上下各留 6 像素内缩,避免顶满整个高度,视觉上更透气。这些数值不是固定的,跟标签高度有关,标签高 30 像素左右时这套参数比较协调。3.2 字体设置与文字居中偏移DrawItem里用的字体默认继承控件字体,但自绘时经常需要单独设。比如选中标签加粗、未选中常规。注意DRAWITEMSTRUCT的hDC里已经选入了控件字体,你要换字体得先保存旧字体,画完恢复。CFont fontSelected, fontNormal; fontSelected.CreatePointFont(90, _T("Microsoft YaHei")); fontNormal.CreatePointFont(90, _T("Microsoft YaHei")); CFont* pOldFont = NULL; if (bSelected) { fontSelected.GetLogFont(&lf); lf.lfWeight = FW_BOLD; fontSelected.DeleteObject(); fontSelected.CreateFontIndirect(&lf); pOldFont = dc.SelectObject(&fontSelected); } else { pOldFont = dc.SelectObject(&fontNormal); } // ... 绘制文字 ... dc.SelectObject(pOldFont); // 恢复旧字体CreatePointFont的第一个参数是字体大小的十分之一,90 表示 9 磅。lfWeight设FW_BOLD实现加粗。这里有个坑:CFont对象如果重复Create而不先DeleteObject,会泄漏 GDI 对象。GDI 对象总数有上限(默认 10000),标签页多、切换频繁时,泄漏几十次就可能让整个程序绘制异常。所以字体对象最好在控件初始化时创建一次,DrawItem里只做SelectObject,不要每次重画都新建。文字居中用DT_CENTER | DT_VCENTER | DT_SINGLELINE。如果标签带图标,图标和文字要并排,就得手动算偏移:先取图标尺寸,算出图标加间距加文字总宽,再算起始 x 坐标。3.3 图标与文字混排的坐标计算带图标的标签,TCITEM的mask要加TCIF_IMAGE,iImage是图标索引。绘制时先画图标再画文字,两者整体居中。// 假设图标 16x16,图标与文字间距 6px int nIconSize = 16; int nGap = 6; CSize szText = dc.GetTextExtent(strText); int nTotalW = nIconSize + nGap + szText.cx; int nStartX = rcItem.left + (rcItem.Width() - nTotalW) / 2; int nIconY = rcItem.top + (rcItem.Height() - nIconSize) / 2; // 画图标(m_imgList 是 CImageList 成员) if (nImageIndex >= 0) { m_imgList.Draw(&dc, nImageIndex, CPoint(nStartX, nIconY), ILD_TRANSPARENT); } // 画文字 CRect rcText = rcItem; rcText.left = nStartX + nIconSize + nGap; dc.DrawText(strText, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE);GetTextExtent返回文字像素宽高,用来算总宽。ILD_TRANSPARENT让图标背景透明,否则图标会带一块底色。图标列表要在控件初始化时Create好并SetImageList,DrawItem里只负责画。坐标计算里最容易错的是rcItem的坐标系。DRAWITEMSTRUCT给的rcItem是相对于控件客户区的,不是屏幕坐标,也不是对话框坐标。直接用就行,不要做ScreenToClient之类的转换,转了反而错位。3.4 圆角与边框:用 CRgn 裁剪出圆角矩形想要圆角标签,GDI 没有现成的FillRoundRect,得用RoundRect或者CRgn裁剪。RoundRect画出来边缘有锯齿,CRgn配合CDC::SelectClipRgn更平滑。CRgn rgn; rgn.CreateRoundRectRgn(rcItem.left, rcItem.top, rcItem.right + 1, rcItem.bottom + 1, 8, 8); dc.SelectClipRgn(&rgn); dc.FillSolidRect(&rcItem, clrBg); dc.SelectClipRgn(NULL); // 恢复裁剪区CreateRoundRectRgn的宽高参数是圆角椭圆的直径,8 表示圆角半径 4 像素。注意right和bottom要加 1,否则最右列和最下行画不到。SelectClipRgn(NULL)恢复默认裁剪区,不恢复的话后续绘制全被限制在这个圆角区域内。圆角标签有个视觉问题:相邻标签的圆角会互相“咬”。解决办法是选中标签圆角大一点,未选中标签圆角小一点,或者只在选中标签上用圆角,未选中用直角,形成对比。4. 避坑与排查:自绘 CTabCtrl 最常见的五个翻车点4.1 重写 DrawItem 不触发现象:编译通过,运行后标签还是系统默认样子,DrawItem里的断点根本不进。原因:控件没有TCS_OWNERDRAWFIXED样式,系统不会发WM_DRAWITEM。解决:在OnInitDialog里ModifyStyle(0, TCS_OWNERDRAWFIXED),然后SetWindowPos带SWP_FRAMECHANGED强制刷新。如果控件是动态创建的,在Create时直接加样式更省事。4.2 切换标签后花屏或残影现象:点另一个标签,原来标签的文字或背景残留,新标签画上去叠在一起。原因:DrawItem里没有先擦除背景,或者dc.Detach没调用导致 HDC 状态混乱。解决:每次绘制开头用FillSolidRect把rcItem填满,不要依赖系统擦背景。dc.Attach和dc.Detach必须成对,中间不要return跳过Detach。如果用了SelectClipRgn,画完必须SelectClipRgn(NULL)。4.3 文字被截断或偏移现象:标签文字显示不全,或者偏左偏右不居中。原因:rcItem直接传给DrawText时,矩形边界和文字实际宽度不匹配;或者DT_VCENTER没配合DT_SINGLELINE使用。解决:DrawText必须同时带DT_SINGLELINE | DT_VCENTER,少一个垂直居中就失效。如果文字还是偏,检查GetItem取文字时cchTextMax是否够大,以及TCITEM结构体是否清零(memset或= {0}),未初始化的字段可能导致GetItem行为异常。4.4 GDI 对象泄漏导致绘制逐渐变慢现象:程序跑一段时间后,标签切换越来越卡,最后整个窗口不刷新。原因:DrawItem里每次新建CFont、CPen、CBrush但没有DeleteObject,GDI 对象数达到进程上限。解决:把字体、画笔、画刷作为控件成员变量,在OnInitDialog或PreSubclassWindow里创建一次,DrawItem里只SelectObject和恢复。用任务管理器加 GDI 对象列,观察数值是否持续上涨,涨了就是泄漏。4.5 高 DPI 下标签模糊或错位现象:在 125% 或 150% 缩放下,标签文字发虚,图标和文字对不齐。原因:坐标和尺寸用了硬编码像素值,没有按 DPI 缩放。解决:用GetDpiForWindow取当前 DPI,所有尺寸乘以dpi / 96.0。字体用CreatePointFont而不是CreateFont指定像素高,点值会自动随 DPI 缩放。图标列表用SetImageList时指定ILD_SCALE或提供多倍图。5. 进阶:把自绘 CTabCtrl 做成可复用组件的三个习惯5.1 颜色和尺寸抽成结构体,别散落在 DrawItem 里自绘代码写多了,最怕改一个颜色翻遍整个函数。我一般定义一个TabStyle结构体,把所有可调参数集中管理。struct TabStyle { COLORREF clrBgNormal; COLORREF clrBgSelected; COLORREF clrTextNormal; COLORREF clrTextSelected; COLORREF clrIndicator; int nIndicatorHeight; int nCornerRadius; int nIconSize; int nIconGap; CString strFontName; int nFontSize; };DrawItem里只读这个结构体,不写死任何数值。换皮肤时改结构体成员,重新绘制即可。这样同一套自绘逻辑可以给多个项目用,只换配置。5.2 用内存 DC 双缓冲消除闪烁标签切换频繁时,直接画到屏幕 DC 会闪。标准做法是先在内存 DC 里画好整张图,再一次性BitBlt到屏幕。CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcItem.Width(), rcItem.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 在 memDC 里完成所有绘制 memDC.FillSolidRect(0, 0, rcItem.Width(), rcItem.Height(), clrBg); // ... 画文字、图标、圆角 ... // 一次性贴到屏幕 dc.BitBlt(rcItem.left, rcItem.top, rcItem.Width(), rcItem.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp);内存 DC 的坐标系原点在rcItem左上角,所以绘制时坐标要减去rcItem.left和rcItem.top。这个转换容易忘,忘了就画到区域外面去了。双缓冲会增加内存占用,但标签区域通常不大,代价可以接受。5.3 验证自绘效果是否正确的检查清单写完自绘代码,别只看静态截图。按下面几步走一遍,能发现大部分隐藏问题:检查项操作预期选中切换依次点击每个标签高亮线跟随移动,无残影键盘导航Ctrl+Tab 切换焦点框正确显示动态增删运行时 InsertItem/DeleteItem标签重新布局,不崩溃高 DPI系统缩放调到 150%文字清晰,图标对齐长时间运行反复切换 100 次GDI 对象数稳定GDI 对象数在任务管理器里加一列就能看。稳定不涨,说明资源管理没问题。涨了,回去查DrawItem里有没有漏掉的DeleteObject。从那以后我每次写完自绘控件,都强制走一遍这个清单,尤其是 GDI 对象数那一项。看起来麻烦,但比上线后用户反馈“用久了就卡”要省事得多。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源是一套自绘 CTabCtrl 控件的完整实现代码,面向具备 MFC 基础、希望突破系统默认控件外观限制的 Windows 开发者。它解决的是标准标签页控件样式单一、难以适配个性化界面需求的问题,通过手动接管绘制流程,让开发者能够自由定制标签的颜色、形状、字体与边框,并加入滑动、淡入淡出等过渡动画以及图标支持,从而提升界面的辨识度与交互质感。压缩包共 2 个文件,包含 1 个 cpp 实现文件与 1 个 h 头文件,整体约 6KB,其中头文件声明类结构与成员,实现文件则集中处理 OnPaint 等消息,借助 GDI/GDI+ 完成线条、矩形与文本绘制,并可能结合定时器驱动动画帧计算。目前已有 360 人学习,适合想深入理解自定义控件绘制机制、研究非客户区处理与动画逻辑的开发者参考借鉴。1. 自绘 CTabCtrl:从默认灰控件到可换肤标签页的那一步MFC 里做界面,绕不开CTabCtrl。默认样式就是 Win95 味道的灰色凸起标签,放在今天任何一套 UI 里都显得突兀。很多做工业软件、上位机、医疗设备界面的同行,产品功能都做完了,最后卡在标签页太丑这一关。自绘CTabCtrl就是解决这个问题的:不改动 MFC 框架,不引入第三方库,通过接管WM_DRAWITEM或重写DrawItem,把标签的绘制权拿到自己手里,想画成什么样就画成什么样。这份资源适合两类人:一是正在用 MFC 做桌面端、被默认控件外观拖后腿的开发者;二是想理解 Windows 自绘机制、但不想啃大部头框架源码的进阶新手。它不涉及复杂算法,核心就是消息映射和 GDI 绘制,但坑不少,下面按实际落地顺序拆开讲。2. 自绘 CTabCtrl 的三种技术路线:选错了后面全是返工2.1 为什么默认 CTabCtrl 不能直接改颜色CTabCtrl封装的是 Win32 的SysTabControl32窗口类。这个系统控件在WM_PAINT里自己画标签,外部拿不到绘制上下文。你调SetBkColor、SetTextColor基本无效,因为系统控件不响应这些消息。想改外观,只有两条路:要么让系统控件把绘制任务交出来,要么完全自己画一个。常见做法是重写DrawItem虚函数。CTabCtrl继承自CWnd,而CWnd提供了DrawItem虚函数,当控件收到WM_DRAWITEM时会调用它。但这里有个前提:控件必须设置TCS_OWNERDRAWFIXED样式,否则系统不会发WM_DRAWITEM。很多人第一步就翻车,样式没加,重写了DrawItem死活不进来。// 在对话框的 OnInitDialog 里,给 Tab 控件加自绘样式 m_tabCtrl.ModifyStyle(0, TCS_OWNERDRAWFIXED); // 注意:ModifyStyle 之后需要重新设置控件大小或调用 SetWindowPos // 否则样式变更可能不触发重绘 m_tabCtrl.SetWindowPos(NULL, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);ModifyStyle的第二个参数是新增样式,TCS_OWNERDRAWFIXED告诉系统“标签绘制我自己来”。SWP_FRAMECHANGED强制窗口重新计算非客户区,确保样式生效。这一步不做,后面所有绘制代码都是白写。2.2 方案对比:DrawItem 重写 vs 完全自绘 vs 子类化三条路线各有适用场景,选之前先看清楚代价。方案改动量可控粒度适用场景主要风险重写 DrawItem小标签整体只换标签外观,保留系统行为样式未生效、坐标算错完全自绘 CWnd大像素级需要异形标签、动画要自己处理所有鼠标消息子类化 + NM_CUSTOMDRAW中部分绘制阶段微调颜色、字体阶段理解错导致画两遍我一般推荐先走DrawItem路线。它能保留CTabCtrl的页管理、键盘导航、TCN_SELCHANGE通知这些现成逻辑,只把绘制换掉。完全自绘适合标签形状完全不像传统 Tab 的场景,比如弧形、斜切、带图标的卡片式标签,但代价是InsertItem、DeleteItem、SetCurSel这些都要自己实现,工作量翻倍。子类化加NM_CUSTOMDRAW是另一条路,但CTabCtrl对NM_CUSTOMDRAW的支持不如ListCtrl完整,很多绘制阶段拿不到,实际用起来反而更绕。除非你只想改文字颜色,否则不建议。2.3 消息映射与 DrawItem 的触发条件DrawItem的签名是virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct)。DRAWITEMSTRUCT里关键字段有四个:hDC是绘制设备上下文,rcItem是当前标签的矩形区域,itemID是标签索引,itemState是状态位(选中、焦点、禁用)。void CMyTabCtrl::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC dc; dc.Attach(lpDrawItemStruct->hDC); int nIndex = lpDrawItemStruct->itemID; CRect rcItem = lpDrawItemStruct->rcItem; bool bSelected = (lpDrawItemStruct->itemState & ODS_SELECTED) != 0; // 根据选中状态选不同背景色 COLORREF clrBg = bSelected ? RGB(255, 255, 255) : RGB(230, 230, 230); dc.FillSolidRect(&rcItem, clrBg); // 取标签文字 CString strText; TCHAR szBuf[256] = { 0 }; TCITEM tci = { 0 }; tci.mask = TCIF_TEXT; tci.pszText = szBuf; tci.cchTextMax = 255; GetItem(nIndex, &tci); strText = szBuf; // 文字颜色随选中状态变化 dc.SetTextColor(bSelected ? RGB(0, 120, 215) : RGB(80, 80, 80)); dc.SetBkMode(TRANSPARENT); dc.DrawText(strText, &rcItem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); dc.Detach(); }dc.Attach把系统传进来的 HDC 绑到 MFC 的 CDC 对象上,用完必须Detach,否则 CDC 析构时会释放这个 HDC,导致系统后续绘制崩溃。这是血泪经验,很多人第一次写自绘就栽在这里,程序跑起来看着正常,一切换标签就花屏。GetItem取文字时,TCITEM的pszText必须指向有效缓冲区,cchTextMax要设对。如果标签文字超过 255 字符,这里会截断,需要动态分配。一般标签不会那么长,但心里要有数。itemState里ODS_SELECTED表示当前选中标签。注意,CTabCtrl的选中标签和焦点标签可能不是同一个,键盘操作时焦点在某个标签上但选中项没变,这时候ODS_FOCUS会置位。如果你要画焦点虚线框,得单独处理。3. 把标签画好看:颜色、字体、图标与圆角的参数化实现3.1 选中态与未选中态的视觉分层默认控件最丑的地方是选中标签和未选中标签几乎没区别,就靠一个凸起边框区分。自绘时要拉开层次:选中标签背景用亮色、文字用主色、底部加一条高亮线;未选中标签背景用浅灰、文字用次级灰。这样一眼就能看出当前在哪一页。// 选中标签底部画一条 2px 高亮线 if (bSelected) { CRect rcLine = rcItem; rcLine.top = rcLine.bottom - 2; dc.FillSolidRect(&rcLine, RGB(0, 120, 215)); } // 未选中标签右侧画一条细分隔线 else { CRect rcSep = rcItem; rcSep.left = rcSep.right - 1; rcSep.top += 6; rcSep.bottom -= 6; dc.FillSolidRect(&rcSep, RGB(200, 200, 200)); }高亮线宽度一般 2 到 3 像素,太细看不清,太粗显得笨重。分隔线上下各留 6 像素内缩,避免顶满整个高度,视觉上更透气。这些数值不是固定的,跟标签高度有关,标签高 30 像素左右时这套参数比较协调。3.2 字体设置与文字居中偏移DrawItem里用的字体默认继承控件字体,但自绘时经常需要单独设。比如选中标签加粗、未选中常规。注意DRAWITEMSTRUCT的hDC里已经选入了控件字体,你要换字体得先保存旧字体,画完恢复。CFont fontSelected, fontNormal; fontSelected.CreatePointFont(90, _T("Microsoft YaHei")); fontNormal.CreatePointFont(90, _T("Microsoft YaHei")); CFont* pOldFont = NULL; if (bSelected) { fontSelected.GetLogFont(&lf); lf.lfWeight = FW_BOLD; fontSelected.DeleteObject(); fontSelected.CreateFontIndirect(&lf); pOldFont = dc.SelectObject(&fontSelected); } else { pOldFont = dc.SelectObject(&fontNormal); } // ... 绘制文字 ... dc.SelectObject(pOldFont); // 恢复旧字体CreatePointFont的第一个参数是字体大小的十分之一,90 表示 9 磅。lfWeight设FW_BOLD实现加粗。这里有个坑:CFont对象如果重复Create而不先DeleteObject,会泄漏 GDI 对象。GDI 对象总数有上限(默认 10000),标签页多、切换频繁时,泄漏几十次就可能让整个程序绘制异常。所以字体对象最好在控件初始化时创建一次,DrawItem里只做SelectObject,不要每次重画都新建。文字居中用DT_CENTER | DT_VCENTER | DT_SINGLELINE。如果标签带图标,图标和文字要并排,就得手动算偏移:先取图标尺寸,算出图标加间距加文字总宽,再算起始 x 坐标。3.3 图标与文字混排的坐标计算带图标的标签,TCITEM的mask要加TCIF_IMAGE,iImage是图标索引。绘制时先画图标再画文字,两者整体居中。// 假设图标 16x16,图标与文字间距 6px int nIconSize = 16; int nGap = 6; CSize szText = dc.GetTextExtent(strText); int nTotalW = nIconSize + nGap + szText.cx; int nStartX = rcItem.left + (rcItem.Width() - nTotalW) / 2; int nIconY = rcItem.top + (rcItem.Height() - nIconSize) / 2; // 画图标(m_imgList 是 CImageList 成员) if (nImageIndex >= 0) { m_imgList.Draw(&dc, nImageIndex, CPoint(nStartX, nIconY), ILD_TRANSPARENT); } // 画文字 CRect rcText = rcItem; rcText.left = nStartX + nIconSize + nGap; dc.DrawText(strText, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE);GetTextExtent返回文字像素宽高,用来算总宽。ILD_TRANSPARENT让图标背景透明,否则图标会带一块底色。图标列表要在控件初始化时Create好并SetImageList,DrawItem里只负责画。坐标计算里最容易错的是rcItem的坐标系。DRAWITEMSTRUCT给的rcItem是相对于控件客户区的,不是屏幕坐标,也不是对话框坐标。直接用就行,不要做ScreenToClient之类的转换,转了反而错位。3.4 圆角与边框:用 CRgn 裁剪出圆角矩形想要圆角标签,GDI 没有现成的FillRoundRect,得用RoundRect或者CRgn裁剪。RoundRect画出来边缘有锯齿,CRgn配合CDC::SelectClipRgn更平滑。CRgn rgn; rgn.CreateRoundRectRgn(rcItem.left, rcItem.top, rcItem.right + 1, rcItem.bottom + 1, 8, 8); dc.SelectClipRgn(&rgn); dc.FillSolidRect(&rcItem, clrBg); dc.SelectClipRgn(NULL); // 恢复裁剪区CreateRoundRectRgn的宽高参数是圆角椭圆的直径,8 表示圆角半径 4 像素。注意right和bottom要加 1,否则最右列和最下行画不到。SelectClipRgn(NULL)恢复默认裁剪区,不恢复的话后续绘制全被限制在这个圆角区域内。圆角标签有个视觉问题:相邻标签的圆角会互相“咬”。解决办法是选中标签圆角大一点,未选中标签圆角小一点,或者只在选中标签上用圆角,未选中用直角,形成对比。4. 避坑与排查:自绘 CTabCtrl 最常见的五个翻车点4.1 重写 DrawItem 不触发现象:编译通过,运行后标签还是系统默认样子,DrawItem里的断点根本不进。原因:控件没有TCS_OWNERDRAWFIXED样式,系统不会发WM_DRAWITEM。解决:在OnInitDialog里ModifyStyle(0, TCS_OWNERDRAWFIXED),然后SetWindowPos带SWP_FRAMECHANGED强制刷新。如果控件是动态创建的,在Create时直接加样式更省事。4.2 切换标签后花屏或残影现象:点另一个标签,原来标签的文字或背景残留,新标签画上去叠在一起。原因:DrawItem里没有先擦除背景,或者dc.Detach没调用导致 HDC 状态混乱。解决:每次绘制开头用FillSolidRect把rcItem填满,不要依赖系统擦背景。dc.Attach和dc.Detach必须成对,中间不要return跳过Detach。如果用了SelectClipRgn,画完必须SelectClipRgn(NULL)。4.3 文字被截断或偏移现象:标签文字显示不全,或者偏左偏右不居中。原因:rcItem直接传给DrawText时,矩形边界和文字实际宽度不匹配;或者DT_VCENTER没配合DT_SINGLELINE使用。解决:DrawText必须同时带DT_SINGLELINE | DT_VCENTER,少一个垂直居中就失效。如果文字还是偏,检查GetItem取文字时cchTextMax是否够大,以及TCITEM结构体是否清零(memset或= {0}),未初始化的字段可能导致GetItem行为异常。4.4 GDI 对象泄漏导致绘制逐渐变慢现象:程序跑一段时间后,标签切换越来越卡,最后整个窗口不刷新。原因:DrawItem里每次新建CFont、CPen、CBrush但没有DeleteObject,GDI 对象数达到进程上限。解决:把字体、画笔、画刷作为控件成员变量,在OnInitDialog或PreSubclassWindow里创建一次,DrawItem里只SelectObject和恢复。用任务管理器加 GDI 对象列,观察数值是否持续上涨,涨了就是泄漏。4.5 高 DPI 下标签模糊或错位现象:在 125% 或 150% 缩放下,标签文字发虚,图标和文字对不齐。原因:坐标和尺寸用了硬编码像素值,没有按 DPI 缩放。解决:用GetDpiForWindow取当前 DPI,所有尺寸乘以dpi / 96.0。字体用CreatePointFont而不是CreateFont指定像素高,点值会自动随 DPI 缩放。图标列表用SetImageList时指定ILD_SCALE或提供多倍图。5. 进阶:把自绘 CTabCtrl 做成可复用组件的三个习惯5.1 颜色和尺寸抽成结构体,别散落在 DrawItem 里自绘代码写多了,最怕改一个颜色翻遍整个函数。我一般定义一个TabStyle结构体,把所有可调参数集中管理。struct TabStyle { COLORREF clrBgNormal; COLORREF clrBgSelected; COLORREF clrTextNormal; COLORREF clrTextSelected; COLORREF clrIndicator; int nIndicatorHeight; int nCornerRadius; int nIconSize; int nIconGap; CString strFontName; int nFontSize; };DrawItem里只读这个结构体,不写死任何数值。换皮肤时改结构体成员,重新绘制即可。这样同一套自绘逻辑可以给多个项目用,只换配置。5.2 用内存 DC 双缓冲消除闪烁标签切换频繁时,直接画到屏幕 DC 会闪。标准做法是先在内存 DC 里画好整张图,再一次性BitBlt到屏幕。CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcItem.Width(), rcItem.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 在 memDC 里完成所有绘制 memDC.FillSolidRect(0, 0, rcItem.Width(), rcItem.Height(), clrBg); // ... 画文字、图标、圆角 ... // 一次性贴到屏幕 dc.BitBlt(rcItem.left, rcItem.top, rcItem.Width(), rcItem.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp);内存 DC 的坐标系原点在rcItem左上角,所以绘制时坐标要减去rcItem.left和rcItem.top。这个转换容易忘,忘了就画到区域外面去了。双缓冲会增加内存占用,但标签区域通常不大,代价可以接受。5.3 验证自绘效果是否正确的检查清单写完自绘代码,别只看静态截图。按下面几步走一遍,能发现大部分隐藏问题:检查项操作预期选中切换依次点击每个标签高亮线跟随移动,无残影键盘导航Ctrl+Tab 切换焦点框正确显示动态增删运行时 InsertItem/DeleteItem标签重新布局,不崩溃高 DPI系统缩放调到 150%文字清晰,图标对齐长时间运行反复切换 100 次GDI 对象数稳定GDI 对象数在任务管理器里加一列就能看。稳定不涨,说明资源管理没问题。涨了,回去查DrawItem里有没有漏掉的DeleteObject。从那以后我每次写完自绘控件,都强制走一遍这个清单,尤其是 GDI 对象数那一项。看起来麻烦,但比上线后用户反馈“用久了就卡”要省事得多。希望帮到你。本文还有配套的精品资源,点击获取