Click here to Skip to main content
Click here to Skip to main content

ImageListView

, 18 Feb 2010 Apache
Rate this:
Please Sign up or sign in to vote.
A .NET ListView like control for displaying image files with asynchronously loaded thumbnails.
ImageListView demo

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.

ImageListView with custom renderer

Here is the renderer that produces this appearance:

public class DemoRenderer : ImageListView.ImageListViewRenderer
{
    // Returns item size for the given view mode.
    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);
    }
    // Draws the background of the control.
    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);
    }
    // Draws the specified item on the given graphics.
    public override void DrawItem(Graphics g, ImageListViewItem item,
        ItemState state, Rectangle bounds)
    {
        if (ImageListView.View == View.Thumbnails)
        {
            // Black background
            using (Brush b = new SolidBrush(Color.Black))
            {
                Utility.FillRoundedRectangle(g, b, bounds, 4);
            }
            // Background of selected items
            if ((state & ItemState.Selected) == ItemState.Selected)
            {
                using (Brush b = new SolidBrush(Color.FromArgb(128,
                                     SystemColors.Highlight)))
                {
                    Utility.FillRoundedRectangle(g, b, bounds, 4);
                }
            }
            // Gradient background
            using (Brush b = new LinearGradientBrush(
                bounds,
                Color.Transparent,
                Color.FromArgb(96, SystemColors.Highlight),
                LinearGradientMode.Vertical))
            {
                Utility.FillRoundedRectangle(g, b, bounds, 4);
            }
            // Light overlay for hovered items
            if ((state & ItemState.Hovered) == ItemState.Hovered)
            {
                using (Brush b =
                       new SolidBrush(Color.FromArgb(32, SystemColors.Highlight)))
                {
                    Utility.FillRoundedRectangle(g, b, bounds, 4);
                }
            }
            // Border
            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
            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);
                // Image border
                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);
    }
    // Draws the selection rectangle.
    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:

// Adds a range of items to the ImageListViewItemCollection.
public void AddRange(ImageListViewItem[] items)
{
    // Suspend the renderer while items are being added.
    mImageListView.Renderer.SuspendPaint();

    // Each item addition will request the control to refresh itself.
    // But since the renderer is suspended, the control will not be
    // refreshed at all.
    foreach (ImageListViewItem item in items)
        Add(item);

    // Resume the renderer. This will also refresh the control if any
    // refresh requests were made between SuspendPaint/ResumePaint
    // calls.
    mImageListView.Renderer.ResumePaint();
}

The implementation is quite simple as shown below:

// Suspends painting until a matching ResumePaint call is made.
internal void SuspendPaint()
{
    if (suspendCount == 0) needsPaint = false;
    suspendCount++;
}
// Resumes painting. This call must be matched by a prior
// SuspendPaint call.
internal void ResumePaint()
{
    suspendCount--;
    // Render the control if we received refresh requests
    // between SuspendPaint/ResumePaint calls.
    if (needsPaint)
        Refresh();
}
// Redraws the control.
internal void Refresh()
{
    // Render the control only after we exit the final
    // suspend block.
    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:

// Get file type via platform invoke
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:

// cachedFileTypes is the dictionary to memorize
// file types.
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));
        // Let the base class do its work first.
        object codeObject = baseSerializer.Serialize(manager, value);

        // Let us now add our own initialization code.
        if (codeObject is CodeStatementCollection)
        {
            CodeStatementCollection statements = (CodeStatementCollection)codeObject;
            // This is the code reference to our ImageListView instance.
            CodeExpression imageListViewCode =
			base.SerializeToExpression(manager, value);
            if (imageListViewCode != null && value is ImageListView)
            {
                // Walk through columns...
                foreach (ImageListViewColumnHeader column in
				((ImageListView)value).Columns)
                {
                    // Create a line of code that will invoke SetColumnHeader.
                    // Generated code will be something like this:
                    // myImageListView.SetColumnHeader(ColumnType.Name,
                    //            "Column Name", 120, 1, true);
                    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)
                        );
                    // Add to the list of code statements.
                    statements.Add(columnSetCode);
                }
            }
            return codeObject;
        }

        return base.Serialize(manager, value);
    }

    public override object Deserialize(IDesignerSerializationManager manager,
                                       object codeObject)
    {
        // Let the base class handle deserialization.
        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:

// This is a comment

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)
{
    // check is true
}
else
{
    // check is false
}

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:

Built-in renderers

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)

License

This article, along with any associated source code and files, is licensed under The Apache License, Version 2.0

Share

About the Author

Ozgur Ozcitak

Turkey Turkey
No Biography provided

