Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Multi-Image Viewer

0.00/5 (No votes)
29 Dec 2004 3  
A multi-image viewer with drag and drop source and sink capability

Introduction

The multi-image viewer is a follow-up on my previous article, the single image viewer. This applet demonstrates:

  • receiving multiple files from a drag-drop operation
  • being a drag-drop source
  • optimizing the viewer for performance--creating thumbnails on the fly and owner draw issues
  • usability issues--changing image and form sizes and utilizing a non-paging scrollbar (see Understanding Scrollbars)

This applet is part of the prototypes in the Yet Another Photo Organizer (YAPO) open source project hosted by Wdevs here.

This code is prototype code intended to explore performance and usability issues. Keep in mind that some of it is a bit hacked.

Receiving Multiple Files From A Drag-Drop

This is very simple. In particular, the application receives only filenames and directory names. You can drop a folder onto the viewer or individual files. The current implementation does recurse into sub-folders.

When a drag operation is completed, the application calls GetFiles which parses the DragEventArgs data and adds only files of "jpg", "png", or "bmp" extension. (There isn't any error checking to make sure that those files are actually real image files.)

protected ArrayList GetFiles(DragEventArgs e)
{
  ArrayList files=new ArrayList();

  if ( (e.AllowedEffect & DragDropEffects.Copy) == DragDropEffects.Copy)
  {
    Array data=((IDataObject)e.Data).GetData("FileDrop") as Array;
    if (data != null)
    {
      foreach(string fn in data)
      {
        string ext=Path.GetExtension(fn).ToLower();
        if ( (ext==".jpg") || (ext==".png") || (ext==".bmp") )
        {
          files.Add(fn);
        }
        else
        {
          string[] dirFiles=Directory.GetFiles(fn);
          foreach(string fn2 in dirFiles)
          {
            ext=Path.GetExtension(fn2).ToLower();
            if ( (ext==".jpg") || (ext==".png") || (ext==".bmp") )
            {
              files.Add(fn2);
            }
          }
        }
      }
    }
  }
  return files;
}

Why Cast To IDataObject?

This is something I discovered after I wrote my single image viewer article. When receiving a file or file list from say, Explorer, e.Data is of type DataObject and the data returned by the "FileDrop" data format type is an Array. However, if I create my own string array for sourcing a drag-drop event and try dropping it onto my single image viewer, e.Data is of type System.__ComObject. After posting a question on the C# forum and poking around some more, I discovered that System.__ComObject implements IDataObject (this is not something that's documented in IDataObject). Therefore, casting to IDataObject properly handles both System.Windows.Forms.DataObject and System.__ComObject object types.

Being a Drag-Drop Source

I wanted to be able to drag an image in the multi-image viewer to my already created single image viewer. Based on the code example for the DoDragDrop method, the primary consideration is to pay attention to the SystemInformation.DragSize value. This is a system value that the user can set to establish how much the mouse has to move while the left button is down before the motion is considered the beginning of a drag event.

On Mouse Down

On a mouse down event, the image index is obtained and the drag box, which the mouse has to move outside of, is established.

private void OnMouseDown(object sender, MouseEventArgs e)
{
  if ((e.Button & MouseButtons.Left)==MouseButtons.Left)
  {
    // ignore SystemInformation.DragSize for now
    int col=e.X / panelWidth;
    int row=(e.Y+scrollBar.Value) / panelHeight;
    int imgIdx=row * cols + col;
    if (imgIdx < files.Count)
    {
      dragImageFilename=(string)files[imgIdx];
      Size dragSize=SystemInformation.DragSize;
      dragBox=new Rectangle(new Point(e.X - dragSize.Width/2,
         e.Y - dragSize.Height/2), dragSize);
      dragging=true;
    }
  }
}

On Mouse Up

On a mouse up event, the dragging flag is cleared.

private void OnMouseUp(object sender, MouseEventArgs e)
{
  if ((e.Button & MouseButtons.Left)==MouseButtons.Left)
  {
    dragging=false;
  }
}

On Mouse Move

On the mouse move event, the mouse position is checked to be outside of the minimum drag box window. If it is, the DoDragDrop method is called. This method does not return until the left mouse button is released on an application that either accepts or does not accept the data object. If dropping on an application that does accept the data object, then this method returns as soon as the GetData call is made on the DragEventARgs.Data object.

private void OnMouseMove(object sender, MouseEventArgs e)
{
  if (dragging)
  {
    if (!dragBox.Contains(e.X, e.Y))
    {
      string[] filenames=new string[] {dragImageFilename};
      DataObject data=new DataObject(DataFormats.FileDrop, filenames);
      ((Control)sender).DoDragDrop(data, DragDropEffects.Copy);
      dragging=false;
    }
  }
}

Since I'm only enabling the Copy effect, I don't particularly care what the return value is--whether the drop operation was successful or not. Note also that instead of putting the image into the data object, I'm storing the filename to the image. This is because the viewer doesn't actually preserve the source image and I don't want to spend CPU time loading a large image file before it's actually dropped--I'd rather let the receiving application deal with it.

Optimizing The Viewer

There are a few optimization issues to consider:

  • Loading the original image files
  • Creating thumbnails
  • Using an owner draw surface

Loading The Original Image Files

Once the drag-drop operation completes, it starts a thread to load the images:

private void OnDragDrop(object sender, System.Windows.Forms.DragEventArgs e)
{
  files=GetFiles(e);
  images.Clear();
  loadImagesThread=new Thread(new ThreadStart(LoadImages));
  loadImagesThread.Start();
}

private void LoadImages()
{
  foreach(string fn in files)
  {
    try
    {
      Bitmap bitmap=new Bitmap(fn);
      Image image=new Bitmap(bitmap, 256, 256*bitmap.Height / bitmap.Width);

      // Dispose of the large image right away rather than waiting
      // for the GC to do it.
      bitmap.Dispose(); 
      lock(images)
      {
        images.Add(image);
      }
      OnSizeChanged(null, EventArgs.Empty);
    }
    catch(Exception)
    {
    }
  }
}

Using a thread allows the application to start displaying the images as they are converted to thumbnails and the user can also start changing the viewer size, the image size, and dragging images to the single viewer.

Of note here is that after the original bitmap is loaded and converted to a thumbnail, I call the Dispose method right away. If I don't, the GC doesn't get around to collecting the images for quite a while (seconds to minutes) and memory fills up very quickly, slowing down the whole machine.

The images array is locked when adding an image because the main application thread might be in the middle of accessing it.

Creating Thumbnails

Notice that after the original image is loaded, a thumbnail is created:

Image image=new Bitmap(bitmap, 256, 256*bitmap.Height / bitmap.Width);

that is proportional to the master image dimensions, with the width fixed at 256 pixels across. This seems like a good value that creates presentable images across a variety of resolutions.

Resizing images on the fly is very time consuming for large images. I had toyed with the idea of a "smart" algorithm that might create three or so different thumbnails at different sizes. The idea here is that, when a large image is being displayed, there are fewer of them on the screen. Therefore, you can increase the resolution of the thumbnail. Essentially, an algorithm that balances the number of images with the quality of the image. However, I decided not to implement that yet.

Using An Owner Draw Surface

My original idea was that the viewing surface would be comprised of Panel controls with PictureBox child controls. The first thing I discovered is that Windows renders the Form very slowly--too many controls and it gets doggy very quickly. So that threw out the whole idea of using pre-canned controls.

The owner drawn control is derived from a Panel control anchored to the Form, leaving a little bit at the top for the image size slider. The usual initialization has to be done for double-buffering along with the Paint event:

SetStyle(ControlStyles.DoubleBuffer |
    ControlStyles.UserPaint | 
    ControlStyles.AllPaintingInWmPaint, true);

Paint+=new PaintEventHandler(OnPaint);

On Paint

The OnPaint method first draws the rectangle to indicate the image frame:

private void OnPaint(object sender, PaintEventArgs e)
{
  int imgIdx=imgOffset;

  Point p=new Point(0, -vOffset);
  for (int j=0; j<rows; j++)
  {
    for (int i=0; i<cols; i++)
    {
      e.Graphics.DrawRectangle(pen, p.X, p.Y, size.Width, size.Height);
      ...

The next set of calculations determines the width and height of the image scaled to the current dimensions of the image frame. The image inside the frame has to be proportional to the thumbnail, but it also has to be based on the size that the slider sets. The slider controls the width of the image, thus the height has to be proportional to the width. However, whether the image is in landscape or portrait mode has to be determined. If in portrait mode, the height is "master" (we don't want the height to exceed the image frame because the width is smaller), and in landscape, the width is "master" (the width of the image shouldn't exceed the width of the image frame). This is all done in the following calculations:

...
if ( (images != null) && (imgIdx < images.Count) )
{
  Image image;
  lock(images)
  {
    image=(Image)images[imgIdx];
  }
  ++imgIdx;
  float fw=size.Width;
  float fh=size.Height;
  float iw=imgWidth;
  float ih=imgWidth * image.Height / image.Width;

  // iw/fw > ih/fh, then iw/fw controls ih

  // frame width is always >= image width

  float rw=fw/iw; // ratio of frame width to image width
  float rh=fh/ih; // ratio of frame height to image height

  int width;
  int height;

  // determine which dimension takes precedence
  if (rw < rh)
  {
    width=(int)fw;
  }
  else
  {
    width=(int)(iw * rh);
  }

  // scale width based on the % of the image width is filling the
  // frame
  width=(int)(width * iw/fw);

  // adjust height to maintain aspect ratio
  height=width * image.Height / image.Width;
  ...

Finally, the image should be centered in the image frame, then drawn:

  int x=(size.Width-width)/2;
  int y=(size.Height-height)/2;
  // +1 provides better centering
  e.Graphics.DrawImage(image, new Rectangle(p.X+x+1, p.Y+y+1,
      width, height));
  }
...

Usability Issues

Of anything, this probably took the longest time to figure out how to get right. I looked at usability from the perspective of performance and the application "feel". The performance issues are adequately addressed with a couple options to improve performance as a balance between speed and image quality. As to application feel, I discovered that it is driven entirely by the idea that the user can change the image size with the slider control.

Image Size And Frame Size

Here's the issue: when the user changes the image size, this directly affects the number of images that can be displayed horizontally. Let's take an extreme example. Given a horizontal width of 300 and an image width of 100, three images (300/100) can be displayed. Now, change the image width to 101 pixels. Now only two images can be displayed, and there's 98 pixels of wasted space.

So if we go from three 100 pixel images to two 101 pixel images, what actually needs to happen is three things:

  1. the frame needs to change to fit the new image size, which affects the number of frames that can be displayed
  2. the number of frames that can be displayed on the form should determine the frame size rather than the image size determining the frame size
  3. the image has to be centered within this frame

Point number 2 results in some interesting behavior. Let's say that our form size is also 300 pixels. When we go to two frames (because only two images of 101 pixels width can be displayed in a 300 pixel width form), the resulting frames are actually form width / 2 in size, or 150 pixels. This means that our image is 49 pixels smaller than our frame, and as long as the user increases the image size up to 150, the image will grow in the frame, but the frame will remain the same size. Conversely, when we shrink image sizes, the frame remains the same size while the images shrink, until the image size allows more frames to be displayed.

Explaining this takes a lot longer than the actual code to calculate it:

int imgWidth=tbarImageSize.Value;

// Get the # of columns required to display the images fit to the columns.
// +4 ensures a small margin within the image viewing rectangle.
cols=pnlImages.ClientSize.Width/(imgWidth+4);
if (cols==0)
{
  cols=1;
}

// Now get the actual width of the panels, which may be larger.
// Rounding will result in an unused edge on the right of the window.
// We're not going to deal with this minor issue.
panelWidth=pnlImages.ClientSize.Width/cols;

The result is an interesting visual effect, in which the frames take up as much of the form space as possible and the images grow and shrink within the frames. I find this display nicer than leaving large amounts of dead space along one edge, which would happen if images were just displayed from left to right based on image size. It's also nicer than centering images as this creates a strange ballooning expanding/contracting effect.

Scrolling

The next thing I wanted to do was to be able to scroll by pixel. This is aesthetically pleasing. On the other hand, when the user pages up or down by clicking on the scrollbar's track, I wanted to only scroll one image row at a time. To accomplish this, I had to override how the LargeChange value works so as to maintain a proper thumb to track height ratio while at the same time allowing sub-page large change and sub-row small changes in scrolling. You can read more about that solution in the article on scrollbars.

As to scrolling the owner draw area, the algorithm is designed to display the least amount of images required to display, accounting for rows partially truncated at the top and bottom of the viewer. This is handled very simply by calculating the vertical offset of the viewer modulus the frame height and also the actual image offset in the array for the first partially viewable image:

vOffset=scrollBar.Value % panelHeight;
imgOffset=cols * (scrollBar.Value / panelHeight);

Now go back up to the OnPaint method described above and note how the starting pixel row is determined by the negative vOffset.

Conclusion

The result is a nice multiple image viewer that you can use as a kind of "test strip" for a folder of images and that supports dragging single images to another application. It makes for a good prototype to iron out all the complexities of a viewer before writing a full-blown application.

License

This article has no explicit license attached to it, but may contain usage terms in the article text or the download files themselves. If in doubt, please contact the author via the discussion board below.

A list of licenses authors might use can be found here.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here