前言: 如果选择 MFC 来做界面, 那么, MFC 中各种功能有限的控件一定让你蛋疼不已. 比如, TreeCtrl 的 CheckBox 居然不支持三态以及 CheckBox 居然不能垂直居中对齐. 下面我将为大家介绍如何实现一个支持三态 CheckBox 的 TreeCtrl 控件, 谨作抛砖引玉.
首先, 从 CtreeCtrl 派生一个类, 姑且命名为 CExTreeCtrl.
第二,响应 NM_CUSTOMDRAW 消息来自绘 TreeItem (仅仅要支持三态的话不用重绘, 但是要垂直居中的话就免不了了). 另外再上今天的主角, 虚函数 DrawItem(HTREEITEM hItem, LPNMTVCUSTOMDRAW lpNMTVCD), 由它负责绘制 Item. 以下我将详细说明 OnNMCustomdraw 与 DrawItem 的实现:
void CExTreeCtrl::OnNMCustomdraw(NMHDR *pNMHDR, LRESULT *pResult)
{
LPNMCUSTOMDRAW pNMCD = reinterpret_cast<LPNMCUSTOMDRAW>(pNMHDR);
LPNMTVCUSTOMDRAW lpNMTVCD = (LPNMTVCUSTOMDRAW) pNMCD;
//默认处理
*pResult = CDRF_DODEFAULT;
// TODO: Add your control notification handler code here
if(lpNMTVCD->nmcd.dwDrawStage == CDDS_PREPAINT)
{//返回 CDRF_NOTIFYITEMDRAW 标志, 告诉系统通知你每个绘制的细节
*pResult = CDRF_NOTIFYITEMDRAW;
return;
}
else if(lpNMTVCD->nmcd.dwDrawStage == CDDS_ITEMPREPAINT)
{//在 ItemPrePaint 状态实现自绘
CRect rcItem(pNMCD->rc);
CPoint ptItem(rcItem.left + 1, rcItem.top + 1);
UINT uFlags = 0;
HTREEITEM hItem = HitTest(ptItem, &uFlags);
//取出要绘制的 Item 的句柄
if(!hItem) return; DrawItem(hItem, lpNMTVCD);
//返回 CDRF_SKIPDEFAULT 标志, 告诉系统跳过默认处理
*pResult = CDRF_SKIPDEFAULT; return;
}
}
void CExTreeCtrl::DrawItem( HTREEITEM hItem, LPNMTVCUSTOMDRAW lpNMTVCD )
{
DWORD dwStyle = GetStyle();
CRect rcItem;
CRect rcTemp;
CPoint ptTemp;
UINT indent = GetIndent();
//取得 Item 文本的矩形范围
GetItemRect(hItem, &rcItem, TRUE);
CDC dc;
dc.Attach(lpNMTVCD->nmcd.hdc);
if(lpNMTVCD->nmcd.uItemState & CDIS_SELECTED)
{//如果 Item 被选中, 填充背景 (背景色我乱写的)
dc.FillSolidRect(&rcItem, RGB(255, 123, 128));
}
dc.SetBkMode(TRANSPARENT);
dc.SetTextColor(RGB(0, 0, 0));
rcTemp = rcItem;
rcTemp.left += 2;
rcTemp.top += 2;
//绘制文本
dc.DrawText(GetItemText(hItem), &rcTemp, DT_LEFT | DT_SINGLELINE | DT_VCENTER);
int nImage, nImageSelected;
//取出与 Item 关联的 ImageList
CImageList* imageList = GetImageList(TVSIL_NORMAL);
if(imageList && GetItemImage(hItem, nImage, nImageSelected))
{//绘制与 Item 相关联的 image
rcItem.left -= 18;//据我目测,系统本身是在 18px 宽的矩形内绘制图片的
rcItem.right = rcItem.left + 18;
CPoint pt(rcItem.left, rcItem.top);
IMAGEINFO imageInfo = {0};
imageList->GetImageInfo( ((lpNMTVCD->nmcd.uItemState & CDIS_SELECTED ) ? nImageSelected : nImage), &imageInfo);
//center alignment
pt.y += ( rcItem.Height() - imageInfo.rcImage.bottom + imageInfo.rcImage.top ) / 2;
//pt.x += ( rcItem.Width() - imageInfo.rcImage.right + imageInfo.rcImage.left ) / 2;
imageList->Draw(&dc, nImage, pt, ILD_NORMAL);
}
//取出 CheckBox 的各个选中状态的图片,系统本身的CheckBox只支持两态, 因此得自己准备一张包含各个状态图片的位图资源, 并通过SetImageList(XX, TVSIL_STATE)将它设置到 ExTreeCtrl中, 图片规格(以索引说明): 0-空白图片,1-未选中状态图片, 2-选中状态图片, 3-部分选中状态图片
imageList = GetImageList(TVSIL_STATE);
if(imageList && (dwStyle & TVS_CHECKBOXES))
{//绘制CheckBox相应状态的图片
//将状态码变换成图片在ImageList中的索引, MFC中有一个宏是把索引变换到StateImageState的, 实际就是 <<12 操作, 因此反过来也成立
nImage = GetItemState( hItem, TVIS_STATEIMAGEMASK ) >> 12;
rcItem.left -= 18;//据我目测...,
rcItem.right = rcItem.left + 18;
CPoint pt(rcItem.left, rcItem.top);
IMAGEINFO imageInfo = {0};
imageList->GetImageInfo(nImage, &imageInfo);
//居中对齐
pt.y += ( rcItem.Height() - imageInfo.rcImage.bottom + imageInfo.rcImage.top ) / 2;
pt.x += ( rcItem.Width() - imageInfo.rcImage.right + imageInfo.rcImage.left ) / 2;
imageList->Draw(&dc, nImage, pt, ILD_TRANSPARENT);
}
//如果要画线
if(dwStyle & TVS_HASLINES)
{
//创建一个真正的点线画笔
LOGBRUSH logBrush;
logBrush.lbColor = RGB(0xa2, 0xa2, 0xa2);
logBrush.lbStyle = BS_SOLID;
CPen pen(PS_COSMETIC | PS_ALTERNATE, 1, &logBrush);
CPen* oldPen = dc.SelectObject(&pen);
rcTemp = rcItem;
rcTemp.left -= indent;//从当前向左移动一个 缩进 的量
rcTemp.right = rcTemp.left + indent;
ptTemp = rcTemp.CenterPoint();
//如果 Item 有父 Item 则在自己面前画一个'L'型的线,拐点正是缩进矩形的中心点
if( GetParentItem(hItem) != NULL )
{
//如果 Item 有一个弟弟节点(哥哥排上面), 则在自己面前画一条竖线, 否则画半条
dc.MoveTo( ptTemp.x - 1,rcTemp.top - 1 );
if(GetNextSiblingItem(hItem) != NULL)
dc.LineTo( ptTemp.x - 1, rcTemp.bottom );
else dc.LineTo( ptTemp.x - 1, pt.y - 1 );
dc.MoveTo( rcTemp.right, ptTemp.y - 1 );
dc.LineTo( ptTemp.x - 1, ptTemp.y - 1 );
}
//依次绘制各个 Item 的父节点与叔叔节点之间被撑开的部分的连线
HTREEITEM hItemTemp = hItem;
while( hItemTemp = GetParentItem(hItemTemp) )
{
rcTemp.OffsetRect(-indent, 0);
ptTemp = rcTemp.CenterPoint();
if(GetNextSiblingItem(hItemTemp))
{
dc.MoveTo( ptTemp.x - 1, rcTemp.top );
dc.LineTo( ptTemp.x - 1, rcTemp.bottom );
}
}
//显示删除 MFC GDI 对象, 因为以前版本的 MFC 貌似有个BUG, 如果你不显示删除它就不会帮你删除, 这个BUG好像还存在
dc.SelectObject(oldPen);
pen.DeleteObject();
pen.DeleteTempMap();
}
//绘制小'+'框
if(dwStyle & TVS_HASBUTTONS)
{
rcItem.left -= indent;
rcItem.right = rcItem.left + indent;
//确定绘制范围
rcTemp.SetRect(0, 0, 9, 9);
rcTemp.OffsetRect(rcItem.CenterPoint());
rcTemp.OffsetRect(-5, -5);
//绘制, 因为使用的是MFC10.0, 因此实际绘制工作可交给 CMFCVisualManager 完成,其实... 自己画难度也不大
if( ItemHasChildren(hItem) ) CMFCVisualManager::GetInstance()->OnDrawExpandingBox(&dc, rcTemp, ( GetItemState(hItem, TVIS_EXPANDED) & TVIS_EXPANDED ?TRUE:FALSE), RGB(0, 0, 0));
}
//完成
dc.Detach();
}
接下来处理事件, 让支持三态 CheckBox 的TreeCtrl 能够根据自己的状态改变父节点与子节点的状态, 因此, 再添加几个辅助函数来帮助我们完成.
1, TVM_SETITEM 的响应函数, 让它处理State变化的情况. 当然, 重载SetItemState也是可以的, 但不完美, 因为有些程序员就是喜欢用 SendMessage(TVM_SETITEM, XX,XXX) 来破坏你精心设计好的重载. 因此, 响应该消息就能解决问题. 以下是函数的实现:
LRESULT CExTreeCtrl::DoSetItem( WPARAM wParam, LPARAM lParam )
{
LPTVITEM lpItem = (LPTVITEM)lParam;
UINT uItemState = 0;
//如果是设置 State 就自定义处理
if(lpItem && (lpItem->mask & TVIF_STATE))
{
uItemState = lpItem->state >> 12;
//设置父节点与子节点的状态
SetParentState(lpItem->hItem, uItemState);
SetChildrenState(lpItem->hItem, uItemState);
}
//调用默认处理
return DefWindowProc(TVM_SETITEM, wParam, lParam);
}
2, SetParentState 函数, 用来设置父节点状态, 如果本层所有节点状态一致, 则父节点与本层状态一致, 否则父节点应该是部分选中的状态. 以下具体实现:
void CExTreeCtrl::SetParentState( HTREEITEM hCurrentItem, UINT nState )
{
if(hCurrentItem == NULL) return;
//如果状态未改变, 则直接返回
if( (GetItemState(hCurrentItem, TVIS_STATEIMAGEMASK) >> 12) == nState ) return;
HTREEITEM hParentItem = GetParentItem(hCurrentItem);
if(hParentItem == NULL) return;
//从当前节点开始循环遍历, 直到遇到与当前节点状态不一致的节点
for(HTREEITEM hItemItr = GetNextSiblingItem(hCurrentItem); hItemItr != hCurrentItem; )
{
if( (hItemItr != NULL) && (nState != GetItemState(hItemItr, TVIS_STATEIMAGEMASK) >> 12) )
{//不一致, 则父节点应该为部分选中状态
nState = 3;
break;
}
hItemItr = GetNextSiblingItem(hItemItr);
if(hItemItr == NULL) hItemItr = GetChildItem(hParentItem);
}
//如果父节点状态与要设置的状态相同, 则返回
if( (GetItemState(hParentItem, TVIS_STATEIMAGEMASK) >> 12) == nState ) return;
//递归
SetParentState(hParentItem, nState);
TVITEM item;
item.hItem = hParentItem;
item.mask = TVIF_STATE;
item.pszText = (LPTSTR) 0;
item.iImage = 0;
item.iSelectedImage = 0;
item.state = INDEXTOSTATEIMAGEMASK(nState);
item.stateMask = TVIS_STATEIMAGEMASK;
item.lParam = 0;
//调用默认处理过程
DefWindowProc(TVM_SETITEM, 0, (LPARAM)&item);
}
3, SetChildrenState, 设置子节点的状态. 子节点的状态应该与自身状态一致.
void CExTreeCtrl::SetChildrenState( HTREEITEM hCurrentItem, UINT nState )
{
if(hCurrentItem == NULL) return;
//自身状态未改变, 则退出
if( (GetItemState(hCurrentItem, TVIS_STATEIMAGEMASK) >> 12) == nState ) return;
//遍历所有子节点, 并处理
TVITEM item;
for(HTREEITEM hItemItr = GetChildItem(hCurrentItem); hItemItr != NULL; hItemItr = GetNextSiblingItem(hItemItr))
{
SetChildrenState(hItemItr, nState);
item.hItem = hItemItr;
item.mask = TVIF_STATE;
item.pszText = (LPTSTR) 0;
item.iImage = 0;
item.iSelectedImage = 0;
item.state = INDEXTOSTATEIMAGEMASK(nState);
item.stateMask = TVIS_STATEIMAGEMASK;
item.lParam = 0;
//调用默认处理过程
DefWindowProc(TVM_SETITEM, 0, (LPARAM)&item);
}
}
最后处理鼠标事件, 因为部分选中状态并不一个用户直接可以用鼠标操作就能设置的状态, 因此要屏蔽掉. 先在CExTreeCtrl中添加一个成员变量 CPoint m_ptClickPoint, 然后响应三个消息: WM_LBUTTONDOWN, WM_LBUTTONDBCLK 和 NM_CLICK, 下以是这三个函数的具体实现:
void CExTreeCtrl::OnNMClick(NMHDR *pNMHDR, LRESULT *pResult)
{
// TODO: Add your control notification handler code here
UINT uFlags = 0;
//取得单击时的Item
HTREEITEM hItem = HitTest(m_ptClickPoint, &uFlags);
if(uFlags & TVHT_ONITEMSTATEICON)
{
//1: 未选中, 2: 选中, 3: 部分选中
UINT uItemState = CTreeCtrl::GetItemState(hItem, TVIS_STATEIMAGEMASK) >> 12;
uItemState = uItemState >=2 ? 1 : 2;
CTreeCtrl::SetItemState(hItem, INDEXTOSTATEIMAGEMASK(uItemState), TVIS_STATEIMAGEMASK);
}
*pResult = 0;
}
void CExTreeCtrl::OnLButtonDblClk(UINT nFlags, CPoint point)
{
// TODO: Add your message handler code here and/or call default
UINT uHTFlags = 0;
HitTest(point, &uHTFlags);
//如果点击到了CheckBox, 则阻止原来的折叠动作
if(uHTFlags & TVHT_ONITEMSTATEICON)
{
SendMessage(WM_LBUTTONDOWN, uHTFlags, MAKELONG(point.x, point.y));
return;
}
CTreeCtrl::OnLButtonDblClk(nFlags, point);
}
void CLayertree::OnLButtonDown( UINT nFlags, CPoint point )
{
//记录点击的位置
m_ptClickPoint = point;
CTreeCtrl::OnLButtonDown(nFlags, point);
}
好, 至此, 一个基本可用的支持三态CheckBox的TreeCtrl已经完成, 当然其中还有一些细节问题需要处理, 使用方式与原来的控件没有区别, 以下是范例:
CRect rectDummy;
rectDummy.SetRectEmpty();
// Create view:
const DWORD dwViewStyle = WS_CHILD | WS_VISIBLE | TVS_LINESATROOT | TVS_HASBUTTONS | TVS_CHECKBOXES | TVS_SHOWSELALWAYS | TVS_HASLINES | 0;
if (!m_wndTree.Create(dwViewStyle, rectDummy, this, 4))
{
TRACE0("Failed to create file view\n");
return -1; // fail to create
}
// Load view images:
m_LayertreeImges.Create(IDB_LAYRETREE, 16, 0, RGB(255, 0, 255));
m_CheckStateImages.Create(IDB_CHECK_STATE, 16, 0, RGB(255, 0, 255));
m_wndTree.SetImageList(&m_LayertreeImges, TVSIL_NORMAL);
m_wndTree.SetImageList(&m_CheckStateImages, TVSIL_STATE);
m_wndLayertree.SetItemHeight(24);
最后, 再说一句, C++本来就不擅长界面, 而随着PC硬件水平的提高, 采用C++来做界面所带来的性能优势会越来越不明显..