自动调整大小的 CListCtrl 中抑制闪烁的滚动条






4.87/5 (27投票s)
如何避免在调整具有 LVSCW_AUTOSIZE_USEHEADER 值的最后一列的 CListCtrl 大小时出现的闪烁滚动条。
引言
LVSCW_AUTOSIZE_USEHEADER
值简化了列表视图处于详细信息视图时列宽的调整。已有几篇文章介绍了此值的使用(例如,“Autosize ListCtrl Header”)。但是,调整此类控件的大小可能会产生令人讨厌的伪影,而且我找不到任何能够一致处理此问题的资源。本文提出了一种避免这些问题的解决方案。该方案通过从 CListCtrl
控件派生的 CLastColumnAutoResizingListCtrl
类来实现。
背景
通常,调整列表视图控件中最后一列的宽度,使其延伸到控件的右侧末端非常有用。合乎逻辑的方法是,通过从列表控件的客户端矩形宽度中减去所有其他列的宽度来计算最后一列的宽度。为了简化这一点,已为列宽度引入了一个特殊值 LVSCW_AUTOSIZE_USEHEADER
(等于 -2
)。如果此值用于最后一列,则其宽度将自动计算以填充控件的剩余宽度。对于其他列,此值将列宽调整为适合其内容。
但是,如果控件被调整大小,最后一列的宽度将保持其初始宽度。如果我们理解 LVSCW_AUTOSIZE_USEHEADER
只是一个布局过程用于计算列宽的提示,而不是永久存储的,那么这是非常合乎逻辑的。因此,为了确保在每次调整大小时都调整列宽,有必要在每次调整大小事件后设置 LVSCW_AUTOSIZE_USEHEADER
标志。一种方法是在父对话框调整大小时处理此问题,但然后必须为每个列表控件实例单独应用。更好的方法是子类化 CListCtrl
并在重写的 WM_SIZE
消息处理程序中重新应用 LVSCW_AUTOSIZE_USEHEADER
值(假定读者熟悉如何添加消息处理程序,并且文章中只列出函数体)。
void CLastColumnAutoResizingListCtrl::OnSize(UINT nType, int cx, int cy)
{
CListCtrl::OnSize(nType, cx, cy);
// apply only when control is not minimized
if (cx > 0)
SetColumnWidth(GetLastColumnIndex(), LVSCW_AUTOSIZE_USEHEADER);
}
其中调用了 GetLastColumnIndex
辅助函数
int CLastColumnAutoResizingListCtrl::GetLastColumnIndex()
{
return GetHeaderCtrl()->GetItemCount() - 1;
}
这样,每个控件实例都会负责自己的列调整。
然而,在缩小控件时,水平滚动条会闪烁,如下面的动画截图所示。当同时调整多个列表控件大小时,或者当控件的高度导致水平和垂直滚动条同时闪烁时,这可能会非常令人讨厌。
此外,即使不需要,滚动条也可能保持可见。为了防止这种情况,OnSize
函数必须包含将窗口更新过程推迟到所有列都已评估完毕的语句。
void CLastColumnAutoResizingListCtrl::OnSize(UINT nType, int cx, int cy)
{
CListCtrl::OnSize(nType, cx, cy);
// apply only when control is not minimized
if (cx > 0) {
SetRedraw(FALSE);
SetColumnWidth(GetLastColumnIndex(), LVSCW_AUTOSIZE_USEHEADER);
SetRedraw(TRUE);
Invalidate();
UpdateWindow();
}
}
然而,闪烁滚动条的问题仍然存在。显然,在控件调整大小时,客户端区域的计算并不总是正确的。
避免闪烁滚动条
当应用 LVSCW_AUTOSIZE_USEHEADER
值时,最后一列的宽度会计算出来,以填充控件的右侧。在控件调整大小时,该列将保持该宽度,直到重新应用 LVSCW_AUTOSIZE_USEHEADER
值,因此当控件被缩小但滚动条出现时,会闪烁。为了在不需要时抑制滚动条,必须在控件重绘之前调整最后一列的宽度。这可以通过重写 WM_WINDOWPOSCHANGING
消息处理程序来完成。该函数以 WINDOWPOS
结构指针作为参数,该结构除了其他内容外,还包含控件调整大小后的新宽度。因此,足够了,评估宽度变化并相应地更正最后一列。
void CLastColumnAutoResizingListCtrl::OnWindowPosChanging(WINDOWPOS* lpwndpos)
{
CListCtrl::OnWindowPosChanging(lpwndpos);
// override only if control is resized
if ((lpwndpos->flags & SWP_NOSIZE) != 0)
return;
// get current size of the control
RECT rect;
GetWindowRect(&rect);
// calculate control width change
int deltaX = lpwndpos->cx - (rect.right - rect.left);
// if control is narrowed, correct the width of the last column
// to prevent horizontal scroll bar to appear
if (deltaX < 0) {
int lastColumnIndex = GetLastColumnIndex();
int columnWidth = GetColumnWidth(lastColumnIndex);
SetColumnWidth(lastColumnIndex, columnWidth + deltaX);
}
}
空白行问题
不幸的是,在使用上述实现时,派生列表控件会表现出一个奇怪的功能:如果列表向下滚动(垂直滚动条可见),然后放大直到垂直滚动条隐藏,第一个项目通常不会移到控件顶部,而是在列表顶部出现一个或多个空白行。这种情况如下图所示。
显然,在滚动条需要隐藏的时刻,布局没有正确评估。为了规避此问题,OnWindowPosChanging
函数成员必须包含检查可见滚动条是否将要隐藏的代码。在这种情况下,将调用 EnsureVisible
成员函数,以确保第一个项目滚动到客户端区域的顶部。
kipvoid CLastColumnAutoResizingListCtrl::OnWindowPosChanging(WINDOWPOS* lpwndpos)
{
CListCtrl::OnWindowPosChanging(lpwndpos);
// override only if control is resized
if ((lpwndpos->flags & SWP_NOSIZE) != 0)
return;
// get current size of the control
RECT rect;
GetWindowRect(&rect);
// calculate control width change
int deltaX = lpwndpos->cx - (rect.right - rect.left);
// if control is narrowed, correct the width of the last column
// to prevent horizontal scrollbar to appear
if (deltaX < 0) {
int lastColumnIndex = GetLastColumnIndex();
int columnWidth = GetColumnWidth(lastColumnIndex);
SetColumnWidth(lastColumnIndex, columnWidth + deltaX);
}
// calculate control height change
int deltaY = lpwndpos->cy - (rect.bottom - rect.top);
// if area decreases, skip further processing
if (deltaX <= 0 && deltaY <= 0)
return;
// is vertical scrollbar visible?
if (IsScrollBarVisible(WS_VSCROLL)) {
// height required for all items to be visible
int allItemsHeight = GetAllItemsHeight();
// row (i.e item) width
int rowWidth = GetRowWidth();
// calculate new client height and width after resize
RECT clientRect;
GetClientRect(&clientRect);
int newClientHeight = clientRect.bottom - GetHeaderHeight() + deltaY;
// is horizontal scrollbar is visible?
if (IsScrollBarVisible(WS_HSCROLL)) {
int newClientWidth = clientRect.right + deltaX;
int hScrollBarHeight = GetSystemMetrics(SM_CYHSCROLL);
int vScrollBarWidth = GetSystemMetrics(SM_CXVSCROLL);
// if both scrollbars will be hidden then correct
// new height of client area
if ((newClientHeight + hScrollBarHeight >= allItemsHeight) &&
(newClientWidth + vScrollBarWidth >= rowWidth))
newClientHeight += hScrollBarHeight;
// more code to come here! (see next section)
}
// ensure the first item is moved to the top
if (newClientHeight >= allItemsHeight)
EnsureVisible(0, FALSE);
}
}
在上面的代码中,使用了几个辅助函数。
int CLastColumnAutoResizingListCtrl::GetAllItemsHeight()
{
if (GetItemCount() == 0)
return 0;
RECT itemRectLast;
GetItemRect(GetItemCount() - 1, &itemRectLast, LVIR_BOUNDS);
RECT itemRectFirst;
GetItemRect(0, &itemRectFirst, LVIR_BOUNDS);
return itemRectLast.bottom - itemRectFirst.top;
}
int CLastColumnAutoResizingListCtrl::GetRowWidth()
{
if (GetItemCount() == 0)
return 0;
RECT rect;
GetItemRect(0, &rect, LVIR_BOUNDS);
return rect.right - rect.left;
}
int CLastColumnAutoResizingListCtrl::GetHeaderHeight()
{
RECT headerRect;
GetHeaderCtrl()->GetWindowRect(&headerRect);
return headerRect.bottom - headerRect.top;
}
bool CLastColumnAutoResizingListCtrl::IsScrollBarVisible(DWORD scrollBar)
{
return (GetWindowLong(m_hWnd, GWL_STYLE) & scrollBar) != 0;
}
类似地,当滚动条可见并且从列表中删除项目时,可能会出现前导空白行。因此,校正过程已提取到 PrepareForScrollbarsHiding()
方法中,该方法从上述描述的 OnWindowPosChanging()
方法和处理 LVN_DELETEITEM
通知 OnLvnDeleteitem()
方法中调用。
列偏移问题
扩展具有水平视图偏移的列表控件时,在水平滚动条隐藏的瞬间,控件左端可能会出现一个空白的垂直条纹,如下图最右侧所示。
显然,必须滚动视图才能将第一列对齐到左边框。这可以通过将以下语句插入 OnWindowPosChanging
函数来完成。
void CLastColumnAutoResizingListCtrl::OnWindowPosChanging(WINDOWPOS* lpwndpos)
{
// same as above
// following statements should replace
// "more code to come here" comment
// if vertical scrollbar is going to be hidden then
// correct new width of client area
if (newClientHeight >= allItemsHeight)
newClientWidth += vScrollBarWidth;
// horizontal scrollbar is going to be hidden...
if (newClientWidth >= rowWidth) {
// ...so scroll the view to the left to avoid
// blank column at the left end
SendMessage(WM_HSCROLL, LOWORD(SB_LEFT), NULL);
// ensure that bottom item remains visible
if (IsItemVisible(GetItemCount() - 1))
PostMessage(WM_VSCROLL, LOWORD(SB_BOTTOM), NULL);
}
// same as above (final if condition)
// ...
}
最后一个 if
条件确保在隐藏水平滚动条时底部项目仍然可见。如果省略此条件,则会发生下图右侧所示的情况(最后一个项目未滚动到视图中)。
标题调整
列表控件还应在调整任何列标题大小时调整最后一列的宽度。用户可以通过以下方式调整列的大小:
- 拖动列标题之间的分隔符
- 双击列标题分隔符,或者
- 同时按下数字键盘上的
<Ctrl>
和+
键
每个操作将单独处理。
拖动分隔符
如果用户将两个列标题之间的分隔符向右拖动,则会出现水平滚动条,因为最右边的列被推出了客户端区域。这会触发 WM_SIZE
消息,调用我们重写的 OnSize
函数,该函数重新评估列宽,然后滚动条将被隐藏(如果不需要)。显然,这会导致滚动条闪烁。为了避免这种闪烁,必须在控件重绘之前缩小最后一列。另一方面,如果用户将分隔符向左拖动,则必须加宽右边的列,否则它将偏离控件的右边框。
为了解决这个问题,必须处理在标题分隔符拖动期间调用的三个通知。
HDN_BEGINTRACK
,当用户开始拖动分隔符时- 一系列
HDN_ITEMCHANGING
通知,在拖动分隔符期间,最后 HDN_ENDTRACKING
,当用户释放分隔符时
当用户开始拖动分隔符时,将存储调整大小的列的当前宽度,并设置一个标志指示拖动过程正在进行。
void CLastColumnAutoResizingListCtrl::OnHdnBegintrack(NMHDR *pNMHDR, LRESULT *pResult)
{
LPNMHEADER phdr = reinterpret_cast<LPNMHEADER>(pNMHDR);
if ((phdr->pitem) != 0 && (phdr->pitem->mask & HDI_WIDTH) != 0) {
// prevent resizing the last column
if (phdr->iItem == GetLastColumnIndex()) {
*pResult = 1;
return;
}
// save current width of the column being resized
m_oldColumnWidth = phdr->pitem->cxy;
m_trackingHeaderDivider = TRUE;
}
*pResult = 0;
}
请注意,我们必须阻止调整最后一列的大小,因为它的宽度是由控件自动计算的。因此,对于该列,函数将 *pResult
设置为 1。m_oldColumnWidth
和 m_trackingHeaderDivider
分别是 int
和 BOOL
类型的类数据成员。
指示拖动过程的标志在 HND_ENDTRACKING
通知处理程序中重置。
void CLastColumnAutoResizingListCtrl::OnHdnEndtrack(NMHDR *pNMHDR, LRESULT *pResult) {
m_trackingHeaderDivider = FALSE;
*pResult = 0;
}
最后一列标题的调整大小在 HDN_ITEMCHANGING
通知处理程序中完成。
void CLastColumnAutoResizingListCtrl::OnHdnItemchanging(NMHDR *pNMHDR, LRESULT *pResult)
{
LPNMHEADER phdr = reinterpret_cast<LPNMHEADER>(pNMHDR);
if ((phdr->pitem) != 0 && (phdr->pitem->mask & HDI_WIDTH)
!= 0 && m_trackingHeaderDivider) {
int lastColumnIndex = GetLastColumnIndex();
// if resizing any column except the last one...
if (phdr->iItem < lastColumnIndex) {
SetRedraw(FALSE);
int newWidth = phdr->pitem->cxy;
// if column is being widened, correct width of the last column
// to avoid flickering horizontal scrollbar
if (newWidth > m_oldColumnWidth) {
int lastColumnWidth =
GetColumnWidth(lastColumnIndex) - newWidth + m_oldColumnWidth;
SetColumnWidth(lastColumnIndex, lastColumnWidth);
}
// if column is narrowed, set LVSCW_AUTOSIZE_USEHEADER for the last column
else
SetColumnWidth(lastColumnIndex, LVSCW_AUTOSIZE_USEHEADER);
// store new width of the column
m_oldColumnWidth = newWidth;
}
else {
// all columns have been resized, so redraw the control
SetRedraw(TRUE);
Invalidate();
UpdateWindow();
}
}
*pResult = 0;
}
在拖动过程中,该函数实际上是成对调用的。最初,它会为正在调整大小的列调用。在该调用中,函数设置最后一列的宽度,因此它也会为最后一列再次调用。
HDS_FULLDRAG 和 HDN_TRACK 问题
自 ComCtl32.dll 版本 4.70 以来,报表视图中的列表控件标题默认应用了 HDS_FULLDRAG
样式,以便在拖动时显示列内容。通过 HDN_ITEMCHANGING
消息发送列宽变化的通知。但是,如果未设置 HDS_FULLDRAG
,则会生成一系列 HDN_TRACK
消息,而不是 HDN_ITEMCHANGING
。
实际上,我们不需要处理 HDN_TRACK
通知,因为在用户释放分隔符之前,控件不会更新。此时,列表控件需要重绘列,并发送 HDN_ITEMCHANGING
通知,然后是 HDN_ITEMCHANGED
。将使用 HDN_ITEMCHANGED
通知处理程序来处理 HDS_FULLDRAG
重置的标题。由于该函数在分隔符释放后调用,因此 m_trackingHeaderDivider
数据成员将被重置并用作过滤器。
void CLastColumnAutoResizingListCtrl::OnHdnItemchanged(NMHDR *pNMHDR, LRESULT *pResult) {
LPNMHEADER phdr = reinterpret_cast<LPNMHEADER>(pNMHDR);
if ((phdr->pitem) != 0 && (phdr->pitem->mask &
HDI_WIDTH) != 0 && m_trackingHeaderDivider == FALSE) {
int lastColumnIndex = GetLastColumnIndex();
// if any column except the last one was resized
if (phdr->iItem < lastColumnIndex) {
SetRedraw(FALSE);
SetColumnWidth(lastColumnIndex, LVSCW_AUTOSIZE_USEHEADER);
SetRedraw(TRUE);
Invalidate();
UpdateWindow();
}
}
*pResult = 0;
}
双击分隔符
双击分隔符会将相应列的宽度调整为适合其内容。同样,必须重新调整最后一列的宽度。为了实现这一点,必须添加 HDN_DIVIDERDBLCLICK
通知处理程序。
void CLastColumnAutoResizingListCtrl::OnHdnDividerdblclick(NMHDR *pNMHDR, LRESULT *pResult)
{
LPNMHEADER phdr = reinterpret_cast<LPNMHEADER>(pNMHDR);
int lastColumnIndex = GetLastColumnIndex();
// prevent double-click resizing for the last column
if (phdr->iItem < lastColumnIndex) {
SetRedraw(FALSE);
SetColumnWidth(phdr->iItem, LVSCW_AUTOSIZE_USEHEADER);
SetColumnWidth(lastColumnIndex, LVSCW_AUTOSIZE_USEHEADER);
SetRedraw(TRUE);
Invalidate();
UpdateWindow();
}
*pResult = 0;
}
Ctrl 和加号组合键
按下数字键盘上的 Ctrl
和 +
键会将所有列的宽度调整为适合其内容。对于我们的列表控件,最后一列必须排除。为了实现这一点,实现了 WM_KEYDOWN
消息处理程序,在其中重写了键组合的基类实现。
void CLastColumnAutoResizingListCtrl::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags)
{
// handle CTRL + Add to adjust all column widths
if (nChar == VK_ADD && ::GetKeyState(VK_CONTROL) != 0) {
SetRedraw(FALSE);
for (int i = 0; i <= GetLastColumnIndex(); ++i)
SetColumnWidth(i, LVSCW_AUTOSIZE_USEHEADER);
SetRedraw(TRUE);
Invalidate();
UpdateWindow();
return;
}
CListCtrl::OnKeyDown(nChar, nRepCnt, nFlags);
}
读者可能会注意到,它只是遍历所有列,将 LVSCW_AUTOSIZE_USEHEADER
值应用于每个列。
光标外观
上述实现(希望)解决了我们列表控件的所有功能方面。仍然存在一个视觉方面:虽然无法手动调整最后一列的大小,但光标会在控件右边缘的最后一个分隔符上方改变形状。为了解决这个问题,列表控件的标题将被 CNonExtendableHeaderCtrl
(从 CHeaderCtrl
子类化)替换,这将阻止光标更改(一种类似于 Charles Herman 在“Prevent column resizing (2)” 中提出的方法)。WM_NCHITTEST
消息处理程序检查光标是否在调整大小区域上方,并相应地设置 m_headerResizeDisabled
数据成员(BOOL
类型)。
LRESULT CNonExtendableHeaderCtrl::OnNcHitTest(CPoint point)
{
POINT clientPoint = point;
ScreenToClient(&clientPoint);
m_headerResizeDisabled = IsOnLastColumnDivider(clientPoint);
return CHeaderCtrl::OnNcHitTest(point);
}
IsOnLastColumnDivider
是一个辅助函数。
BOOL CNonExtendableHeaderCtrl::IsOnLastColumnDivider(const CPoint& point)
{
// width of the area above header divider in which cursor
// changes its shape to double-pointing east-west arrow
int dragWidth = GetSystemMetrics(SM_CXCURSOR);
// last column's header rectangle
RECT rect;
GetItemRect(GetItemCount() - 1, &rect);
return point.x > rect.right - dragWidth / 2;
}
WM_SETCURSOR
消息处理程序负责通过检查 m_headerResizeDisabled
标志来防止光标更改。
BOOL CNonExtendableHeaderCtrl::OnSetCursor(CWnd* pWnd, UINT nHitTest, UINT message)
{
if (m_headerResizeDisabled)
return TRUE;
return CHeaderCtrl::OnSetCursor(pWnd, nHitTest, message);
}
最后,我们必须在我们的列表控件中将原始 CHeaderCtrl
替换为 CNonExtendableHeaderCtrl
。在列表控件定义中添加了一个类型为 CNonExtendableHeaderCtrl
的数据成员 m_header
,并在重写的 PreSubClass
函数成员中将其附加到列表控件。
void CLastColumnAutoResizingListCtrl::PreSubclassWindow()
{
m_header.SubclassDlgItem(0, this);
CListCtrl::PreSubclassWindow();
}
Using the Code
只需将 CLastColumnAutoResizingListCtrl
和 CNonExtendableHeaderCtrl
类的头文件和源文件包含到您的代码中。然后,将项目中的 CListCtrl
实例声明替换为 CLastColumnAutoSizingListCtrl
。即使标题隐藏(LVS_NOCOLUMNHEADER
样式),该控件仍将保留其功能,因此当使用单个列时,它可以被列表框替换。
请注意,代码是假设控件处于报表视图(即设置了 LVS_REPORT
样式)编写的,代码中未包含相应的检查。另外,如果在控件显示后添加新项目或更改现有项目,您不能忘记重新将 LVSCW_AUTOSIZE_USEHEADER
值应用于最后一列。
演示项目包含两个与父窗口一起调整大小的列表控件。左侧的控件是一个普通的 CListCtrl
,在每次调整父对话框大小时都会设置 LVSCW_AUTOSIZE_USEHEADER
。右侧是 CLastColumnAutoResizingListCtrl
控件。
历史
- 2011年2月20日:初始版本。
- 2011年2月22日:修复了 Hans Dietrich 提出的错误。
- 2011年3月1日:修改了
OnWindowPosChanging
函数,添加了“空白列问题”部分。 - 2013年11月19日:修复了从列表中删除项目时出现的空白行问题。