Comments and Discussions

 
BugForm Localization [modified] PinprofessionalJohnny J.21-Nov-14 5:13 
BugProblem with viewing images [modified] PinmemberA7mad_23-Oct-14 4:32 
QuestionMouseclick on the Gallery Image PinprofessionalJohnny J.20-Oct-14 4:52 
QuestionFilename selected item PinmemberGabee86-Oct-14 9:41 
AnswerRe: Filename selected item PinprofessionalJohnny J.21-Oct-14 0:09 
GeneralRe: Filename selected item PinmemberGabee822-Oct-14 7:03 
QuestionIs the any way to read XP Comments from file? Pinmembermiguelponte21-Aug-14 7:55 
Questionrequired code in mfc Pinmembervivek.chauhan20095-Aug-14 2:33 
GeneralWow Pinmemberroman_20125-Jul-14 4:59 
Questioncan i Group images Pinmemberesaaco12-Mar-14 2:41 
Questionvirtual mode PinmemberSergelp1-Mar-14 7:31 
QuestionAdd Folders/Directories to ImageListView PinmemberMember 1027630019-Dec-13 0:52 
AnswerRe: Add Folders/Directories to ImageListView PinmemberMember 1027630019-Dec-13 20:30 
QuestionAWESOME - Thank you! Pinmemberitchygreenfeet18-Dec-13 1:53 
QuestionGreat Code & Double click event trigger PinmemberMember 1036790229-Oct-13 5:44 
AnswerRe: Great Code & Double click event trigger PinmemberOzgur Ozcitak29-Oct-13 8:59 
Generalcongratulations Pinmemberpo172514-Aug-13 14:30 
QuestionSeamless Image Update Pinmembergregrutter13-Aug-13 18:39 
AnswerRe: Seamless Image Update PinmemberOzgur Ozcitak29-Oct-13 9:02 
How do you feed the webcam images to the control?
QuestionColumn Header Functionality in thumbnails view PinmemberTim Fitzhardinge23-May-13 21:55 
AnswerRe: Column Header Functionality in thumbnails view PinmemberOzgur Ozcitak23-May-13 23:32 
GeneralRe: Column Header Functionality in thumbnails view PinmemberTim Fitzhardinge26-May-13 20:17 
QuestionBest way to populate representative image of an object (from a set) and display all object images on selection of representative image? [modified] PinmemberEklavya217-Feb-13 5:36 
GeneralAmazing! PinmemberRavi Bhavnani26-Dec-12 12:29 
QuestionSorting Images Pinmemberacosano1-Nov-12 22:58 
AnswerRe: Sorting Images PinmemberOzgur Ozcitak1-Nov-12 23:33 
QuestionPossible to save and read the cache? Pinmemberacosano9-Oct-12 11:29 
AnswerRe: Possible to save and read the cache? PinmemberOzgur Ozcitak10-Oct-12 0:50 
Questionhow to prevent from selecting more than image?? Pinmemberesaaco23-Sep-12 4:01 
AnswerRe: how to prevent from selecting more than image?? PinmemberOzgur Ozcitak23-Sep-12 22:59 
Questionvb.net 2008 list view image thumbnail PinmemberRajpal singh siddhu2-Sep-12 3:03 
AnswerRe: vb.net 2008 list view image thumbnail PinmemberOzgur Ozcitak2-Sep-12 22:24 
Questionturn on/off grid Pinmemberyoke31-Aug-12 14:24 
AnswerRe: turn on/off grid PinmemberOzgur Ozcitak2-Sep-12 22:14 
GeneralRe: turn on/off grid Pinmemberyoke2-Sep-12 22:37 
GeneralRe: turn on/off grid PinmemberOzgur Ozcitak17-Oct-12 21:47 
Questionis it free?? Pinmemberankuu1-Aug-12 2:17 
QuestionConvet to Vb.Net Pinmembertamilpuyal_2831-Jul-12 21:14 
Question.NET 4.0 Support Pinmembergogora15-Jul-12 6:33 
AnswerRe: .NET 4.0 Support PinmemberOzgur Ozcitak16-Jul-12 0:04 
QuestionSelect an Imagelistview Item Programmaticaly PinmemberMember 82167409-Jun-12 3:41 
AnswerRe: Select an Imagelistview Item Programmaticaly PinmemberOzgur Ozcitak12-Jun-12 1:24 
GeneralMy vote of 5 PinmemberSerhiy Perevoznyk24-May-12 21:34 
QuestionScroll to item upon KeyPress event PinmemberAlex.kwt1-May-12 12:33 
AnswerRe: Scroll to item upon KeyPress event PinmemberOzgur Ozcitak1-May-12 22:17 
GeneralRe: Scroll to item upon KeyPress event PinmemberAlex.kwt2-May-12 5:16 
QuestionLoad item from image or byte[] [modified] PinmemberBenjamin Krause21-Apr-12 13:56 
AnswerRe: Load item from image or byte[] PinmemberOzgur Ozcitak1-May-12 22:14 
Questionimage Source Pinmember55307527720-Apr-12 0:04 
AnswerRe: image Source PinmemberOzgur Ozcitak1-May-12 22:23 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.141223.1 | Last Updated 18 Feb 2010
Article Copyright 2009 by Ozgur Ozcitak
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid