MFC列表控件标题换行实战:CListCtrl自绘与ListBox多行显示方案 📅 发布时间:2026/9/7 10:28:41 👁 浏览次数: 简介这是一份面向VC/MFC开发者的List控件标题栏自动换行实现源码基于自定义Header控件HeaderCtrlEx解决ListView列标题过长默认被截断为省略号的问题适用于需要展示较长列标题的MFC界面场景。压缩包共30个文件其中8个.h头文件与7个.cpp源文件构成核心实现并附带工程配置、可执行示例以及图标、工具栏等资源整体仅24KB代码精简、易读易改。已有414人学习参考适合想深入理解MFC控件自绘和消息处理的初中级开发者。通过阅读与集成HeaderCtrlEx可以掌握标题宽度计算、换行逻辑与重绘、字体样式调整以及窗口尺寸变化时的动态更新等关键技巧并能快速移植到自己的项目中。 最近在维护一个老MFC项目时接到一个需求客户表格里那一排列标题太长列宽又固定结果标题全变成了省略号客户要求做成两行显示。关键词自然是VC List标题栏自动换行代码但这几个字搜出来能用的东西真不多大部分帖子都在说“给文本加\n”试完毫无反应。这篇文章把我在这个需求上踩过的路、最后的正确做法以及顺手解决的ListBox条目换行问题一次性讲清楚供做MFC界面又不想引入第三方控件的朋友参考。先说结论Windows的列表控件本身就没有“自动换行”这个开关标题栏要换行核心思路就一句话——拿到绘制权要么拦截消息自己画要么干脆自绘整个控件。下面把三种常见场景拆开讲。1. 先把需求说清楚你到底要换哪一行“List标题栏自动换行”在开发语境里至少对应三种完全不同的控件行为很多人代码不好使就是因为拿A场景的方案去改了B场景的控件。动手之前先确认自己面对的是哪一个。你要换行的对象涉及的控件核心技术方案实现难度列表表头文字CListCtrl 内部的 Header ControlHDN 通知消息 NM_CUSTOMDRAW 绘制回调中列表单元格内容CListCtrl 本身LVS_OWNERDRAWFIXED 自绘列表项高ListBox 条目文字CListBoxLBS_OWNERDRAWFIXED 自绘条目中这三种控件的内部数据结构有着本质区别。Header Control 存的是列标题字符串你可以在 HDITEM 结构里塞一长串文字但它默认绘制时只认单行CListCtrl 的 LVITEM.pszText 字段也是单行文本CListBox 稍微好一点至少还允许你控制每个条目的矩形高度但默认同样不会对超长文本做折行处理。所以你要换行的一定得先定位清楚是顶部那排列名还是表格里的某一格内容还是 ListBox 里的一行条目。这三套方案代码不能混用。如果客户只说了“标题栏换行”我一般先跑过去看一眼具体界面把问题限定到具体控件再动手别凭猜写代码。2. 为什么原生控件不肯自动换行单行绘制的底层习惯搞清楚原理比抄代码重要。Windows 的 List 系列控件之所以不支持自动换行根源在于它的数据结构和绘制逻辑都是按单行设计的。LVITEM 结构里的 pszText 字段就是一个普通的 C 字符串ListView 绘制时拿到这个字符串直接用单行方式画在矩形里。Header Control 同样如此它的文本绘制内部默认走的是 DT_SINGLELINE 那一套逻辑字多了就是截断甚至不会主动给你省略号——省略号还是列表视图在特定样式下自动加上的。你可以往文本里塞 \n但绘制引擎不会把 \n 翻译成真正的换行它只会把这个不可见字符当作一个异常字符处理掉结果因系统版本不同表现得乱七八糟。这就好比你打开一个单行模式的编辑框粘贴一段带回车的文字它不会变成多行因为编辑框的绘制逻辑根本不认回车。想折行只有两种结果要么换一个多行控件要么自己接管绘制过程。另外一个反直觉的点是很多人以为“让标题换行”就是把表头行高加高然后文本自然就折行了。这个想法只对了一半。行高加高只是给绘制区域腾出了空间如果绘制逻辑还是单行模式空间再高文本也不会折行。反过来你光让文本折行行高不够第二行也会被裁掉。所以正确做法是“加高 自绘”两步走缺一不可。这就是为什么网上流传的“SetItemHeight 文本里塞\n”方案时灵时不灵。它依赖的是系统绘制引擎对不可见字符的偶然处理不同 Windows 版本、不同主题下表现完全不一样。这种东西只能算碰运气不能写进交付代码里。3. 表头标题两行显示先加高再教会 Header 画两行表头换行的正路是两条先把 Header 的行高撑起来然后通过 NM_CUSTOMDRAW 回调在 Header 绘制完默认内容后再覆盖画一遍多行文本。3.1 第一步把 Header 行高撑起来CListCtrl 内部的 Header Control 是一个标准的 SysHeader32 控件可以用 GetHeaderCtrl() 拿到指针后调用 SetItemHeight。我一般用 36 像素起步具体数值根据字体大小和 DPI 缩放调整后面第 5 章专门说。// 假设 m_list 是 CListCtrl并且已经完成了 InsertColumn CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (pHeader ! nullptr) { pHeader-SetItemHeight(36); }这里有个很容易踩的坑如果你的列是动态添加的或者在 OnInitDialog 阶段设置完高度后后面又调用了 InsertColumnHeader 会重新计算自身高度你设的 36 像素可能直接被覆盖掉。更稳妥的办法是拦截 HDN_ITEMCHANGED 通知在列结构发生变化后强制把高度设回来。BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_NOTIFY(HDN_ITEMCHANGED, 0, CMyDialog::OnHdnItemchangedList) END_MESSAGE_MAP() void CMyDialog::OnHdnItemchangedList(NMHDR* pNMHDR, LRESULT* pResult) { CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); if (pHeader ! nullptr) { pHeader-SetItemHeight(36); } *pResult 0; }注意 ON_NOTIFY 的第一个参数是 HDN_ITEMCHANGED第二个参数是 Header 控件的 ID。CListCtrl 内部 Header 控件的 ID 固定是 0所以这里写 0 没有毛病。拦截这个通知的好处是不管列是初始化加的还是运行期动态加的只要列属性一变高度就会被重新纠正比只写一行 SetItemHeight 要稳得多。3.2 第二步拦截 NM_CUSTOMDRAW 绘制多行文本行高解决了接下来就是绘制。我不建议派生 CHeaderCtrl 子类去重写 DrawItem那样工程量太大还要处理背景、分隔线、排序箭头等一堆细节。更轻量的做法是在对话框的 OnNotify 里拦截 Header 发出的 NM_CUSTOMDRAW 消息。BOOL CMyDialog::OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult) { NMHDR* pNMHDR (NMHDR*)lParam; if (pNMHDR-hwndFrom m_list.GetHeaderCtrl()-GetSafeHwnd()) { if (pNMHDR-code NM_CUSTOMDRAW) { LPNMCUSTOMDRAW pNMCD (LPNMCUSTOMDRAW)lParam; if (pNMCD-dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return TRUE; } else if (pNMCD-dwDrawStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYPOSTPAINT; return TRUE; } else if (pNMCD-dwDrawStage CDDS_ITEMPOSTPAINT) { DrawHeaderText(pNMCD); *pResult CDRF_SKIPDEFAULT; return TRUE; } } } return CDialogEx::OnNotify(wParam, lParam, pResult); }绘制逻辑单独抽一个函数出来这样处理逻辑干净一点。在 CDDS_ITEMPOSTPAINT 阶段pNMCD-rc 就是当前列的矩形区域dwItemSpec 是列索引。先取出该列的标题文本然后用 DrawText 配合 DT_WORDBREAK 强制折行同时加上 DT_CENTER 让文字水平居中。void CMyDialog::DrawHeaderText(LPNMCUSTOMDRAW pNMCD) { HDC hdc pNMCD-hdc; CRect rc(pNMCD-rc); CHeaderCtrl* pHeader m_list.GetHeaderCtrl(); int nIndex static_castint(pNMCD-dwItemSpec); TCHAR szText[128] { 0 }; HDITEM hdi { 0 }; hdi.mask HDI_TEXT; hdi.pszText szText; hdi.cchTextMax 127; pHeader-GetItem(nIndex, hdi); // 用列文本里的分隔符拆行比如 姓名\n部门 CString strText szText; rc.DeflateRect(6, 3, 6, 3); ::SetBkMode(hdc, TRANSPARENT); ::DrawText(hdc, strText, -1, rc, DT_WORDBREAK | DT_CENTER | DT_TOP | DT_NOPREFIX); }这里有两个细节必须强调。第一DrawText 一定要加 DT_NOPREFIX否则文本里的 字符会被当成助记符处理显示效果乱七八糟具体原因第 5 章展开。第二CDRF_SKIPDEFAULT 会让系统跳过默认文本绘制但分隔线和排序箭头这些也一并被跳过了如果你只是简单覆盖文字会导致文字底下还压着一层系统画出来的文字出现重影。我实际项目里更常用的方案是在 CDDS_ITEMPOSTPAINT 阶段先用 GetSysColor(COLOR_BTNFACE) 填充当前列的背景矩形再绘制多行文本不返回 CDRF_SKIPDEFAULT让系统继续画分隔线和边框。这样既避免了重影又保留了 Header 的基本外观。如果你需要保留排序箭头就要在自绘代码里自己画三角箭头逻辑也不复杂只是多几行代码的事。3.3 “文本塞\n”的偷懒方案到底能不能用这里单独说一下那个流传很广的偷懒方案给 Header 文本直接塞一个 \n然后 SetItemHeight 加高。我在 Win7 上确实遇到过显示成两行的情况但那不是系统主动折行而是字体渲染把不可见字符当成了空白配合高度刚好凑出了视觉上的两行效果。换到 Win10 高分屏下同一个代码可能直接显示成空心方块或者干脆什么都看不见。所以我的建议很简单演示用可以试试生产环境千万别用。老老实实走 NM_CUSTOMDRAW代码量也没多到哪里去胜在稳定可控。4. 单元格内容换行与 ListBox 条目的自绘一条路走到底如果说表头换行还属于“加高 覆盖绘制”的取巧范畴那单元格内容和 ListBox 条目的自动换行就基本没有取巧路可走必须走 Owner Draw 自绘。4.1 CListCtrl 单元格内容换行CListCtrl 的单元格在 LVS_REPORT 视图下文本是单行绘制的LVITEM 里也不支持按行拆分。想让某个单元格显示多行现实的做法是把整个列表设为自绘模式然后自己在 DrawItem 里用 DrawText 排版。m_list.ModifyStyle(0, LVS_OWNERDRAWFIXED);设了 LVS_OWNERDRAWFIXED 之后系统会通过 WM_DRAWITEM 通知你绘制每一行。第一步是重写 MeasureItem告诉系统每行需要多高否则换行内容会画出边界。void CMyListCtrl::MeasureItem(LPMEASUREITEMSTRUCT lpMIS) { CString strText GetNeedWrapText(lpMIS-itemID); CDC* pDC GetDC(); CFont* pFont GetFont(); CFont* pOldFont pDC-SelectObject(pFont); // 列宽假设 100实际应该取对应列的宽度 CRect rc(0, 0, 100, 0); pDC-DrawText(strText, rc, DT_WORDBREAK | DT_CALCRECT); lpMIS-itemHeight rc.Height() 8; pDC-SelectObject(pOldFont); ReleaseDC(pDC); }DrawItem 里的绘制核心是先取出整行所有单元格的文本然后每个子项矩形分别调用 DrawText加上 DT_WORDBREAK 和 DT_NOPREFIX。选中态要自己处理背景用高亮色文字用高亮文字色否则选中前后对比不明显。这种自绘方案代码量确实大一些但换来的是完全自由的排版能力单元格里能放图片、多行文本、进度条甚至自定义按钮。我们做备注列的时候就是这么干的客户看了相当满意。4.2 ListBox 条目自动换行ListBox 的情况比 CListCtrl 简单不少因为它天然允许每个条目有不同的高度。设置自绘样式后只要在 MeasureItem 里按文本实际折行后的高度返回值即可。m_listBox.ModifyStyle(0, LBS_OWNERDRAWFIXED | LBS_HASSTRINGS);绘制时同样是用 DrawText 的 DT_WORDBREAKvoid CMyListBox::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC* pDC CDC::FromHandle(lpDIS-hDC); CRect rc lpDIS-rcItem; CString strText; GetLBText(lpDIS-itemID, strText); if (lpDIS-itemState ODS_SELECTED) { pDC-FillSolidRect(rc, ::GetSysColor(COLOR_HIGHLIGHT)); pDC-SetTextColor(::GetSysColor(COLOR_HIGHLIGHTTEXT)); } else { pDC-FillSolidRect(rc, ::GetSysColor(COLOR_WINDOW)); pDC-SetTextColor(::GetSysColor(COLOR_WINDOWTEXT)); } rc.DeflateRect(4, 2, 4, 2); pDC-DrawText(strText, rc, DT_WORDBREAK | DT_LEFT | DT_TOP | DT_NOPREFIX); }这里有个体验细节ListBox 换行之后条目高度会参差不齐视觉上其实挺自然但如果条目太多滚动逻辑会变得有点涩。我的经验是尽量限制每条的最大高度超过一定行数就显示省略号避免某一条内容过长把整个列表撑得很难看。4.3 纯展示场景的救急方案多行 Static 控件最后分享一个不算正统但很实用的技巧。如果需求只是“展示多行信息”不需要用户选中也不需要滚动那完全没必要动自绘代码。直接用一个 Static 控件设置 SS_LEFT然后根据文本内容和宽度用 DT_CALCRECT 算出所需高度动态调整 Static 控件的高度即可。CRect rcText(0, 0, 200, 0); pDC-DrawText(strContent, rcText, DT_WORDBREAK | DT_CALCRECT); m_static.MoveWindow(10, 10, 200, rcText.Height()); m_static.SetWindowText(strContent);这种方案代码量极小改动风险低遇到临时展示需求完全可以顶一阵。缺点是没了原生选中高亮和滚轮逻辑所以只适合那种“列出来看看就行”的场景。5. 资源编辑器、助记符、DPI三个很容易忽略的隐形坑代码写完跑通只是第一步实际交付路上还有几个很隐蔽的坑每个我都踩过写出来给大家省点时间。5.1 资源编辑器里根本输不了换行很多新手的第一反应是在 VS 的资源编辑器里给 Header 的标题栏属性直接写两行文字。这个操作是行不通的资源编辑器不支持在 Caption 里输入真正的换行符你就算复制粘贴一个换行进去保存后也会被过滤掉。Header 的文本必须切换到代码里设置常用的做法是在资源字符串里存一个特殊分隔符比如“姓名|部门”运行时再替换成 \n或者干脆在 InsertColumn 时直接拼好。5.2 助记符会把 字符吞掉Header 文本和 DrawText 默认会把 当成助记符前缀来处理。意思就是如果你列名里恰好有个“”字符不处理的话这个字符可能直接消失后面的字母还会多个下划线。我在一次给设备清单做表头时列名叫“S/NModel”结果界面上一看变成了“S/NModel”排查了半天才意识到是助记符在捣乱。自绘代码里 DrawText 一定要加 DT_NOPREFIX如果文本需要完整显示 本身通常要写 转义具体看你的输入来源。5.3 DPI 缩放下的高度换算这是最容易出问题的地方。程序在 100% 缩放下 SetItemHeight(36) 显示正常换到 125% 或 150% 缩放的电脑上标题文字可能直接压线甚至被裁掉。因为 Header 会根据系统 DPI 自动调整字体和默认高度而 SetItemHeight 的像素值是物理像素还是逻辑像素取决于进程的 DPI 感知设置行为很微妙。我现在的做法是不写死像素值而是参考字体度量来算行高。简单说就是获取当前 DC 的 LOGPIXELSY用 MulDiv 做一次比例换算。这样在常见缩放级别下不会出现标题画到框外的问题。另外如果程序用到了双缓冲绘制或自定义换肤绘制 Header 的上下文 DC 也要保持一致混用物理像素和逻辑像素的后果就是文字和背景对不齐怎么看怎么别扭。做完这几个点VC 列表标题换行这件事基本就齐活了。回想这个需求其实核心就一件事系统不帮你做的事你就得自己接管绘制。只要想通这一点不管是 Header 换行、单元格换行还是 ListBox 换行本质都是同一套思路在具体控件上的延伸代码写起来也就不会慌了。本文还有配套的精品资源点击获取