Saturday, September 8, 2007

Transparent ListBox


Like most other transparent ListBox controls found in on the Internet, mine is not the true one. I cheated, making underlying control to expose its background as a bitmap - which is then used to paint ListBox background. This doesn't work for any container, just for the one I'm using. You will not find complete source here, just the essential bits.

What I wanted:
On a form, I have a gradient-filled panel hosting various controls, one of them is a data-bound ListBox showing progress messages. Because the ListBox occupies the majority of the panel's real estate, I wanted it to be transparent. Here is the final result (colors were adjusted to better show gradient fill):


The suggestions found on the Internet were only partially working: whatever method I tried had a little glitch here or there - but that little problem was enough to ruin the entire idea.

OnDrawItem approach:
First was "transparent background" approach: we set background color to transparent and voilĂ  - everything is magically working. Not quite. In .NET's ListBox, setting background color to transparent is illegal; an exception is thrown when trying to assign a color with alpha less than 255.

There are at least two ways to make .NET bypass transparent backgrounds: either enable transparency in control's constructor, or override CreateParams method. Both ways require deriving our class from WinForm's ListBox:


protected override CreateParams CreateParams{
get {
CreateParams cp = base.CreateParams;
cp.ExStyle |= 0x20; // WS_EX_TRANSPARENT
return cp;
}
}

Or, use SetStyle method in control's constructor:

public TransparentListBox(): base() {
SetStyle(ControlStyles.SupportsTransparentBackColor, true);
this.BackColor = Color.FromArgb(0, Color.Transparent);
}

This is where another gotcha is awaiting. In WinForms, transparency is not real. It is simulated by asking the control's form to paint its background in the rectangle where our control resides. Any intermediate controls are not painted. If we'd placed our transparent control on a Panel hosted within a TabPage, not the Panel nor TabPage is painted, only the underlying form.

Obviously, this is not too useful, in my case the ListBox is sitting on a gradient Panel.

The workaround is to bypass WinForms and paint item's background manually. This is achieved by setting DrawStyle to OwnerDrawFixed and implementing custom OnDrawItem method. Details aside, the method's skeleton would look similarly to:

protected override void OnDrawItem(DrawItemEventArgs e) {
SolidBrush brush = new SolidBrush(this.ForeColor);
string val = this.GetItemText(this.Items[e.Index]);
e.Graphics.DrawString(val, this.Font, brush, e.Bounds.X, e.Bounds.Y);
brush.Dispose();
}

Here we do not paint background thus letting our gradient panel to shine through.

Note the use of GetItemText rather than Items[e.Index].ToString(), that is because the control is data-bound.

This mostly works except for two things. Firstly, the text flickers when items are added. That is because ListBox painting routine first erases everything with BackColor before calling our OnDrawItem(). And secondly, when the number of items in ListBox is too small to fill it up entirely, the remaining bottom space is still filled up with BackColor. Did you think of making background transparent as described above? Won't help: the background has been filled up with BackColor already.

The first problem could be partially alleviated by setting ListBox BackColor to a color close to the gradient's background, so the flicker is not that noticeable.

The second one could be approached by setting DrawMode to OwnerDrawVariable and making the last item span to the bottom of the control. This would be handled in OnMeasureItem() method (which we must provide in Variable mode). Still, this won't work when ListBox is empty - and a possible solution is to never have it empty: always add an empty line and replace it with real text when it is first added.

I didn't try these ideas - my ListBox is data bound and the logic seems to get overcomplicated.

OnPaint approach:
Giving up on OnDrawItem() idea, we're left with the last resort: custom painting. This requires to override OnPaint() and OnPaintBackground() and draw the entire control's contents (at least its client area). To tell .NET we're paitning on our own, we set a few styles in the control's constructor:

public TransparentListBox(): base() {
SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
SetStyle(ControlStyles.UserPaint, true);
SetStyle(ControlStyles.AllPaintingInWmPaint, true);
SetStyle(ControlStyles.ResizeRedraw, true);
SetStyle(ControlStyles.Opaque, false);
}

