简介:面向VC++/MFC桌面应用开发者的TabControl自绘演示工程,演示如何为选项卡添加图片并自定义绘制样式,弥补默认TabCtrl仅显示纯文本标签、界面单调的不足。压缩包共34个文件,以h头文件与cpp源文件为核心,附带dsp工程文件、dsw工作区、rc资源脚本、bmp位图、exe可执行示例以及pdb调试符号,整包约2.19MB,结构清晰,便于直接打开工程跟踪学习。已有160人学习下载。DEMO围绕WM_NOTIFY消息注册、TCN_SELCHANGE切换响应、NM_CUSTOMDRAW自绘通知展开,完整展示了画刷、字体、图标等GDI对象的创建、绘制与释放流程,并给出了不同选项卡状态下的样式处理思路。通过EnTabCtrl、BaseTabCtrl等可复用封装类,开发者能快速掌握在选项卡上绘制图片的关键方法,并将这一技术迁移到实际项目中,打造更美观、易用的个性化界面。 前阵子接手一个老界面工程,系统默认的TabControl被产品经理批得体无完肤,说那套蓝白样式跟新设计稿完全是两个物种。同事救急甩过来一个zip包,文件名很直白:tabcontrol_demo.zip,里面是一套自绘TabControl的DEMO工程,类名带着TabCtrl,代码注释不多,但能跑。我从这个demo里拆出来不少东西,也填了不少坑,今天把整个过程整理一下。
这篇文章不是教科书,是常年跟Windows界面开发打交道的人拆解一个自绘TABCTRL示例的实际经验。如果你在用MFC、Win32做界面,或者手头正被统一的UI定制折磨,读这篇正合适。我会按解压后的第一印象、绘制路线选择、标签绘制细节、常见翻车点和生产级扩展五个层面展开,尽量做到看完能直接照搬思路。
1. 解压tabcontrol_demo.zip之后,先别急着编译运行
1.1 先看工程里有哪些关键文件
我拿到这类zip,第一步永远是打开文件夹看结构,而不是双击sln。一个典型的自绘TabControl demo,内部文件通常分三类:对话框/窗口壳、自绘控件类、资源文件。比如TabCtrlDemoDlg是宿主窗口,用来放一个Tab区域和几个分页;CTabCtrlEx这种名字的类是核心,负责接管绘制和消息;资源文件里可能定义了图标、背景色或光标。
我通常从控件类入手,因为它决定了这个demo的绘制机制。打开头文件,先看类声明里有没有写TCS_OWNERDRAWFIXED风格,有没有重写DrawItem,或者重写OnPaint。这一个动作基本就能判断它是走系统辅助自绘还是完全自绘。看完这两个文件,再跑程序,观察它在不同系统下的表现,心里会对demo的真实质量有数。
1.2 先界定自绘边界,别被demo带偏
很多自绘demo的问题不是画得不好看,而是把边界划得太野。有的demo把整个TabControl的交互都重写了,键盘方向键切换、鼠标中键关闭、拖动排序全堆在一个类里,看着很牛,真要复用却处处得改。我拿到demo后第一件事是从需求出发,问一句:是只想换皮,还是交互也要动?
我把需求大致分为三档:换皮(改颜色、字体、圆角、选中的下划线)、换交互(加关闭按钮、拖拽排序、右键菜单)、完全重做(标签区域排布和内容区都自己画)。tabcontrol_demo这类文件一般只解决“皮”的问题,交互还是靠系统消息处理。理解了这个边界,后面读代码、删改代码就不容易迷路。
2. 两条自绘路线,我为什么先试Owner Draw
2.1 系统辅助自绘和完全接管WM_PAINT的区别
自绘TabControl有两条主流路线,很多人概念混在一起。
第一路是Owner Draw(所有者绘制),核心是给控件加上TCS_OWNERDRAWFIXED风格,然后处理WM_DRAWITEM或重写DrawItem。控件框架仍然负责标签排布、命中测试、鼠标点击和切换逻辑,只是绘制内容由你交给系统。缺点是每个标签宽度固定、高度固定,不能单个标签设置不同尺寸;优点是实现量小,系统帮你兜底了几乎所有交互。
第二路是完全自绘,通常是把整个控件做成自绘窗口,重写OnPaint,标签头、内容区、滚动条、焦点框全部自己画,鼠标消息也全部自己处理。这条路适合做不规则样式、动画和复杂交互,代码量明显上去了,而且键盘导航、辅助功能、DPI变化这些细节都得自己处理。
我拆tabcontrol_demo的时候,通常会先在工程里搜TCS_OWNERDRAWFIXED是否被定义。下面的代码是MFC里常见的设置方式:
// 在CMyTabCtrl::PreSubclassWindow或创建后设置 void CMyTabCtrl::EnableOwnerDraw() { ModifyStyle(0, TCS_OWNERDRAWFIXED); }一旦设了这个风格,所有标签绘制都会走DrawItem,你需要自己把背景、文字、选中条一笔笔画出来。而完全自绘的代码则通常是走OnPaint:
void CMyTabCtrl::OnPaint() { CPaintDC dc(this); // 先画底栏背景 // 再逐个画标签rect // 最后画内容区边框 }2.2 选Owner Draw的代价和收益
以我平时改MFC老项目的习惯,能走Owner Draw就不做完全自绘。最大的收益是交互逻辑不用重写,鼠标点击、切换页签、Ctrl+Tab这些系统行为天然可用。代价集中在绘制上:你要对每个标签的矩形负责,包括背景颜色、边框、文字的对齐和颜色、选中和悬停的差异,还要处理窗口失去焦点后选中态是否变灰这类细节。
这套做法的另一个好处是刷新范围小。系统知道你改的是标签区的样式,内容区不用整个重画,性能压力比完全自绘小很多。我在实际项目里接的UI需求,80%以上是“选中标签一条强调色、悬停一个浅背景、普通标签透明背景”这种,Owner Draw足够。只有涉及圆角标签重叠、过渡动画、拖拽重排时,我才会考虑走完全自绘。
3. 标签绘制从零开始:每个像素都应该有明确归属
3.1 先算矩形,再谈好看
Owner Draw模式下,系统每次要画某个标签时,会在DRAWITEMSTRUCT里给出rcItem,这是标签在客户区内的矩形位置,不需要你自己计算。但实际开发里你会发现很多场景还是要自己算:比如完全自绘时需要遍历所有标签累加宽度;或者你做了关闭按钮,需要在文字旁边预留位置。
给一个简单的宽度计算思想:标签宽度等于文字宽度加上左右内边距,再考虑关闭按钮或图标占位。比如:
CSize sizeText = dc.GetTextExtent(strText); int nIconWidth = m_bShowIcon ? 20 : 0; int nCloseWidth = m_bShowClose ? 18 : 0; int nItemWidth = sizeText.cx + 32 + nIconWidth + nCloseWidth;这个计算很直白,但demo里往往会被字体、DPI这些变量干扰。我的建议是所有宽度计算统一走一套函数,不要在每个绘制分支里各算一次,不然后面改字号会改到崩溃。
3.2 状态区分:normal、hover、selected、disabled
自绘标签的观感,基本取决于你对四种状态的处理。normal是底色透明或非常淡的背景;hover是鼠标悬停时出现一层浅色;selected是当前选中,通常配一条2~3像素的强调色条;disabled则降低前景文字对比度。
在DrawItem里,我通常先用ODS_SELECTED判断选中状态,再用GetCursorPos加ScreenToClient判断hover。代码骨架像这样:
void CMyTabCtrl::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC = CDC::FromHandle(lpDIS->hDC); CRect rc = lpDIS->rcItem; BOOL bSelected = (lpDIS->itemState & ODS_SELECTED) != 0; CMemDC memDC(*pDC, rc); // 双缓冲 CDC& dc = memDC.GetDC(); dc.FillSolidRect(rc, bSelected ? m_clrTabSelectedBg : m_clrTabNormalBg); // 绘制顶部/底部强调条 if (bSelected) { CRect rcLine = rc; rcLine.top = rcLine.bottom - 3; dc.FillSolidRect(rcLine, m_clrAccent); } // 绘制文字 CString strText; TCITEM item = {0}; item.mask = TCIF_TEXT; item.pszText = strText.GetBuffer(256); item.cchTextMax = 255; GetItem(lpDIS->itemID, &item); strText.ReleaseBuffer(); dc.SetTextColor(bSelected ? m_clrTextSelected : m_clrTextNormal); dc.SetBkMode(TRANSPARENT); dc.DrawText(strText, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }注意SetBkMode(TRANSPARENT)一定要做,否则文字背景会盖掉你刚画的选中背景。这个坑我至少见过三次出现在不同的自绘工程里。
3.3 绘制顺序就是图层的剧本
自绘标签绘制顺序不是乱来的。我的固定套路是:先铺底色,再画边框和背景装饰,然后画选中强调条,再画图标,再画文字,最后画关闭按钮。顺序反了会出现颜色互相覆盖。
比如强调条如果在文字之后画,选中时文字底部会被条盖住,视觉上很脏。关闭按钮如果先画,背景色刷新后它会被新背景遮掉。demo里看着正常的代码,多半就是因为这个顺序被踩过好几轮。
4. 闪烁、DPI和换肤:自绘TabControl最常见的三个翻车点
4.1 双缓冲解决绘制闪烁
自绘TabControl最典型的毛病是闪烁。闪烁的根源是绘制过程中背景被反复擦除:WM_ERASEBKGND擦了一次,WM_PAINT又画一次,两次内容不一致就会闪。
解决思路很成熟:在OnEraseBkgnd里直接返回TRUE阻止擦除,然后把所有绘制放进WM_PAINT一次完成。或者用CMemDC双缓冲,在内存DC里画好再一次性BitBlt到屏幕。MFC里可以这样:
BOOL CMyTabCtrl::OnEraseBkgnd(CDC* pDC) { return TRUE; // 背景交给OnPaint统一处理 }然后OnPaint里用一个CMemDC包住绘制代码。这套组合拳能消除绝大多数闪烁。我见过一些demo没做这步,切换标签速度快一点屏幕右边就留下一道残影,原因就是绘制不完整,残留了上一帧的背景。
用表格总结一下闪烁常见原因和对应解法:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 切换标签时标签区闪 | WM_ERASEBKGND擦除和WM_PAINT不一致 | OnEraseBkgnd返回TRUE,全部在WM_PAINT里画 |
| 鼠标悬停改变背景时闪 | 悬停分支只画部分区域 | 统一内存DC绘制后一次性刷新 |
| 文字变模糊或抖动 | 绘制文字前背景模式没设透明 | SetBkMode(TRANSPARENT) |
| 分页内容区闪 | 内容区也被当作背景反复擦除 | 关闭ERASEBKGND,子窗口自行管理 |
4.2 DPI缩放下的坐标换算
Windows下不同屏幕缩放比例(125%、150%)对自绘控件是实打实的考验。如果标签宽度、字体大小写死成像素值,在带缩放的屏幕上会出现文字截断、标签高度不够、关闭按钮偏移等问题。
我的做法是封装一个DPIScale函数,所有涉及像素的关键尺寸都从这里取:
int CDpiHelper::Scale(int nValue) { return ::MulDiv(nValue, GetDpiForWindow(m_hWnd), 96); }标签高度、文字字体大小、关闭按钮边长、内边距,全部用Scale()跑一遍。这样同一个demo,在100%和150%缩放下表现基本一致。尤其当标签高度是用ItemHeight设置时,要在DPI变化消息里重新设置。很多demo没做这一步,笔记本外接高分屏一拉过去,界面直接崩掉观感。
4.3 把颜色和字体抽离成主题对象
democode最常见的问题就是颜色值散落各处,背景色写死在FillSolidRect参数里,字体写在DC创建处。要换肤、要做深色模式,就得在几十处地方改颜色。
我建议一开始就把颜色和尺寸收进一个结构体:
struct TabTheme { COLORREF clrNormalBg; COLORREF clrHoverBg; COLORREF clrSelectedBg; COLORREF clrAccent; COLORREF clrTextNormal; COLORREF clrTextSelected; int nItemHeight; int nPadding; int nCornerRadius; };绘制函数只从theme取数据。换肤就是换一个theme实例。demo拆解到这一步,基本就可以扔进生产项目里用了。
5. 从demo升级到生产级:关闭按钮、拖拽排序和动画过渡
5.1 关闭按钮的正确命中判定
很多demo画的关闭按钮只是画了个样式,实际点不到。正确做法是绘制时保存每个标签的关闭按钮矩形,然后在鼠标左键释放(或按下)时判断点是否落在矩形内。
矩形建议不小于16x16像素,太小了用户根本点不中。绘制时可以画一个圆角背景加一个“x”字形。命中判定时要先用DPIScale缩放,再和当前鼠标位置比较:
CRect rcClose = GetCloseButtonRect(nIndex); if (rcClose.PtInRect(point) && nIndex != m_nSelected) { DeleteItem(nIndex); // 重新调整选中项 }这里有个容易忽略的点:当标签在非客户区或者被裁切时,GetItemRect可能不可用。我一般统一用自己维护的标签矩形数组,避免和系统计算的rect打架。
5.2 标签拖拽排序的粗实现
完全自绘的TabControl做拖拽排序比较自由,Owner Draw也能做,只是绕一点。基本思路是:在鼠标左键按下时记录初始索引;移动超过一定距离后判定进入拖拽状态;在鼠标移动时用GetItemRect遍历,找鼠标当前落在哪个标签上,然后把当前标签的位置和目标标签互换;最后重绘。
demo里如果只是展示排序效果,不需要做复杂的拖拽影像。用TrackMouseEvent处理鼠标离开,避免拖拽出去后状态卡住,是很容易漏的一步。拖拽过程中还要屏蔽Tab控件的原生点击切换逻辑,否则一边拖一边切页,体验很差。
5.3 切换动画的简单做法
自绘标签加动画很容易让人上头,但我不建议一上来就做整套插值。最稳妥的做法是用一个简单的进度值:选中状态改变后启动一个定时器,每次tick把强调条的位置从旧标签平滑移动到新标签,用线性插值。
伪代码思路:
void CMyTabCtrl::AnimateSelection() { double t = (GetTickCount64() - m_dwAnimStart) / 200.0; if (t >= 1.0) t = 1.0; int nX = (int)((double)m_rcOld.left + ((double)m_rcNew.left - (double)m_rcOld.left) * t); // 用nX绘制强调条 if (t < 1.0) SetTimer(ID_ANIM, 16, nullptr); else KillTimer(ID_ANIM); }动画时间控制在150~250毫秒最合适,太短没效果,太长显得拖沓。做动画前先保证非动画状态下的绘制是稳定的,否则动画只会把问题放大。
我从tabcontrol_demo这个包中最受益的一点,是它证明了自绘TabControl的底层逻辑并不复杂:矩形、状态、绘制顺序这三件事理顺了,剩下的只是往里填视觉细节。真正耗时间的往往不是画,而是闪烁、DPI这些边角料。如果你正准备做一个自己的自绘TABCTRL,建议第一步先把上面提到的DPIScale和主题结构体搭好,再开始画,后面省下的时间不止一点。
本文还有配套的精品资源,点击获取