ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MFC CListCtrl列标题自动换行实现:CHeaderCtrl子类化与DrawText用法

MFC CListCtrl列标题自动换行实现:CHeaderCtrl子类化与DrawText用法 简介一套VC List标题栏自动换行的完整工程源码面向MFC开发者解决ListView控件在显示长标题时被截断为省略号的问题。通过自定义HeaderCtrl控件接管标题绘制实现多行文本自动换行让界面信息更清晰。压缩包共30个文件包含8个h头文件、7个cpp源文件以及rc资源脚本、ico图标、可执行示例等完整项目文件整体仅24KB便于快速查看和集成省去重新搭建的麻烦。已有414人学习浏览。读者可通读源码学习自定义控件绘制、标题宽度测量、换行位置计算、窗口重绘与字体对齐处理等关键技巧同时获得可直接编译的VC工程既能快速应用到自研项目中也能作为理解MFC控件扩展与消息机制的典型范例对提升界面开发水平很有帮助。尤其适合MFC开发中的数据报表、管理工具或信息管理系统界面场景。 做MFC界面开发的朋友十有八九都会遇到这个需求CListCtrl的列标题文字太长在固定列宽下被截断成一串省略号要么就得把列宽拉得很宽整个界面看起来特别不协调。这篇文章就围绕VC环境下List控件CListCtrl的标题栏自动换行展开把我实际项目里验证过的思路、代码和坑一次性讲清楚。适合正在做MFC维护、老工程改造、或者刚接触Visual Studio 2017里Windows窗体程序开发的同学参考能直接抄作业。先说明一点CListCtrl本身没有提供“标题换行”的开关标题绘制由它内部的Header ControlCHeaderCtrl负责。所以要让标题栏自动换行核心思路就两条一是拿Header控件的高度做文章让它根据标题文本自动变高二是在绘制标题文本时用DrawText的DT_WORDBREAK参数把长文本拆成多行。两条路要配合起来标题才会既换得了行又显示得完整。1. 需求与方案选型1.1 什么时候需要“标题栏自动换行”我最早遇到这个需求是在一个资产管理类的老工程里列名都是“设备编号”、“资产负责人及联系方式”、“最近一次盘点时间及结果”这种又长又啰嗦的中文标题。如果按默认方式显示列宽只要一收窄标题立刻变成“资产负责人及联…”要是把列宽放得足够宽窗口宽度又撑不住。PM给的要求是“列宽固定标题写全看不全就换行”。这类需求在几个场景下特别常见固定列宽或自适应列宽模式下标题超长。表格需要打印、导出标题必须完整显示。多语言界面同一列标题在中文、英文、日文下长度差异很大。标题包含单位、备注等副信息比如“温度(℃) / 湿度(%)”。默认情况下CListCtrl的Header只支持单行不会自动撑高。这也是为什么很多人第一反应是“加个\n换行符试试”结果发现插了换行符也白搭——Header在绘制时并不会处理换行。1.2 两条实现路线怎么选我在查资料和实际测试后把可行方案归纳成两条方案核心技术点改动量效果方案A子类化Header 自绘CHeaderCtrl子类化DrawItem自绘HDN_ITEMCHANGED动态调行高较大但控制力最强标题完整换行可自定义字体、颜色、箭头方案B拦截通知 NM_CUSTOMDRAW在List派生类中拦Header通知用NM_CUSTOMDRAW绘制文本较小保留系统风格标题换行分割线、背景、箭头尽量保留系统样式如果你是给一个庞大的老工程做局部改动我建议优先尝试方案B因为它不需要改变Header控件的绘制模式系统自带的背景、边框、排序箭头都还在视觉上最接近原生风格。如果你需要完全自定义标题栏样式比如改变背景色、加图标、调整文字对齐方式那就用方案A。下面的内容我会以方案A为主把完整实现拆开讲方案B的关键代码也会附上。2. 子类化Header与动态行高计算2.1 拿到List的Header控件CListCtrl的Header是它的子窗口标准控件ID是0。所以拿到它的方式就是// CMyListCtrl 是 CListCtrl 的派生类 BOOL CMyListCtrl::InitHeader() { // GetDlgItem(0) 取到的就是内部 Header 控件 CHeaderCtrl* pHeader (CHeaderCtrl*)GetDlgItem(0); if (pHeader NULL) return FALSE; // 子类化到我们自己的 CWordWrapHeader return m_wrapHeader.SubclassWindow(pHeader-GetSafeHwnd()); }这里有两个容易踩的坑第一GetDlgItem(0)拿到的是临时CWnd指针不能直接保存一定要通过SubclassWindow挂到成员变量上否则指针随时可能失效。第二InitHeader()的调用时机要选对。不要在PreSubclassWindow里调用因为此时List的窗口句柄虽然已经有了但Header子窗口不一定创建完成。我一般放在对话框的OnInitDialog里或者在List创建完成后显式调用一次。如果你是在Create之后马上调用可以先UpdateWindow()再取子窗口。2.2 Header子类的核心结构子类化之后的CWordWrapHeader需要做三件事设置自绘模式给每个Header item加上HDF_OWNERDRAWN标志。计算每个item的标题文本在多行显示时需要的行数取最大行高调用SetItemHeight()。在DrawItem里用DrawText的DT_WORDBREAK绘制多行文本。头文件大致如下// WordWrapHeader.h #pragma once class CWordWrapHeader : public CHeaderCtrl { DECLARE_DYNAMIC(CWordWrapHeader) public: CWordWrapHeader(); virtual ~CWordWrapHeader(); void SetWrapMode(BOOL bWrap); protected: afx_msg void OnItemChanged(NMHDR* pNMHDR, LRESULT* pResult); virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct); void RecalcHeight(); int CalcItemTextHeight(CDC* pDC, const CString strText, int nWidth, int nLineHeight); BOOL m_bWrap; DECLARE_MESSAGE_MAP() };这里我用了一个m_bWrap开关因为不是所有标题栏都需要换行给调用方留一个控制口子。消息映射里最关键的是反射通知处理。Header的HDN_ITEMCHANGED正常情况下是发给List控件也就是Header的父窗口的但MFC会把通知反射回Header子类自身所以我们可以在CWordWrapHeader内部处理BEGIN_MESSAGE_MAP(CWordWrapHeader, CHeaderCtrl) ON_NOTIFY_REFLECT(HDN_ITEMCHANGED, CWordWrapHeader::OnItemChanged) ON_NOTIFY_REFLECT(HDN_DIVIDERCHANGE, CWordWrapHeader::OnItemChanged) END_MESSAGE_MAP()HDN_ITEMCHANGED在item属性变化比如文字、宽度、格式时触发HDN_DIVIDERCHANGE在用户拖动列宽分隔条时触发。这两个事件都会导致换行高度变化所以统一交给OnItemChanged处理。2.3 换行高度计算的核心算法高度计算的原理不复杂拿到每个item的文本和当前列宽用DrawText的DT_CALCRECT模式算出文本在指定宽度下实际需要占用的矩形高度再除以单行文本高度得到行数遍历所有列取最大行数换算成Header总高度后调用SetItemHeight()。代码是这样的void CWordWrapHeader::RecalcHeight() { if (!m_bWrap || !GetSafeHwnd()) return; CClientDC dc(this); CFont* pFont GetFont(); CFont* pOldFont dc.SelectObject(pFont); TEXTMETRIC tm; dc.GetTextMetrics(tm); int nLineHeight tm.tmHeight tm.tmExternalLeading; int nMaxLines 1; int nCount GetItemCount(); for (int i 0; i nCount; i) { HDITEM hdi {0}; TCHAR szText[256] {0}; hdi.mask HDI_TEXT | HDI_WIDTH | HDI_FORMAT; hdi.pszText szText; hdi.cchTextMax 255; GetItem(i, hdi); // 自绘或空文本的项按单行处理 if (hdi.fmt HDF_OWNERDRAW || _tcslen(szText) 0) continue; CRect rcItem; GetItemRect(i, rcItem); int nTextWidth rcItem.Width() - 12; // 左右各留6像素 CRect rcText(0, 0, nTextWidth, 0); dc.DrawText(szText, rcText, DT_WORDBREAK | DT_CALCRECT); int nLines rcText.Height() / nLineHeight; if (rcText.Height() % nLineHeight ! 0) nLines; nMaxLines max(nMaxLines, nLines); } dc.SelectObject(pOldFont); int nNewHeight nMaxLines * nLineHeight 8; // 上下各留4像素 int nCurHeight GetItemHeight(); if (nNewHeight ! nCurHeight) { SetItemHeight(nNewHeight); // 强制刷新避免残留绘制 Invalidate(); } }几个细节值得展开说一下。第一GetTextMetrics拿到的tmHeight是单行文本的像素高度tmExternalLeading是额外的行间距。Header里字体行高我习惯用tmHeight tmExternalLeading这样多行文字上下不会挤在一起。有些系统字体tmExternalLeading是0问题不大。第二用DrawText的DT_CALCRECT计算多行文本高度时传入的矩形初始高度为0DrawText会按照DT_WORDBREAK的规则自动换行最后把矩形高度改成实际需要的高度。注意如果文本里有空格、换行符DT_WORDBREAK会按空格位置断行如果是连续中文长文本没有空格也会按字符逐个断行这一点实测下来对中文很友好。第三SetItemHeight()的第二个参数可以设置按钮高度我们这里只传一个高度就可以。但要注意这个高度单位是像素不是对话框单位。这也是很多人发现“明明设置了高度却差一点”的原因——如果中间经过了MapDialogRect或者MulDiv换算容易出现偏差。计算完高度后调用Invalidate()刷新。HDN_ITEMCHANGED的通知处理函数里直接调用RecalcHeight即可void CWordWrapHeader::OnItemChanged(NMHDR* pNMHDR, LRESULT* pResult) { RecalcHeight(); *pResult 0; }3. 标题文本换行绘制3.1 自绘模式下用DrawText画多行文本高度算好了但如果不自绘Header绘制时依然只画单行。所以要让每个item进入自绘模式需要在设置列的时候加上HDF_OWNERDRAWN// 原来是 InsertColumn需要修改格式 LVCOLUMN lvCol {0}; lvCol.mask LVCF_TEXT | LVCF_WIDTH | LVCF_FORMAT; lvCol.fmt LVCFMT_LEFT | HDF_OWNERDRAWN; // 注意这里不是LVCFMT而是同时置上HDF_OWNERDRAWN lvCol.cx 150; lvCol.pszText _T(设备编号及资产状态); GetListCtrl().InsertColumn(0, lvCol);严格来说InsertColumn的LVCOLUMN结构体里fmt字段可以同时包含LVCFMT_*和HDF_*标志因为ListView会把fmt直接传递给Header的HDITEM。所以HDF_OWNERDRAWN放在这里是可以生效的。然后重写DrawItemvoid CWordWrapHeader::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC dc; dc.Attach(lpDrawItemStruct-hDC); CRect rcItem(lpDrawItemStruct-rcItem); CRect rcText rcItem; rcText.DeflateRect(6, 4, 6, 4); // 画背景 dc.FillSolidRect(rcItem, GetSysColor(COLOR_BTNFACE)); // 设置透明背景和前景色 dc.SetBkMode(TRANSPARENT); dc.SetTextColor(GetSysColor(COLOR_BTNTEXT)); CFont* pFont GetFont(); CFont* pOldFont dc.SelectObject(pFont); // 取文本 CString strText; HDITEM hdi {0}; hdi.mask HDI_TEXT | HDI_FORMAT; hdi.pszText strText.GetBuffer(256); hdi.cchTextMax 255; GetItem(lpDrawItemStruct-itemID, hdi); strText.ReleaseBuffer(); // 多行居中绘制 dc.DrawText(strText, rcText, DT_WORDBREAK | DT_CENTER | DT_VCENTER | DT_END_ELLIPSIS); // 画右侧分隔线可选 CPen pen(PS_SOLID, 1, GetSysColor(COLOR_BTNSHADOW)); CPen* pOldPen dc.SelectObject(pen); dc.MoveTo(rcItem.right - 1, rcItem.top 2); dc.LineTo(rcItem.right - 1, rcItem.bottom - 2); dc.SelectObject(pOldPen); dc.SelectObject(pOldFont); dc.Detach(); }这里四个参数的选择是我调过不少次才定下来的DT_WORDBREAK是核心没有它换行就不存在。DT_VCENTER和DT_CENTER让文本在变高的Header里垂直水平居中视觉上更稳。DT_END_ELLIPSIS加上之后极端情况下如果某一行实在放不下会在行尾显示省略号避免文字溢出。但要注意DT_END_ELLIPSIS与DT_WORDBREAK在某些场景下有兼容问题在Win7以后的系统上实测是可以共存的老系统没测过。如果不想冒险可以不加。3.2 用NM_CUSTOMDRAW保留系统风格方案A有个绕不开的代价HDF_OWNERDRAWN设置后整个Header的背景、按下效果、高亮效果全部要自己画容易画得不像原生的。如果你只是想让标题换行不想破坏系统样式可以用方案B核心代码如下// 在CMyListCtrl的OnNotify中拦截HeaderCustomDraw BOOL CMyListCtrl::OnNotify(WPARAM wParam, LPARAM lParam, LRESULT* pResult) { NMHDR* pNMHDR (NMHDR*)lParam; if (pNMHDR-hwndFrom GetDlgItem(0)-GetSafeHwnd()) { if (pNMHDR-code NM_CUSTOMDRAW) { LPNMCUSTOMDRAW pCD (LPNMCUSTOMDRAW)lParam; if (pCD-dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; return TRUE; } else if (pCD-dwDrawStage CDDS_ITEMPREPAINT) { // 这里拿到每个item的文本用DrawText DT_WORDBREAK绘制 *pResult CDRF_NEWFONT; return TRUE; } } } return CListCtrl::OnNotify(wParam, lParam, pResult); }NM_CUSTOMDRAW方案的精髓是你不需要设置HDF_OWNERDRAWNHeader的默认绘制流程照常只是在每个item绘制前插入一次自己的绘制机会。你可以在CDDS_ITEMPREPAINT阶段修改字体或者直接覆盖绘制文本。不过NM_CUSTOMDRAW有一个麻烦Header系统绘制文本的时机在CDDS_ITEMPREPAINT之后如果你不返回CDRF_SKIPDEFAULT系统会把文本再画一遍形成重影。如果你返回CDRF_SKIPDEFAULT那么分割线、背景、排序箭头这些系统绘制也会被跳过。想完美覆盖文本又保留系统背景需要比较仔细地组合CDRF_SKIPDEFAULT和手动绘制逻辑。从落地角度讲我最终在项目里用的还是方案A因为控制力最强排查问题时心智负担小。方案B适合对原生样式有执念、且Header上不需要太多自定义元素的场景。3.3 列宽变化时的联动刷新用户拖动列宽分隔条时HDN_DIVIDERCHANGE会触发我们在OnItemChanged里统一调用了RecalcHeight()所以列宽变小导致文本行数变多时Header高度会自动增高列宽变大时行数变少高度自动降低。这里有个细节HDN_DIVIDERCHANGE触发时列宽其实还没最终生效紧接着还会触发HDN_DIVIDERCHANGED多一个D。如果要基于最终列宽计算行数应该处理HDN_DIVIDERCHANGED而不是HDN_DIVIDERCHANGE。我的经验是在HDN_DIVIDERCHANGE里用SetTimer延迟一小段时间再重算或者同时处理两个消息在HDN_DIVIDERCHANGED里再RecalcHeight一次避免计算拿到旧宽度。4. 常见问题与排查技巧实录4.1 行高设置了但没生效这个问题出现频率最高。我排查过的案例里原因基本集中在三处第一SetItemHeight调用时Header还没创建完成或者GetItemHeight()返回值根本没变。这种情况检查一下调用时机确保在InitHeader()之后再设置。第二HDN_ITEMCHANGED通知没有正确捕获。如果你没有用ON_NOTIFY_REFLECT而是想在List那边处理注意HDN_ITEMCHANGED消息里lParam指向的是NMHEADER结构不是普通的NMHDR取pNMHDR-code没问题但取item信息要用((NMHEADER*)lParam)-iItem。第三行高计算出来的是单行高度但Header默认高度本来就比文字高一些导致高度看起来没变化。这种情况可以在RecalcHeight()里加TRACE输出实际算出来的nLineHeight和nMaxLines一眼就能定位。4.2 DPI缩放和字体变化导致文字被裁切高分屏下如果工程没有做DPI适配GetTextMetrics拿到的字体高度可能是按照96DPI算的而实际绘制时系统已经做了缩放结果就是文字上下被切了一部分。我的处理方式是在PreSubclassWindow或者WM_SETTINGCHANGE、WM_DPICHANGED消息里重新获取字体并调用RecalcHeight()。代码不需要复杂关键是不要只在初始化时算一次高度。另外如果你的Header用了自定义字体一定要在RecalcHeight之前SetFont并且确保GetFont()拿到的字体和DrawItem里SelectObject的是同一个字体。两边字体不一致算出来的行高和画出来的文字高度就对不上。4.3 排序箭头、图标等场景避坑设置HDF_OWNERDRAWN之后排序箭头不再由系统绘制需要自己画。在DrawItem里可以这样判断// 在DrawItem中判断当前列的排序状态 HDITEM hdi {0}; hdi.mask HDI_FORMAT | HDI_IMAGE; GetItem(iItem, hdi); if ((hdi.fmt HDF_SORTUP) || (hdi.fmt HDF_SORTDOWN)) { // 自己画箭头或者把文本区域往左/往上缩一点给箭头留出空间 BOOL bUp (hdi.fmt HDF_SORTUP) ! 0; // 用GDI画三角形箭头注意箭头要画在Header右侧 }如果列头设置了图标HDI_IMAGE也要在DrawItem里先画图标再画文本并且计算文本可用宽度时要减去图标宽度和间距。说实话自绘排序箭头是个力气活画出来不难但要对齐系统风格需要调半天。我的建议是如果项目里列头确实需要排序优先用方案BNM_CUSTOMDRAW保留系统的排序箭头只在文本层做换行叠加如果非要自绘就做一个简单的上下三角颜色用COLOR_BTNTEXT位置固定在右侧8像素处实测大部分场景都能接受。4.4 一个容易被忽略的缓冲问题Header的高度变化后List Control的客户区会跟着调整但如果List里有LVS_EX_GRIDLINES网格线有时网格线不会立即刷新到底出现一小条空白。解决办法很简单RecalcHeight最后除了InvalidateHeader还需要给List也发一个Invalidate(FALSE)或者调用RedrawWindow。这个小问题让我当时排查了大半天最后发现就是刷新不及时。写在最后的落地体会如果让我再实现一次这个功能我会直接把CWordWrapHeader封装成一个独立的类消息反射、行高计算、自绘逻辑都收拢在一个文件里调用方只需要InitHeader()一行代码就能开启换行不需要改动List主流程。这个思路在多个老工程上验证过改动面小回滚也快。如果你只是临时救急也可以先用NM_CUSTOMDRAW快速验证效果确认换行高度算法没问题之后再决定要不要迁移到自绘方案。标题栏自动换行本身不难难的是把刷新时机、字体一致性、DI缩放这些边角料处理好这些细节才是实际项目里真正耗时间的地方。本文还有配套的精品资源点击获取
返回列表