Introduction
Microsoft's CListCtrl
has support for displaying data in a grid, but it will not support subitem keyboard navigation out of the box.
This article will demonstrate the following:
- How to listen for events that tell what cell currently has focus.
- How to display what cell currently has focus, while maintaining the Windows XP/Vista look.
Background
There are lots of advanced grid controls that extend the CListCtrl
so it can perform subitem navigation. But, because these grid controls can be very complex, it can be difficult to see how they do it.
The article SubItem Selection in List Control demonstrates how to use custom drawing to show cell focus, but it doesn't support selecting multiple rows, and it doesn't support Windows Vista.
How to Implement Subitem Navigation in CListCtrl
There are several issues one has to take care of:
- Have to react to the keyboard events for left and right arrow-keys, and update the focused cell accordingly.
CListCtrl
already supports the up and down arrow-keys, so no need to handle those.
- Have to react to events from the left mouse button, and update the focused cell accordingly.
- When updating which cell has focus, one must be aware of the column display order in
CHeaderCtrl
.
- When updating which cell has focus, one must check whether to scroll to display the whole cell.
- Have to modify the
CListCtrl
drawing to make the focused cell visible to the user.
- Extend the keyboard search navigation to also support searching in the focused column.
CListCtrl
already handles row selection, so we have to ensure that this feature stays intact when implementing subitem focus.
Handle Keyboard Events
Usually, when handling keyboard events in CListCtrl
, one would react to the LVN_KEYDOWN
event, which is generated by the CListCtrl
itself. But, if the CListCtrl
has a horizontal scrollbar (many columns), then the right- and left-arrow key-events will cause the CListCtrl
to scroll. Therefore, we have to intercept the keyboard event before the CListCtrl
reacts to it, and this can be done with ON_WM_KEYDOWN()
.
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_KEYDOWN()
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
switch(nChar)
{
case VK_RIGHT: MoveFocusCell(true); return;
case VK_LEFT: MoveFocusCell(false); return;
}
CListCtrl::OnKeyDown(nChar, nRepCnt, nFlags);
}
The MoveFocusCell()
function is explained in detail later in this article.
Handle Mouse Events
When the user clicks on a subitem using the left mouse-button, we have to give it focus. After having found out what subitem was clicked, we pass the event to the CListCtrl
so it can handle the normal row-selection.
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_LBUTTONDOWN()
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnLButtonDown(UINT nFlags, CPoint point)
{
LVHITTESTINFO hitinfo = {0};
hitinfo.flags = nFlags;
hitinfo.pt = point;
SubItemHitTest(&hitinfo);
m_FocusCell = hitinfo.iSubItem;
CListCtrl::OnLButtonDown(nFlags, point);
UpdateFocusCell(hitinfo.iSubItem);
}
void CListCtrl_CellNav::UpdateFocusCell(int nCol)
{
m_FocusCell = nCol;
int nFocusRow = GetNextItem(-1, LVNI_FOCUSED);
if (nFocusRow >= 0)
{
CRect itemRect;
VERIFY( GetItemRect(nFocusRow, itemRect, LVIR_BOUNDS) );
InvalidateRect(itemRect);
UpdateWindow();
}
}
We also need to handle the right-click mouse event ON_WM_RBUTTONDOWN()
, but it is pretty much identical to how the left-click mouse event is handled, which can be seen in the source code.
Handle Column Display Order
Microsoft extended the CListCtrl
with the ability to change the order of columns by using drag 'n' drop (LVS_EX_HEADERDRAGDROP
). When a column is dragged to a new position, it keeps its column-ID, but the display-order in the CHeaderCtrl
is changed.
We have to use the display-order to find the following column, and this job is handled by MoveFocusCell()
.
void CListCtrl_CellNav::MoveFocusCell(bool right)
{
if (GetItemCount()<=0)
{
m_FocusCell = -1;
return;
}
if (m_FocusCell == -1)
{
if (right)
{
m_FocusCell = GetHeaderCtrl()->OrderToIndex(0);
}
}
else
{
int nOrderIndex = -1;
for(int i = 0; i < GetHeaderCtrl()->GetItemCount(); ++i)
{
int nCol = GetHeaderCtrl()->OrderToIndex(i);
if (nCol == m_FocusCell)
{
nOrderIndex = i;
break;
}
}
if (right)
nOrderIndex++;
else
nOrderIndex--;
if (nOrderIndex >= 0
&& nOrderIndex < GetHeaderCtrl()->GetItemCount())
{
m_FocusCell = GetHeaderCtrl()->OrderToIndex(nOrderIndex);
}
else if (!right)
m_FocusCell = -1;
}
if (m_FocusCell >= 0)
{
VERIFY( EnsureColumnVisible(m_FocusCell, false) );
}
UpdateFocusCell(m_FocusCell);
}
Ensure that the Column is Visible
The CListCtrl
can contain multiple columns, and when using the arrow-keys to navigate, we have to ensure that the focused column is visible for the user. This is done by scrolling to the focused column, and I have pretty much "stolen" the code from Daniel Frey's article: Ensure (partial) visibility of a column.
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;
}
Handle Custom Drawing of Focused Subitem
The CListCtrl
handles the drawing of items and selection all by itself. If holding down the CTRL key and using the arrow-keys to navigate, then we will see that the CListCtrl
just uses a focus-rectangle to display the focused row.
With custom drawing, we can change the normal drawing of the row-focus rectangle. The trick is to remove the flag that tells the CListCtrl
that it should draw the focus-rectangle (for the entire row), and instead provide our own implementation of how to draw the row focus rectangle (for the subitem in focus). We will soon realize that the focus rectangle is quite hard to see when moving within a selected row. This is solved by removing the flag that tells the CListCtrl
that it should draw the selection background (for the subitem in focus).
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;
case CDDS_ITEMPREPAINT:
{
if (pLVCD->nmcd.uItemState & CDIS_FOCUS)
{
if (GetNextItem(-1, LVNI_FOCUSED)==nRow)
{
if (m_FocusCell >= 0)
{
pLVCD->nmcd.uItemState &= ~CDIS_FOCUS;
*pResult |= CDRF_NOTIFYPOSTPAINT;
}
}
}
if (pLVCD->nmcd.uItemState & CDIS_SELECTED)
{
if (m_FocusCell!=-1)
*pResult |= CDRF_NOTIFYSUBITEMDRAW;
}
} break;
case CDDS_ITEMPREPAINT | CDDS_SUBITEM:
{
int nCol = pLVCD->iSubItem;
if (pLVCD->nmcd.uItemState & CDIS_SELECTED
&& m_FocusCell==nCol
&& GetNextItem(-1, LVNI_FOCUSED)==nRow)
{
pLVCD->nmcd.uItemState &= ~CDIS_SELECTED;
}
} break;
case CDDS_ITEMPOSTPAINT:
{
if (GetNextItem(-1, LVNI_FOCUSED)!=nRow)
break;
if (m_FocusCell >= 0)
{
CRect rcHighlight;
CDC* pDC = CDC::FromHandle(pLVCD->nmcd.hdc);
VERIFY( GetCellRect(nRow, m_FocusCell, rcHighlight) );
pDC->DrawFocusRect(rcHighlight);
}
} break;
}
}
BOOL CListCtrl_CellNav::GetCellRect(int nRow, int nCol, CRect& rect)
{
CRect rowRect;
if (GetItemRect(nRow, rowRect, LVIR_BOUNDS)==FALSE)
return FALSE;
CRect colRect;
if (GetHeaderCtrl()->GetItemRect(nCol, colRect)==FALSE)
return FALSE;
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;
}
The reason for using GetCellRect()
is that GetSubItemRect()
cannot return the proper focus-rectangle for the label-column (even when using LVIR_LABEL
). The label column is the first column inserted in the CListCtrl
, and it has some small differences compared to the following inserted columns.
I have excluded the details about handling the extended style LVS_EX_GRIDLINES
, but this can be seen in the source code. This is because, when CListCtrl
draws grid lines, we have to ensure that the focus rectangle is not placed on top of the grid-lines.
Extending Keyboard Search to Subitems
Microsoft Windows Explorer allows one to skip to the first row matching the characters entered by the keyboard. This makes row navigation a lot easier, and this feature is also supported by the CListCtrl
, but only for the label column. We want to handle the keyboard characters events, but we will use ON_WM_CHAR()
instead of ON_WM_KEYDOWN()
, because we only want letters and digits and not all of the keyboard characters.
BEGIN_MESSAGE_MAP(CListCtrl_CellNav, CListCtrl)
ON_WM_CHAR()
END_MESSAGE_MAP()
void CListCtrl_CellNav::OnChar(UINT nChar, UINT nRepCnt, UINT nFlags)
{
if (m_LastSearchTime.GetCurrentTime() >= (m_LastSearchTime+2)
&& m_LastSearchString.GetLength()>0)
m_LastSearchString = "";
if (m_LastSearchCell!=m_FocusCell)
m_LastSearchString = "";
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)
{
}
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();
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)
{
SetItemState(-1, 0, LVIS_SELECTED);
SetItemState(i, LVIS_SELECTED, LVIS_SELECTED);
SetItemState(i, LVIS_FOCUSED, LVIS_FOCUSED);
EnsureVisible(i, FALSE);
m_LastSearchRow = i;
return;
}
}
}
nRowCount = nRow;
nRow = -1;
}
}
We have on purpose prevented the event to reach the CListCtrl::OnChar()
method, because it would attempt to search in the label-column.
Points of Interest
If using Windows XP, then one will see the following drawing flaws with the label-column (first column), because of its special margin. When dragging the label-column in the middle, then the selection marking of the entire row, will have a gap where the background shows through.
If running the application in classic style without Windows XP themes or Windows Vista themes, then the left border of the focus rectangle will remain visible on the previous row when moving focus to another row. One can easily fix this bug by moving the left border of the focus rectangle a few pixels, but this doesn't solve the issue above. Instead one should consider hiding the label column.
If using Windows Vista, then the trick of removing the selection marking for a single cell doesn't have any effect. Luckily enough, the focus rectangle is easy to see on Vista, even within a selected row.
Using the Code
The source code provided includes a simple implementation of a CListCtrl
which supports cell navigation (CListCtrl_CellNav
).
History
- 2008-08-22 - First release of the article
- 2008-08-23 - Added handling of the right-click mouse event
- 2008-08-29 - Added keyboard searching in subitems