使用自定义绘制在列表控件中进行的精彩操作






4.93/5 (223投票s)
使用通用控件4.70版本中的自定义绘制功能来定制列表控件的外观和感觉
引言
通用控件4.70版本引入了一个名为自定义绘制的功能。这个名字模糊地暗示了它的用途,MSDN文档提供了一些冗长的解释和示例,但没有一个地方能告诉你真正想知道的。那就是,它有什么用。自定义绘制可以看作是所有者绘制的轻量级、易于使用的版本。易用性来自于只需要处理一个消息(NM_CUSTOMDRAW
),并且你可以让Windows为你做一些工作,这样你就不用处理所有所有者绘制过程中繁琐的工作了。
本文将重点关注自定义绘制与列表视图控件的结合使用,部分原因是作者在自己的工作中做了一些自定义绘制的列表控件,并且熟悉这个过程;但也因为可以用很少的代码实现一些精彩的效果。自定义绘制代码甚至可以替代CodeProject上一些较老的列表视图文章!
本文代码是在Windows 98和Microsoft Visual C++ 6 SP2上编写的,使用了通用控件DLL 5.0版本。我还在NT 4上进行了Unicode测试。运行代码的系统至少需要安装通用控件4.71版本。然而,由于它随IE 4(本身也随VC6一起安装)一同提供,所以这应该不是问题。
自定义绘制基础
我将尽力在此总结自定义绘制的过程,而不只是重复文档内容。在这些示例中,假设你在一个对话框中有一个列表控件,并且该列表处于报表视图模式,包含多个列。
连接自定义绘制消息映射条目
自定义绘制是一个类似于回调的过程。在绘制列表控件的某些阶段,Windows会通过一个通知消息通知你的程序。你可以选择完全忽略这些通知(这样你会看到标准的列表控件),自己处理绘制的某个部分(用于简单效果),甚至自己绘制控件(就像所有者绘制控件一样)。真正的卖点在于,你可以选择只响应部分通知。这样,你只需要绘制你需要的那些部分,而Windows会处理其余的部分。
假设你想为现有的列表控件添加自定义绘制以使其更具特色。假设系统上安装了正确的通用控件DLL,Windows已经在发送NM_CUSTOMDRAW
消息给你;你只需要为该消息添加一个处理程序就可以开始使用自定义绘制。处理程序会像这样:
ON_NOTIFY ( NM_CUSTOMDRAW, IDC_MY_LIST, OnCustomdrawMyList )
原型看起来像这样:
afx_msg void OnCustomdrawMyList ( NMHDR* pNMHDR, LRESULT* pResult );
这告诉MFC你想处理来自列表控件(ID为IDC_MY_LIST
)的WM_NOTIFY
消息,当通知代码为NM_CUSTOMDRAW
时。处理程序是一个名为OnCustomdrawMyList
的函数。如果你有一个CListCtrl派生类并想为其添加自定义绘制,你可以使用ON_NOTIFY_REFLECT
代替:
ON_NOTIFY_REFLECT ( NM_CUSTOMDRAW, OnCustomdraw )
消息处理程序的原型与上面相同,但它放在你的派生类中。
自定义绘制阶段
自定义绘制将绘制过程分为两个部分:擦除和绘制。Windows可能会在每个部分的开始和结束时发送NM_CUSTOMDRAW
消息。因此总共有四条消息。然而,你的应用程序实际上可能只收到一条或多于四条消息,这取决于你告诉Windows你想要什么。发送通知的时间点被称为“绘制阶段”。你需要很好地理解这个概念,因为它贯穿于自定义绘制的整个过程。所以,总结一下这个有些枯燥的段落,你可能在以下时间(或阶段)收到通知:
- 在绘制项之前
- 在绘制项之后
- 在擦除项之前
- 在擦除项之后
并非所有这些都同样有用,实际上,我还没有需要处理超过一个阶段的情况。事实上,在编写本文的过程中进行一些实验时,我无法让Windows发送预擦除或后擦除消息!所以不要让这部分让你感到畏惧。
响应NM_CUSTOMDRAW消息
从自定义绘制处理程序返回的值至关重要,因为它告诉Windows你完成了绘制过程的多少,以及间接地,你希望Windows自己做多少。你可以从自定义绘制处理程序发送五种响应:
- 我现在不想做任何事情;Windows应该像没有自定义绘制处理程序一样绘制控件或项。
- 我更改了控件正在使用的字体;Windows必须重新计算正在绘制的项的
RECT
。 - 我已经绘制了整个控件或项;Windows不应该再对控件或项做任何事情了。
- 我想在列表中每个项的绘制阶段接收额外的
NM_CUSTOMDRAW
消息。 - 我想在当前正在绘制的行的每个子项的绘制阶段接收额外的
NM_CUSTOMDRAW
消息。
请注意,“控件或项”这个短语经常出现。还记得我说过你可能会收到超过四条NM_CUSTOMDRAW
消息吗?事情就发生在这里。你收到的第一条NM_CUSTOMDRAW
消息适用于整个控件。如果你返回上面的响应4(请求按项通知),那么当每个项(行)经过其绘制阶段时,你就会收到消息。如果你随后返回响应5,当每个子项(列)经过其绘制阶段时,你将收到更多的消息。
在报表模式的列表控件中,你可以使用任何这些响应,具体取决于你想要实现的效果。稍后我将展示一些如何响应NM_CUSTOMDRAW
消息的示例。
NM_CUSTOMDRAW消息提供的信息
NM_CUSTOMDRAW
消息将一个指向NMLVCUSTOMDRAW
结构的指针传递给你的处理程序,其中包含以下信息:
- 控件的窗口句柄
- 控件的ID
- 控件当前所处的绘制阶段
- 如果你执行任何绘制,你应该使用的设备上下文的句柄
- 正在绘制的控件、项或子项的
RECT
- 正在绘制的项的项号(索引)
- 正在绘制的子项的子项号(索引)
- 指示正在绘制的项状态的标志(选中、灰色等)
- 正在绘制的项的
LPARAM
数据,由CListCtrl::SetItemData
设置
根据你想要的效果,其中任何一项都可能很重要,但你总是会用到绘制阶段和设备上下文。项索引和LPARAM
也经常非常有用。
一个简单示例
好了,经过所有枯燥的细节后,是时候实际看看代码了。第一个示例会非常简单,它只会改变控件中文本的颜色,在红色、绿色和蓝色之间循环。这涉及四个步骤:
- 在控件的预绘制阶段处理
NM_CUSTOMDRAW
- 告诉Windows我们想为每个项获取
NM_CUSTOMDRAW
消息 - 处理随后为每个项发送的
NM_CUSTOMDRAW
消息。 - 为每个项设置文本颜色。
这是处理程序:
void CMyDlg::OnCustomdrawMyList ( NMHDR* pNMHDR, LRESULT* pResult )
{
NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>( pNMHDR );
// Take the default processing unless we
// set this to something else below.
*pResult = CDRF_DODEFAULT;
// First thing - check the draw stage. If it's the control's prepaint
// stage, then tell Windows we want messages for every item.
if ( CDDS_PREPAINT == pLVCD->nmcd.dwDrawStage )
{
*pResult = CDRF_NOTIFYITEMDRAW;
}
else if ( CDDS_ITEMPREPAINT == pLVCD->nmcd.dwDrawStage )
{
// This is the prepaint stage for an item. Here's where we set the
// item's text color. Our return value will tell Windows to draw the
// item itself, but it will use the new color we set here.
// We'll cycle the colors through red, green, and light blue.
COLORREF crText;
if ( (pLVCD->nmcd.dwItemSpec % 3) == 0 )
crText = RGB(255,0,0);
else if ( (pLVCD->nmcd.dwItemSpec % 3) == 1 )
crText = RGB(0,255,0);
else
crText = RGB(128,128,255);
// Store the color back in the NMLVCUSTOMDRAW struct.
pLVCD->clrText = crText;
// Tell Windows to paint the control itself.
*pResult = CDRF_DODEFAULT;
}
}
这段代码的结果如下所示。看,每一行都有我们告诉Windows使用的颜色?很酷,而且这一切都只需要几个if语句!
![[sample list view 1 - 3K]](https://cloudfront.codeproject.com/list/lvcustomdraw/lvcustomdraw1.gif)
需要记住的一点是,你必须先检查绘制阶段,然后才能做任何其他事情,因为你的处理程序会收到许多消息,而绘制阶段决定了你的代码将采取什么行动。
一个稍微复杂一点的例子
下一个示例展示了如何处理子项(即列)的自定义绘制。我们的处理程序将设置文本和单元格背景颜色,但它不会比上一个复杂多少;只是多了一个if块。处理子项所涉及的步骤是:
- 在控件的预绘制阶段处理
NM_CUSTOMDRAW
- 告诉Windows我们想为每个项获取
NM_CUSTOMDRAW
消息 - 当其中一条消息到来时,告诉Windows我们想在每个子项的预绘制阶段接收
NM_CUSTOMDRAW
消息。 - 每次收到子项的后续消息时,设置文本和背景颜色。
请注意,我们会收到关于整个项的NM_CUSTOMDRAW
消息,以及关于子项0(第一列)的另一条消息。以下是代码:
void CMyDlg::OnCustomdrawMyList ( NMHDR* pNMHDR, LRESULT* pResult )
{
NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>( pNMHDR );
// Take the default processing unless
// we set this to something else below.
*pResult = CDRF_DODEFAULT;
// First thing - check the draw stage. If it's the control's prepaint
// stage, then tell Windows we want messages for every item.
if ( CDDS_PREPAINT == pLVCD->nmcd.dwDrawStage )
{
*pResult = CDRF_NOTIFYITEMDRAW;
}
else if ( CDDS_ITEMPREPAINT == pLVCD->nmcd.dwDrawStage )
{
// This is the notification message for an item. We'll request
// notifications before each subitem's prepaint stage.
*pResult = CDRF_NOTIFYSUBITEMDRAW;
}
else if ( (CDDS_ITEMPREPAINT | CDDS_SUBITEM) == pLVCD->nmcd.dwDrawStage )
{
// This is the prepaint stage for a subitem. Here's where we set the
// item's text and background colors. Our return value will tell
// Windows to draw the subitem itself, but it will use the new colors
// we set here.
// The text color will cycle through red, green, and light blue.
// The background color will be light blue for column 0, red for
// column 1, and black for column 2.
COLORREF crText, crBkgnd;
if ( 0 == pLVCD->iSubItem )
{
crText = RGB(255,0,0);
crBkgnd = RGB(128,128,255);
}
else if ( 1 == pLVCD->iSubItem )
{
crText = RGB(0,255,0);
crBkgnd = RGB(255,0,0);
}
else
{
crText = RGB(128,128,255);
crBkgnd = RGB(0,0,0);
}
// Store the colors back in the NMLVCUSTOMDRAW struct.
pLVCD->clrText = crText;
pLVCD->clrTextBk = crBkgnd;
// Tell Windows to paint the control itself.
*pResult = CDRF_DODEFAULT;
}
}
结果列表如下所示:
![[sample list view 2 - 3K]](https://cloudfront.codeproject.com/list/lvcustomdraw/lvcustomdraw2.gif)
这里有几点需要注意:
clrTextBk
颜色仅在列中绘制。最后一列右侧和最后一行的下方的区域仍然会显示控件的背景颜色。- 在我查阅文档时,我看到了“
NM_CUSTOMDRAW
(list view)”页面,它说你可以从第一条自定义绘制消息返回CDRF_NOTIFYSUBITEMDRAW
,而无需处理CDDS_ITEMPREPAINT
绘制阶段。我测试过,但它不起作用。你确实需要处理CDDS_ITEMPREPAINT
阶段。
处理后绘制阶段
到目前为止,示例已经处理了预绘制阶段,以在Windows绘制列表项时更改它们的外观。然而,在预绘制阶段,你的选择仅限于更改文本的颜色或外观。如果你想改变图标的绘制方式,你可以选择在预绘制阶段绘制整个项(有点大材小用),或者在后绘制阶段进行自定义绘制。当你执行后绘制阶段的自定义绘制时,你的自定义绘制处理程序会在Windows绘制完整个项或子项后被调用,然后你可以进行任何你想要的额外绘制。
在这个示例中,我将创建一个列表控件,其中选定项的图标不会改变颜色。涉及的步骤是:
- 在控件的预绘制阶段处理
NM_CUSTOMDRAW
- 告诉Windows我们想为每个项获取
NM_CUSTOMDRAW
消息 - 当其中一条消息到来时,告诉Windows我们想在项的后绘制阶段接收
NM_CUSTOMDRAW
消息。 - 每次收到关于某项的后续消息时,根据需要重新绘制图标。
void CMyDlg::OnCustomdrawMyList ( NMHDR* pNMHDR, LRESULT* pResult )
{
NMLVCUSTOMDRAW* pLVCD = reinterpret_cast<NMLVCUSTOMDRAW*>( pNMHDR );
*pResult = 0;
// If this is the beginning of the control's paint cycle, request
// notifications for each item.
if ( CDDS_PREPAINT == pLVCD->nmcd.dwDrawStage )
{
*pResult = CDRF_NOTIFYITEMDRAW;
}
else if ( CDDS_ITEMPREPAINT == pLVCD->nmcd.dwDrawStage )
{
// This is the pre-paint stage for an item. We need to make another
// request to be notified during the post-paint stage.
*pResult = CDRF_NOTIFYPOSTPAINT;
}
else if ( CDDS_ITEMPOSTPAINT == pLVCD->nmcd.dwDrawStage )
{
// If this item is selected, re-draw the icon in its normal
// color (not blended with the highlight color).
LVITEM rItem;
int nItem = static_cast<int>( pLVCD->nmcd.dwItemSpec );
// Get the image index and state of this item. Note that we need to
// check the selected state manually. The docs _say_ that the
// item's state is in pLVCD->nmcd.uItemState, but during my testing
// it was always equal to 0x0201, which doesn't make sense, since
// the max CDIS_* constant in commctrl.h is 0x0100.
ZeroMemory ( &rItem, sizeof(LVITEM) );
rItem.mask = LVIF_IMAGE | LVIF_STATE;
rItem.iItem = nItem;
rItem.stateMask = LVIS_SELECTED;
m_list.GetItem ( &rItem );
// If this item is selected, redraw the icon with its normal colors.
if ( rItem.state & LVIS_SELECTED )
{
CDC* pDC = CDC::FromHandle ( pLVCD->nmcd.hdc );
CRect rcIcon;
// Get the rect that holds the item's icon.
m_list.GetItemRect ( nItem, &rcIcon, LVIR_ICON );
// Draw the icon.
m_imglist.Draw ( pDC, rItem.iImage, rcIcon.TopLeft(),
ILD_TRANSPARENT );
*pResult = CDRF_SKIPDEFAULT;
}
}
}
再次强调,自定义绘制让我们能够尽量少地工作。这个示例让Windows为我们完成所有绘制,然后覆盖每个选定项的图标。这样,用户看到的就是我们绘制的图标。结果列表如下图所示。请注意,Stan的图标与未选定项的图标看起来一样。
![[sample list view 3 - 8K]](https://cloudfront.codeproject.com/list/lvcustomdraw/lvcustomdraw3.gif)
这种绘制方式唯一的缺点是,有时会看到一些闪烁,因为图标会被快速连续地绘制两次。
将自定义绘制用作所有者绘制的替代
使用自定义绘制的另一个很棒之处在于,你可以用它来做所有者绘制能做的事情。不同之处在于,我认为使用自定义绘制编写和理解代码要容易得多。另一个优点是,如果你只需要对某些行进行所有者绘制,你可以这样做,而让Windows绘制其他行。在进行真正的所有者绘制控件时,你必须处理所有事情,即使某些行不需要任何“特殊效果”。
当你使用自定义绘制进行所有者绘制时,你会处理在项的预绘制阶段发送的NM_CUSTOMDRAW
消息,完成所有绘制,并从处理程序返回CDRF_SKIPDEFAULT
。这与我们迄今为止所做的不同。CDRF_SKIPDEFAULT
告诉Windows不要在该行进行任何绘制,因为你已经全部完成了。
我不会在这篇文章中包含这个示例的代码,因为它有点长,但你可以在调试器中逐行查看代码,了解发生了什么。如果你将窗口排列好,可以同时看到演示应用程序的对话框和代码,你将看到绘制是逐步进行的。列表控件非常简单,只有一个列,没有标题控件。列表看起来像这样:
![[sample list view 4 - 6K]](https://cloudfront.codeproject.com/list/lvcustomdraw/lvcustomdraw4.gif)
其他你可以(据说)做的事情
通过一点想象力,你可以利用自定义绘制创造一些相当精彩的效果。在我最近的一个项目中,我写了一个看起来像这样的列表控件:
![[sample 2-line report view - 5K]](https://cloudfront.codeproject.com/list/lvcustomdraw/lvcustomdraw5.gif)
我不会提及产品名称,因为我不想把它变成广告,但你可能可以猜出来。 :) 注意列表控件项在文本适合一行时看起来正常,而在必要时文本会换行到两行。这样,所有文本都可见,用户不必反复滚动来阅读所有文本。我通过处理预绘制阶段并自己完成所有绘制来实现这一效果。
至于本节标题为什么说“据说”:如前所述,文档说明你可以在预擦除和后擦除绘制阶段进行自定义绘制处理。我以前从未编写过在这些阶段执行的代码,所以在我编写本文的过程中,我计划做一个示例,在列表项被擦除后绘制一个背景图案。然而,我无法让Windows在预擦除或后擦除阶段发送NM_CUSTOMDRAW
消息!我在自定义绘制处理程序中尝试了几种方法,并进行了一些实验,但最终不得不放弃。我怀疑这方面的文档,因为在标题为“NM_CUSTOMDRAW
(list view)”的页面上,它列出了一个返回代码(CDRF_NOTIFYITEMERASE
),但这个代码在commctrl.h头文件中根本不存在!当如此重要的信息出错时,我倾向于认为周围的文档的准确性存疑。
无论如何,如果你处理了预擦除或后擦除阶段,你也需要处理预绘制阶段。否则,默认的Windows行为会擦除你在擦除阶段所做的绘制。考虑到这一点,我无法想出任何需要处理擦除阶段之一的事情;任何特殊效果都可以很容易地在绘制阶段实现。
演示项目
演示项目是一个向导,其中包含我在本文中介绍的四个列表控件。该项目包含了列表控件和自定义绘制处理程序的完整代码,你应该检查并逐行查看这些代码,以便更好地了解自定义绘制的工作原理以及如何在自己的程序中使用它。