65.9K
CodeProject 正在变化。 阅读更多。
Home

多图像查看器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (23投票s)

2004年12月29日

9分钟阅读

viewsIcon

213633

downloadIcon

7598

一个具有拖放源和汇功能的多个图像查看器。

引言

多图像查看器是我之前文章《单图像查看器》的后续。此小程序演示了

  • 接收来自拖放操作的多个文件
  • 作为拖放源
  • 优化查看器的性能——即时创建缩略图和自定义绘制问题
  • 可用性问题——更改图像和窗体大小以及使用非分页滚动条(参见理解滚动条

此小程序是 Wdevs 托管的 Yet Another Photo Organizer (YAPO) 开源项目中的原型之一,在此处

此代码是用于探索性能和可用性问题的原型代码。请记住,其中一些代码有点粗糙。

从拖放操作接收多个文件

这非常简单。特别是,应用程序仅接收文件名和目录名。您可以将文件夹或单个文件拖放到查看器上。当前实现会递归进入子文件夹。

拖放操作完成后,应用程序将调用 GetFiles,该方法解析 DragEventArgs 数据,并仅添加扩展名为“jpg”、“png”或“bmp”的文件。(没有错误检查以确保这些文件实际上是真实图像文件。)

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;
}

为什么转换为 IDataObject?

这是我在写完单图像查看器文章后发现的一个问题。当从 Explorer 等处接收文件或文件列表时,e.Data 的类型为 DataObject,并且“FileDrop”数据格式类型返回的数据是 Array。但是,如果我创建自己的 string 数组作为拖放事件的源并尝试将其拖放到我的单图像查看器上,那么 e.Data 的类型将是 System.__ComObject。在 C# 论坛上提问并进行更多探索后,我发现 System.__ComObject 实现 IDataObject(这一点在 IDataObject 中没有记录)。因此,转换为 IDataObject 可以正确处理 System.Windows.Forms.DataObjectSystem.__ComObject 对象类型。

作为拖放源

我想将多图像查看器中的图像拖放到我已创建的单图像查看器中。根据 DoDragDrop 方法的代码示例,主要考虑的是 SystemInformation.DragSize 值。这是一个系统值,用户可以设置它来确定在按下鼠标左键的情况下鼠标必须移动多少距离才算拖动事件的开始。

鼠标按下时

在鼠标按下事件中,获取图像索引,并建立拖动框(鼠标必须移出该框)。

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;
    }
  }
}

鼠标释放时

在鼠标释放事件中,清除拖动标志。

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

鼠标移动时

在鼠标移动事件中,检查鼠标位置是否在最小拖动框窗口之外。如果超出,则调用 DoDragDrop 方法。此方法将一直返回,直到在接受或不接受数据对象的应用程序上释放鼠标左键。如果将数据对象拖放到接受数据对象的应用程序上,则在 DragEventARgs.Data 对象上调用 GetData 方法后,此方法将立即返回。

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;
    }
  }
}

由于我只启用了 Copy 效果,因此我并不特别关心返回值——拖放操作是否成功。另外请注意,我没有将图像放入数据对象,而是存储了图像的文件名。这是因为查看器实际上不保留源图像,我也不想在实际拖放到接收应用程序之前花费 CPU 时间加载大型图像文件——我宁愿让接收应用程序处理它。

优化查看器

有几个优化问题需要考虑

  • 加载原始图像文件
  • 创建缩略图
  • 使用自定义绘制表面

加载原始图像文件

拖放操作完成后,将启动一个线程来加载图像

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)
    {
    }
  }
}

使用线程允许应用程序在图像转换为缩略图时开始显示它们,并且用户还可以开始更改查看器的大小、图像大小,以及将图像拖放到单图像查看器中。

值得注意的是,在加载原始位图并将其转换为缩略图后,我会立即调用 Dispose 方法。如果我不这样做,GC 会在很长一段时间(几秒到几分钟)内无法收集图像,内存会很快填满,导致整个机器变慢。

在添加图像时,images 数组会被锁定,因为主应用程序线程可能正在访问它。

创建缩略图

请注意,在加载原始图像后,会创建一个缩略图

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

该缩略图与主图像尺寸成比例,宽度固定为 256 像素。这似乎是一个不错的值,可以在各种分辨率下创建可展示的图像。

对于大型图像,即时调整图像大小非常耗时。我曾考虑过一个“智能”算法,可以创建大约三种不同尺寸的缩略图。这里的想法是,当显示一个大图像时,屏幕上的图像数量较少。因此,可以提高缩略图的分辨率。基本上,这是一个平衡图像数量和图像质量的算法。不过,我决定暂时不实现它。

