CStaticTreeCtrl - 一个 CStatic 派生的自定义 Tree 控件






4.95/5 (79投票s)
2005年3月21日
17分钟阅读

342655

13364
分步创建自定义树形控件,基于 CStatic 控件,实现基本功能、视觉效果(字体、位图背景等)、滚动(滚动条和鼠标滚轮)、多行(自动换行)文本和音频上下文菜单。
引言
在之前的教程(CWinListBox)中,我讨论了如何从零开始创建列表框,作为一个派生自 CWin
的自定义控件。现在,我想通过一种稍微不同的方法,即使用 CStatic
控件作为基类,来开发一个简单的树形控件。
正如我之前解释过的,我的目标读者是新手程序员,我唯一的目的是展示看似复杂的 GUI 控件的基本功能如何用相对简单的代码来实现。但请记住,通常情况下,除非要实现的功能明显超出标准范围,否则从零开始开发自定义控件不是一个好主意,并且,这里介绍的代码**绝不**是为了取代现有的 MFC CTreeCtrl
。
要实现的目标
目标是创建一个实现最基本功能的树形控件:节点的插入和删除、单击展开/折叠、垂直滚动(滚动条和鼠标滚轮)、一些视觉效果(颜色和字体、位图背景)以及所有者绘制的音频上下文菜单。标准的 CTreeCtrl
控件每个节点只允许单行文本(无换行),每行最多约 260 个字符,因此我决定通过一个“额外”功能来解决这个灾难性的问题,即自动调整大小的多行(换行)文本。
敏锐的读者可能会想知道,我是边写演示项目边写这篇教程的。下面的说明、解释和代码**确实**完成了创建上图中自定义树形控件的过程。
开始编码。
分步过程
项目启动
设置很简单。创建一个新的基于对话框的项目,并将警告级别设置为 4(项目设置 -> C/C++ 选项卡)。4 级将确保任何可疑之处都会被提示我们,由**我们**决定如何处理“通常可以安全忽略的信息性警告”(来自文档)。
[题外话:现在也是创建 UNICODE 项目配置的好时机,如果需要的话(我已包含在演示项目中,供热情的国际主义者参考)。简而言之,您需要将 `_UNICODE` 和 `UNICODE` 定义添加到预处理器定义中(项目设置 -> C/C++ 选项卡),同时确保删除 `_MBCS` 定义,然后将 `wWinMainCRTStartup` 添加为入口点符号(项目设置 -> 链接选项卡 ->“输出”类别)。如果您选择 UNICODE,请立即启用调试时显示 UNICODE 字符串(工具/选项 -> 调试选项卡),**并**阅读 Chris Maunder 的文章 Unicode, MBCS and Generic text mappings,以避免任何头痛问题。
让我们开始处理树形控件。创建一个名为 `CStaticTreeCtrl` 的新 MFC 类,并将 `CStatic` 作为基类。
在资源编辑器中,添加一个 ID 为 `IDC_ST_TREE` 的静态文本控件,然后使用 MFC 类向导,为 `IDC_ST_TREE` 添加一个名为 `m_ST_Tree` 的成员变量,确保选择“控件”作为类别,选择 `CStaticTreeCtrl` 作为变量类型。
点击确定后,一个消息框会警告我们,确保已将 `CStaticTreeCtrl` 类的头文件包含在我们的对话框代码中。如果尚未包含,请立即进行。
由于控件将从头开始制作,我们可以选择自己的字体和默认文本颜色。声明如下,实现非常简单(请查看源代码)
// Attributes protected: CFont m_Font; COLORREF m_crDefaultTextColor; // Operations public: virtual CStaticTreeCtrl& SetTextFont ( LONG nHeight, BOOL bBold, BOOL bItalic, const CString& csFaceName ); virtual CStaticTreeCtrl& SetDefaultTextColor ( COLORREF crText );
数据结构
任何树形控件的核心都是一个数据结构,用于存储将要显示的信息。有多种方法可以做到这一点,我将使用我能想到的最简单的一种。
class CTreeNode { public: CTreeNode() { pParent = NULL; pSibling = NULL; pChild = NULL; } CTreeNode* pParent; CTreeNode* pSibling; CTreeNode* pChild; };
树形控件中的任何节点都将知道其父节点、第一个子节点以及下一个同级节点。这三个指针将允许我们以一种相当简单的方式在任何节点之间进行导航,如下所示。节点 B、C 和 D 都是同级节点(具有相同的父节点,即节点 A),并且没有子节点(指针为 null)。节点 D 没有下一个同级节点(指针为 null)。节点 A 只需一个指向第一个子节点(此处为节点 B)的指针即可访问其所有子节点。
那么,我们希望树中的每个节点拥有哪些信息?它自己的字体?前景色和背景色?选择切换或复选框?图标/位图?什么?我将编写基础代码,您可以根据需要添加。
class CTreeNode { public: CTreeNode() { csLabel.Empty(); rNode.SetRectEmpty(); bUseDefaultTextColor = TRUE; bOpen = TRUE; pParent = NULL; pSibling = NULL; pChild = NULL; } virtual ~CTreeNode() { csLabel.Empty(); } CString csLabel; CRect rNode; COLORREF crText; BOOL bUseDefaultTextColor; BOOL bOpen; CTreeNode* pParent; CTreeNode* pSibling; CTreeNode* pChild; }; #define HTREENODE CTreeNode* #define HTOPNODE ( (HTREENODE) -0x10000 )
每个节点将具有文本标签和前景色。除此之外,记住节点是打开/关闭状态(用于绘图、搜索等)以及它所占用的控件区域(用于鼠标单击、绘制连接线等)会很有用。
为了使结构有用,必须能够插入和删除节点。我们还需要跟踪我们树结构的顶层节点。声明如下
// Attributes protected: HTREENODE m_pTopNode; // Operations public: HTREENODE InsertSibling ( HTREENODE pInsertAfter, const CString& csLabel, COLORREF crText = 0, BOOL bUseDefaultTextColor = TRUE, BOOL bInvalidate = FALSE ); HTREENODE InsertChild ( HTREENODE pParent, const CString& csLabel, COLORREF crText = 0, BOOL bUseDefaultTextColor = TRUE, BOOL bInvalidate = FALSE ); void DeleteNode ( HTREENODE pNode, BOOL bInvalidate = FALSE ); protected: void DeleteNodeRecursive ( HTREENODE pNode );
请注意,顶层节点在 `CStaticTreeCtrl` 构造函数中初始化,并将成为我们树在整个生命周期中的句柄。
关于添加新节点,这些节点可以作为子节点或现有节点的同级节点插入。概念化正确后,它将允许我们在树的任何位置插入节点。
///////////////////////////////////// // Constructor ///////////////////////////////////// CStaticTreeCtrl::CStaticTreeCtrl() { m_pTopNode = new CTreeNode(); } ///////////////////////////////////// // Public methods ///////////////////////////////////// HTREENODE CStaticTreeCtrl::InsertSibling( HTREENODE pInsertAfter, const CString& csLabel, COLORREF crText /* = 0 */, BOOL bUseDefaultTextColor /* = TRUE */, BOOL bInvalidate /* = FALSE */) { // Make sure the node exists ASSERT( pInsertAfter != NULL ); HTREENODE pNewNode = new CTreeNode(); // New node's label pNewNode->csLabel = csLabel; if( bUseDefaultTextColor ) // Use the default text color pNewNode->bUseDefaultTextColor = TRUE; else // New node's text color pNewNode->crText = crText; // Nas the same parent pNewNode->pParent = pInsertAfter->pParent; // Insert the new node between // pInsertAfter and its next sibling pNewNode->pSibling = pInsertAfter->pSibling; pInsertAfter->pSibling = pNewNode; // Repaint the control if so desired if( bInvalidate ) Invalidate(); return pNewNode; } HTREENODE CStaticTreeCtrl::InsertChild( HTREENODE pParent, const CString& csLabel, COLORREF crText /* = 0 */, BOOL bUseDefaultTextColor /* = TRUE */, BOOL bInvalidate /* = FALSE */) { // Make sure the node exists ASSERT( pParent != NULL ); if( pParent == HTOPNODE ) // Check for top node pParent = m_pTopNode; HTREENODE pNewNode = new CTreeNode(); // Basic node information pNewNode->csLabel = csLabel; // New node's label if( bUseDefaultTextColor ) // Use the default text color pNewNode->bUseDefaultTextColor = TRUE; else // New node's text color pNewNode->crText = crText; // New node's parent pNewNode->pParent = pParent; // Insert the new node as pParent's first child pNewNode->pSibling = pParent->pChild; pParent->pChild = pNewNode; // Repaint the control if so desired if( bInvalidate ) Invalidate(); return pNewNode; }
插入节点的第一步是创建它,然后调整现有指针,使新节点成为树结构的功能部分。例如,如果插入子节点,新节点的同级节点将成为父节点的子节点,以便父节点的子节点可以被设置为这个新节点。
删除节点涉及一些设计决策。例如,我们是否只应在节点没有子节点时删除它,还是应该递归删除其所有子节点?每个选项都有其优点,但请屏住呼吸,我将实现后者,以便我们能初步了解递归。
///////////////////////////////////// // Public methods ///////////////////////////////////// void CStaticTreeCtrl::DeleteNode( HTREENODE pNode, BOOL bInvalidate /* = FALSE */) { ASSERT( pNode != NULL ); // Make sure the node exists // Don't delete the top node if( pNode == HTOPNODE ) DeleteNode( m_pTopNode, bInvalidate ); // Delete childs if( pNode->pChild != NULL ) DeleteNodeRecursive( pNode->pChild ); // If the node is not the top node, fix pointers // in sibling list and then delete the node if( pNode != m_pTopNode ) { HTREENODE pRunner = pNode->pParent; // If the node is the first child, // set the parent pointer to the next sibling // Otherwise, find sibling before the node // and set this sibling pointer to the node's sibling if( pRunner->pChild == pNode ) pRunner->pChild = pNode->pSibling; else { pRunner = pRunner->pChild; // Loop until the next node is the one being deleted while( pRunner->pSibling != pNode ) pRunner = pRunner->pSibling; pRunner->pSibling = pNode->pSibling; } // Pointers fixed, the node can be safely deleted delete pNode; pNode = NULL; } if( bInvalidate ) Invalidate(); } //////////////////////////////////////////////////////////////// // PROTECTED METHODS //////////////////////////////////////////////////////////////// void CStaticTreeCtrl::DeleteNodeRecursive( HTREENODE pNode ) { if( pNode->pSibling != NULL ) DeleteNodeRecursive( pNode->pSibling ); if( pNode->pChild != NULL ) DeleteNodeRecursive( pNode->pChild ); delete pNode; pNode = NULL; }
这两种方法都相当直接。受保护的递归方法 `DeleteNodeRecursive` 调用自身一次又一次,直到到达最后一个子节点的最后一个同级节点,然后从**该节点**向后删除所有访问过的节点。通过这种方式,可以确保我们永远不会删除**具有指向更深层节点的链接**的节点,即没有指向子节点或同级节点的非 null 指针。否则,如果我们删除一个仍有子节点或下一个同级节点的节点,这些节点将变得无法访问,因此无法删除(内存泄漏泛滥)。
公共方法 `DeleteNode` 检查该方法是否被调用以删除整个树,如果是,则保留顶层节点指针(我们整个生命周期的树句柄)。无论是否为顶层节点,该方法都会检查要删除的节点是否有子节点,如果有,则调用递归方法将其删除。完成后,该方法将继续查找要删除的节点在结构中的位置,然后将其从同级链中移除。
再次注意,顶层节点无法通过此方法删除,因为它是我们树的句柄,我们需要在程序关闭前将其保留。因此,`CStaticTreeCtrl` 析构函数如下所示
///////////////////////////////////// // Destructor ///////////////////////////////////// CStaticTreeCtrl::~CStaticTreeCtrl() { DeleteNode( m_pTopNode ); // Delete all childs if there are any delete m_pTopNode; // Delete top node m_pTopNode = NULL; }
就这样。树结构已就位,它具有足够的功能可用,并且在程序终止时会自我清理,防止内存泄漏和其他难看的问题。
绘制树:基础知识
绘制树需要什么?嗯,多亏了递归,导航树 pretty simple,正如我们所见。但首先,让我们通过类向导添加一个 `WM_PAINT` 消息处理程序来设置它。我们将离屏绘制(双缓冲)以避免闪烁。查看骨架实现。
void CStaticTreeCtrl::OnPaint() { // Device context for painting CPaintDC dc(this); // Double-buffering CDC* pDCMem = new CDC; CBitmap* pOldBitmap = NULL; CBitmap bmpCanvas; CRect rFrame; GetClientRect( rFrame ); pDCMem->CreateCompatibleDC( &dc ); bmpCanvas.CreateCompatibleBitmap( &dc, rFrame.Width(), rFrame.Height() ); pOldBitmap = pDCMem->SelectObject( &bmpCanvas ); // START DRAW ------------------------------------------------- pDCMem->FillSolidRect( rFrame, RGB(255,255,255) ); // Background pDCMem->Draw3dRect( rFrame, RGB(0,0,0), RGB(0,0,0) ); // Border // END DRAW ------------------------------------------------- dc.BitBlt( 0, 0, rFrame.Width(), rFrame.Height(), pDCMem, 0, 0, SRCCOPY ); pDCMem->SelectObject( pOldBitmap ); delete pDCMem; }
此时,您可以编译并运行应用程序。您将看到一个带有黑色边框的白色矩形。
现在,我们将添加递归绘制方法来绘制树节点(您也应该在树中添加一些节点,以查看测试时的效果)。
///////////////////////////////////////////////////////////////////// // PROTECTED METHODS ///////////////////////////////////////////////////////////////////// int CStaticTreeCtrl::DrawNodesRecursive( CDC* pDC, HTREENODE pNode, int x, int y, CRect rFrame ) { int iDocHeight = 0; // Total document height CRect rNode; // The node's location and dimensions on screen rNode.left = x; rNode.top = y; rNode.right = rFrame.right - m_iPadding; rNode.bottom = y + m_iLineHeight; pNode->rNode.CopyRect( rNode ); // Record the rectangle COLORREF cr = ( pNode->bUseDefaultTextColor )? m_crDefaultTextColor:pNode->crText; COLORREF crOldText = pDC->SetTextColor( cr ); // Draw the text - THIS LINE WILL BE REPLACED BY THE MULTILINE CODE pDC->DrawText( pNode->csLabel, rNode, DT_LEFT | DT_SINGLELINE | DT_VCENTER ); pDC->SetTextColor( crOldText ); // If there are no child or siblings, then this branch is done if( pNode->pChild == NULL && pNode->pSibling == NULL ) return pNode->rNode.Height(); // If the node is open AND it has childs, then draw those if( pNode->bOpen && pNode->pChild != NULL ) iDocHeight = DrawNodesRecursive( pDC, pNode->pChild, x + m_iIndent, y + pNode->rNode.Height(), rFrame ); // If the node has siblings, then draw those if( pNode->pSibling != NULL ) iDocHeight += DrawNodesRecursive( pDC, pNode->pSibling, x, y + pNode->rNode.Height() + iDocHeight, rFrame ); return iDocHeight + pNode->rNode.Height(); } ///////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers void CStaticTreeCtrl::OnPaint() { .... .... // START DRAW ------------------------------------------------- pDCMem->FillSolidRect( rFrame, RGB(255,255,255) ); // Background UINT nMode = pDCMem->SetBkMode( TRANSPARENT ); CFont* pOldFont = pDCMem->SelectObject( &m_Font ); DrawNodesRecursive( pDCMem, m_pTopNode->pChild, rFrame.left + m_iIndent, m_iPadding, rFrame ); pDCMem->SelectObject( pOldFont ); pDCMem->SetBkMode( nMode ); pDCMem->Draw3dRect( rFrame, RGB(0,0,0), RGB(0,0,0) ); // Border // END DRAW ------------------------------------------------- .... .... }
在 `OnPaint` 中填充背景后,但在绘制边框之前(以确保我们不会覆盖它),应调用递归方法 `DrawNodesRecursive` 来绘制树节点。传递给 `DrawNodesRecursive` 的参数是绘制设备的句柄、要绘制的节点(我们不绘制顶层节点)、节点的位置(x 和 y)以及控件区域的尺寸。
那么,`DrawNodesRecursive` 方法是如何工作的呢?首先,它计算当前节点的大小并绘制其文本。其次,它遍历递归代码,这同样很简单。如果当前节点没有子节点或同级节点,则返回;否则,如果它**已打开并有子节点**,则绘制这些子节点,**完成后**,绘制任何其他同级节点(如果存在)。在脑海中想象一下,这是有道理的。如果一个节点已打开,我们需要在移动到下一个同级节点之前绘制其子节点。
(请注意,`m_iIndent` 和 `m_iPadding` 是默认值,`m_iLineHeight` 在 `SetTextFont` 中计算。)
如何在进行一次又一次的递归调用时计算要绘制的节点的坐标?毕竟,这是整个关键,对吧?嗯,在绘制子节点时,子节点的水平偏移量会增加 `m_iIndent`,这很容易做到,即当前位置加上几个像素(常量值 `m_iIndent`)。
现在,更困难一点,请注意垂直偏移量是树的当前**总**高度(即下一个要绘制的节点的位置)。计算树高度的一种时髦的方法是每次绘制节点时将其递增,然后将**其作为返回值**从 `DrawNodesRecursive` 返回。像往常一样,递归会产生简洁而强大的代码,起初可能有点难以理解,但不要绝望,不要污染水道,要坚持。您会想明白的。
编译并运行。您应该会看到类似以下内容
不错,它看起来确实像一棵树(请注意,您永远不知道会期望什么)……然而,您可能注意到有些节点的文本会延伸到侧面,所以让我们换行文本并将节点变成多行。
绘制树:自动换行和连接线
但是,哦啦啦,我的朋友,自动换行!到底是怎么做到的?嗯,让我们将节点的文本逐词分割,计算每行可以容纳多少,等等。类似这样
///////////////////////////////////////////////////////////////////////////// // PROTECTED METHODS ///////////////////////////////////////////////////////////////////////////// int CStaticTreeCtrl::HowMuchTextFits( CDC* pDC, int iAvailableWidth, CString csText ) { int iValidSoFar = csText.GetLength() - 1; // Assume the entire text fits // If the text's pixel width is larger than what's available if( pDC->GetTextExtent( csText ).cx > iAvailableWidth ) { int iNextBlank = 0; // Position of the next blank in text int iPixelWidth = 0; // Text's pixel width // Loop until we can fit no more of the text while( iPixelWidth < iAvailableWidth ) { iValidSoFar = iNextBlank; // Record the char pos so far iNextBlank = csText.Find( ' ', iNextBlank + 1 ); // Advance one word at a time // Have reached the end of the string? if( iNextBlank == -1 ) iNextBlank = csText.GetLength(); // Calculate the new width iPixelWidth = pDC->GetTextExtent( csText.Left( iNextBlank ) ).cx; } } return iValidSoFar; } int CStaticTreeCtrl::DrawNodesRecursive( CDC* pDC, HTREENODE pNode, int x, int y, CRect rFrame ) { .... .... // MULTILINE TEXT - begins CString cs = pNode->csLabel; int iPos = 0; // Draw text until there is nothing left to draw while( cs.GetLength() > 0 ) { // Height of a line of text rNode.bottom = rNode.top + m_iLineHeight; // Find out how much text fits in one line iPos = HowMuchTextFits( pDC, rFrame.right - m_iPadding - rNode.left, cs ); // Draw only if the node is visible if( rNode.bottom > 0 && rNode.top < rFrame.bottom ) pDC->DrawText( cs.Left( iPos + 1 ), rNode, DT_LEFT | DT_SINGLELINE | DT_VCENTER ); // Eliminate the text that has been already drawn cs = cs.Mid( iPos + 1 ); // The node grows everytime another line of text is drawn pNode->rNode.UnionRect( pNode->rNode, rNode ); // Move down the drawing rectangle for the next line of text rNode.top = rNode.bottom; } // MULTILINE TEXT - ends .... .... }
请注意,有一个内置的替代自动换行方法,即调用两次 `DrawText`,第一次使用格式标志 `DT_CALCRECT`,第二次实际绘制文本。使用 `DT_CALCRECT` 标志,`DrawText` 方法*“确定矩形的宽度和高度。如果有多行文本,DrawText
将使用 lpRect
指向的矩形的宽度,并扩展矩形底部以包含最后一行文本。如果只有一行文本,DrawText
将修改矩形右侧,使其包含该行的最后一个字符。在任一情况下,DrawText
返回格式化文本的高度,但不会绘制文本”*(来自文档)。像往常一样,编写自己的代码会给您带来更大的灵活性,对于这个项目,我选择了该路线。
现在只剩下连接线了,让我们添加它们。想法是连接线看起来像一个大写的“L”。因此,我们可以计算肘关节的位置,然后从那里绘制两条线,一条垂直线和一条水平线。
///////////////////////////////////////////////////////////////////////////// // PROTECTED METHODS ///////////////////////////////////////////////////////////////////////////// void CStaticTreeCtrl::DrawLinesRecursive( CDC* pDC, HTREENODE pNode ) { // Draw lines from childs if the node is open // before drawing lines from this node if( pNode->bOpen && pNode->pChild != NULL ) DrawLinesRecursive( pDC, pNode->pChild ); // Where is the elbow joint of this connecting line? int iJointX = pNode->rNode.left - m_iIndent - 6; int iJointY = pNode->rNode.top + ( m_iLineHeight / 2 ); // If the parent is not the top node, throw a connecting line to it if( pNode->pParent != m_pTopNode ) { // How far up from the joint is the parent? int iDispY = iJointY - pNode->pParent->rNode.top - ( m_iLineHeight / 2 ); // Use 1 pixel wide rectangles to draw lines pDC->FillSolidRect( iJointX, iJointY, m_iIndent, 1, m_crConnectingLines ); // Horizontal line pDC->FillSolidRect( iJointX, iJointY, 1, -iDispY, m_crConnectingLines ); // Vertical line } // Put a solid dot to mark a node pDC->FillSolidRect( iJointX + m_iIndent - 2, iJointY - 2, 5, 5, m_crConnectingLines ); // Hollow out the dot if the node has no childs if( pNode->pChild == NULL ) pDC->FillSolidRect( iJointX + m_iIndent - 1, iJointY - 1, 3, 3, RGB(255,255,255) ); // Draw the next sibling if there are any if( pNode->pSibling != NULL ) DrawLinesRecursive( pDC, pNode->pSibling ); }
现在,树看起来是这样的,带有连接线和多行换行文本
一个节点如果有关联的子节点,则有一个实心方点,如果没有,则是一个空心方点。简单而有效。
绘制树:滚动条
滚动条呢?需要什么样的疯狂才能让这种功能生效?别担心,几乎所有 CWin
派生类都可以带有滚动条,您只需要调用 ShowScrollBar( SB_VERT, TRUE )
然后处理 WM_VSCROLL
消息。这是代码
///////////////////////////////////////////////////////////////////////////// // PROTECTED METHODS ///////////////////////////////////////////////////////////////////////////// void CStaticTreeCtrl::ResetScrollBar() { // Flag to avoid a call from OnSize while resetting the scrollbar m_bScrollBarMessage = TRUE; CRect rFrame; GetClientRect( rFrame ); // Need for scrollbars? if( rFrame.Height() > m_iDocHeight + 8 ) { ShowScrollBar( SB_VERT, FALSE ); // Hide it SetScrollPos( SB_VERT, 0 ); } else { SCROLLINFO si; si.cbSize = sizeof(SCROLLINFO); si.fMask = SIF_PAGE | SIF_RANGE; si.nPage = rFrame.Height(); si.nMax = m_iDocHeight + 8; si.nMin = 0 ; SetScrollInfo(SB_VERT, &si); EnableScrollBarCtrl( SB_VERT, TRUE ); } m_bScrollBarMessage = FALSE; } ///////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers void CStaticTreeCtrl::OnPaint() { .... .... // START DRAW ------------------------------------------------- .... .... int iLastNodePos = 0 if( m_pTopNode->pChild != NULL ) { iLastNodePos = DrawNodesRecursive( pDCMem, m_pTopNode->pChild, rFrame.left + m_iIndent, m_iPadding - GetScrollPos( SB_VERT ), rFrame ); if( m_bShowLines ) DrawLinesRecursive( pDCMem, m_pTopNode->pChild ); } .... .... // END DRAW ------------------------------------------------- .... .... // Has the total document height changed? if( iLastNodePos != m_iDocHeight ) { BOOL bInvalidate = ( ( m_iDocHeight < rFrame.Height() ) != ( iLastNodePos < rFrame.Height() ) ); m_iDocHeight = iLastNodePos; ResetScrollBar(); // If the scrollbar has just been hidden/shown, repaint if( bInvalidate ) Invalidate(); } } void CStaticTreeCtrl::OnSize(UINT nType, int cx, int cy) { // Setting the scroll sends its own size message. // Prevent it thus avoiding an ugly loop. // Other than that, resizing the control means // that the tree height may change (word-wrap). if( !m_bScrollBarMessage ) ResetScrollBar(); CStatic::OnSize(nType, cx, cy); }
请记住,递归方法 `DrawNodesRecursive` 返回最后一个节点的位置,换句话说,就是树的高度。将此返回值与之前存储的值进行比较,可以告诉我们是否需要重新评估垂直滚动条(隐藏它或修改其范围)。此外,在调整控件大小时(这可能会发生,不要翻白眼),自动换行功能可能会改变树的高度,这也是组织调用 `ResetScrollBar` 的地方。
WM_VSCROLL
的实现如下。没有什么特别之处,除了 G. Steudtel 提出的一个问题值得一提(事实上,他提出了**几个**值得一看的问题)。当向树中加载大量节点时,滚动条行为异常。这是因为 SB_THUMBTRACK
和 SB_THUMBPOSITION
类型的滚动消息只有 16 位宽度。
/////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers void CStaticTreeCtrl::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pScrollBar) { int iScrollBarPos = GetScrollPos( SB_VERT ); CRect rFrame; GetClientRect( rFrame ); switch( nSBCode ) { case SB_LINEUP: iScrollBarPos = max( iScrollBarPos - m_iLineHeight, 0 ); break; case SB_LINEDOWN: iScrollBarPos = min( iScrollBarPos + m_iLineHeight, GetScrollLimit( SB_VERT ) ); break; case SB_PAGEUP: iScrollBarPos = max( iScrollBarPos - rFrame.Height(), 0 ); break; case SB_PAGEDOWN: iScrollBarPos = min( iScrollBarPos + rFrame.Height(), GetScrollLimit( SB_VERT ) ); break; case SB_THUMBTRACK: case SB_THUMBPOSITION: { SCROLLINFO si; ZeroMemory( &si, sizeof(SCROLLINFO) ); si.cbSize = sizeof(SCROLLINFO); si.fMask = SIF_TRACKPOS; if( GetScrollInfo( SB_VERT, &si, SIF_TRACKPOS ) ) iScrollBarPos = si.nTrackPos; else iScrollBarPos = (UINT)nPos; break; } } SetScrollPos( SB_VERT, iScrollBarPos ); Invalidate(); }
问题是,**我们根本没有收到点击滚动条的消息!!#**@$*!@#*?!!
这一点上,您可能会希望您从未投票选施瓦辛格上台,并且使用了 CWin
而不是 CStatic
作为基类。您会正确地抱怨,这在 CWin
中绝不会发生,什么样的**白痴**会想到给**静态控件**添加**滚动条**?
好了,好了……有一个解决方法。启动类向导,然后在消息(尽管这个不是)下,双击 `WindowProc`。包含以下代码
///////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers LRESULT CStaticTreeCtrl::WindowProc(UINT message, WPARAM wParam, LPARAM lParam) { if( message == WM_NCHITTEST || message == WM_NCLBUTTONDOWN || message == WM_NCLBUTTONDBLCLK ) return ::DefWindowProc( m_hWnd, message, wParam, lParam ); return CStatic::WindowProc(message, wParam, lParam); }
想法是通过重定向相关消息来避免静态控件的标准处理(感谢 Vasily Pavlik)。
鼠标:打开/关闭节点并使用滚轮滚动
树形控件需要响应鼠标单击,否则就没什么价值了。调出类向导,并为 `WM_LBUTTONUP` 和 `WM_MOUSEWHEEL` 添加消息处理程序。请注意,我们使用左键释放消息而不是左键按下消息,因为我们尊重那些在单击时容易改变主意的用户。数字化的政治正确?确实,让我们都为自己鼓掌,我们难道不是最棒的吗?
现在回到现实,只是一会儿,又一个巧妙的递归方法被用来遍历树,搜索被单击(或者更确切地说,单击的)节点(因为消息在鼠标按钮释放时处理)。
///////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS ///////////////////////////////////////////////////////////////////////////// void CStaticTreeCtrl::ToggleNode( HTREENODE pNode, BOOL bInvalidate /* = FALSE */) { ASSERT( pNode != NULL ); pNode->bOpen = !( pNode->bOpen ); if( bInvalidate ) Invalidate(); } ///////////////////////////////////////////////////////////////////////////// // PROTECTED METHODS ///////////////////////////////////////////////////////////////////////////// HTREENODE CStaticTreeCtrl::FindNodeByPoint( const CPoint& point, HTREENODE pNode ) { HTREENODE pFound = NULL; // Found it? if( pNode->rNode.PtInRect( point ) ) pFound = pNode; // If this node isn't it then check the node's childs // if it is open and there are any if( pFound == NULL && pNode->bOpen && pNode->pChild != NULL ) pFound = FindNodeByPoint( point, pNode->pChild ); // If didn't find it among the node's childs, then check the next sibling if( pFound == NULL && pNode->pSibling != NULL ) pFound = FindNodeByPoint( point, pNode->pSibling ); return pFound; } ///////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers void CStaticTreeCtrl::OnLButtonUp(UINT nFlags, CPoint point) { HTREENODE pClickedOn = NULL; // Assume no node was clicked on if( m_pTopNode->pChild != NULL) // If the tree is populated, search it pClickedOn = FindNodeByPoint( point, m_pTopNode->pChild ); if( pClickedOn != NULL ) // If a node was clicked on ToggleNode( pClickedOn, TRUE ); else CStatic::OnLButtonUp(nFlags, point); } BOOL CStaticTreeCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { // zDelta greater than 0, means rotating away // from the user, that is, scrolling up OnVScroll( ( zDelta > 0 )? SB_LINEUP:SB_LINEDOWN, 0, NULL ); return CStatic::OnMouseWheel(nFlags, zDelta, pt); }
唯一需要注意的是,控件**必须具有焦点**才能接收 WM_MOUSEWHEEL
消息。没什么奇怪的,我只是以防万一您开始折腾鼠标滚轮而没有任何反应,所以告诉您。先单击树形控件,或者确保它具有焦点,然后像 Fonda 一样,用尽洪荒之力转动滚轮。
收尾工作:位图背景和上下文菜单
是的,这一切都不是必需的,树形控件已经可以工作了,并且在所有实际用途上都已完成。然而,墨菲定律建议,出于突然的奇想,要篡改完美的正常代码。系好安全带。
位图背景代码一点也不难。我们需要一个公共方法来选择位图文件,并在 `OnPaint()` 方法中稍作修改,以便在绘制树节点和线条之前,在设备上下文中绘制位图。类似这样
///////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS ///////////////////////////////////////////////////////////////////////////// void CStaticTreeCtrl::SetBackgroundBitmap( BOOL bInvalidate /* = FALSE */) { CFileDialog fd( TRUE, NULL, NULL, OFN_EXPLORER | OFN_FILEMUSTEXIST, "Bitmap Files (*.bmp)|*.bmp||", this ); // If the user clicked 'ok' if( fd.DoModal() == IDOK ) { // If there is a bitmap already loaded, delete it if( m_bmpBackground.GetSafeHandle() != NULL ) m_bmpBackground.DeleteObject(); // Load the bitmap from the file selected HBITMAP hBitmap = (HBITMAP)LoadImage( NULL, fd.GetPathName(), IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION | LR_DEFAULTSIZE ); // Attach it to the CBitmap object m_bmpBackground.Attach( hBitmap ); // Repaint if so desired if( bInvalidate ) Invalidate(); } } ///////////////////////////////////////////////////////////////////////////// // CStaticTreeCtrl message handlers void CStaticTreeCtrl::OnPaint() { .... .... // START DRAW ------------------------------------------------- // If there is a bitmap loaded, use it // Otherwise, paint the background white if( m_bmpBackground.GetSafeHandle() != NULL ) { CDC* pDCTemp = new CDC;; BITMAP bmp; pDCTemp->CreateCompatibleDC( &dc ); m_bmpBackground.GetBitmap( &bmp ); // Select the bitmap into the temp device context CBitmap* pOldBitmap = (CBitmap*) pDCTemp->SelectObject( &m_bmpBackground ); // Stretch the bitmap to fill the entire control area pDCMem->StretchBlt( 0, 0, rFrame.Width(), rFrame.Height(), pDCTemp, 0, 0, bmp.bmWidth, bmp.bmHeight, SRCCOPY); pDCTemp->SelectObject( pOldBitmap ); delete pDCTemp; } else pDCMem->FillSolidRect( rFrame, RGB(255,255,255) ); UINT nMode = pDCMem->SetBkMode( TRANSPARENT ); CFont* pOldFont = pDCMem->SelectObject( &m_Font ); int iLastNodePos = 0 if( m_pTopNode->pChild != NULL ) { .... .... // END DRAW ------------------------------------------------- .... .... }
您也可以轻松地进行设置,以便在没有位图时,背景色可以更改或设置为渐变色等。
我们将通过为我们的树添加一个所有者绘制的音频上下文菜单来完成本教程,从中可以访问节点功能、视觉效果等。嘿,嘿,嘿……等一下,Mork from Ork,你说的是**音频**上下文菜单?没错,Mindy,这是吓唬爷爷或欺负你妹妹的机会。
所以,让我们创建一个派生自 CMenu
的新类。
将菜单转为所有者绘制的基本步骤是覆盖两个虚拟方法,即 `MeasureItem` 和 `DrawItem`(猜猜它们各自做什么)。但首先,让我们用一个受保护的类来设置它,用于存储菜单项信息和其他相关公共方法。
public: virtual CContextMenu& AppendMenuItem ( UINT nFlags, UINT nID, CString csText, CString csWavFile, CDC* pDC ); virtual CContextMenu& SetTextFont ( CFont* font ); virtual CContextMenu& SetColors ( COLORREF crText, COLORREF crBackground, COLORREF crDisabled, COLORREF crSelected, COLORREF crBorder ); protected: class CContextMenuItem { public: CContextMenuItem( CString csText, CString csWavFile ) { m_csText = csText; m_csWavFile = csWavFile; } ~CContextMenuItem() { m_csText.Empty(); m_csWavFile.Empty(); } CString m_csText; CString m_csWavFile; };
CContextMenuItem
类可以用于存储菜单项的任何相关信息,无论是图标、缩略图位图、URL 等。在这里,我编写了基本功能(文本)和可选功能(Wav 文件名)。
`SetColors` 和 `SetTextFont` 方法仅填充受保护的成员。然而,`AppendMenuItem` 方法执行一些有趣的操作。首先,它创建一个菜单项对象,其中包含菜单文本和要播放的 Wav 文件名。其次,它以 `MF_OWNERDRAW` 作为标志之一(这将触发对 `MeasureItem` 和 `DrawItem` 的调用)将此对象添加到菜单中。第三,计算并存储菜单项文本的大小。
///////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS ///////////////////////////////////////////////////////////////////////////// CContextMenu& CContextMenu::AppendMenuItem( UINT nFlags, UINT nID, CString csText, CString csWavFile, CDC* pDC ) { CContextMenuItem* cccItem = new CContextMenuItem( csText, csWavFile ); // Store the pointer m_cptrMenuItems.Add( cccItem ); // Append menu CMenu::AppendMenu( nFlags | MF_OWNERDRAW, nID, (ODDCHAR*)cccItem ); // Calculate the size of the menu's text if( !csText.IsEmpty() ) { CSize cSize = pDC->GetTextExtent( csText ); m_iWidth = max( m_iWidth, cSize.cx ); m_iHeight = max( m_iHeight, 8 + cSize.cy ); } return *this; } ////////////////////////////////////////////////////////////////////// // Construction/Destruction ////////////////////////////////////////////////////////////////////// CContextMenu::~CContextMenu() { for( int i = 0; i < m_cptrMenuItems.GetSize(); i++ ) delete (CContextMenuItem*)( m_cptrMenuItems.GetAt( i ) ); m_cptrMenuItems.RemoveAll(); }
受保护的 CPtrArray
对象 `m_cptrMenuItems` 用于存储每个菜单项的指针。而且,别忘了,就像我做的那样(谢谢 Steve Mayfield),在上下文菜单被丢弃后,在析构函数中删除所有菜单项对象!
现在,我们按如下方式处理 `MeasureItem`。每次系统需要知道菜单项大小时,它都会调用此方法。关键的见解是,每个单独的菜单项都可以有自己的高度。
void CContextMenu::MeasureItem( LPMEASUREITEMSTRUCT lpMIS ) { // Separator? if( GetMenuState( lpMIS->itemID, MF_BYCOMMAND ) & MF_SEPARATOR ) { lpMIS->itemWidth = m_iWidth; lpMIS->itemHeight = 6; } else { lpMIS->itemWidth = m_iWidth; lpMIS->itemHeight = m_iHeight; } }
最后,任何所有者绘制菜单的核心,即 `DrawItem` 方法。此方法为每个项目调用一次,因此我们不能像处理树形控件那样一次性绘制所有内容。相反,我们一次绘制一个菜单项,因为系统会请求它们。看看代码,它有很多注释。
void CContextMenu::DrawItem( LPDRAWITEMSTRUCT lpDIS ) { // Get the relevant information CDC* pDC = CDC::FromHandle( lpDIS->hDC ); CRect rItem = lpDIS->rcItem; BOOL bSelected = lpDIS->itemState & ODS_SELECTED; UINT nAction = lpDIS->itemAction; UINT nState = GetMenuState( lpDIS->itemID, MF_BYCOMMAND ); CContextMenuItem* cccItem = reinterpret_cast<CContextMenuItem*>( lpDIS->itemData ); // Does this menu item need to be drawn? if( nAction & ODA_SELECT || nAction & ODA_DRAWENTIRE ) { // Background pDC->FillSolidRect( rItem, m_crBackground ); // Separator or Text if( nState & MF_SEPARATOR ) { rItem.DeflateRect( 4, 2, 4, 2 ); // A thin rectangle that could be anything you want pDC->FillSolidRect( rItem, m_crBorder ); } else { // Prepare the device context and store previous values COLORREF crOldColor = pDC->SetTextColor( m_crText ); int iMode = pDC->SetBkMode( TRANSPARENT ); CFont* pOldFont = pDC->SelectObject( m_pFont ); // Is the item disabled? if( nState & MFS_DISABLED ) { rItem.DeflateRect( 8, 0, 0, 0 ); pDC->SetTextColor( m_crDisabled ); pDC->DrawText( cccItem->m_csText, rItem, DT_VCENTER | DT_LEFT | DT_SINGLELINE ); } else { // If the item is selected, paint a rectangle, // change the background color // and play the wav file if relevant if( bSelected ) { rItem.DeflateRect( 2, 2, 2, 2 ); pDC->Draw3dRect( rItem, m_crBorder, m_crBorder ); rItem.DeflateRect( 1, 1, 1, 1 ); pDC->FillSolidRect( rItem, m_crSelected ); rItem.DeflateRect( 5, -3, 0, -3 ); if( m_bSoundOn ) { // Stop any currently playing wav PlaySound( NULL, NULL, SND_NOWAIT | SND_PURGE ); // Play this item's wav PlaySound( cccItem->m_csWavFile, NULL, SND_NOWAIT | SND_FILENAME | SND_ASYNC ); } } else rItem.DeflateRect( 8, 0, 0, 0 ); // Last, draw the text on top of everything else pDC->DrawText( cccItem->m_csText, rItem, DT_VCENTER | DT_LEFT | DT_SINGLELINE ); } // Clean up pDC->SelectObject( pOldFont ); pDC->SetBkMode( iMode ); pDC->SetTextColor( crOldColor ); } } }
魔力归结为从 `LPDRAWITEMSTRUCT` 结构中获取相关信息,然后遵循逻辑路径。菜单项是否需要绘制?如果需要,它是分隔符还是常规菜单项?如果它是菜单项,它是启用还是禁用?如果它已启用,是否还被选中?嗯,如果它被选中,那么就绘制一个邪恶的矩形,带有有趣的背景,然后再绘制文本。是的,既然我们在这里,我们也播放一个 Wav 文件来纪念这一场合。
请注意,为了能够使用 `PlaySound`,我们需要在源文件中包含头文件 *mmsystem.h*,**并在**项目(项目设置 -> 链接选项卡 ->“常规”类别 -> 对象/库模块)中包含库 *winmm.lib*。播放菜单中 Wav 的代码如此简单,以至于人们可能想知道为什么它不常被使用(我的意思是,除了它很容易变得烦人之外)。顺便说一句,如果找不到 Wav 文件或类似情况,您会听到滴答、叮当或一些其他优雅的系统提示音。
上下文菜单已完成。我们现在将为每个菜单项定义一个消息,并在我们的树形控件中为 `OnContextMenu` 编写代码,以动态创建菜单。我们开始吧。
// In StaticTreeCtrl.h #define CM_INSERTCHILD WM_APP + 10000 #define CM_INSERTSIBLING WM_APP + 10001 #define CM_DELETENODE WM_APP + 10002 #define CM_MODIFYNODETEXT WM_APP + 10003 #define CM_CHANGENODECOLOR WM_APP + 10004 #define CM_TOGGLECONNECTINGLINES WM_APP + 10010 #define CM_SETCONNECTINGLINESCOLOR WM_APP + 10011 #define CM_SETFONT WM_APP + 10020 #define CM_SETDEFAULTCOLOR WM_APP + 10021 #define CM_SETBACKGROUNDBITMAP WM_APP + 10022 #define CM_TOGGLEMENUSOUND WM_APP + 10030 // In StaticTreeCtrl.cpp void CStaticTreeCtrl::OnContextMenu(CWnd* /*pWnd*/, CPoint point) { CPoint cp( point ); // WM_CONTEXTMENU passes absolute coordinates, we need them local ScreenToClient( &cp ); // Find the node that has been clicked on if( m_pTopNode->pChild == NULL ) m_pSelected = NULL; // Empty tree else m_pSelected = FindNodeByPoint( cp, m_pTopNode->pChild ); CContextMenu ccmPopUp; ccmPopUp.CreatePopupMenu(); // Customize the menu appearance and behavior ccmPopUp .ToggleSound ( m_bAudioOn ) .SetTextFont ( &m_Font ) .SetColors ( RGB(70,36,36), RGB(253,249,249), RGB(172,96,96), RGB(244,234,234), RGB(182,109,109) ); // Get a device context so that it'll be possible for the context menu // to calculate the size of the menu item's text CDC *pDC = GetDC(); int iSaved = pDC->SaveDC(); CFont* pOldFont = pDC->SelectObject( &m_Font ); // ADDING MENU ITEMS - Start // If a node has been clicked on, use the first 45 chars of its text as the // first menu item (always disabled) if( m_pSelected != NULL ) { CString csDots = ( m_pSelected->csLabel.GetLength() > 45 )? _T("..."):_T(""); CString cs = m_pSelected->csLabel.Left( 45 ) + csDots; ccmPopUp.AppendMenuItem( MF_DISABLED, WM_APP, cs, _T(""), pDC ); ccmPopUp.AppendMenuItem( MF_SEPARATOR, 0, _T(""), _T(""), pDC ); } UINT nFlag = ( m_pSelected != NULL )? MF_ENABLED:MF_GRAYED; // Node related items ccmPopUp.AppendMenuItem( MF_ENABLED, CM_INSERTCHILD, _T("Insert Child"), _T("insertChild.wav"), pDC ); ccmPopUp.AppendMenuItem( nFlag, CM_INSERTSIBLING, _T("Insert Sibling"), _T("insertSibling.wav"), pDC ); ccmPopUp.AppendMenuItem( nFlag, CM_DELETENODE, _T("Delete Node"), _T("deleteNode.wav"), pDC ); ccmPopUp.AppendMenuItem( nFlag, CM_MODIFYNODETEXT, _T("Modify Node Text"), _T("modifyNodeText.wav"), pDC ); ccmPopUp.AppendMenuItem( nFlag, CM_CHANGENODECOLOR, _T("Change Node Color"), _T("changeNodeColor.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_SEPARATOR, 0, _T(""), _T(""), pDC ); // Connecting lines related items ccmPopUp.AppendMenuItem( MF_ENABLED, CM_TOGGLECONNECTINGLINES, _T("Toggle Connecting Lines"), _T("toggleConnectingLines.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_ENABLED, CM_SETCONNECTINGLINESCOLOR, _T("Set Connecting Lines Color"), _T("setConnectingLinesColor.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_SEPARATOR, 0, _T(""), _T(""), pDC ); // Tree appearance items ccmPopUp.AppendMenuItem( MF_ENABLED, CM_SETFONT, _T("Set Font"), _T("setFont.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_ENABLED, CM_SETDEFAULTCOLOR, _T("Set Default Text Color"), _T("setDefaultColor.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_ENABLED, CM_SETBACKGROUNDBITMAP, _T("Set Background Bitmap"), _T("setBackgroundBitmap.wav"), pDC ); ccmPopUp.AppendMenuItem( MF_SEPARATOR, 0, _T(""), _T(""), pDC ); // Context menu sound toggle item ccmPopUp.AppendMenuItem( MF_ENABLED, CM_TOGGLEMENUSOUND, _T("Toggle Menu Sound"), _T("toggleMenuSound.wav"), pDC ); // ADDING MENU ITEMS - End // Display the context menu ccmPopUp.TrackPopupMenu( TPM_LEFTALIGN, point.x, point.y, this ); // Clean up pDC->SelectObject( pOldFont ); pDC->RestoreDC( iSaved ); ReleaseDC( pDC ); }
`OnContextMenu` 方法即使对于胆小的人来说也很容易执行。首先,找出在调用上下文菜单时鼠标悬停在哪个节点上(当响应上下文菜单生成的消息时,我们将需要此信息)。然后,创建菜单,自定义外观,并用适当的条目(启用或禁用)填充它。
当用户单击菜单项时,相应的消息会发送到所有者窗口(树形控件)。因此,我们编写代码来处理这些消息。这需要三个步骤:首先,声明受保护的方法来处理所有消息;其次,在源文件中的消息映射中添加 `ON_COMMAND` 宏;第三,编写这些方法的实现。
声明如下。请查看源代码以了解这些方法的实现,那里没有什么特别之处。
// In StaticTreeCtrl.h protected: .... .... // Message handlers void OnCM_InsertChild(); void OnCM_InsertSibling(); void OnCM_DeleteNode(); void OnCM_ModifyNodeText(); void OnCM_ChangeNodeColor(); void OnCM_ToggleConnectingLines(); void OnCM_SetConnectingLinesColor(); void OnCM_SetFont(); void OnCM_SetDefaultColor(); void OnCM_SetBackgroundBitmap(); void OnCM_ToggleMenuSound(); // In StaticTreeCtrl.cpp BEGIN_MESSAGE_MAP(CStaticTreeCtrl, CStatic) //{{AFX_MSG_MAP(CStaticTreeCtrl) .... .... ON_COMMAND(CM_INSERTCHILD, OnCM_InsertChild) ON_COMMAND(CM_INSERTSIBLING, OnCM_InsertSibling) ON_COMMAND(CM_DELETENODE, OnCM_DeleteNode) ON_COMMAND(CM_MODIFYNODETEXT, OnCM_ModifyNodeText) ON_COMMAND(CM_CHANGENODECOLOR, OnCM_ChangeNodeColor) ON_COMMAND(CM_TOGGLECONNECTINGLINES, OnCM_ToggleConnectingLines) ON_COMMAND(CM_SETCONNECTINGLINESCOLOR, OnCM_SetConnectingLinesColor) ON_COMMAND(CM_SETFONT, OnCM_SetFont) ON_COMMAND(CM_SETDEFAULTCOLOR, OnCM_SetDefaultColor) ON_COMMAND(CM_SETBACKGROUNDBITMAP, OnCM_SetBackgroundBitmap) ON_COMMAND(CM_TOGGLEMENUSOUND, OnCM_ToggleMenuSound) //}}AFX_MSG_MAP END_MESSAGE_MAP()
编译并运行。我们完成了。我想。我再也感觉不到我的脚趾了。
反馈
我的目的是提供一个代码清晰、易于理解和遵循的教程。我相信这里实现的功能有更好的解决方案。任何能改进、简化或更好地解释代码的建议都受欢迎。
致谢
对于演示项目,我使用了一个旧版本的 Paolo Messina 的 CResizableDialog,它在我硬盘里闲置着。很棒的代码,谢谢 Paolo。
除此之外,我还要感谢 CodeProject 的所有人。我感谢那些促成此事的人,特别是那些继续免费分享他们知识的人。谢谢大家。