What does it take to make an Asynchronously drawn ListBox in Windows Forms?
Yeah, yeah, it's been done I'm sure, but I still ran into the problem when making my own picture viewer. The basic issues were thumbnailing images to be displayed in the owner-drawn ListBox while still allowing the UI to respond to the user. Then making sure that no matter how the user scrolled the ListBox, no graphical artifacts would be displayed. This turned out to be a bit harder than I thought it would because of the way scrolling messages are handled by the ListBox.
To even get started you have to set up an owner drawn list box. This isn't too hard. Just a couple of properties and hooking up the necessary events.
listPictures.Dock = DockStyle.Fill;
listPictures.ColumnWidth = 125;
listPictures.ItemHeight = 140;
listPictures.DrawMode = DrawMode.OwnerDrawFixed;
listPictures.IntegralHeight = false;
listPictures.MultiColumn = true;
listPictures.SelectionMode = SelectionMode.MultiExtended;
listPictures.DrawItem += new DrawItemEventHandler(this.ListPictures_DrawItem);
listPictures.DoubleClick += new EventHandler(this.ListPictures_DoubleClick);
listPictures.MouseMove += new MouseEventHandler(this.ListPictures_MouseMove);
listPictures.SelectedIndexChanged += new EventHandler(this.ListPictures_SelectedIndexChanged);
I'm doing some unique stuff with my picture box by allowing multi-selection and slideshows and such, but the big points are in the ColumnWidth, ItemHeight, DrawMode, and MultiColumn properties. All of these come together to make a ListBox very much like a picture thumbnailer. Hooking the DrawItem event ensures I'll get a chance to display my thumbnailed picture.
Next you really need an object to hold your cached thumbnails, handle whether or not an object is currently thumbnailing (so you don't schedule it twice), and to hold any other information for display purposes. Personally, I like to use TreeNode objects as my ListBox items. Why, I'm not exactly sure, but here goes at defining a file node.
public class MetaTreeNode : TreeNode {
private string ioPath;
private string comments = null;
private int rating = -1;
private string flags = null;
private XmlNode xmlMetaNode = null;
public MetaTreeNode(string text, string ioPath) : base(text) {
this.ioPath = ioPath;
}
public string IOPath {
get {
return this.ioPath;
}
}
public int Rating {
get {
return this.rating;
}
set {
this.ImageIndex = value + 1;
this.SelectedImageIndex = value + 1;
this.rating = value;
}
}
public string Flags {
get {
return this.flags;
}
set {
this.flags = value;
}
}
public string Comments {
get {
return this.comments;
}
set {
this.comments = value;
}
}
public XmlNode XMLMetaNode {
get {
return this.xmlMetaNode;
}
set {
this.xmlMetaNode = value;
}
}
public bool ContainsFlag(string flag) {
if ( this.flags != null ) {
string[] flagParts = this.flags.Split(';');
for(int i = 0; i < flagParts.Length; i++) {
if ( flagParts[i] == flag ) {
return true;
}
}
}
return false;
}
}
public class MetaFileTreeNode : MetaTreeNode {
private Image thumb = null;
private bool thumbnailing = false;
public MetaFileTreeNode(string displayText, string fullPath) : base(displayText, fullPath) {
}
public bool Thumbnailing {
get {
return this.thumbnailing;
}
set {
this.thumbnailing = value;
}
}
public Image Thumbnail {
get {
return this.thumb;
}
set {
this.thumb = value;
}
}
}
We'll be using the MetaFileTreeNode. The MetaTreeNode is a base node I use to define directories as well. The concept is a fully browsable tree structure for your pictures that allows you to attach meta-data. This comes in really handy at family re-unions and such because I can rate images, flag images, provide descriptions, and all kinds of other stuff that normally can't be attached to an image. The important parts here are the IOPath for grabbing the actual file, Thumbnailing and Thumbnail for painting the picture.
Add a bunch of the MetaFileTreeNode's to your ListBox. That'll get you started. Use your BeginUpdate and EndUpdate methods to make sure all the nodes are added before painting calls start to be made. Adds a bit of performance to the process. Then hop right into the DrawItem event handler code.
private void ListPictures_DrawItem(object sender, DrawItemEventArgs e) {
if ( e.Index < 0 ) {
return;
}
MetaFileTreeNode mfTn = listPictures.Items[e.Index] as MetaFileTreeNode;
if ( mfTn != null ) {
if ( mfTn.Thumbnail == null ) {
if ( !mfTn.Thumbnailing ) {
mfTn.Thumbnailing = true;
ThreadPool.UnsafeQueueUserWorkItem(
new WaitCallback(this.Thumbnail_AsyncCache),
new Thumbnail_AsyncCache_State(mfTn, e)
);
}
return;
}
Rectangle boxBounds = e.Bounds;
Rectangle textBounds = new Rectangle(boxBounds.Left, boxBounds.Top + boxBounds.Width, boxBounds.Width, boxBounds.Height - boxBounds.Width);
Rectangle picBounds = new Rectangle(
(boxBounds.Width - mfTn.Thumbnail.Width) / 2 + boxBounds.Left,
(boxBounds.Height - mfTn.Thumbnail.Height) / 2 + boxBounds.Top,
mfTn.Thumbnail.Width,
mfTn.Thumbnail.Height);
e.Graphics.DrawImage(mfTn.Thumbnail, picBounds);
e.Graphics.DrawRectangle(new Pen(e.BackColor), boxBounds);
}
}
Basically, if the Index < 0 don't do anything. For some reason you get these at times. Grab out your tree node and see if it has been thumb'ed yet. If not make sure it isn't thumbnailing and then add a new thread pool work item to thumbnail it. At this point return so you don't have to paint what you don't have. I've also provided the code that actually paints into the available region using a bunch of code similar to the Explorer code that automatically sizes things based on horizontally versus verically large images with all of the appropriate scaling. Guess you get that as a freebie ;-)
The big code is in the thumbnailer itself though and goes to show what needs to be done to overcome scrolling issues with the control while thumbnailing.
private class Thumbnail_AsyncCache_State {
public MetaFileTreeNode mfTn;
public DrawItemEventArgs diEventArgs;
public Thumbnail_AsyncCache_State(MetaFileTreeNode mfTn, DrawItemEventArgs args) {
this.mfTn = mfTn;
this.diEventArgs = args;
}
}
private void Thumbnail_AsyncCache(object workItemState) {
Thumbnail_AsyncCache_State thumbState = workItemState as Thumbnail_AsyncCache_State;
if ( thumbState == null ) {
return;
}
Bitmap picture = null;
try {
picture = new Bitmap(thumbState.mfTn.IOPath);
} catch {
picture = new Bitmap(64, 64);
Graphics gfx = Graphics.FromImage(picture);
gfx.DrawRectangle(Pens.Red, 0, 0, 63, 63);
gfx.DrawLine(Pens.Red, 0, 0, 64, 64);
gfx.DrawLine(Pens.Red, 64, 0, 0, 64);
gfx.Dispose();
}
float largestExtent = (float) Math.Max(picture.Width, picture.Height);
int width = (int) ((((float) picture.Width) / largestExtent)* (float) thumbState.diEventArgs.Bounds.Width);
int height = (int) ((((float) picture.Height) / largestExtent)* (float) thumbState.diEventArgs.Bounds.Height);
thumbState.mfTn.Thumbnail = picture.GetThumbnailImage(width, height, new Image.GetThumbnailImageAbort(this.ThumbnailAbort), IntPtr.Zero);
picture.Dispose();
listPictures.Invoke(new ListPicturesInvalidateHandler(this.ListPictures_Invalidate), new object[] { thumbState });
}
This is the work-horse. It tries to open the bitmap, and if it can't thumbnail, just paints a red box out. This happens for text files and such. Again, the cool code is used to properly size all of your images so they are ratio correct when viewed. We dispose of the bitmaps explicitly to reclaim some memory. I've thought about better ways to judge load on the system and do this later, but it really doesn't matter. This thumbnailer does 2-4 images per second which is plenty fast for government work. The last line of code invokes the invalidation logic. That single line of code and the code to follow took me a couple of days to work out so I could get rid of ALL graphics artifacts.
private delegate void ListPicturesInvalidateHandler(Thumbnail_AsyncCache_State thumbState);
private void ListPictures_Invalidate(Thumbnail_AsyncCache_State thumbState) {
listPictures.Invalidate(thumbState.diEventArgs.Bounds);
if ( listPictures.Items.Count > thumbState.diEventArgs.Index ) {
listPictures.Invalidate(listPictures.GetItemRectangle(thumbState.diEventArgs.Index));
}
}
Okay, so once the delgate gets invoked back on the listPictures thread, you can see that we invalidate the location where the drawing was SUPPOSED to have occurred when the method was originally called. If scrolling has occurred since then, we also have to invalidate the NEW location where the image actually needs to be displayed. Internally I think there is some ScrollDC type GDI behavior going on, and the underlying control expects that the region scrolled was already painted, when it actually wasn't. This results in blank areas of your canvas.
Well, that is all there is to it. Hopefully I haven't left out any of the necessary plumbing for you to get this up and running. If I have, then bugger off. I'll probably come out with a full on ImageListBox control that allows some cool image display formats. Time permitting of course.