UserPaint instructs .NET to call our OnPaint method and AllPaintingInWmPaint tells it to omit calls to OnPaintBackground(): we draw all ListBox items in OnPaint() and let non-covered underlying background to remain untouched. The method loops through all visible items (first visible item is determined by ListBox.TopIndex property).

protected override void OnPaint(PaintEventArgs e) {

float x = this.ClientRectangle.X;
float y = this.ClientRectangle.Y;

SolidBrush sel_bg_brush = new SolidBrush(this.SelBackColor);
SolidBrush fore_brush = new SolidBrush(this.ForeColor);

int cnt = this.Height / this.ItemHeight;

for(int k = 0; k < cnt; k++ ) {
int idx = k + this.TopIndex;
if( idx >= this.Items.Count )
break;

bool selected = this.SelectedIndices.Contains(idx);

if( selected )
e.Graphics.FillRectangle(sel_bg_brush, x, y, this.ClientRectangle.Width, this.ItemHeight);

fore_brush.Color = selected ? this.SelForeColor : this.ForeColor;
string val = this.GetItemText(this.Items[idx]);
e.Graphics.DrawString(val, this.Font, fore_brush, x, y);
y += this.ItemHeight;
}

sel_bg_brush.Dispose();
fore_brush.Dispose();
}

Custom properties SelForeColor and SelBackColor are exposed to Designer - to customize color of selected items. Since our mode is OwnerDrawFixed, we still override OnDrawItem(), but this time it just invalidates the control, making Windows to invoke our OnPaint(). One more important method to override is OnSelectedIndexChanged(), we call Refresh() there to immediately redraw the control (unlike Invalidate, where redraw is postponed till next Update). Without this, our selection bar does not get redrawn until selection changes the next time: it is always behind, showing the previously selected item

protected override void OnDrawItem(DrawItemEventArgs e) { this.Invalidate(); }
protected override void OnSelectedIndexChanged(EventArgs e) { this.Refresh(); }

The code worked quite well, failing in only one point: the vertical scrollbar. The scrollbar gets painted by Windows at intervals which do not to seem to be related to control redrawns - and I couldn't find any way to force its redraw. Moreover, sometimes the bar is only partially painted: only slider is shown without the up/down buttons. While this is unacceptable, the behavour is only observed for data-bound controls. Finally, I ended up catching BindingSource's ListChanged event and adding items to the list manually. Since the ListBox is append-only, that was simple.

This is not the complete truth though. My ListBox's binding source is a subordinate to another BS. I had to catch changes to that parent BS as well and reload entire ListBox contents. Another improvement was to make ListBox auto-scroll when selected item is the last one. This way the list would auto-scroll unless user moved out of the last line. This is achieved by setting SelectedIndex to Items.Count-1, although this would add the item to selection rather than replace it for multi-selected ListBoxes. The code is irrelevant, I might provide it some other time.

BackgroundBitmap:
With the scrollbar workaround the solution worked, but I wanted more. While ListBox is placed immediately on gradient panel, everything works fine. But a couple of days later I ran out of screen space and decided to move the ListBox into a TabControl page. This changed immediate parent to TabControl and the transparency effect was lost.

The next idea was to give up on transparency and paint ListBox background from an image, saved by its any parent. The "parent" may not be immediate nor even host ListBox: we just tell the ListBox where to get its underlying image from.

For this to work, parent must expose its background image somehow: an interface was extracted to indicate so:

public interface IExposingBackground { Bitmap BackgroundBitmap(); }

Implementation is simple enough: since parent is drawing itself with OnPaint(), we modify it a bit to save image in a bitmap prior to drawing:

public class FancyPanel : ..., IExposingBackground {
private Bitmap bgr_bmp;

public Bitmap BackgroundBitmap() { return bgr_bmp; }

private void init_bitmap(Graphics g) {
if( bgr_bmp == null
|| bgr_bmp.Width != this.ClientRectangle.Width
|| bgr_bmp.Height != this.ClientRectangle.Height
) {
if( bgr_bmp != null )
bgr_bmp.Dispose();
bgr_bmp = new Bitmap(this.ClientRectangle.Width, this.ClientRectangle.Height, g);
}
}

private void OnPaint(PaintEventArgs e) {
init_bitmap(e.Graphics);
Graphics g = Graphics.FromImage(bgr_bmp);
... draw background on g ...
e.Graphics.DrawImageUnscaledAndClipped(bgr_bmp, this.ClientRectangle);
g.Dispose();
}
}

