Introduction
ImageListView
is a .NET 2.0 control for displaying a list of image files. It looks and operates similar to the standard ListView
control. Image thumbnails are loaded asynchronously with a separate background thread. The look of the control can be completely customized using custom renderers.
Background
This project actually started as an owner-drawn ListView
. However, this first version required way too many hacks. Determining the first/last visible items especially proved to be a challenge. Halfway through, I decided to roll my own control. Thus was born the ImageListView
.
Using the Code
To use the control, add the ImageListView
to your control toolbox and drag it on the form. You can then customize the appearance of the control by changing the view mode (Thumbnails, Gallery, Pane or Details), thumbnail size, column headers, etc.
Custom Rendering
The ImageListViewRenderer
class is responsible for drawing the control. This is a public
class with virtual functions that can be overridden by derived classes. Derived classes can modify the display size of items and column headers and draw any or all parts of the control.
Here is the renderer that produces this appearance:
public class DemoRenderer : ImageListView.ImageListViewRenderer
{
public override Size MeasureItem(View view)
{
if (view == View.Thumbnails)
{
Size itemPadding = new Size(4, 4);
Size sz = ImageListView.ThumbnailSize + ImageListView.ItemMargin +
itemPadding + itemPadding;
return sz;
}
else
return base.MeasureItem(view);
}
public override void DrawBackground(Graphics g, Rectangle bounds)
{
if (ImageListView.View == View.Thumbnails)
g.Clear(Color.FromArgb(32, 32, 32));
else
base.DrawBackground(g, bounds);
}
public override void DrawItem(Graphics g, ImageListViewItem item,
ItemState state, Rectangle bounds)
{
if (ImageListView.View == View.Thumbnails)
{
using (Brush b = new SolidBrush(Color.Black))
{
Utility.FillRoundedRectangle(g, b, bounds, 4);
}
if ((state & ItemState.Selected) == ItemState.Selected)
{
using (Brush b = new SolidBrush(Color.FromArgb(128,
SystemColors.Highlight)))
{
Utility.FillRoundedRectangle(g, b, bounds, 4);
}
}
using (Brush b = new LinearGradientBrush(
bounds,
Color.Transparent,
Color.FromArgb(96, SystemColors.Highlight),
LinearGradientMode.Vertical))
{
Utility.FillRoundedRectangle(g, b, bounds, 4);
}
if ((state & ItemState.Hovered) == ItemState.Hovered)
{
using (Brush b =
new SolidBrush(Color.FromArgb(32, SystemColors.Highlight)))
{
Utility.FillRoundedRectangle(g, b, bounds, 4);
}
}
using (Pen p = new Pen(Color.FromArgb(128, SystemColors.Highlight)))
{
Utility.DrawRoundedRectangle(g, p, bounds.X, bounds.Y, bounds.Width - 1,
bounds.Height - 1, 4);
}
Image img = item.ThumbnailImage;
if (img != null)
{
int x = bounds.Left + (bounds.Width - img.Width) / 2;
int y = bounds.Top + (bounds.Height - img.Height) / 2;
g.DrawImageUnscaled(item.ThumbnailImage, x, y);
using (Pen p = new Pen(Color.FromArgb(128, SystemColors.Highlight)))
{
g.DrawRectangle(p, x, y, img.Width - 1, img.Height - 1);
}
}
}
else
base.DrawItem(g, item, state, bounds);
}
public override void DrawSelectionRectangle(Graphics g, Rectangle selection)
{
using (Brush b = new HatchBrush(
HatchStyle.DarkDownwardDiagonal,
Color.FromArgb(128, Color.Black),
Color.FromArgb(128, SystemColors.Highlight)))
{
g.FillRectangle(b, selection);
}
using (Pen p = new Pen(SystemColors.Highlight))
{
g.DrawRectangle(p, selection.X, selection.Y,
selection.Width, selection.Height);
}
}
}
Once you write your own renderer, you need to assign it to the ImageListView
.
imageListView1.SetRenderer(new DemoRenderer());
Asynchronous Operation
ImageListView
generates thumbnail images asynchronously with a background thread. Generated thumbnails are kept in a cache, which is managed by the ImageListViewCacheManager
class. There are two modes in which the cache manager operates. The control can be switched between the two modes using the CacheMode
property.
In the OnDemand
mode, thumbnail images are generated only after they are requested. For example, when the user scrolls the view, items newly made visible will request their thumbnail images from the ImageListViewCacheManager
. The cache manager will then add those items to a queue, which is monitored and exhausted by the worker thread. The user can limit the number of thumbnail images to be kept in the cache. When this limit is reached, the cache manager will remove some thumbnails from the cache to free up space. This mode is useful for using the control with many (thousands) of image files.
The other cache mode is Continuous
. In this mode, the control will continuously generate and cache image thumbnails, regardless of item visibility. In this mode, is not possible to limit the cache size. This mode is probably best suited for using the control with a moderate number of items.
Points of Interest
Performance
ImageListView
was designed to be used with a large number of images. To maintain smooth operation with thousands of image files, I had to make a number of optimizations.
Consolidating Control Paint
The ImageListViewRenderer
class mentioned above is responsible for drawing the client area of the control. I had made sure that the renderer drew only the visible items when the control needed a refresh. One optimization I made afterwards was to add the functions: SuspendPaint
and ResumePaint
. They are used to consolidate render requests when the control is refreshed multiple times in a row. The following example should clarify their usage:
public void AddRange(ImageListViewItem[] items)
{
mImageListView.Renderer.SuspendPaint();
foreach (ImageListViewItem item in items)
Add(item);
mImageListView.Renderer.ResumePaint();
}
The implementation is quite simple as shown below:
internal void SuspendPaint()
{
if (suspendCount == 0) needsPaint = false;
suspendCount++;
}
internal void ResumePaint()
{
suspendCount--;
if (needsPaint)
Refresh();
}
internal void Refresh()
{
if (suspendCount == 0)
mImageListView.Refresh();
else
needsPaint = true;
}
The suspendCount
variable above is incremented when SuspendPaint
is called and decremented when ResumePaint
is called. This allows the suspend calls to be nested and the control will be refreshed only after the outermost ResumePaint
is called and suspendCount
is decremented to zero.
Caching File Properties
In details mode, ImageListView
displays detailed information of the image files: such as the modification date, file size, file type, etc. File properties are read and cached when the item is created. They are updated only if the filename is changed. One bottleneck I identified here was the file type retrieval code. The .NET Framework does not have a native function to get the file type, so I had to use platform invoke. Here is what I had:
SHFILEINFO shinfo = new SHFILEINFO();
SHGetFileInfo(path,
(FileAttributes)0,
out shinfo,
(uint)Marshal.SizeOf(shinfo),
SHGFI.TypeName
);
typeName = shinfo.szTypeName;
And here is the time it takes to add 1000 items with this:
Added 1000 items in 1282 milliseconds.
One second to load a thousand items actually doesn't sound that bad. But going through the above code, I realized that file types could be memorized. Most of the time, all images added to the control will be JPEG images, and the file type retrieval need only be called once. Here is the modified code:
if (!cachedFileTypes.TryGetValue(Extension, out typeName))
{
SHFILEINFO shinfo = new SHFILEINFO();
SHGetFileInfo(path,
(FileAttributes)0,
out shinfo,
(uint)Marshal.SizeOf(shinfo),
SHGFI.TypeName
);
typeName = shinfo.szTypeName;
cachedFileTypes.Add(Extension, typeName);
}
Once I added a dictionary to memorize the file types, this is what I got:
Added 1000 items in 138 milliseconds.
Reading Embedded EXIF Thumbnails
Modern digital cameras embed thumbnail images of each shot taken. ImageListViewCacheManager
can extract those embedded images to speed up thumbnail loading time. For this, I needed a fast method to extract embedded thumbnails. I tried the GetThumbnailImage
method, I also tried manually reading the ThumbnailData
Exif tag; both methods were too slow for my needs. The bottleneck was the Image.FromStream
method. Here is the average time required to load a 3472x2604 1 MB JPEG file:
Reading a 3472x2604 JPEG file: 320.2 milliseconds.
Going from here, the time required to cache a thousand thumbnails (which is my minimum performance goal for ImageListView
) would take 300 seconds, or 5 full minutes. I needed a faster method to read the embedded thumbnails. Searching further, I came across one particular overload of the Image.FromStream
function:
public static Image FromStream(
Stream stream,
bool useEmbeddedColorManagement,
bool validateImageData
)
With this overload, setting validateImageData
to false
results in the image being loaded much faster, since the framework does not validate image data. Here is the above experiment repeated with this overload:
Reading a 3472x2604 JPEG file: 0.47 milliseconds.
You read that right, 0.47 milliseconds. Caching a thousand thumbnails using this method would take 0.5 seconds. Although this (almost a thousand-fold) performance increase is astoundingly attractive, there are some issues to consider before using this method:
- As the parameter name suggests, you are using invalidated image data with this method, which may result in errors if the image data happened to be corrupt. For example, GDI will likely throw an exception if you use an invalid image with any of the
Graphics.DrawImage
functions. This was a non-issue for me because I did not need the image data at all, just the ThumbnailData
Exif tag.
- The second issue is not related to this method in particular but to the usage of
Image.FromStream
in general. You must hold on to the source stream for the life time of your image; which may not be practical in some cases. Again this was not an issue for me, because I copied the contents of the ThumbnailData
Exif tag and disposed of both the source image and stream immediately afterwards.
- You may be tempted to use this method in a
try/catch
block to safely benefit from the performance increase. However my intuition is that, not getting exceptions may not mean that the image data is valid. If you must be sure that you get a valid image, the only way is to let the framework validate the image data.
To conclude, use this method if you need a fast way to read image properties: dimensions, Exif tags, etc. If you need the actual image data, use the slow method and let the framework validate the image.
Custom CodeDom Serializer
During the course of this project, I learned a lot of things. In the source code, you will find a custom editor for column headers, a custom designer, and a designer serializer. I consider the designer serializer the most interesting of those, so I will write a few words about it.
You may have noticed that when you drag a control onto your form, the initialization code magically appears in InitializeComponent
. Most of the time, the default serialization behavior is sufficient. For ImageListView
, this was not the case. The column header collection of the ImageListView
is a read-only list without the Add
method. The user cannot add or remove the columns, but she can show/hide the columns, change the display order, column texts and widths. I have the following method for letting the user customize all properties of a column at once.
public void SetColumnHeader(ColumnType type, string text,
int width, int displayIndex, bool visible)
{
}
I wanted the designer to generate my column initialization code by using this function, instead of the standard Add
method of the collection. In order to do that, I wrote a new designer serializer class derived from CodeDomSerializer
and assigned it to the ImageListView
using the DesignerSerializer
attribute, like follows:
[DesignerSerializer(typeof(ImageListViewSerializer), typeof(CodeDomSerializer))]
My CodeDomSerializer
derived class overrides the Serialize
method and adds my custom column initialization code.
internal class ImageListViewSerializer : CodeDomSerializer
{
public override object Serialize
(IDesignerSerializationManager manager, object value)
{
CodeDomSerializer baseSerializer = (CodeDomSerializer)manager.GetSerializer(
typeof(ImageListView).BaseType,
typeof(CodeDomSerializer));
object codeObject = baseSerializer.Serialize(manager, value);
if (codeObject is CodeStatementCollection)
{
CodeStatementCollection statements = (CodeStatementCollection)codeObject;
CodeExpression imageListViewCode =
base.SerializeToExpression(manager, value);
if (imageListViewCode != null && value is ImageListView)
{
foreach (ImageListViewColumnHeader column in
((ImageListView)value).Columns)
{
CodeMethodInvokeExpression columnSetCode =
new CodeMethodInvokeExpression(
imageListViewCode,
"SetColumnHeader",
new CodeFieldReferenceExpression(
new CodeTypeReferenceExpression(typeof(ColumnType)),
Enum.GetName(typeof(ColumnType), column.Type)),
new CodePrimitiveExpression(column.Text),
new CodePrimitiveExpression(column.Width),
new CodePrimitiveExpression(column.DisplayIndex),
new CodePrimitiveExpression(column.Visible)
);
statements.Add(columnSetCode);
}
}
return codeObject;
}
return base.Serialize(manager, value);
}
public override object Deserialize(IDesignerSerializationManager manager,
object codeObject)
{
CodeDomSerializer baseSerializer = (CodeDomSerializer)manager.GetSerializer(
typeof(ImageListView).BaseType,
typeof(CodeDomSerializer));
return baseSerializer.Deserialize(manager, codeObject);
}
}
This walks through the column collection, and for each column, it calls the SetColumnHeader
method of the ImageListView
instance with the parameters set by the user. If this looks complicated, here are some basic examples to get you started.
Creating a one-line comment:
CodeCommentStatement commentCode = new CodeCommentStatement("This is a comment");
will result in:
A simple declaration with initialization:
CodePrimitiveExpression valueCode = new CodePrimitiveExpression("hello");
CodeVariableDeclarationStatement declarationCode =
new CodeVariableDeclarationStatement(typeof(string), "myString", valueCode);
will result in:
string myString = "hello";
The conditional:
CodeVariableReferenceExpression testCode =
new CodeVariableReferenceExpression("check");
CodeStatement[] trueBlock =
new CodeStatement[] { new CodeCommentStatement("check is true") };
CodeStatement[] falseBlock =
new CodeStatement[] { new CodeCommentStatement("check is false") };
CodeConditionStatement ifCode =
new CodeConditionStatement(testCode, trueBlock, falseBlock);
will result in:
if (check)
{
}
else
{
}
The property access:
CodeThisReferenceExpression thisCode = new CodeThisReferenceExpression();
CodePropertyReferenceExpression propCode =
new CodePropertyReferenceExpression(thisCode, "MyProperty");
CodePropertyReferenceExpression otherPropCode =
new CodePropertyReferenceExpression(thisCode, "MyOtherProperty");
CodeAssignStatement assignCode = new CodeAssignStatement(propCode, otherPropCode);
will result in:
this.MyProperty = this.MyOtherProperty;
For further information, here are some references from the MSDN:
Built-In Renderers
Writing custom renderers for ImageListView
is an involved task. Instead of writing a renderer from scratch, you can use one of the built-in renderers or use a built-in renderer as a starting point for your custom renderer. The following built-in renderers are currently available:
Resources
History
- 25 October 2009 - Initial release
- 26 October 2009 - Updated demo and source files
- 29 October 2009 - Added the capability to read embedded thumbnails
- 01 November 2009 - Added drag&drop support and minor bug fixes
- 04 November 2009 - Article updated and minor bug fixes
- 09 November 2009 - .NET 2.0 version added and minor bug fixes
- 12 November 2009
- .NET 3.5 version discontinued
- Items can now be reordered by dragging them in the control
- Item properties are now fetched by a background thread. Adding items should be much faster
- Item details added for Image Dimensions and Resolution
- 15 November 2009 - Cached item indices to speed-up item lookups
- 16 December 2009
- Added the Gallery view mode
- Added new column types for common image metadata
- Added the
BeginEdit()
and EndEdit()
methods to ImageListViewItem
. They should be used while editing items to prevent collisions with cache threads.
- Added the
GetImage()
method to ImageListViewItem
- Added the new overridable method,
OnLayout
to the ImageListViewRenderer
. It can be used to modify the size of the item area by custom renderers.
- Added
Clip
, ItemAreaBounds
and ColumnHeaderBounds
properties to ImageListViewRenderer
- Renderers can now draw items in a specific order using the new
ImageListViewRenderer.ItemDrawOrder
property. A finer control is also possible using the new ImageListViewItem.ZOrder
property.
- Added built-in renderers
- Maximum size of the thumbnail cache can now be (approximately) set by the user using the new
ImageListView.CacheLimit
property
- Default column texts are now loaded from resources to allow localization
- Cached images are now properly disposed
- Custom renderers now use the central thumbnail cache instead of their own worker threads
- 29 December 2009
- Adjustable properties of built-in renderers are now
public
- Removed
ImageListView.ItemMargin
property in favor of the new overridable ImageListViewRenderer.MeasureItemMargin
method
- Gallery image is now updated after editing an item
- Moved column sort icons to the neutral resource
- Cleaned up the utility class
- Fixed a bug where updating an item did not update the item thumbnail
- Removed the
ImageListViewRenderer.GetSortArrow
function. Sort arrow is now drawn in the DrawColumnHeader
method
- Fixed the issue about the missing semicolon in GIF files
- Removed the
SortOrder enum
, it was a duplicate of Windows.Forms.SortOrder
- Fixed the issue where double clicking on a separator raised a column click event
- Added the
NewYear2010Renderer
. You need to define the preprocessor symbol BONUSPACK
to include in the binary. Happy new year people!
- 4 January 2010
- Added the new
Pane
view mode, removed PanelRenderer
- Added
NoirRenderer
- Renamed
ImageListViewRenderer.OnDispose
to Dispose
- Removed
ImageListViewRenderer.DrawScrollBarFiller
virtual method
- 17 February 2010
- Added support for virtual items
- The control is now scrolled while dragging items to the edges of the client area
- Added the
RetryOnError
property. When set to true
, the cache thread will continuously poll the control for a thumbnail, until it gets a valid image. When set to false
, the cache thread will give up after the first error and display the ErrorImage
.
- Added the
ItemHover
and ColumnHover
events
- Added the
DropFiles
event
- Added the
CacheMode
property to support continuous caching
- Added Mono support (tested with Mono 2.6)