ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

MFC列表控件标题自动换行:Header Control自绘与NM_CUSTOMDRAW实现

2026/9/9 22:12:33 拓冰建站 浏览量
MFC列表控件标题自动换行:Header Control自绘与NM_CUSTOMDRAW实现 简介面向VC与MFC开发者的自定义控件源码包针对ListView控件标题栏长文本被截断的常见问题提供基于CHeaderCtrl扩展的自动换行解决方案。包内共30个文件以C头文件.h与源文件.cpp为主同时包含图标、位图、资源脚本及可执行示例程序完整展示HeaderCtrlEx控件的实现与集成方式。已在CSDN获得414人学习浏览。通过阅读与运行代码可掌握OnPaint重绘、消息映射如HDN_ITEMCHANGED、字体对齐等关键技巧并可直接将HeaderCtrlEx替换项目中的原生标题栏控件实现多列标题自动换行、信息完整显示。适合希望深入理解MFC自绘控件、提升界面可用性的中高级VC开发者。 做MFC界面开发的老哥应该都遇到过这种情况VC里放一个List Control列标题一长窗口一缩文字就被截成“订单编…”“收货人姓…”怎么看怎么难受。我之前在一个后台订单管理工具里就碰到这个需求产品经理丢过来一句“把标题自动换行吧”于是我就去研究VC List标题栏自动换行。研究完发现这个功能看起来简单背后却牵扯到Header Control的通知机制、自定义绘制、动态行高计算好几个环节而且能直接抄的完整资料不多。今天把整个思路、代码和踩过的坑都整理出来给同样被列表标题折磨的朋友做个参考。1. 先把问题场景和控件的本质看清楚1.1 场景回顾列名长了显示不全先说说什么场景会用到这个功能。列表标题一般来源于数据库字段对应的中文名比如“订单编号”“收货人姓名”“手机号码”“商品名称”“订单状态”“创建时间”短的两三个字长的七八个字。窗口默认宽度够用还好用户拖窄某列、调整界面布局之后长标题一定被截断。我当时的第一反应是简化列名比如把“收货人姓名”改成“收货人”“商品名称”改成“商品”但改完之后发现业务文档、导出Excel的表头、筛选下拉框都要跟着改牵一发动全身。而且更常见的场景是用户会主动拖拽列宽来查看不同内容列头文字如果能跟着列宽自动折行体验会好非常多。与其改业务命名不如直接在UI层解决显示问题。1.2 标题栏其实是Header Control先搞懂它是什么做这个功能前必须先明确一个概念CListCtrl的标题栏并不是列表控件自己直接画的它内部维护了一个子窗口类型是Header ControlMFC里面写成CHeaderCtrl。你在资源编辑器里把List Control拖到对话框上运行时它会自动创建一个Header子窗口每一列的标题就是这个Header里面的一个Item。这个Header的默认行为很简单——标题文字永远是单行居中绘制列宽一旦小于文字宽度直接截断或显示省略号。所以要让标题自动换行本质不是去改CListCtrl而是接管Header的绘制流程自己画出多行文本然后把Header整体高度调大让它容得下两行甚至三行文字。这两个动作配合好效果就对了。1.3 默认不换行的根本原因Header Control是Win32公共控件里一个非常轻量的组件。它内部绘制标题时用的是标准的单行DrawText并没有传DT_WORDBREAK标志也没有提供类似“允许换行”的属性开关。MSDN的Header文档里你可以设置高度、宽度、对齐方式、图标、排序箭头但就是找不到一个直接开启自动换行的选项。既然系统不提供那就只能自己接管绘制流程这是唯一可行的路线。2. 整体设计与关键技术点2.1 方案选型接管Header自绘而不是整控件自绘遇到标题换行新人容易走入一个误区干脆把整个List Control全部Owner-Draw自己做列表的绘制。这个方案理论可行但成本非常高——你要自己画单元格、处理鼠标命中、支持不同状态的高亮颜色还要保证复选框、图标、网格线、滚动条这些功能不受影响工作量瞬间变成一个大工程。实际上表头只是Header子窗口上的一小块区域完全没必要把整个列表拖下水。更合理的做法是利用Header的NM_CUSTOMDRAW通知。这是Win32公共控件提供的一种“自定义绘制”机制专门用来让父窗口在控件绘制的关键阶段插入自己的代码。它的拦截成本极低不会破坏List本身已有的功能又有官方文档支持是性价比最高的方案。2.2 实现这个功能必须抓住的三个关键点整个实现可以拆成三件事这三件事缺一不可第一在父窗口的WM_NOTIFY消息里识别出来自Header的NM_CUSTOMDRAW通知进入列头绘制流程。Header是List的子窗口它的通知会通过WM_NOTIFY一路传到List的父窗口也就是你的对话框或视图窗口所以要在那里拦截。第二在Header的ITEMPOSTPAINT绘制阶段用DrawText里的DT_WORDBREAK标志把标题文字画成多行注意居中、垂直居中这些显示效果。第三在列宽变化HDN_ITEMCHANGED / HDN_TRACK的时候用DT_CALCRECT量出每一列换行之后实际需要的像素高度取最大值再通过HDM_SETITEMHEIGHT消息把Header整体撑高。不做这一步即使画出了多行文字Header高度不够也会被裁掉。2.3 别忘了通知消息的A/W版本兼容这里有个容易忽略的坑Header尺寸变化通知分为UNICODE和ANSI两个版本例如HDN_ITEMCHANGEDW对应Unicode工程HDN_ITEMCHANGEDA对应多字节字符集工程HDN_TRACK也同理。VS2017默认的MFC工程都使用Unicode字符集但如果你是为了兼容老代码把字符集改成了多字节那消息名就变了。最省事的做法是消息映射里A和W两个版本都写上处理函数共用。这样工程怎么切字符集都不怕反正都是一样处理逻辑。3. 实操步骤与代码实现3.1 准备工作用VS2017建一个MFC对话框工程这里以VS2017的MFC对话框程序为例控件ID假设为IDC_LIST_DATA。先在对话框上放一个List Control修改属性为Report视图然后加上网格线和整行选中样式更贴近真实业务界面。初始化代码大致是这样BOOL CListHeaderWrapDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 设置List控件扩展样式 m_list.SetExtendedStyle(LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); // 插入三列测试数据列宽后面用户可以拖 m_list.InsertColumn(0, _T(订单编号), LVCFMT_LEFT, 90); m_list.InsertColumn(1, _T(收货人联系电话), LVCFMT_LEFT, 100); m_list.InsertColumn(2, _T(商品名称), LVCFMT_LEFT, 100); // 插入几行数据用于观察 m_list.InsertItem(0, _T(20250101001)); m_list.SetItemText(0, 1, _T(138****8888)); m_list.SetItemText(0, 2, _T(机械键盘)); // 初始计算一次Header高度 UpdateListHeaderHeight(); return TRUE; }3.2 在消息映射中接收Header的自绘通知接下来是核心工作。在对话框的消息映射里把NM_CUSTOMDRAW以及两个版本的高度变化通知都加上BEGIN_MESSAGE_MAP(CListHeaderWrapDlg, CDialogEx) ON_NOTIFY(NM_CUSTOMDRAW, IDC_LIST_DATA, CListHeaderWrapDlg::OnNMCustomdrawListData) ON_NOTIFY(HDN_ITEMCHANGEDW, IDC_LIST_DATA, CListHeaderWrapDlg::OnHdnItemchangedListData) ON_NOTIFY(HDN_ITEMCHANGEDA, IDC_LIST_DATA, CListHeaderWrapDlg::OnHdnItemchangedListData) ON_NOTIFY(HDN_TRACKW, IDC_LIST_DATA, CListHeaderWrapDlg::OnHdnTrackListData) ON_NOTIFY(HDN_TRACKA, IDC_LIST_DATA, CListHeaderWrapDlg::OnHdnTrackListData) END_MESSAGE_MAP()这里要特别留意Header控件的通知虽然发到了对话框窗口但通知里的hdr.hwndFrom指向的是Header子窗口而不是List控件本身判断的时候不能搞混。函数声明就是常规的afx_msg void OnNMCustomdrawListData(NMHDR* pNMHDR, LRESULT* pResult)这种形式。3.3 核心绘制代码用DT_WORDBREAK画多行标题下面是标题换行绘制的核心处理函数。关键点在ITEMPOSTPAINT阶段因为系统在ITEMPREPAINT阶段虽然会画按钮背景、边框但文字用的还是单行逻辑我们要在它画完之后用自己的多行绘制覆盖掉那部分文字。void CListHeaderWrapDlg::OnNMCustomdrawListData(NMHDR* pNMHDR, LRESULT* pResult) { LPNMCUSTOMDRAW lpNMCD (LPNMCUSTOMDRAW)pNMHDR; CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (pHeader lpNMCD-hdr.hwndFrom pHeader-GetSafeHwnd()) { *pResult CDRF_DODEFAULT; // 第一阶段告诉系统我要处理每一项的绘制 if (lpNMCD-dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return; } // 每一项绘制前告诉系统绘制完成后通知我 if (lpNMCD-dwDrawStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYPOSTPAINT; return; } // 每一项绘制后覆盖绘制多行标题 if (lpNMCD-dwDrawStage CDDS_ITEMPOSTPAINT) { CDC* pDC CDC::FromHandle(lpNMCD-hdc); CRect rc lpNMCD-rc; rc.DeflateRect(3, 0, 3, 0); // 左右留3像素边距 TCHAR szText[256] { 0 }; HDITEM hdi { 0 }; hdi.mask HDI_TEXT | HDI_FORMAT; hdi.pszText szText; hdi.cchTextMax 256; if (pHeader-GetItem((int)lpNMCD-dwItemSpec, hdi)) { // 如果这列有排序箭头给右边留出12像素避免覆盖箭头 if ((hdi.fmt HDF_SORTUP) || (hdi.fmt HDF_SORTDOWN)) rc.right - 12; CFont* pFont pHeader-GetFont(); CFont* pOldFont pDC-SelectObject(pFont); pDC-SetBkMode(TRANSPARENT); pDC-SetTextColor(GetSysColor(COLOR_BTNTEXT)); pDC-DrawText(hdi.pszText, rc, DT_CENTER | DT_VCENTER | DT_WORDBREAK); pDC-SelectObject(pOldFont); } *pResult CDRF_DODEFAULT; return; } return; } *pResult CDRF_DODEFAULT; }为什么不直接在ITEMPREPAINT阶段画因为那时候系统还没绘制背景你画上去的文字会被系统随后绘制的按钮背景盖住。用POSTPAINT阶段相当于在系统画好的按钮上面追加文字层系统负责背景和边框我们负责换行文本两者互不干扰这是最稳的写法。3.4 动态计算Header高度列宽变了就要重新量多行文字画出来了如果Header高度不够第二行还是会看不见。所以必须根据当前列宽实时计算每一列换行后需要的高度取最大值设置给Header。计算的核心是用DT_CALCRECT先量文本在指定宽度下实际占用的矩形再对比各列数据void CListHeaderWrapDlg::UpdateListHeaderHeight() { CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (!pHeader) return; CClientDC dc(pHeader); CFont* pOldFont dc.SelectObject(pHeader-GetFont()); int nMaxHeight 0; int nCount pHeader-GetItemCount(); for (int i 0; i nCount; i) { HDITEM hdi { 0 }; TCHAR szText[256] { 0 }; hdi.mask HDI_TEXT | HDI_FORMAT; hdi.pszText szText; hdi.cchTextMax 256; if (!pHeader-GetItem(i, hdi)) continue; CRect rcItem; pHeader-GetItemRect(i, rcItem); rcItem.DeflateRect(3, 0, 3, 0); // 用当前列宽量出换行后的实际高度 CRect rcText rcItem; dc.DrawText(hdi.pszText, rcText, DT_CALCRECT | DT_CENTER | DT_WORDBREAK); if (rcText.Height() nMaxHeight) nMaxHeight rcText.Height(); } dc.SelectObject(pOldFont); // 限制合理范围最小20防止按钮不可点最大80防止竖排失控 nMaxHeight max(nMaxHeight, 20); nMaxHeight min(nMaxHeight, 80); pHeader-SetItemHeight(nMaxHeight); pHeader-Invalidate(); }这里有个细节计算高度必须用Header自己的字体不能随手用对话框字体。因为Header的字体可能和对话框字体不同用错字体量出来的高度要么偏大要么偏小标题会出现被截或者留有大量空白的情况。然后处理列宽变化通知。HDN_ITEMCHANGED是列宽调整结束后触发HDN_TRACK是拖动过程中持续触发。想让用户拖拽列宽时标题实时跟着变必须两个都处理void CListHeaderWrapDlg::OnHdnItemchangedListData(NMHDR* pNMHDR, LRESULT* pResult) { LPNMHEADER phdr (LPNMHEADER)pNMHDR; CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (pHeader phdr-hdr.hwndFrom pHeader-GetSafeHwnd()) { UpdateListHeaderHeight(); } *pResult 0; } void CListHeaderWrapDlg::OnHdnTrackListData(NMHDR* pNMHDR, LRESULT* pResult) { LPNMHEADER phdr (LPNMHEADER)pNMHDR; CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (pHeader phdr-hdr.hwndFrom pHeader-GetSafeHwnd()) { UpdateListHeaderHeight(); } *pResult 0; }3.5 运行效果与实测观察我实际编译运行下来系统默认9号字体下“收货人联系电话”这七个字在列宽100像素时刚好一行放下。把列宽拖到70像素标题会立刻折成两行Header高度从默认的24像素自动变成40像素左右把列宽拖回100像素又恢复单行。整个过程是实时的用户拖拽列宽的分割线时标题文字就在眼前一行一行地折叠没有闪烁也没有卡顿。如果列宽被拖到40像素以下中文会变成竖排这也是换行的正常结果。所以我才在UpdateListHeaderHeight里加了80像素的高度上限超过上限后就保持两到三行避免界面完全失控。4. 常见问题与排查技巧4.1 问题速查表我把实际操作中容易踩的坑整理成了一张表遇到问题直接对照排查现象可能原因解决办法标题还是单行没变化没有接住Header的NM_CUSTOMDRAW或者判断hwndFrom时写错了窗口句柄先断点打到OnNMCustomdrawListData确认hdr.hwndFrom是Header的句柄拖动列宽时标题不实时刷新只处理了HDN_ITEMCHANGED漏掉HDN_TRACK把HDN_TRACKW和HDN_TRACKA也加到消息映射里换行后第一行文字被裁掉一截Header高度设置不够或者左右边距太紧在量出的高度基础上上下各加4~6像素DeflateRect边距加大到4排序箭头消失绘制文本覆盖了右侧箭头区域取HDI_FORMAT发现HDF_SORTUP或HDF_SORTDOWN时给右侧留出12像素高DPI显示器下高度明显不对工程DPI感知设置变化后没有重新计算高度在WM_DPICHANGED的处理函数里重新调用UpdateListHeaderHeight拖动列宽时列表界面闪烁高度反复设置导致重绘频繁在SetItemHeight之前判断高度是否真的变化没变化就跳过4.2 独家避坑经验几个值得记住的细节有些坑是代码能跑通但效果不理想的这里写几个我自己的心得。第一个尽量别用重写CHeaderCtrl的DrawItem那种Owner-Draw方案。网上确实有这种代码但Header控件的自绘逻辑牵扯到按钮状态、分割线、排序箭头自己全画一遍非常麻烦而且系统升级后表现可能变化。用NM_CUSTOMDRAW只是“在系统绘制基础上追加一层”系统逻辑全部保留适配性最好。第二个DrawText的标志位不要同时带DT_END_ELLIPSIS和DT_WORDBREAK。带DT_END_ELLIPSIS时它会在文本超出区域时优先显示省略号这会让换行在某些宽度下失效表现很诡异。换行就是换行让文本老老实实折行不用再加省略号逻辑。第三个在OnInitDialog里第一次调用UpdateListHeaderHeight时如果发现高度没生效不要急着怀疑代码。可能是列还没插入完成或者List控件窗口还没完全准备好。更稳妥的做法是用PostMessage发送一个自定义消息比如WM_APP 1等消息循环跑起来后再更新一次。我实际测试过有些控件用SetItemHeight之后需要Invalidate才能看到效果但PostMessage方式可以规避大部分初始化时序问题。第四个如果你的列表支持用户调整列的顺序那么Header的Item索引会和列索引不一致所有GetItem和GetItemRect的循环仍然基于Header索引不用特别处理。但如果你在别处通过列索引改列宽要记得重新调用UpdateListHeaderHeight否则标题和列宽会不同步。我在实际项目里用这套代码跑了大半年期间又加了固定列宽、导出Excel这些功能标题换行这个模块基本没再被用户提过问题。整体做下来最大的感受是Header Control虽然外表不起眼但它几乎是所有列表类应用里用户视觉的第一入口标题排列舒服了整个界面的质感都会提升一个档次。如果实现过程中遇到别的奇怪现象欢迎在评论区留言交流我看到会尽量回复。本文还有配套的精品资源点击获取