The background bitmap works as a double buffer, therefore buffering provided by .NET can be disabled to conserve memory. This is achieved by calling SetStyle(ControlStyles.OptimizedDoubleBuffer, false) in the control's constructor.

Transparent ListBox can now use parent's bitmap to draw any part of its background:

private Control parentBgr;
private Point offset_in_parent = new Point();

private void paint_background(Graphics g, Rectangle rect) {
Bitmap bmp;
if( ParentBackground != null &&
(bmp = ((IExposingBackground)ParentBackground).BackgroundBitmap()) != null
) {
g.DrawImage(bmp, rect,
offset_in_parent.X + rect.X, offset_in_parent.Y + rect.Y,
rect.Width, rect.Height,
GraphicsUnit.Pixel
);

} else {
Brush brush = new SolidBrush(this.BackColor);
g.FillRectangle(brush, rect);
brush.Dispose();
}
}

Variable parentBgr (exposed via property ParentBackground) is set at design time.

There is one more thing though. Since our "background-exposing" parent control may not be the immediate one, it takes a bit of coding to properly compute our offset in it. Should the parent be immediate, our offset would be the one available in our Location property. But since there could be any number of controls between us and the parent, all these Locations must be added up to compute the proper offset. Two helper methods are used for this:

private void compute_offset() {
Point loc = new Point(>this.Location.X, this.Location.Y);
if( this.ParentBackground != null ) {
Control p = this.Parent;
while( p != null && p != this.ParentBackground ) {
loc.Offset(p.Location);
p = p.Parent;
}
if( p == null )
return;
}
this.offset_in_parent = loc;
}

private void find_suitable_parent() {
if( DesignMode && this.ParentBackground == null )
for(Control p = this.Parent; p != null; p = p.Parent)
if( p is IExposingBackground ) {
this.ParentBackground = p;
break;
}
}

These methods are called whenever our Location changes, parent is changed, or our control is resized. All these handlers look similar, they call the base handler and then recompute the offset. Here is, for example, OnParentChanged:

protected override void OnParentChanged(EventArgs e) {
base.OnParentChanged(e);
find_suitable_parent();
compute_offset();
}

The first procedure, compute_offset(), calculates our position within the parent. The second, find_suitable_parent, is only working in Designer mode. Whenever our ListBox is placed on a form, it re-computes its "background parent" - if not already set. The procedure is merely for programmer's convenience.

Other uses of BackgroundBitmap:
Having fancy panels expose their background turned out to be quite handy. Later on I wanted to add a transparent DataGridView. Working with DGV is much easier than with LisBox, which is "all or nothing" sort of thing: we can only override OnPaint thus taking full responsibility for painting the entire control.

Unlike ListBox, DGV allows us to handle every aspect of cell painting, we can override just a little bit and let the control do the rest. All I had to do is to override PaintBackground and OnRowPrepaint methods. Both methods looked very similar to paint_background() above, except that they called their base handlers in the "else" part.

I've also overrode OnScroll() (another method missing in ListBox, one must override WndProc to handle scrolls) to call the base handler and then Refresh() the DGV. That is because when scrolling, Windows copies unchanged portion of the screen up or down, and then asks our Paint routine to draw just the first or last line. This doesn't work for gradient backgrounds and Refresh() is taking care of it.

As a final touch, double buffering was enabled to eliminate flicker.

Even later, I added a transparent TabControl with very little pain. There, the main challenge was to make page headers sit on the right side of the control, but this is another story.



This post was written a while ago, but it took me ages to fight Blogger's editor. The silly thing won't preserve code formatting: whenever text is reopened, some "improvements" sneak through. For example, Blogger removes one space character in pre-formatted blocks whenever entry is closed/opened.

Preview looks ok, hopefully publishing won't garble formatting.

No comments: