Thursday, January 24, 2008

WM_WINDOWPOSCHANGED.NET

A list view control is handy, but sometimes it needs a little black magic to work properly.

SetStyle (ControlStyles.OptimizedDoubleBuffer, true) ;

in the constructor takes care of the general flicker problem (in Win32, you need to request 6.0 common controls with a manifest and set the LVS_EX_DOUBLEBUFFER style). It seems that suppressing background painting in addition to double-buffer is overkill. Message sniffing and disassembly of comctl32.dll indicates that the background processing is different in 6.0 common controls with «double-buffer» vs. earlier versions. I quote «double-buffer», because I am not sure how much double-buffering is actually involved.

However, if you use the list view in report mode and want to have your columns adjust to the list view's width, you need more magic. The simple solution — setting column width(s) in OnResize — leads to nasty horizontal scrollbar glitching when you shrink the list view. Shuffling the column-resizing code around the various resizing handlers did no good whatsoever. A couple of hours of tedious debugging uncovered the proximate source of this glitch: after the list view gets resized and receives the WM_WINDOWPOSCHANGED message, it passes the message to the list view's original window procedure in comctl32.dll, which faithfully paints the offending horizontal scrollbar, because the columns are now too wide and the OnResize handler wasn't called yet. After this, another, nested WM_WINDOWPOSCHANGED occurs, and this one finally calls UpdateBounds, which calls the OnResize handler. And then the handler of the original WM_WINDOWPOSCHANGED calls UpdateBounds, and thus your handler, again! Why does it work this way? It is beyond me. Conclusion: it seems to be impossible to resize columns properly in response to any resize event.

The remedy is simple enough: run the column-resizing code before the list view is resized if the columns need to be narrower, and after it is resized if the columns need to be wider. The place to do this is the SetBoundsCore function, which is, luckily, virtual:
protected override void SetBoundsCore (int x, int y, int width, int height, BoundsSpecified specified)
{
int newClientWidth = EstimateNewClientWidth (width, height) ;
if (newClientWidth < ClientSize.Width)
{
// Reduce column width first to prevent horizontal scrollbar flicker.
FixColumnWidths (newcx) ;
base.SetBoundsCore (x, y, width, height, specified) ;
}
else
{
base.SetBoundsCore (x, y, width, height, specified) ;
FixColumnWidths (ClientSize.Width) ; // the new client width!
}
}

The remaining tricks are in the EstimateNewClientWidth function, which has to guess whether there was a scrollbar before the resize and whether there will be one after it:
private int EstimateNewClientWidth (int width, int height)
{
if (Items.Count == 0) return ClientSize.Width ;

// Estimate the new client size from the old one and the window size change.
int oldcx = ClientSize.Width ;
int oldcy = ClientSize.Height ;
int newcx = oldcx + width - Bounds.Width ;
int newcy = oldcy + height - Bounds.Height ;
int hdrcy = GetHeaderHeight () ;

Rectangle rect0 = GetItemRect (0, ItemBoundsPortion.Entire) ;
Rectangle rectN = GetItemRect (Items.Count - 1, ItemBoundsPortion.Entire) ;

bool needScrollBar = rect0.Top < hdrcy || rectN.Bottom > newcy ;
bool haveScrollBar = rect0.Top < hdrcy || rectN.Bottom > oldcy ;

// Correct the new client size for vertical scrollbar changes.
if (haveScrollBar)
{
if (!needScrollBar) newcx += SystemInformation.VerticalScrollBarWidth ;
}
else
{
if ( needScrollBar) newcx -= SystemInformation.VerticalScrollBarWidth ;
}

return newcx ;
}

I found no pure .NET method to get the current height of the list view's header control, so I have to resort to P/Invoke:
[StructLayout (LayoutKind.Sequential)]
struct RECT
{
public int left ;
public int top ;
public int right ;
public int bottom ;
}

[DllImport ("user32.dll", EntryPoint = "GetWindowRect")]
static extern int GetWindowRect (IntPtr hWnd, ref RECT rect) ;

[DllImport ("user32.dll")]
static extern IntPtr SendMessageW (IntPtr hWnd, int message, IntPtr wParam, IntPtr lParam) ;

private int GetHeaderHeight ()
{
RECT rect = new RECT () ;
GetWindowRect (SendMessageW (Handle, 0x101F /*LVM_GETHEADER*/, (IntPtr)0, (IntPtr)0), ref rect) ;

return PointToClient (new System.Drawing.Point (rect.left, rect.bottom)).Y ;
}

And that's it for the .NET case ^.^

In Win32, there is a more elegant solution for EstimateNewClientWidth, which does not work under .NET: use SetWindowPos with SWP_NOREDRAW to do a fake resize on the list view, measure its new client width, and fake-resize it back. And instead of the SetBoundsCore, the column-resizing code goes into the parent window's WM_WINDOWPOSCHANGED handler, which is where you resize the list view anyway.

No comments: