CListCtrl 和单元格导航






4.83/5 (10投票s)
实现 MFC 列表控件中子项焦点的示例
引言
Microsoft 的 CListCtrl
支持在网格中显示数据,但它本身并不支持子项键盘导航。
本文将演示以下内容
- 如何监听事件以了解当前哪个单元格具有焦点。
- 如何在保持 Windows XP/Vista 外观的同时,显示当前哪个单元格具有焦点。
背景
有许多高级网格控件可以扩展 CListCtrl
以实现子项导航。但是,由于这些网格控件可能非常复杂,因此很难理解它们是如何实现的。
文章 列表控件中的子项选择 演示了如何使用自定义绘制来显示单元格焦点,但它不支持选择多行,也不支持 Windows Vista。
如何在 CListCtrl 中实现子项导航
有几个问题需要注意
- 必须对左、右箭头键的键盘事件做出响应,并相应地更新焦点单元格。
CListCtrl
已支持上下箭头键,因此无需处理它们。
- 必须对左键鼠标事件做出响应,并相应地更新焦点单元格。
- 在更新具有焦点的单元格时,必须注意
CHeaderCtrl
中的列显示顺序。 - 在更新具有焦点的单元格时,必须检查是否需要滚动以显示整个单元格。
- 必须修改
CListCtrl
的绘制,使焦点单元格对用户可见。 - 将键盘搜索导航扩展到支持在焦点列中进行搜索。
CListCtrl
已处理行选择,因此在实现子项焦点时,我们必须确保此功能保持不变。
处理键盘事件
通常,在 CListCtrl
中处理键盘事件时,我们会响应 CListCtrl
本身生成的 LVN_KEYDOWN
事件。但是,如果 CListCtrl
有水平滚动条(很多列),那么左右箭头键事件会导致 CListCtrl
滚动。因此,我们必须在 CListCtrl
响应之前拦截键盘事件,这可以通过 ON_WM_KEYDOWN()
来完成。
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_KEYDOWN() // OnKeydown
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
// Catch event before the parent listctrl gets it to avoid extra scrolling
// - OBS! This can also prevent the key-events to reach LVN_KEYDOWN handlers
switch(nChar)
{
case VK_RIGHT: MoveFocusCell(true); return; // Do not allow scroll
case VK_LEFT: MoveFocusCell(false); return; // Do not allow scroll
}
CListCtrl::OnKeyDown(nChar, nRepCnt, nFlags);
}
MoveFocusCell()
函数将在本文稍后详细介绍。
处理鼠标事件
当用户使用左键单击子项时,我们必须为其设置焦点。在找出哪个子项被单击后,我们将事件传递给 CListCtrl
,以便它可以处理正常的行选择。
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_LBUTTONDOWN() // OnLButtonDown()
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnLButtonDown(UINT nFlags, CPoint point)
{
// Find out what subitem was clicked
LVHITTESTINFO hitinfo = {0};
hitinfo.flags = nFlags;
hitinfo.pt = point;
SubItemHitTest(&hitinfo);
// Update the focused cell before calling CListCtrl::OnLButtonDown()
// as it might cause a row-repaint
m_FocusCell = hitinfo.iSubItem;
CListCtrl::OnLButtonDown(nFlags, point);
// CListCtrl::OnLButtonDown() doesn't always cause a row-repaint,
// call our own method to ensure the row is repainted
UpdateFocusCell(hitinfo.iSubItem);
}
// Force redraw of focus row, so the focus cell becomes visible
void CListCtrl_CellNav::UpdateFocusCell(int nCol)
{
m_FocusCell = nCol; // Update focus cell before starting re-draw
int nFocusRow = GetNextItem(-1, LVNI_FOCUSED);
if (nFocusRow >= 0)
{
CRect itemRect;
VERIFY( GetItemRect(nFocusRow, itemRect, LVIR_BOUNDS) );
InvalidateRect(itemRect);
UpdateWindow();
}
}
我们还需要处理右键单击鼠标事件 ON_WM_RBUTTONDOWN()
,但它与左键单击鼠标事件的处理方式几乎相同,可以在源代码中看到。
处理列显示顺序
Microsoft 扩展了 CListCtrl
的功能,使其能够通过拖放(LVS_EX_HEADERDRAGDROP
)来更改列的顺序。当一列被拖到新的位置时,它会保留其列 ID,但 CHeaderCtrl
中的显示顺序会改变。
我们必须使用显示顺序来查找下一列,这项工作由 MoveFocusCell()
处理。
void CListCtrl_CellNav::MoveFocusCell(bool right)
{
if (GetItemCount()<=0)
{
m_FocusCell = -1; // Entire row selected
return;
}
if (m_FocusCell == -1)
{
// Entire row already selected
if (right)
{
// Change to the first column in the current order
m_FocusCell = GetHeaderCtrl()->OrderToIndex(0);
}
}
else
{
// Convert focus-cell to order index
int nOrderIndex = -1;
for(int i = 0; i < GetHeaderCtrl()->GetItemCount(); ++i)
{
int nCol = GetHeaderCtrl()->OrderToIndex(i);
if (nCol == m_FocusCell)
{
nOrderIndex = i;
break;
}
}
// Move to the following column
if (right)
nOrderIndex++;
else
nOrderIndex--;
// Convert order-index to focus cell
if (nOrderIndex >= 0
&& nOrderIndex < GetHeaderCtrl()->GetItemCount())
{
m_FocusCell = GetHeaderCtrl()->OrderToIndex(nOrderIndex);
}
else if (!right)
m_FocusCell = -1; // Entire row selection
}
// Ensure the column is visible
if (m_FocusCell >= 0)
{
VERIFY( EnsureColumnVisible(m_FocusCell, false) );
}
// Ensure the row is repainted, so the focused cell is visible
UpdateFocusCell(m_FocusCell);
}
确保列可见
CListCtrl
可以包含多列,在使用箭头键导航时,我们必须确保焦点列对用户可见。这通过滚动到焦点列来实现,我几乎是“窃取”了 Daniel Frey 的文章中的代码:确保列(部分)可见。
BOOL CListCtrl_CellNav::EnsureColumnVisible(int nCol, bool bPartialOK)
{
if (nCol < 0 || nCol >= GetHeaderCtrl()->GetItemCount())
return FALSE;
CRect rcHeader;
if (GetHeaderCtrl()->GetItemRect(nCol, rcHeader)==FALSE)
return FALSE;
CRect rcClient;
GetClientRect(&rcClient);
int nOffset = GetScrollPos(SB_HORZ);
if(bPartialOK)
{
if((rcHeader.left - nOffset < rcClient.right)
&& (rcHeader.right - nOffset > 0) )
{
return TRUE;
}
}
int nScrollX = 0;
if((rcHeader.Width() > rcClient.Width()) || (rcHeader.left - nOffset < 0))
{
nScrollX = rcHeader.left - nOffset;
}
else if(rcHeader.right - nOffset > rcClient.right)
{
nScrollX = rcHeader.right - nOffset - rcClient.right;
}
if(nScrollX != 0)
{
CSize size(nScrollX, 0);
if (Scroll(size)==FALSE)
return FALSE;
}
return TRUE;
}
处理焦点子项的自定义绘制
CListCtrl
会自行处理项目和选择的绘制。如果按住 CTRL 键并使用箭头键导航,我们会看到 CListCtrl
只使用一个焦点矩形来显示焦点行。
通过自定义绘制,我们可以改变行焦点矩形的正常绘制。技巧是移除告诉 CListCtrl
它应该绘制焦点矩形(针对整行)的标志,而是提供我们自己如何绘制行焦点矩形(针对焦点子项)的实现。我们很快就会发现,当在选择的行内移动时,焦点矩形很难看到。这可以通过移除告诉 CListCtrl
它应该绘制选择背景(针对焦点子项)的标志来解决。
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_NOTIFY_REFLECT(NM_CUSTOMDRAW, OnCustomDraw)
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnCustomDraw(NMHDR* pNMHDR, LRESULT* pResult)
{
NMLVCUSTOMDRAW* pLVCD = (NMLVCUSTOMDRAW*)(pNMHDR);
int nRow = (int)pLVCD->nmcd.dwItemSpec;
int nRowItemData = (int)pLVCD->nmcd.lItemlParam;
switch (pLVCD->nmcd.dwDrawStage)
{
case CDDS_PREPAINT:
*pResult |= CDRF_NOTIFYITEMDRAW;
break;
// Before painting a row
case CDDS_ITEMPREPAINT:
{
if (pLVCD->nmcd.uItemState & CDIS_FOCUS)
{
// If drawing focus row, then remove focus state
// and request to draw it later
if (GetNextItem(-1, LVNI_FOCUSED)==nRow)
{
if (m_FocusCell >= 0)
{
// We want to draw a cell-focus-rectangle
// instead of row-focus-rectangle
pLVCD->nmcd.uItemState &= ~CDIS_FOCUS;
*pResult |= CDRF_NOTIFYPOSTPAINT;
}
}
}
if (pLVCD->nmcd.uItemState & CDIS_SELECTED)
{
// Remove the selection color for the focus cell,
// to make it easier to see focus
if (m_FocusCell!=-1)
*pResult |= CDRF_NOTIFYSUBITEMDRAW;
}
} break;
// Before painting a cell
case CDDS_ITEMPREPAINT | CDDS_SUBITEM:
{
// Remove the selection color for the focus cell,
// to make it easier to see focus
int nCol = pLVCD->iSubItem;
if (pLVCD->nmcd.uItemState & CDIS_SELECTED
&& m_FocusCell==nCol
&& GetNextItem(-1, LVNI_FOCUSED)==nRow)
{
pLVCD->nmcd.uItemState &= ~CDIS_SELECTED;
}
} break;
// After painting the entire row
case CDDS_ITEMPOSTPAINT:
{
if (GetNextItem(-1, LVNI_FOCUSED)!=nRow)
break;
// Perform the drawing of the focus rectangle
if (m_FocusCell >= 0)
{
// Draw the focus-rectangle for a single-cell
CRect rcHighlight;
CDC* pDC = CDC::FromHandle(pLVCD->nmcd.hdc);
VERIFY( GetCellRect(nRow, m_FocusCell, rcHighlight) );
pDC->DrawFocusRect(rcHighlight);
}
} break;
}
}
// Used instead of GetSubItemRect(), which returns the entire
// row-rect for label-column (nCol==0)
BOOL CListCtrl_CellNav::GetCellRect(int nRow, int nCol, CRect& rect)
{
// Find the top and bottom of the cell-rectangle
CRect rowRect;
if (GetItemRect(nRow, rowRect, LVIR_BOUNDS)==FALSE)
return FALSE;
// Find the left and right of the cell-rectangle using the CHeaderCtrl
CRect colRect;
if (GetHeaderCtrl()->GetItemRect(nCol, colRect)==FALSE)
return FALSE;
// Adjust for scrolling
colRect.left -= GetScrollPos(SB_HORZ);
colRect.right -= GetScrollPos(SB_HORZ);
rect.left = colRect.left;
rect.top = rowRect.top;
rect.right = colRect.right;
rect.bottom = rowRect.bottom;
return TRUE;
}
使用 GetCellRect()
的原因在于 GetSubItemRect()
无法为标签列(即使使用 LVIR_LABEL
)返回正确的焦点矩形。标签列是插入到 CListCtrl
中的第一列,与后面插入的列相比,它有一些细微的差别。
我已排除处理扩展样式 LVS_EX_GRIDLINES
的详细信息,但可以在源代码中找到。这是因为,当 CListCtrl
绘制网格线时,我们必须确保焦点矩形不会绘制在网格线上方。
将键盘搜索扩展到子项
Microsoft Windows 资源管理器允许用户跳转到键盘输入字符所匹配的第一行。这使得行导航更加容易,并且 CListCtrl
也支持此功能,但仅限于标签列。我们希望处理键盘字符事件,但我们将使用 ON_WM_CHAR()
而不是 ON_WM_KEYDOWN()
,因为我们只需要字母和数字,而不是所有键盘字符。
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_CHAR() // OnChar
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags)
{
// No input within 2 seconds, resets the search
if (m_LastSearchTime.GetCurrentTime() >= (m_LastSearchTime+2)
&& m_LastSearchString.GetLength()>0)
m_LastSearchString = "";
// Changing cells, resets the search
if (m_LastSearchCell!=m_FocusCell)
m_LastSearchString = "";
// Changing rows, resets the search
if (m_LastSearchRow!=GetNextItem(-1, LVNI_FOCUSED))
m_LastSearchString = "";
m_LastSearchCell = m_FocusCell;
m_LastSearchTime = m_LastSearchTime.GetCurrentTime();
if ( m_LastSearchString.GetLength()==1
&& m_LastSearchString.GetAt(0)==nChar)
{
// When the same first character is entered again,
// then just repeat the search
}
else
m_LastSearchString.AppendChar(nChar);
int nRow = GetNextItem(-1, LVNI_FOCUSED);
if (nRow < 0)
nRow = 0;
int nCol = m_FocusCell;
if (nCol < 0)
nCol = GetHeaderCtrl()->OrderToIndex(0);
int nRowCount = GetItemCount();
// Perform the search loop twice
// - First search from current position down to bottom
// - Then search from top to current position
for(int j = 0; j < 2; ++j)
{
for(int i = nRow + 1; i < nRowCount; ++i)
{
CString cellText = GetItemText(i, nCol);
if (cellText.GetLength()>=m_LastSearchString.GetLength())
{
cellText.Truncate(m_LastSearchString.GetLength());
if (cellText.CompareNoCase(m_LastSearchString)==0)
{
// De-select all other rows
SetItemState(-1, 0, LVIS_SELECTED);
// Select row found
SetItemState(i, LVIS_SELECTED, LVIS_SELECTED);
// Focus row found
SetItemState(i, LVIS_FOCUSED, LVIS_FOCUSED);
// Scroll to row found
EnsureVisible(i, FALSE);
m_LastSearchRow = i;
return;
}
}
}
nRowCount = nRow;
nRow = -1;
}
}
我们特意阻止了该事件到达 CListCtrl::OnChar()
方法,因为它会尝试在标签列中进行搜索。
关注点
如果使用 Windows XP,则由于标签列(第一列)的特殊边距,会看到以下绘制缺陷。当在中间拖动标签列时,整个行的选择标记将出现一个间隙,背景会透过。
如果在没有 Windows XP 主题或 Windows Vista 主题的经典样式下运行应用程序,那么当焦点移到另一行时,焦点矩形的左边框将保留在前一行上可见。可以通过将焦点矩形的左边框移动几个像素来轻松修复此错误,但这并不能解决上述问题。相反,应该考虑隐藏标签列。
如果使用 Windows Vista,则移除单个单元格选择标记的技巧没有效果。幸运的是,即使在选择的行内,焦点矩形在 Vista 上也很容易看到。
Using the Code
提供的源代码包含一个支持单元格导航的简单 CListCtrl
实现(CListCtrl_CellNav
)。
历史
- 2008-08-22 - 文章首次发布
- 2008-08-23 - 添加了右键单击鼠标事件的处理
- 2008-08-29 - 添加了子项中的键盘搜索