使用自定义绘制表面

我最初的想法是,查看表面将由 Panel 控件和 PictureBox 子控件组成。我发现的第一个问题是 Windows 渲染 Form 非常慢——控件太多,很快就会变得卡顿。这彻底否定了使用预制控件的整个想法。

自定义绘制控件派生自锚定在 Form 上的 Panel 控件,在顶部留出一小部分用于图像大小滑块。对于双缓冲以及 Paint 事件,必须进行常规初始化

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

Paint+=new PaintEventHandler(OnPaint);

绘制时

OnPaint 方法首先绘制矩形以指示图像框

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);
      ...

下一组计算确定图像缩放到当前图像框尺寸的宽度和高度。图像框内的图像必须与缩略图成比例,但也必须基于滑块设置的大小。滑块控制图像的宽度,因此高度必须与宽度成比例。但是,必须确定图像是纵向还是横向模式。如果是纵向模式,则高度是“主导”(我们不希望高度超过图像框,因为宽度较小),如果是横向模式,则宽度是“主导”(图像宽度不应超过图像框的宽度)。所有这些都在以下计算中完成

      ...
      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;
        ...

最后,图像应居中放置在图像框中,然后绘制

        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));
        }
      ...

可用性问题

在所有方面中,这可能是最花时间才弄清楚如何正确实现的部分。我从性能和应用程序“感觉”的角度来看待可用性。性能问题通过一些选项来解决,以提高性能,平衡速度和图像质量。至于应用程序感觉,我发现它完全由用户可以通过滑块控件更改图像大小的想法驱动。

图像大小和框架大小

问题来了:当用户更改图像大小时,这会直接影响可以水平显示的图像数量。举个极端的例子。给定水平宽度为 300 像素,图像宽度为 100 像素,可以显示三个图像(300/100)。现在,将图像宽度更改为 101 像素。现在只能显示两个图像,并且有 98 像素的浪费空间。

因此,如果我们从三个 100 像素的图像变为两个 101 像素的图像,实际需要发生三件事

  1. 框架需要更改以适应新的图像大小,这会影响可以显示的框架数量
  2. 窗体上可以显示的框架数量应决定框架大小,而不是图像大小决定框架大小
  3. 图像必须在此框架内居中

第 2 点导致了一些有趣的现象。假设我们的窗体大小也是 300 像素。当变为两个框架时(因为在 300 像素宽的窗体中只能显示两个 101 像素宽的图像),结果框架的大小实际上是窗体宽度/2,即 150 像素。这意味着我们的图像比框架小 49 像素,只要用户将图像大小增加到 150 像素,图像就会在框架内增长,但框架的大小保持不变。相反,当缩小图像大小时,框架保持不变,而图像缩小,直到图像大小允许显示更多框架。

解释这一点比实际计算它的代码花费的时间要长得多

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;

结果是一个有趣的视觉效果,其中框架尽可能多地占据窗体空间,图像在框架内增长和收缩。我觉得这种显示比在某一边留下大块空白要好,因为如果图像只是根据图像大小从左到右显示,就会出现这种情况。它也比居中图像要好,因为这会产生一种奇怪的膨胀/收缩效果。

滚动

接下来我想要的是能够逐像素滚动。这在美学上很赏心悦目。另一方面,当用户通过单击滚动条的轨迹向上或向下翻页时,我只想一次滚动一个图像行。要实现这一点,我必须覆盖 LargeChange 值的行为,以便在保持滑块到轨迹高度的适当比例的同时,允许子页面大更改和子行小滚动更改。您可以在关于滚动条的文章中阅读更多关于该解决方案的信息。

至于滚动自定义绘制区域,该算法旨在显示最少数量的必需图像,同时考虑在查看器顶部和底部部分截断的行。这通过计算查看器的垂直偏移量模数框架高度以及第一个部分可见图像的实际图像偏移量来非常简单地处理。

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

现在回到上面描述的 OnPaint 方法,并注意起始像素行是如何由负 vOffset 确定的。

结论

结果是一个不错的多个图像查看器,您可以将其用作图像文件夹的“测试条”,并支持将单个图像拖放到另一个应用程序。它为编写完整的应用程序之前,可以作为原型来解决查看器的所有复杂性。

许可证

本文未附加明确的许可证,但可能在文章文本或下载文件本身中包含使用条款。如有疑问,请通过下面的讨论区联系作者。

作者可能使用的许可证列表可以在此处找到。

© . All rights reserved.