简介:围绕 VC6 环境下 MFC 中 CListCtrl 表格可编辑功能展开,面向需要扩展单文档应用界面、实现列表项就地编辑与交互处理的 C++/MFC 开发者。内容涉及动态创建列表控件、设置可编辑标签风格、响应 LVN_BEGINLABELEDIT 与 LVN_ENDLABELEDIT 消息,以及窗口尺寸变化时的布局调整,并结合控件追踪器示例说明编辑、选择与拖动等交互的衔接方式,对编辑状态同步、输入校验和结果更新也有可借鉴的处理思路。压缩包共 58 个文件、约 3.8MB,包含 h/cpp 源码、dsp/dsw 工程文件、rc/res/ico 界面资源及 exe、调试中间文件,便于直接打开工程对照编译和调试。已有 841 人学习,适合作为 VC6 单文档类扩展与可编辑表格实现的参考样例,帮助梳理消息映射、控件生命周期和界面资源组织思路。
1. CListCtrl 表格可编辑:一个看着五分钟、实际能磨一天的需求
CListCtrl 表格可编辑这件事,在 VC6 单文档工程里出现的频率高得离谱:列表把数据列出来了,需求方看一眼就问“能不能直接改”。很多人的第一反应是加LVS_EDITLABELS,动手之后才发现它只管第一列、只认键盘、点到别处还不提交;再往上叠一个 CEdit 盖在单元格上,焦点、滚动、中文输入法、列宽拖动又全是坑。这里要说清的是:这套做法解决的是“单文档/对话框程序里 CListCtrl 任意列的原地编辑”,技术栈是 MFC 6.0 + 动态创建控件 + 事件处理,不引入第三方网格控件,适合还在维护 VC6 老工程、又不想为了一个表格引入整套新库的人。
2. 选型先立住:CListCtrl 可编辑的四条路和它们的边界
动手之前先把方案摆平,比写完再推翻重来划算得多。MFC 的 CListCtrl 从设计上就不是一个编辑控件,它只提供报告模式下的显示和选择,任何“可编辑”都是在外围补出来的。补法有四种,代价和适用范围差得很远,选错了后面全是返工。
2.1 LVS_EDITLABELS 的能力上限:只解决第一列
LVS_EDITLABELS是 CListCtrl 自带的一个风格位,加上去之后点中某项再点一次,第一列的文本会原地变成可编辑状态,并抛出LVN_BEGINLABELEDIT和LVN_ENDLABELEDIT两个通知。它的好处是零成本,一行ModifyStyle就能用;坏处是硬伤太多:只能编辑第 0 列,编辑框的创建、定位、销毁都由系统内部代码完成,你插不上手;编辑框样式固定,想要一个下拉框、想要限制只能输数字,都得在LVN_ENDLABELEDIT里“事后驳回”。
还有一个更隐蔽的问题:它的编辑框是系统的 label edit 窗口,父窗口不是你,PreTranslateMessage收不到它的按键,想拦 ESC 或者回车提交,只能依赖默认行为。在数据录入场景里这个限制很致命——用户按回车,默认是提交并跳回列表,但你往往希望回车后直接进入下一行同一列的编辑状态,这在LVS_EDITLABELS上做不到。
所以这条路的定位很清楚:只读表格里偶尔改个名字、改个备注,够用;只要涉及多列、涉及输入校验、涉及连续录入,直接放弃,别在这上面浪费时间。
2.2 原地编辑:CEdit 盖在单元格上
真正能覆盖任意列的做法只有一个:自己在列表控件上动态创建一个 CEdit,把它挪到目标单元格的矩形位置上,编辑结束时读回文本再SetItemText。这套东西业内叫原地编辑(in-place edit),本质是把列表控件当成一块画布,编辑框是浮在上面的一个子窗口。
关键点有三个。第一是坐标系:编辑框的父窗口必须设成列表控件本身,坐标用列表的客户区坐标,这样滚动时两者能一起动;如果父窗口设成桌面或者 View,滚动之后编辑框就会脱离原来的位置,变成“贴错位置”。第二是矩形计算:必须精确拿到第 nItem 行第 nSubItem 列那个格子的矩形,不能拿整行矩形凑合,否则编辑框会横跨好几列。第三是生命周期:创建一次、反复复用,比每次new一个再delete稳得多,VC6 下频繁创建销毁子窗口很容易在退出时留下野句柄。
这条路的成本在于要自己处理一堆边角:回车提交、ESC 取消、点别处提交、滚动时跟随或关闭、列宽拖动后重新定位、中文输入法的候选窗口。好处是全程可控,任何一列都能编辑,输入校验可以在提交前做,交互想怎么改就怎么改。
2.3 对话框式编辑与整行编辑的适用场景
如果业务允许,还有一种更省事的做法:双击某行弹一个模态对话框,把所有字段摊开让用户填,确定后一次性回写整行。这种方案在 VC6 时代非常常见,做起来最快,事件处理也最简单——不需要管焦点、不需要管滚动、不需要管输入法,对话框本身就是标准控件容器。
代价是交互重。用户改一个单元格的数字,要先弹窗、再定位、再关窗,连续录入几十行会非常难受。它适合“行数据字段多、单条编辑耗时长、修改频率低”的场景,比如配置项维护;不适合“一屏几十行、逐格微调”的场景,比如价格表、库存表。
还有一种折中是整行编辑:双击行之后,把整行的每一列都换成编辑框(或者只把当前行切成输入态),一次性提交。实现上比单格原地编辑复杂,因为要同时维护多个编辑框的创建、Tab 顺序、提交时机,但比弹窗体验好,适合列数固定且不多的表格。选型时按“编辑频率 × 列数”两个维度判断即可,频率高、列数少就用单格原地编辑,反过来就上对话框。
2.4 单文档框架里的类扩展位置:为什么放在派生类而不是 View
VC6 单文档程序的结构是 CWinApp → CFrameWnd → CView → 控件。CListCtrl 通常是 CView 里的一个成员,或者 CView 本身就是一个CListCtrl派生类(CListView形式)。可编辑逻辑放哪儿,直接决定了后面好不好维护。
放到 View 里最省事,所有消息映射都写在CXXXView上,OnLButtonDblClk、PreTranslateMessage、滚轮消息一把抓。问题是这套逻辑一旦写进 View,就绑死在这个视图上,下一个工程想复用,只能整段复制粘贴,改一处漏一处。而且 View 里的消息又多又杂,OnDraw、OnSize、菜单命令全在一个 cpp 里,编辑逻辑混进去之后排查问题会非常痛苦。
更合适的做法是先派生一个CListCtrl子类,把编辑框的创建、定位、提交、取消、键盘拦截全部封在里面,View 只负责Create它、给它喂数据、接收它抛上来的“编辑完成”通知。这样类扩展就是一次性的:视图类只需要包含头文件,编辑能力就跟着来了,跟单文档还是对话框没关系。这也是后面第 6 章要抽基类的原因——先把边界划清楚,再谈复用。
3. 动态创建控件:把 CEdit 精确盖到单元格上
方案定了就开始动手。这一章给出一套能直接跑的骨架:派生一个 CListCtrl 子类,在里面挂一个 CEdit 成员,双击进入编辑,回车/ESC/失焦三条路径退出。代码按 VC6 + MFC 6.0 写,注意里面有几个 VC6 特有的宏缺失问题,需要自己补定义。
3.1 打开扩展风格与准备字体
列表控件要先把“能编辑”的基础条件铺好:整行选中、网格线、选中项失焦后仍然高亮(否则编辑框一拿到焦点,原来那行就变灰,用户看不出来在改哪一行)、双击激活。扩展风格通过LVS_EX_*系列设置,VC6 的commctrl.h里这些宏都有,但列表控件需要 comctl32.dll 4.71 以上版本,系统里一般是满足的。
// EditableListCtrl.h #pragma once // VC6 的 commctrl.h 是 IE4 之前的版本,这两个消息宏可能没有定义,必须自己补 #ifndef LVM_GETSUBITEMRECT #define LVM_GETSUBITEMRECT (LVM_FIRST + 56) #endif #ifndef LVM_SUBITEMHITTEST #define LVM_SUBITEMHITTEST (LVM_FIRST + 57) #endif #define IDC_INPLACE_EDIT 1001 class CEditableListCtrl : public CListCtrl { public: CEditableListCtrl(); virtual ~CEditableListCtrl(); void BeginEdit(int nItem, int nSubItem); // 进入编辑 void EndEdit(BOOL bCancel = FALSE); // 退出编辑 protected: CEdit m_wndEdit; // 复用的编辑框,只创建一次 CFont m_fontEdit; // 与列表保持一致的字体 int m_nEditItem; // 当前正在编辑的行,-1 表示没有 int m_nEditSub; // 当前正在编辑的列 CString m_strOldText; // 编辑前的原文,用于 ESC 回滚 int GetColumnWidth(int nCol); void PlaceEdit(int nItem, int nSubItem); int SubItemHitTest(LVHITTESTINFO* pHti); afx_msg void OnLButtonDblClk(UINT nFlags, CPoint point); afx_msg void OnEditKillFocus(); afx_msg void OnDestroy(); virtual BOOL PreTranslateMessage(MSG* pMsg); DECLARE_MESSAGE_MAP() };风格设置放在PreSubclassWindow或者OnCreate之后统一做一次,别散在各处。LVS_EX_FULLROWSELECT和LVS_EX_GRIDLINES是必开的,LVS_EX_TWOCLICKACTIVATE不要开——它会跟双击编辑抢消息,导致第一次双击被吞掉。字体方面,VC6 默认给控件用的是SYSTEM_FONT,中文在编辑框里会发虚、行高也对不齐,统一用DEFAULT_GUI_FONT最稳,列表和编辑框用同一份。
// EditableListCtrl.cpp CEditableListCtrl::CEditableListCtrl() { m_nEditItem = -1; m_nEditSub = -1; } void CEditableListCtrl::OnInitialUpdateStyle() // 放在 OnCreate 之后调用 { SetExtendedStyle(GetExtendedStyle() | LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); m_fontEdit.CreateStockObject(DEFAULT_GUI_FONT); SetFont(&m_fontEdit); // 列表本身也用同一字体,保证行高对齐 }CreateStockObject(DEFAULT_GUI_FONT)是 VC6 里少数几个能直接拿到中文字体的可靠方式,比CreateFont拼一堆参数省事。注意m_fontEdit要是成员变量,不能是局部变量——字体对象析构之后控件引用的句柄就失效了,表现是界面文字突然变成乱码或者小方块。这种问题调试时看不出来,跑一段时间才崩,属于典型的“看着能跑,实际在漏水”。
3.2 用 LVM_GETSUBITEMRECT 精确算格子
编辑框盖不准,九成问题出在矩形计算。很多人第一反应是GetItemRect(nItem, &rc, LVIR_BOUNDS),拿到的却是整行的矩形,编辑框会横跨所有列。正确做法是走LVM_GETSUBITEMRECT,但这个 API 有个非常反直觉的约定:它复用RECT结构的前两个成员当输入参数,调用前必须先把left填成列号、top填成行号,返回时这两个值被覆盖成真实坐标。不预先赋值的话,拿回来的矩形基本是垃圾数据。
还有一个坑:第 0 列不能用LVM_GETSUBITEMRECT,对它调用会返回失败或者全零。第 0 列得走LVM_GETITEMRECT拿整行矩形,再把右边界改成第 0 列的宽度。
int CEditableListCtrl::GetColumnWidth(int nCol) { CHeaderCtrl* pHeader = GetHeaderCtrl(); if (pHeader == NULL) return 0; HDITEM hd; ::ZeroMemory(&hd, sizeof(hd)); hd.mask = HDI_WIDTH; if (!pHeader->GetItem(nCol, &hd)) return 0; return hd.cxy; } void CEditableListCtrl::PlaceEdit(int nItem, int nSubItem) { CRect rcCell(0, 0, 0, 0); if (nSubItem == 0) { // 第 0 列:LVM_GETSUBITEMRECT 不适用,用整行矩形截出首列宽度 rcCell.top = nItem; GetItemRect(nItem, &rcCell, LVIR_BOUNDS); rcCell.right = rcCell.left + GetColumnWidth(0); } else { // 重点:left 放列号,top 放行号,这两个是“入参”,不是输出 rcCell.left = nSubItem; rcCell.top = nItem; SendMessage(LVM_GETSUBITEMRECT, (WPARAM)nItem, (LPARAM)&rcCell); } // 格子已经滚出可视区就别显示了,否则编辑框会飘在列表外面 CRect rcClient; GetClientRect(&rcClient); if (rcCell.bottom > rcClient.bottom || rcCell.top < rcClient.top) { m_wndEdit.ShowWindow(SW_HIDE); return; } m_wndEdit.MoveWindow(&rcCell); m_wndEdit.ShowWindow(SW_SHOW); m_wndEdit.SetFocus(); m_wndEdit.SetSel(0, -1); // 全选,用户双击进来直接覆盖输入 }MoveWindow一定要在ShowWindow之前,顺序反了会看到编辑框从 (0,0) 闪一下再跳到目标位置,观感很差。另外SetFocus之后不要再回头调用列表的SetFocus,否则输入会丢失,这个顺序问题在第 4 章还会展开。
3.3 创建、子类化与显示编辑框
编辑框不需要每次重建,构造函数里不创建,第一次进入编辑时创建一次,之后一直复用。创建时的关键参数是父窗口和样式:父窗口必须是this(列表控件本身),样式至少要WS_CHILD | ES_AUTOHSCROLL | ES_NOHIDESEL,ES_NOHIDESEL是为了让编辑框在失焦时仍显示选中内容,用户体验更连贯。不要加WS_BORDER,边框会和列表网格线打架,看上去像多了一条线。
void CEditableListCtrl::BeginEdit(int nItem, int nSubItem) { if (nItem < 0 || nSubItem < 0) return; // 已有编辑在进行,先提交掉,保证同一时刻只有一个编辑框 if (m_nEditItem >= 0) EndEdit(FALSE); m_nEditItem = nItem; m_nEditSub = nSubItem; m_strOldText = GetItemText(nItem, nSubItem); if (!::IsWindow(m_wndEdit.GetSafeHwnd())) { // 父窗口必须是列表自己,坐标才是客户区坐标,滚动才能跟着走 if (!m_wndEdit.Create(WS_CHILD | ES_AUTOHSCROLL | ES_NOHIDESEL, CRect(0, 0, 0, 0), this, IDC_INPLACE_EDIT)) { m_nEditItem = -1; return; } m_wndEdit.SetFont(&m_fontEdit); } m_wndEdit.SetWindowText(m_strOldText); PlaceEdit(nItem, nSubItem); }这里用的是“直接 Create 到父窗口”的方式,没有走资源里的对话框控件再SubclassDlgItem。两种都行,但如果是单文档视图里嵌列表,资源里加一个 IDC_EDIT 再子类化反而绕,多一个资源、多一步GetDlgItem,不如直接 Create 干净。代价是编辑框的 ID 要自己定义,并且要确保不和工程里已有的控件 ID 冲突,1001这种范围值建议先全局搜一遍再定。
3.4 提交、取消与数据回写
退出编辑有三条路径:回车提交、ESC 取消、焦点转移到别处提交。三条路最后都汇到EndEdit,用一个bCancel参数区分。函数里先取文本、再隐藏窗口、再清状态,最后才SetItemText——顺序反了容易在SetItemText触发的重绘里又踩到编辑框。
void CEditableListCtrl::EndEdit(BOOL bCancel) { if (m_nEditItem < 0 || !::IsWindow(m_wndEdit.GetSafeHwnd())) return; CString strNew; m_wndEdit.GetWindowText(strNew); int nItem = m_nEditItem; int nSub = m_nEditSub; m_nEditItem = -1; // 先清状态,避免 SetItemText 重入 m_nEditSub = -1; m_wndEdit.ShowWindow(SW_HIDE); if (bCancel || strNew == m_strOldText) return; // 取消或没改动,直接返回 SetItemText(nItem, nSub, strNew); // 抛给父窗口,让 View 同步到自己的数据模型/数据库 CWnd* pParent = GetParent(); if (pParent != NULL) pParent->PostMessage(WM_USER + 100, (WPARAM)nItem, (LPARAM)nSub); }PostMessage而不是SendMessage是有意的:SendMessage会在当前调用栈里同步执行父窗口的处理逻辑,如果父窗口里又去刷新列表、又去查数据库,重入风险很高。PostMessage把它推到消息队列,等当前这轮消息处理完再执行,安全得多。父窗口收到之后自己用GetItemText取值即可,或者你干脆在消息里带一个结构体指针,但那样要管内存释放,不如让父窗口自己取。
4. 事件处理的分寸:键盘、滚动、列宽与输入法
编辑框盖上去只是开始,真正决定好不好用的是事件处理这一层。VC6 下没有现成的事件框架,全靠消息映射和PreTranslateMessage手工拦截,分寸拿捏不好就会出现“按回车没反应”“滚一下轮编辑框留在原地”“中文打一半变乱码”这类问题。这一章把四类高频事件拆开讲。
4.1 PreTranslateMessage 拦截回车与 ESC 的分寸
编辑框是列表的子窗口,用户按下的键先进入消息队列,再由 MFC 的消息泵沿着父窗口链调用PreTranslateMessage。这个“沿父链走”的行为是 MFC 的CWnd::WalkPreTranslateTree实现的,意味着列表控件的PreTranslateMessage能收到子编辑框的按键——这是一个很好用的口子,不用给 CEdit 单独派生一个类就能拦回车和 ESC。
BOOL CEditableListCtrl::PreTranslateMessage(MSG* pMsg) { if (m_nEditItem >= 0 && ::IsWindow(m_wndEdit.GetSafeHwnd()) && pMsg->hwnd == m_wndEdit.GetSafeHwnd()) { if (pMsg->message == WM_KEYDOWN) { switch (pMsg->wParam) { case VK_RETURN: EndEdit(FALSE); return TRUE; // 吃掉,别让编辑框再处理 case VK_ESCAPE: EndEdit(TRUE); return TRUE; case VK_TAB: EndEdit(FALSE); // 这里可以接“跳到下一格”的逻辑,见 6.1 return TRUE; } } } return CListCtrl::PreTranslateMessage(pMsg); }分寸在于“只拦自己该拦的”。判断条件里必须带上pMsg->hwnd == m_wndEdit.GetSafeHwnd(),否则列表本身的按键(比如方向键移动选中行)也会被误吃。返回TRUE表示消息已处理、不再往下传,返回FALSE则继续默认流程。有人在PreTranslateMessage里不判断 hwnd,直接把所有WM_KEYDOWN都拦下来,结果列表的键盘导航全废,用户只能靠鼠标,这种问题在验收时很容易被漏过去。
还有一点:VK_TAB在单文档程序里默认是对话框导航用的,编辑框作为普通子窗口其实不参与 Tab 循环,但为了后续扩展(比如 Tab 跳下一格),在这里拦掉比在后面补要省事。
4.2 编辑框的位置如何跟滚动保持一致
列表滚动分两种:滚轮和滚动条。滚轮消息WM_MOUSEWHEEL会先发给有焦点的窗口,编辑框有焦点时它会收到这个消息,但它自己不认识,会往上抛给列表,列表再滚动内容——这时候编辑框还留在原来的屏幕位置上,跟单元格错位了。
处理方式有两种。简单粗暴的是滚动即取消编辑:
BOOL CEditableListCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { EndEdit(TRUE); // 滚动就放弃本次编辑,避免错位 return CListCtrl::OnMouseWheel(nFlags, zDelta, pt); }温和一点的是滚动结束后重新定位:
void CEditableListCtrl::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar) { CListCtrl::OnVScroll(nSBCode, nPos, pScrollBar); if (m_nEditItem >= 0) { // 滚动完成后按当前行列重新算一次矩形,编辑框跟着走 PlaceEdit(m_nEditItem, m_nEditSub); } }选哪个看业务。数据录入场景里,用户滚动往往是想看别的行,这时候保留编辑框反而碍事,取消更合理;而“看大表格边改边翻”的场景,跟随更顺手。我的习惯是先做取消,等真的有人抱怨了再改成跟随——因为跟随要处理“单元格滚出可视区”的情况,PlaceEdit里那段隐藏逻辑就是为它准备的,边界多,容易翻车。
4.3 列宽拖动与列重排时的坐标重算
列表头是可以拖的,用户把某列拉宽之后,编辑框还停在原来的宽度上,看着就像贴歪了。头控件是列表的子窗口,它发出的HDN_BEGINTRACK、HDN_ENDTRACK、HDN_ITEMCHANGED通知会先到列表,再往上抛。在列表子类里用ON_NOTIFY_REFLECT接住,就能在拖动结束时重新定位。
BEGIN_MESSAGE_MAP(CEditableListCtrl, CListCtrl) ON_WM_LBUTTONDBLCLK() ON_WM_MOUSEWHEEL() ON_WM_VSCROLL() ON_WM_DESTROY() ON_CONTROL(EN_KILLFOCUS, IDC_INPLACE_EDIT, OnEditKillFocus) ON_NOTIFY_REFLECT(HDN_ENDTRACK, OnHeaderEndTrack) END_MESSAGE_MAP() void CEditableListCtrl::OnHeaderEndTrack(NMHDR* pNMHDR, LRESULT* pResult) { if (m_nEditItem >= 0) { // 列宽变了,编辑框宽度必须跟着变,否则文字会被截断 PlaceEdit(m_nEditItem, m_nEditSub); } *pResult = 0; }ON_NOTIFY_REFLECT和ON_NOTIFY的区别要拎清:头控件是列表的子窗口,通知默认发往列表的父窗口(也就是 View),如果你在 View 里用ON_NOTIFY接,也能收到,但那样编辑逻辑就漏到 View 里去了;用ON_NOTIFY_REFLECT是把通知“反射”回列表自己处理,封装才完整。VC6 的 MFC 支持反射,宏名就是ON_NOTIFY_REFLECT,别写成ON_NOTIFY_REFLECT_EX,那是后面版本才有的。
还有一处容易漏:列被拖动重排之后,nSubItem和实际显示位置的对应关系变了。如果你的表格不允许拖动列,直接在头控件上禁掉HDS_DRAGDROP最省事;允许拖的话,PlaceEdit里的列号要用LVM_MAPINDEXTOID/LVM_MAPIDTOINDEX换算,或者干脆在重排时结束编辑。
4.4 中文输入法与焦点的先后顺序
中文输入法是 VC6 可编辑表格里最容易出玄学的地方。表现是:用户切到拼音输入法,敲第一个字母,编辑框突然消失,或者候选窗口弹出来但按键进不去编辑框,或者打完字按空格上屏,第一个字丢失。
原因通常在于失焦处理写得太粗暴。编辑框的EN_KILLFOCUS会在焦点转移到任何其他窗口时触发,输入法的候选窗口、状态窗口在某些实现下会短暂抢焦点,于是OnEditKillFocus被调用,里面无条件EndEdit(FALSE),编辑框就没了。正确的做法是在失焦时先判断焦点到底去了哪儿:
void CEditableListCtrl::OnEditKillFocus() { // 输入法候选窗口抢焦点时,GetFocus() 可能返回 NULL 或者输入法自己的窗口 CWnd* pFocus = CWnd::GetFocus(); if (pFocus != NULL && pFocus->GetSafeHwnd() == m_wndEdit.GetSafeHwnd()) return; // 焦点其实还在编辑框上,忽略 // 延迟提交:给输入法一个上屏的机会,避免第一个字被吞 PostMessage(WM_COMMIT_EDIT, 0, 0); }PostMessage而不是直接EndEdit,是给自己留一条“等一下”的路:输入法上屏是会往编辑框里塞字符的,如果失焦的瞬间就销毁编辑框,塞进去的字自然就丢了。延后一轮消息再提交,绝大多数情况下字符已经落到编辑框里了。
另外,编辑框创建时如果没设字体,VC6 会给它一个默认的System字体,中文显示成方块或者大小不对。这一点在 3.1 已经用DEFAULT_GUI_FONT解决,但要注意SetFont之后编辑框的高度不会自动变,MoveWindow用的矩形高度是从列表单元格拿的,如果字体行高比单元格高,文字会被裁掉——所以列表本身的字体必须和编辑框统一,这是两者行高对齐的唯一办法。
5. 避坑与排查:VC6 下可编辑表格的五个翻车现场
下面这五条,基本覆盖了这套方案在 VC6 里九成以上的故障。每条按“现象 → 原因 → 解决”写,遇到问题可以直接对号入座。
5.1 双击第一列能编辑,双击第二列没反应
现象:双击第 0 列正常进入编辑,双击第 1 列之后的位置毫无反应,鼠标怎么点都不出现编辑框。
原因:行列判断用的是GetItemRect或者HitTest,后者只返回行号,不返回列号;又或者用了LVM_GETSUBITEMRECT但没给RECT的left、top预填列号和行号,函数返回失败,rcCell还是全零,MoveWindow到一个零尺寸矩形,看起来就是“没反应”。
解决:命中测试走LVM_SUBITEMHITTEST,它返回的LVHITTESTINFO::iSubItem才是列号;矩形计算按 3.2 的写法分两种情况处理,第 0 列单独走LVM_GETITEMRECT。VC6 的commctrl.h里可能没有LVM_SUBITEMHITTEST宏,先补定义再编译,别指望链接器给你提示。
int CEditableListCtrl::SubItemHitTest(LVHITTESTINFO* pHti) { pHti->flags = LVHT_ONITEM; return (int)SendMessage(LVM_SUBITEMHITTEST, 0, (LPARAM)pHti); }5.2 编辑框第一次显示正常,第二次位置错乱或者跑到左上角
现象:第一次双击编辑没问题,提交后再双击另一行,编辑框出现在 (0,0) 附近,或者宽度只有几个像素。
原因:Create时的初始矩形是全零,之后靠MoveWindow定位;如果某次PlaceEdit中途返回(比如单元格滚出可视区那段逻辑),编辑框就被ShowWindow(SW_HIDE)了,但状态m_nEditItem没清,下一次BeginEdit判断“已有编辑在进行”,走EndEdit(FALSE),EndEdit里因为窗口已隐藏、文本没变又提前返回,状态就错乱了。
解决:PlaceEdit里隐藏编辑框的分支要同步把状态清掉,或者干脆不在PlaceEdit里做隐藏判断,改成在OnVScroll里判断单元格是否可见,不可见就EndEdit(TRUE)。状态机要保持单一入口:进入编辑只有BeginEdit,退出只有EndEdit,中间任何分支都不许偷偷改m_nEditItem。
5.3 输入中文时第一个字被吞,或者编辑框直接消失
现象:切到拼音输入法,敲第一个键,编辑框消失或者候选词上屏后只进去一半。
原因:EN_KILLFOCUS里无条件调用了EndEdit,输入法候选窗口弹出时短暂抢走焦点,触发失焦,编辑框被销毁或隐藏,后续字符自然进不去。
解决:失焦处理改成延迟提交(见 4.4),并且在EndEdit里先GetWindowText再隐藏窗口,保证已经落到编辑框里的字能被读出来。如果你们的输入法比较特殊,还可以在EndEdit之前发一个WM_IME_COMPOSITION相关的处理,但绝大多数情况下延迟一轮消息就够了。
5.4 拖动列宽之后编辑框停在旧位置、宽度也不对
现象:编辑状态下拖动表头分隔线,列变宽了,编辑框还是原样,右边露出一截空白。
原因:没有处理头控件的HDN_ENDTRACK,或者用ON_NOTIFY接在了 View 上,列表子类里收不到。
解决:在列表子类里用ON_NOTIFY_REFLECT(HDN_ENDTRACK, ...)接住,回调里重新PlaceEdit。另外,如果表格允许列拖动重排,同时要处理HDN_ENDDRAG,否则列顺序一变,nSubItem和显示位置就对不上了,最简单的办法是直接禁掉列拖动。
5.5 程序退出时崩溃,或者 Debug 版报断言
现象:关闭主窗口时弹出断言,或者干脆无提示退出;Release 版偶发,Debug 版必现。
原因:窗口销毁顺序问题。列表控件先被销毁,子编辑框跟着销毁,但m_wndEdit的 C++ 对象还在,成员析构时 MFC 会去Detach一个已经无效的句柄;或者EndEdit在WM_DESTROY之后被调用,里面还去SetItemText,访问了已经析构的列表内部数据。
解决:在列表的OnDestroy里先把编辑状态清干净,再交给基类。
void CEditableListCtrl::OnDestroy() { m_nEditItem = -1; // 先断状态,防止后续重入 m_nEditSub = -1; if (::IsWindow(m_wndEdit.GetSafeHwnd())) m_wndEdit.DestroyWindow(); // 主动销毁子窗口,别等父窗口连带销毁 CListCtrl::OnDestroy(); }DestroyWindow之后再走基类,顺序不能反。这一条是所有动态创建子窗口的通病,不只是列表编辑,养成“谁创建谁销毁、销毁在基类之前”的习惯,能省掉大量退出崩溃。
6. 收口:把编辑能力抽成基类,附一份上线自检清单
前面五章的东西散在 View 里也能跑,但下一个工程就得重来一遍。真正省时间的是把编辑能力抽成一个独立的CEditableListCtrl,View 只做三件事:创建它、喂数据、接通知。抽的时候有两个细节值得说一下。
第一个是“编辑完成”的通知方式。最开始我用的是PostMessage(WM_USER+100, ...),父窗口在OnEditDone里自己去GetItemText。后来发现同一个 View 里有好几个列表,消息没法区分来源,只好在WPARAM里塞控件 ID,LPARAM塞打包后的行列号,凑合能用但写起来别扭。更干净的方案是让基类持有一个回调接口:
class IEditNotify { public: virtual void OnCellEdited(CWnd* pList, int nItem, int nSubItem, LPCTSTR lpszNewText) = 0; }; // CEditableListCtrl 里加一个成员 IEditNotify* m_pNotify; // EndEdit 里改成 if (m_pNotify != NULL) m_pNotify->OnCellEdited(this, nItem, nSub, strNew);View 实现这个接口,EndEdit直接回调,不用跟消息号打交道,也不用担心多列表串消息。代价是 View 和列表之间多了一层依赖,但 VC6 工程本来就没多少解耦可言,这点代价换来的是可读性,值。
第二个是 Tab 跳下一格。这块逻辑放在基类里最好,因为要算“当前是不是最后一列、是不是最后一行、要不要自动新增一行”,全都跟列表数据相关。实现上就是在PreTranslateMessage的VK_TAB分支里算下一格坐标,BeginEdit之前先EnsureVisible,保证目标行在可视区内,否则编辑框会创建在一个看不见的位置上。
void CEditableListCtrl::EditNextCell() { int nItem = m_nEditItem; int nSub = m_nEditSub + 1; int nCols = GetHeaderCtrl()->GetItemCount(); if (nSub >= nCols) // 到行尾,跳下一行第一列 { nSub = 0; nItem = nItem + 1; } if (nItem >= GetItemCount()) // 到最后一行,停在原地 return; EnsureVisible(nItem, FALSE); // 先滚动过去,再建编辑框 BeginEdit(nItem, nSub); }抽完基类之后,这个文件大概 300 行左右,把编辑框创建、矩形计算、键盘拦截、滚动处理、销毁清理全包了。剩下的问题基本都在使用方:数据量大的时候SetItemText触发的重绘会卡,那是SetRedraw(FALSE)的事;列表项特别多的时候用虚拟列表LVS_OWNERDATA,那套跟原地编辑配合要另写一版,GetItemText得换成你自己维护的数据源,不在这次的范围内。
最后给一份上线前的自检清单,我在几个 VC6 老工程上都是照着这张表过一遍才敢交付:
| 检查项 | 通过标准 | 常见问题 |
|---|---|---|
| 第 0 列与其余列 | 双击任意列都能进入编辑,位置准确 | 第 0 列走错 API,矩形全零 |
| 回车 / ESC / 失焦 | 三条路径行为一致,无残留 | ESC 后文本没回滚 |
| 中文输入法 | 拼音、五笔打完字完整上屏 | 首字丢失、编辑框消失 |
| 滚动 | 滚轮和滚动条都不留错位框 | 编辑框挂在原位置不动 |
| 列宽拖动 | 编辑框宽度随列变化 | 只在 View 里接了通知 |
| 连续编辑 | 连续双击、Tab 切换 20 次不崩 | 状态机被中途改动 |
| 退出 | 关闭主窗口无断言、无崩溃 | OnDestroy 顺序写反 |
| 大数据量 | 500 行以上滚动流畅 | 没关重绘、逐行 SetItemText |
从那以后我每次接手 VC6 的表格改造,都强制先把这张表过一遍,尤其是第 0 列和退出这两项——它们不会在开发阶段暴露,只会在演示或者交付现场给你一下。希望帮到你。
本文还有配套的精品资源,点击获取