简介:面向Windows MFC开发者的自绘CTabCtrl控件源码包,适合中高级开发者学习参考。针对默认标签控件外观单一、难以融入自定义界面的问题,该源码提供了一套基于MFC的完整自绘实现,支持标签颜色、形状、字体、边框的灵活修改以及图标添加,并可通过定时器与GDI绘图加入滑动、淡入淡出等动画效果。压缩包仅包含2个文件:一个cpp实现文件与一个h头文件,共6KB,整体结构紧凑,代码风格清晰,便于阅读和移植。源码的核心在于OnPaint消息处理,通过MoveToEx、LineTo、Rectangle、TextOut等GDI调用逐像素绘制标签,同时处理WM_NCPAINT与WM_ERASEBKGND以覆盖非客户区,实现真正的深度定制。已有358人学习,对于想掌握MFC控件自绘原理、提升界面表现力的开发者而言,这份代码是很好的学习范例,可直接参考其中自绘框架与动画逻辑,快速应用到自己的项目中。
1. 自绘 CTabCtrl:一个让 MFC 界面摆脱“上古审美”的完整实现
做过 MFC 界面的开发者基本都见过那个默认的 Tab 控件——灰底、细边框、选中项和未选中项区分度极低,字体小得可怜,想在标签上加个图标或者关闭按钮更是无从下手。这个自绘 CTabCtrl 项目解决的就是这个问题:它用完全自绘的方式重写了 Tab 控件的绘制与交互,把默认 Tab 的“黑匣子”打开,让开发者能精确控制每一个像素,从标签背景、文字颜色、选中态高亮到悬浮反馈,全部由自己的代码掌控。
这个资源不是零散片段,而是一个可直接编译运行的完整控件封装,适合两种人:一是被 MFC 默认控件外观折磨、想给老项目做界面现代化的开发者;二是想搞懂 Windows 自绘机制、准备自己封装控件的学习者。整个实现避开了复杂的第三方库,纯 Win32 API 加 MFC 框架完成,你把类拿过去,注册、关联、设置样式,三步就能用在自己工程里。
2. 自绘方案选型:为什么最终选择完整重绘而非 Owner Draw
2.1 三种自绘路线的优劣对比
Windows 下给 Tab 控件做自绘,常见的有三条路:第一条是利用 TCM_SETITEMEXTRA 给每个 Tab 项附加自定义数据,再配合 WM_DRAWITEM 消息做 Owner Draw;第二条是子类化控件,拦截 WM_PAINT 后在原窗口上叠加绘制;第三条是干脆不用 MFC 的 CTabCtrl,自己从 CWnd 派生一个控件,从零绘制所有内容。
这个项目选的是第三条路。原因并不复杂:Owner Draw 虽然省事,但系统仍然会绘制背景、边框和标签之间的分割线,这些“残留物”在深色主题下特别碍眼;叠加绘制则要处理双缓冲、区域裁剪与系统绘制的时序问题,代码很快就变成一堆 if 判断。而完全自绘虽然工作量最大,但换来的是绝对的掌控力——背景色、圆角矩形、标签间距、悬浮渐入渐出,所有视觉效果都由自己定义,不跟系统猜测交互。
我拆这个资源时最欣赏的一点是它没有做无谓的抽象。整个控件就是一个类的完整实现,头文件加源文件几百行,职责清晰:绘制部分与消息处理部分分离,新增一种视觉状态只需要加一个枚举和一个绘制分支,不需要改框架。
2.2 控件类的接口设计与状态管理
// TabCtrlEx.h #pragma once #include <afxwin.h> #include <vector> class CTabCtrlEx : public CWnd { public: CTabCtrlEx(); virtual ~CTabCtrlEx(); // 注册窗口类,在 Create 之前调用 BOOL Create(CWnd* pParentWnd, const CRect& rc, UINT nID); // 标签项操作 int AddItem(const CString& strText, DWORD_PTR dwData = 0); int InsertItem(int nIndex, const CString& strText, DWORD_PTR dwData = 0); BOOL DeleteItem(int nIndex); void RemoveAllItems(); int GetItemCount() const; // 选中项与内容页关联 int GetCurSel() const; int SetCurSel(int nIndex); void SetContentWnd(int nIndex, CWnd* pContentWnd); // 视觉配置 void SetTabColor(BOOL bActive, COLORREF clrBg, COLORREF clrText); void SetTabHeight(int nHeight); void SetFont(CFont* pFont); void SetCloseButton(BOOL bShow); // 自绘回调,子类可重写 virtual void DrawTabItem(CDC* pDC, const CRect& rcItem, int nIndex, BOOL bActive, BOOL bHover); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg void OnSize(UINT nType, int cx, int cy); afx_msg BOOL OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message); DECLARE_MESSAGE_MAP() private: struct TabItem { CString strText; CRect rcItem; // 标签绘制区域 DWORD_PTR dwData; CWnd* pContentWnd; // 关联的内容页窗口 BOOL bVisible; }; std::vector<TabItem> m_items; int m_nCurSel; int m_nHoverIndex; int m_nTabHeight; BOOL m_bShowCloseBtn; CFont* m_pFont; COLORREF m_clrActiveBg, m_clrActiveText; COLORREF m_clrInactiveBg, m_clrInactiveText; BOOL m_bTrackingMouse; // 是否已跟踪鼠标,用于触发 WM_MOUSELEAVE };接口设计上有几个参数值得细说:AddItem与InsertItem的dwData参数用于挂载业务数据,比如页面 ID 或对象指针,在切换标签时可以直接取出来用,避免再做一次字符串匹配;SetContentWnd是内容页的绑定入口,控件切换选中项时会自动把旧页面隐藏、新页面显示,省去外部在响应 TCN_SELCHANGE 时手动操作窗口显隐的重复代码;SetCloseButton控制的关闭按钮会在标签右侧画一个小叉,命中检测做在鼠标消息里,这是很多自绘 Tab 容易漏掉的部分。
绘制方面对外留了虚函数DrawTabItem,这是控件的扩展点。默认实现会按当前主题的配色绘制,如果你要做特殊风格,比如圆角胶囊标签或者带进度条的背景,重写这个方法即可,不需要动消息循环。状态管理上维护了三个关键索引:当前选中项、悬浮项、以及一个鼠标追踪标志。悬浮项的检测没有用 WM_MOUSEMOVE 的坐标暴力判断,而是先判断鼠标是否真的移入了某个标签区域,再调用TrackMouseEvent注册离开通知,这样才能拿到离开事件去清除悬浮高亮。
2.3 自定义窗口类的注册细节
完全自绘的控件绕不开窗口类注册。这个项目在Create里完成了窗口类的注册和窗口创建,注册时指定了CS_DBLCLKS样式以支持双击消息,同时把光标设置为手型,这比默认箭头更符合可点击标签的交互预期。
BOOL CTabCtrlEx::Create(CWnd* pParentWnd, const CRect& rc, UINT nID) { CString strClassName = AfxRegisterWndClass( CS_DBLCLKS, // 支持双击 ::LoadCursor(NULL, IDC_HAND), // 手型光标 NULL, // 默认背景刷,自绘不关心 NULL); // 无图标 return CWnd::Create(strClassName, _T(""), WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN, rc, pParentWnd, nID); }这里有几个细节容易翻车。AfxRegisterWndClass返回的类名每次调用如果参数相同,会复用已注册的类,所以你不需要担心重复创建时类名冲突。光标在注册窗口类时指定后,就不需要再响应 WM_SETCURSOR 了,省了一个消息分支。WS_CLIPCHILDREN是必须的——虽然这个控件自己不是父窗口,但加上这个样式可以避免内容页绘制时跑到标签区域来,减少闪烁。
窗口创建成功后,PreSubclassWindow或者OnCreate里还做了一件事:把控件的默认字体设置为系统 UI 字体,并记录字体高度来计算标签的默认高度。这套逻辑保证了控件在未调用SetFont的情况下也不会出现字体大小失衡的问题,刚拿到代码直接编译就能看到一个和谐的默认效果。
3. 核心绘制流程拆解:从 WM_PAINT 到标签逐项渲染
3.1 双缓冲绘制与消息处理的配合
自绘控件最容易出的问题就是闪烁。根因是系统默认的 WM_ERASEBKGND 会用窗口背景色刷一遍,紧接着 WM_PAINT 再画内容,一擦一画之间就产生了闪动。这个项目对两颗消息都做了处理:OnEraseBkgnd直接返回 TRUE 阻止擦除,OnPaint里则用内存 DC 完成全部绘制后一次性 BitBlt 到屏幕。
void CTabCtrlEx::OnPaint() { CPaintDC dc(this); CDC memDC; memDC.CreateCompatibleDC(&dc); CRect rcClient; GetClientRect(&rcClient); CBitmap bmpMem; bmpMem.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmpMem); // 填充整体背景 memDC.FillSolidRect(&rcClient, RGB(38, 38, 38)); // 绘制所有标签项 for (size_t i = 0; i < m_items.size(); ++i) { if (!m_items[i].bVisible) continue; BOOL bActive = (int)i == m_nCurSel; BOOL bHover = (!bActive) && ((int)i == m_nHoverIndex); DrawTabItem(&memDC, m_items[i].rcItem, (int)i, bActive, bHover); } // 绘制选中标签底部的连接线,让选中项和内容区视觉贯通 if (m_nCurSel >= 0 && m_nCurSel < (int)m_items.size()) { CRect rcSel = m_items[m_nCurSel].rcItem; CRect rcLine(rcSel.left, rcSel.bottom - 1, rcSel.right, rcSel.bottom); memDC.FillSolidRect(&rcLine, m_clrActiveBg); } dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }使用CPaintDC会自动处理 BeginPaint/EndPaint,它能正确裁剪无效区域,避免绘制到窗口外部。内存 DC 的创建是自绘控件双缓冲的标准姿势,但要记得配套SelectObject恢复原对象,否则析构时 GDI 对象可能没有正确释放。绘制顺序上先画背景再画标签,选中项和悬浮项分别传入不同的状态标志,由DrawTabItem决定具体视觉。最后那一行“连接线”是个经验之谈——选中标签底边颜色和内容区背景保持一致,视觉上标签就像长在内容区上,而不是悬浮在背景上。
3.2 标签项的布局计算与文本绘制
标签矩形怎么算,直接决定控件整体是否协调。这个项目在OnSize和AddItem时会调用ReCalcLayout,逻辑是:从左到右依次排布,每个标签的宽度取文本宽度加左右留白(左右各 12 像素),如果开启了关闭按钮,再额外留出按钮的宽度。
void CTabCtrlEx::ReCalcLayout() { if (m_items.empty()) return; CClientDC dc(this); CFont* pOldFont = dc.SelectObject(m_pFont); int x = 0; int y = 0; for (auto& item : m_items) { if (!item.bVisible) continue; CSize szText = dc.GetTextExtent(item.strText); int nWidth = szText.cx + 24; // 文本 + 左右留白 if (m_bShowCloseBtn) nWidth += 16; // 关闭按钮占位 item.rcItem = CRect(x, y, x + nWidth, y + m_nTabHeight); x += nWidth; // 标签紧挨排列,不额外留间隙 } dc.SelectObject(pOldFont); }位置计算里没有用百分比或者平均分配,而是让每个标签按自己的文本长度自适应宽度,这样标签文字长短不一时整体更自然。GetTextExtent返回的是逻辑单位下的文本尺寸,只要 DC 设置了正确的字体,这个数值就是准确的。不过有一个坑:如果控件设置了SetFont但还没有在重绘之前把字体选入 DC,GetTextExtent拿到的是默认字体的宽度,算出来的标签宽度会偏小,所以SetFont之后必须强制调用一次Invalidate并重新布局。这个资源里的SetFont方法内部做了这件事,拿到代码可以留意这个顺序。
绘制单个标签时,默认DrawTabItem会区分三种状态:选中项用亮色背景加粗文字;悬浮项背景色稍微提亮但不改变文字颜色;普通项用暗色背景和中性灰文字。文本绘制使用DrawText配合DT_SINGLELINE | DT_VCENTER | DT_CENTER,这在标签矩形内居中显示;如果标签还带有图标,可以在这个方法里用DrawIcon先画图标,再把文本绘制起点向右偏移,不过默认实现没有做这一步,留给了子类。
3.3 关闭按钮与图标的绘制细节
关闭按钮是自绘 Tab 的经典要素。实现上并不需要在标签右侧单独建一个小窗口,只需要在绘制时画一个“×”符号,然后在鼠标消息里做矩形命中检测。这个项目的DrawTabItem实现里,关闭按钮的矩形是固定的 12x12 像素,绘制时先用直线画出叉号,鼠标悬浮在按钮区域时叉号换成亮色,区分可点击状态。
void CTabCtrlEx::DrawCloseButton(CDC* pDC, const CRect& rcItem, BOOL bHoverOnBtn) { int cx = rcItem.right - 16; int cy = rcItem.CenterPoint().y; CPen pen; pen.CreatePen(PS_SOLID, 1, bHoverOnBtn ? RGB(255, 255, 255) : RGB(160, 160, 160)); CPen* pOldPen = pDC->SelectObject(&pen); pDC->MoveTo(cx - 3, cy - 3); pDC->LineTo(cx + 3, cy + 3); pDC->MoveTo(cx - 3, cy + 3); pDC->LineTo(cx + 3, cy - 3); pDC->SelectObject(pOldPen); }关闭按钮的坐标计算是写死的,但对正常尺寸的标签足够。绘制叉号时要先创建合适颜色的画笔,而不是用SetPixel这类逐像素操作,否则在缩放的显示器上会出现锯齿感。另外,关闭按钮的命中范围和绘制范围必须来自同一个矩形,否则会出现画得到但点不中的灵异现象——我见过不少自绘控件栽在这个一致性上,这个项目把按钮矩形封装在TabItem内部,统一由ReCalcLayout计算,算是一劳永逸的做法。
图标方面,默认控件支持通过SetImageList设置图标列表,绘制时根据标签项的图标索引从列表取出画到文字左侧。这个功能在实际项目中很有用,比如浏览器标签页的站点图标。注意图标和文字之间要保留至少 6 像素间距,不然小尺寸标签下文字会和图标粘在一起。
4. 交互与消息处理:点击切换、关闭、悬浮反馈的完整链路
4.1 鼠标消息解析与命中检测
绘制做得再漂亮,交互跟不上体验也白搭。这个控件处理的第一类鼠标消息是WM_LBUTTONDOWN,职责有两件:根据点击位置判断是否命中关闭按钮,若命中则删除标签并关闭对应内容页;否则执行标签切换逻辑。
void CTabCtrlEx::OnLButtonDown(UINT nFlags, CPoint point) { // 先处理关闭按钮命中 for (size_t i = 0; i < m_items.size(); ++i) { if (!m_items[i].bVisible) continue; if (m_bShowCloseBtn) { CRect rcClose(m_items[i].rcItem.right - 16, m_items[i].rcItem.CenterPoint().y - 6, m_items[i].rcItem.right - 4, m_items[i].rcItem.CenterPoint().y + 6); if (rcClose.PtInRect(point)) { DeleteItem((int)i); return; } } if (m_items[i].rcItem.PtInRect(point)) { SetCurSel((int)i); return; } } CWnd::OnLButtonDown(nFlags, point); }关闭按钮的矩形计算和绘制必须完全一致,这里用right - 16到right - 4的固定偏移,对应的就是绘制代码里rcItem.right - 16的起笔位置。DeleteItem内部会先销毁关联的内容页窗口,然后从m_items中移除记录并重排布局,最后重绘整体。这里有一个易错点:删除选中项之后,当前选中索引需要回退到前一项,否则m_nCurSel会越界,后续的GetCurSel返回无意义值。这个项目在DeleteItem里有对m_nCurSel > 0的判断,处理得比较稳妥。
4.2 悬浮高亮与鼠标追踪的实现
悬浮高亮用的是标准三段式:WM_MOUSEMOVE里更新m_nHoverIndex并判断是否需要触发刷新,同时调用TrackMouseEvent注册WM_MOUSELEAVE;OnMouseMove返回前检查坐标是否在标签间移动,只有索引变化时才Invalidate,避免鼠标在标签内轻微抖动导致频繁重绘;WM_MOUSELEAVE触发后把m_nHoverIndex置为 -1 并重绘。
这里有一个性能上的细节:WM_MOUSELEAVE只有在调用TrackMouseEvent后才会收到,且收到后系统自动注销追踪,下一次WM_MOUSEMOVE需要重新注册。这个状态必须用一个成员变量m_bTrackingMouse记录,否则每次鼠标移动都会重复注册,白白浪费一次系统调用。代码里的写法是if (!m_bTrackingMouse) { TrackMouseEvent(&tme); m_bTrackingMouse = TRUE; },收到WM_MOUSELEAVE时再复位这个标志。
悬浮态的背景色变化不需要做动画,直接重绘即可。如果需要渐入渐出效果,常见做法是准备一个从当前颜色到目标颜色的插值函数,配合定时器逐步改变m_nHoverProgress并触发重绘。这个项目没有做动画,保持了实现的简洁性,但对后续二开来说预留的DrawTabItem虚函数足以承载这类扩展。
4.3 内容页的切换联动机制
SetContentWnd把外部的内容页窗口绑定到标签项上,切换时控件自动处理旧页隐藏、新页显示。这个联动发生在SetCurSel里:
int CTabCtrlEx::SetCurSel(int nIndex) { if (nIndex < 0 || nIndex >= (int)m_items.size()) return -1; int nOldSel = m_nCurSel; if (nOldSel >= 0 && nOldSel < (int)m_items.size()) { CWnd* pOldWnd = m_items[nOldSel].pContentWnd; if (pOldWnd && pOldWnd->GetSafeHwnd()) pOldWnd->ShowWindow(SW_HIDE); } m_nCurSel = nIndex; CWnd* pNewWnd = m_items[nIndex].pContentWnd; if (pNewWnd && pNewWnd->GetSafeHwnd()) { pNewWnd->ShowWindow(SW_SHOW); pNewWnd->SetFocus(); } Invalidate(FALSE); NotifyParentOfSelectionChange(); // 向父窗口发送 TCN_SELCHANGE 通知 return nOldSel; }GetSafeHwnd的判空是必须的,因为外部可能在SetContentWnd之后又销毁了内容窗口,此时拿到一个悬空指针。Invalidate(FALSE)不带擦除参数,配合已经屏蔽掉的WM_ERASEBKGND,可以进一步减少闪烁。最后那个NotifyParentOfSelectionChange模拟了系统 Tab 控件的通知行为,向父窗口发送WM_NOTIFY消息,这样外部代码可以通过TCN_SELCHANGE响应切换事件,和 MFC 原生 Tab 控件的用法一致,接入成本低。
5. 避坑与排查:自绘 Tab 控件最常见的 5 个问题
5.1 标签文字出现残影和重叠
现象是切换标签后,文字残影留在标签背景上,越刷新越乱,重启程序才恢复。
原因是自绘控件在重绘时没有先填充背景。当前标签切换到新位置时,原位置的像素没有被清理,如果OnPaint里直接绘制而不做FillSolidRect,旧的文字笔迹会叠加在新绘制的图形上。解决方式是把OnPaint里“填充整体背景”放到所有绘制操作之前,确保每次重绘都是完全覆盖。这个项目在OnPaint开头就做了整窗背景填充,如果你自己扩展时新增了绘制分支,务必保持这个顺序。
5.2 关闭按钮能画出来但点不中
现象是标签上的“×”号显示正常,鼠标移上去没有任何反应,点击也无效果。
原因几乎都是绘制坐标和命中坐标不一致。有些实现里绘制时用rcItem.right - 16作为起点,命中检测却用了rcItem.Width() - 16,一个是绝对值,一个是相对值,差了一个rcItem.left的偏移量。标签不是从 0 开始时,这个偏差会逐渐累积。解决方式是用同一个成员函数计算按钮矩形,绘制和命中都调用它,确保两者永远一致。我在这个项目的代码里看到它用一个内联函数GetCloseBtnRect(const TabItem&)就干这一件事,值得借鉴。
5.3 高 DPI 显示器下标签文字被截断
现象是程序在 100% 缩放下正常,换到 150% 缩放的笔记本上标签文字显示不全,右侧被截掉一截。
原因是GetTextExtent返回的大小和实际渲染像素大小在系统缩放时出现偏差,如果程序没有声明 DPI 感知,GDI 返回的逻辑尺寸会被缩放,而标签矩形布局却用了原始逻辑值相加,导致容器宽度不够。解决方式是在程序入口调用SetProcessDPIAware()或者使用 manifest 声明 PerMonitorV2 DPI 感知,并确保所有字体、矩形尺寸都基于 DPI 缩放后的值重新计算。这个项目虽然没有显式处理 DPI,但它的布局代码集中在ReCalcLayout,接入 DPI 感知后只需要在字体变化后重新调用一次即可。
5.4 内容窗口切换时闪烁严重
现象是每次点击标签,内容区域先变白再显示新页面,像闪了一下。
原因是内容页窗口在切换显示状态时没有设置合适的裁剪区域,且ShowWindow(SW_HIDE)和ShowWindow(SW_SHOW)之间的间隙过大,系统在隐藏旧窗口后立刻重绘了背景。解决方式是给内容页窗口设置WS_CLIPCHILDREN和WS_CLIPSIBLINGS样式,并且在控件自身的Create里带上WS_CLIPCHILDREN,最后把SetRedraw(FALSE)和SetRedraw(TRUE)包在切换逻辑外,强制合并绘制。如果还不行,考虑把内容页放进WS_EX_COMPOSITED的容器中,让系统用分层合成来消除闪烁。
5.5 标签在窗口拉伸时布局错乱
现象是窗口最大化后标签没有自适应拉伸,或者缩小窗口时标签部分被裁掉,右边看不到关闭按钮。
原因是布局代码在OnSize里没有重新计算标签位置,或者在ReCalcLayout里用了固定坐标而不是从GetClientRect获取实际宽度。解决方式是让ReCalcLayout接收一个CRect参数,用当前客户区矩形约束标签排列,当标签宽度和超出客户区时,启用左右滚动按钮或者允许水平滚动条。这个项目目前是左对齐排列,标签超出时不处理,右侧超出会被裁掉,这是我在二开时第一个会改的点——给控件加上ShowScrollBar支持。
6. 二次开发与改造思路:怎么把这份自绘 Tab 接进你的业务工程
拿到这个资源后最快的接入路径是:把TabCtrlEx.h和TabCtrlEx.cpp拷进工程,在对话框模板里放一个自定义控件,将它的窗口类名改为TabCtrlEx注册的类名,然后在OnInitDialog里创建内容页并调用SetContentWnd绑定。如果不想用对话框模板,也可以在OnInitDialog里直接Create一个CTabCtrlEx实例指定位置和 ID,效果等价。
具体的扩展点我建议从三个方向入手:
第一个是给标签加图标。继承CTabCtrlEx,重写DrawTabItem,在默认绘制逻辑之前调用DrawIcon画出 16x16 的图标,然后顺延文本绘制起点。注意图标资源和标签项的对应关系要用dwData字段存储图标索引,别在外面再维护一个映射表,否则增删标签时容易失同步。
第二个是改造成可关闭的浏览器风格。现有m_bShowCloseBtn已经支持关闭按钮,但缺少“除最后一个标签外禁止关闭”的逻辑。在DeleteItem里加一个判断:如果当前项数量为 1 则直接返回FALSE,同时手动维护m_nCurSel移到删除前一项的选中状态,就能做出大多数桌面应用的标签关闭体验。
第三个是暗色主题适配。默认配色是深灰底亮字,如果你要做浅色主题,把m_clrActiveBg改成白色、m_clrInactiveText改成深灰,同时调整标签底部连接线的颜色,就能快速切肤。更彻底的做法是把颜色值提成结构体TabTheme,用SetTheme统一替换,这样多个主题之间切换只改一个成员。
验证改动是否到位有一个笨但有效的方法:在OnPaint里临时画一个红色 1 像素边框把所有标签矩形标出来,然后用鼠标逐个点击并缩放窗口,观察边框是否覆盖了所有可交互区域,以及有没有出现文字越界。这一步能帮你提前发现一半以上的坐标问题,尤其是关闭按钮的区域不一致问题。每改完一个功能都重复一遍这个检查,不要直接跳到编译运行看结果。
我当初第一次拆这种自绘控件时,自作聪明地把标签间距改成平均分配,结果标签文字一长就被压缩成省略号,后来才意识到 Tab 控件的核心是“文字优先,宽度自适应”,不是“等分美观”。从那以后我每次做界面控件都强制走一遍边界验证——最小尺寸、最长文字、DPI 缩放、窗口拉伸各测一遍,再精致的效果也得先过这四关。希望这份解析能让你少踩几个我踩过的坑。
本文还有配套的精品资源,点击获取