I'm writing a simple custom owner-drawn ListBox
control and banging my head against the wall trying to figure out how to implement what seems like a straight-foward feature. My custom ListBox
is supposed to work similarly to the "Add/Remove Programs" list in Windows XP. That is, it displays a list of items as usual, but when the user selects an item in the list, a clickable button should appear next to the item text. In my case, I'm trying to display an "Import" button next to each item in my ListBox
.
In order to keep the custom ListBox
somewhat encapsulated, I'm trying to display the button by overriding the ListBox.OnDrawItem
method (i.e. the button is conceptually part of the list box item), but I can't get it to work quite right.
I should note that I'm trying to use a single button for the entire ListBox
. When the user selects a item in the list, the OnDrawItem
method just re-positions this single button so that it appears next to the selected item. I have it 99% working: the problem now is that when the ListBox
is scrolled and the selected item goes off-screen, the button is still drawn to its previous position, so it draws on top of the wrong item. I'm guessing this is because Windows won't try to redraw the selected item if it is off-screen, and thus the repositioning code doesn't get called.
Here is trimmed-down version of what I have right now:
public partial class EventListBox : ListBox
{
private Button _importButton;
public EventListBox()
{
InitializeComponent();
this.DrawMode = DrawMode.OwnerDrawVariable;
// set up the button that will appear next to the currently-selected item
_importButton = new Button();
_importButton.Text = "Import...";
_importButton.AutoSize = true;
_importButton.Visible = false;
// Add the button as a child control of this ListBox
Controls.Add(_importButton);
}
protected override void OnDrawItem(DrawItemEventArgs e)
{
if (this.Items.Count > 0)
{
e.DrawBackground();
// draw item here (omitted)
// if drawing the selected item, re-position the "Import" button so that appears
// inside the current item, and make it visible if it is hidden.
// These checks prevent the resulting repaint that will occur from causing an infinite loop
// The problem seems to be that if the ListBox is scrolled such that the selected item
// moves off-screen , this code won't run, because it won't repaint the selected item anymore...
// This means the button will be painted in its previous position.
// The real question is: Is there a better way to approach the whole notion of
// rendering buttons within ListBox items?
if (e.State & DrawItemState.Selected == DrawItemState.Selected)
{
_importButton.Top = e.Bounds.Bottom - _importButton.Height - 20;
_importButton.Left = e.Bounds.Left;
if(!_importButton.Visible) _importButton.Visible = true;
}
}
base.OnDrawItem(e);
}
protected override void OnMeasureItem(MeasureItemEventArgs e)
{
base.OnMeasureItem(e);
e.ItemHeight = 100; //hard-coded for now...
}
}
Rationale for using a single button
I would rather create a separate button for each item in the ListBox
, but I can't find any way to track when items are added/removed from the listbox. I couldn't find any relevant ListBox
methods that I could override, and I can't re-assign the ListBox.Items
property to a custom collection object, since ListBox.Items
is read-only. Because of this, I went with the above approach of using a single button and re-positioning it as needed, but as I mentioned, this isn't very robust and breaks easily.
My current thinking is it would make the most sense to create a new button at the point when new items are added to the ListBox
, and remove buttons when items were removed.
Here are some possible solutions I came up with, but is there a better way to implement this?
- I could just create my own
AddItem
andRemoveItem
methods directly on my derivedListBox
class. I could create a corresponding button each time a new item is added, and remove the item's button inRemoveItem
. However, to me, this is an ugly hack, because it forces me to call these special add/remove methods instead of just usingListBox.Items
. - I could draw a new button manually in
OnDrawItem
usingSystem.Windows.Forms.ButtonRenderer
, but then I have to do a lot of extra work to make it act like a real button, sinceButtonRenderer
is doing nothing more than drawing the button. Figuring out when the user hovers over this "button", and when it is clicked, seems like it would be difficult to get right. - When
OnDrawItem
is called, I could create a new button if the item being drawn doesn't already have a button associated with it (I could keep track of this with aDictionary<Item, Button>
), but I still need a way to remove unused buttons when their corresponding items are removed from the list. I guess I could iterate over my dictionary of item-button mappings and remove items that don't exist in theListBox
anymore, but then I'm iterating over two lists every time an item in theListBox
is redrawn (ack!).
So, is there a better way to include clickable buttons inside a ListBox'? It's obviously been done before, but I can't find anything useful on Google. The only examples I've seen that have buttons in a
ListBox` were WPF examples, but I'm looking for how to do this with WinForms.
-
One Possible Solution
I found a workable solution, and ended up keeping the single button approach for now. I'll post my workaround here, but if anyone has a more elegant answer to my original question, don't hestitate to post it.
My small epiphany
After experimenting some more, it seems the issue of the button not getting repositioned properly on scrolling only happens when using the mouse wheel to scroll through the
ListBox
. Scrolling the "normal" way doesn't seem to reproduce the behavior, but as I couldn't be 100% sure, I included a fix for normal scrolling in my workaround as well.My ugly hack of a workaround
Since I knew the mouse wheel (and scrolling in general) seemed to be at the heart of the issue, I decided to just invalidate my
ListBox
whenever theListBox
is scrolled or whenever the mouse wheel is moved. This does produce some unsightly flicker, which I would like to get rid of, but I can live with the results for now.I added the following methods to my derived
EventListBox
class:protected override void WndProc(ref Message m) { const int WM_VSCROLL = 277; if (m.Msg == WM_VSCROLL) { this.Invalidate(); } base.WndProc(ref m); } protected override void OnMouseWheel(MouseEventArgs e) { this.Invalidate(); base.OnMouseWheel(e); }
I was a little surprised that
ListBox
doesn't inherit fromScrollableControl
and there wasn't anything like anOnScroll
method that I could override, so I did the scroll checking by overriding theWndProc
method. The mouse wheel detection was simpler, since there was already an method available to override.Like I said, this is less than ideal as invalidating the listbox each time the list is scrolled causes flicker, and I suspect performance would degrade significantly when the listbox contains a lot of items.
I won't accept this answer yet, as I'm curious to see if someone has a better solution.
-
There is a 3-rd party control IntegralUI ListBox from Lidor Systems, which allows to include ANY control in every item. There is no limit on how many controls you can add. Also you can create your own templates and order the item content in custom layouts.
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.