可滚动、可缩放、可缩放的图片框
"可滚动、可缩放、可缩放的图片框" 的增强版
引言
此项目基于 Bingzhe Quan 在以下地址的项目“可滚动、可缩放、可缩放的图片框”:https://codeproject.org.cn/Articles/15373/A-scrollable-zoomable-and-scalable-picture-box。我们正在开发一个涉及将图片作为背景缩放的设计软件,并发现原项目非常有用。然而,我们的项目需要一些功能,但这些功能在原项目中不幸缺失。
- 图片可以缩放到任意大小,并且 Quan 在改进部分承认了缺失的“更大的放大倍率”功能。这背后的主要驱动力是,在我们的项目中,小图片应该放大到更大的尺寸,以便详细查看图像,或者将相当大的图片缩放到适应窗口,以便一次性查看整个图像。这需要对原始图像进行缩放,并可能进行额外的像素插值。
- 原始图像可以进行自定义,这同样需要重新绘制图像。在我们的代码中,我们展示了如何在原始图像周围添加一个简单的灰色边框。如果不是过于复杂,可以以类似的方式实现进一步的图像操作。
- 除了通过弹出菜单缩放图像外,像 Google 地图那样使用鼠标滚轮缩放图像也会很好。这是一个小的 UI 增强,但 nevertheless 证明是有用的,因为它还提供了更广泛的缩放选择。
背景
原作者使用 Facade 和 Mediator 设计模式设计了该项目,而这个增强版本认为没有必要更改该设计。因此,我们引用 Quan 的原始文本和图表来解释项目结构,如下所示。
“ScalablePictureBox
是 ScalablePictureBox
控件的门面。它也协调 ScalablePictureBoxImp
和 PictureTracker
。ScalablePictureBoxImp
是可滚动、可缩放、可缩放图片框的核心实现。PictureTracker
是当前图片的跟踪器、滚动器和缩略图查看器。TransparentButton
是 PictureTracker
中用作关闭按钮的一个小型用户控件。Util
提供了一些控件的辅助函数。”
本文绝非一篇独立成文的文章。因此,您应该参考 Quan 的文章以获取有关原始项目如何设计和工作的更多信息。
使用代码
主要的代码更改发生在 ScalablePictureBoxImp
类中。我们引入了一个名为 OriginalPicture
的属性来存储原始图片,以便新的缩放图片可以从原始图片生成,而不是基于前一个缩放图片。事实证明,这可以避免因缩小操作可能造成的像素丢失对后续缩放图片的影响,从而提高了缩放图片的显示质量。
// A copy of the original picture
private Bitmap originalPicture;
public Bitmap OriginalPicture
{
get { return originalPicture;
set
{
originalPicture = value;
if (value == null)
return;
LeftX = 0;
UpperY = 0;
RightX = value.Width;
LowerY = value.Height;
}
为了保留缩放图像尺寸的引用,还使用了另外两个属性 ScaledPictureWidth
和 ScaledPictureHeight
。此外,还添加了几个属性 LeftX
、UpperY
、RightX
和 LowerY
来保留缩放图像在 PictureBox
内的逻辑位置的引用,因为图像不再总是占据整个 PictureBox
。
// Keep record of the dimensions of original & scaled images for fast reference and calculation
public int ScaledPictureWidth { get; set; }
public int ScaledPictureHeight { get; set; }
// Keep record of the logic positions of the image corner points for fast reference and calculation
private int leftX;
public int LeftX { get { return leftX; } set { leftX = value; } }
private int upperY;
public int UpperY { get { return upperY; } set { upperY = value; } }
private int rightX;
public int RightX { get { return rightX; } set { rightX = value; } }
private int lowerY;
public int LowerY { get { return lowerY; } set { lowerY = value; } }
在这里,我们将缩放百分比定义为缩放图像与原始图像的比率,即原始图像的缩放百分比为 100%,放大图像的缩放百分比大于 100%,而缩小图像的缩放百分比小于 100%。
为了同时使用鼠标滚轮和弹出菜单来缩放图像,并避免在没有缩放变化时进行不必要的重绘,我们记录了之前的缩放百分比(在 PreviousScalePercent
中),以便与新指定的缩放百分比(在 CurrentScalePercent
中)进行比较。由于原始缩放百分比为 100%,第一次通过鼠标滚轮缩放操作会使原始图像大小增加 1%,即新的缩放百分比将为 100%*1.01 = 101%。同样,第一次通过鼠标滚轮缩小操作会使原始图像大小减少 1%,即新的缩放记录将为 100%/1.01 = 99.01%。任何进一步的鼠标滚轮滚动将分别增加或减少之前的缩放百分比 1%。
虽然这种通过鼠标滚轮滚动的预设缩放通常工作得很好,但对于接近 100% 的缩放百分比来说,效果并不那么出色。原因很简单:如果当前的缩放百分比是 400%,那么进一步的放大操作将使其增加到 400% * 1.01 = 404%,而两个缩放百分比之间的差异将是 4%。但是,如果当前的缩放百分比是 100%,进一步的放大操作只会产生 1% 的差异。如果原始图像尺寸很小,这不太明显。
// Previous scale percentage for the picture box
public int PreviousScalePercent { get; set; }
// Scale percentage of picture box in zoom mode
private int currentScalePercent = Common.ORIGINALSCALEPERCENT;
// Scale percentage for the picture box
public int CurrentScalePercent
{
get { return currentScalePercent; }
set
{
// No image or the scale remains the same, no need to redraw
if (PictureBox.Image == null || PreviousScalePercent == value)
return;
// Calculate the minimum and maximum scale percentages allowed by predefined values for the
// width and height respectively to make sure neither of them exceeds the preset upper and lower scale limits
int minScalePercent = (int)(100 * Math.Max((float)Common.PICTUREWIDTHMIN / (float)OriginalPicture.Width,
(float)Common.PICTUREHEIGHTMIN / (float)OriginalPicture.Height));
int maxScalePercent = (int)(100 * Math.Min((float)Common.PICTUREWIDTHMAX / (float)OriginalPicture.Width,
(float)Common.PICTUREHEIGHTMAX / (float)OriginalPicture.Height));
// Set the previous scale percent and the current one
PreviousScalePercent = CurrentScalePercent;
currentScalePercent = Math.Max(Math.Min(value, maxScalePercent), minScalePercent);
// Set the parent control scale percent to pass the value to the PictureTracker
scalablePictureBoxParent.ScalePercent = CurrentScalePercent;
// Calculate the scaled picture dimensions
ScaledPictureWidth = (int)(OriginalPicture.Width * (float)currentScalePercent / (float)Common.ORIGINALSCALEPERCENT);
ScaledPictureHeight = (int)(OriginalPicture.Height * (float)currentScalePercent / (float)Common.ORIGINALSCALEPERCENT);
// Redraw scaled picture
ReDrawPicture();
}
}
为了简单解决这个问题,我们在缩放百分比变化之间引入了 10 像素的阈值。因此,在放大/缩小图片时,新图片通过增加 1% 或 10 像素(以较大者为准)的尺寸来使其与前一个图片明显不同。
// Zoom in the picture
private void ZoomInPicture()
{
// Make sure the zoom in ratio is noticeable but also within reasonable range
PreviousScalePercent = CurrentScalePercent;
CurrentScalePercent = Math.Max(PreviousScalePercent + 10, (int)(Common.ZOOMINRATIO * CurrentScalePercent));
}
// Zoom out the picture
private void ZoomOutPicture()
{
// Make sure the zoom out ratio is noticeable but also within reasonable range
PreviousScalePercent = CurrentScalePercent;
CurrentScalePercent = Math.Min(PreviousScalePercent - 10, (int)(Common.ZOOMOUTRATIO * CurrentScalePercent));
}
此外,我们还添加了对父 ScalablePictureBox
控件的引用,以便 ScalablePictureBoxImp
中的当前缩放百分比 CurrentScalePercent
可以传递回 ScalablePictureBox
,从而同步 PictureTracker
中的变量 ScalePercent
。
// The referenece to the parent ScalablePictureBox
public ScalablePictureBox scalablePictureBoxParent { get; set; }
...
// Set the parent control scale percent to pass the value to the PictureTracker
scalablePictureBoxParent.ScalePercent = CurrentScalePercent;
引入了一个布尔标志 mouseWheelEventHandled
,以确保 pictureBox_MouseWheel()
的函数体仅在鼠标滚轮按下和鼠标滚轮再次抬起之间执行一次。
// The flag to stop the pictureBox_MouseWheel event handler being triggered more than once during one scrolling
private bool mouseWheelEventHandled = true;
public void pictureBox_MouseWheel(object sender, System.Windows.Forms.MouseEventArgs e)
{
// Check the flag. Early out if the flag is true
if (!mouseWheelEventHandled)
return;
ZoomRate = e.Delta / 120;
// Set the flag to false to mark the mouse wheel event has not been handled
mouseWheelEventHandled = false;
}
public void pictureBox_MouseUp(object sender, System.Windows.Forms.MouseEventArgs e)
{
// Mouse button up means the wheel scrolling has finished - Set the flag to false
mouseWheelEventHandled = true;
}
我们引入的最后一个变量是 ZoomList
,它用于填充弹出菜单,作为通过鼠标滚轮滚动来缩放图片的另一种方式。
public class ZoomItem
{
public int ZoomRate { get; set; }
public string ZoomName { get; set; }
...
}
public List<ZoomItem> ZoomList { get; set; }
/// <summary>
/// Initialize ZoomList
/// </summary>
private void InitialiseZoomList()
{
if (ZoomList == null)
ZoomList = new List<ZoomItem>();
else
ZoomList.Clear();
/// Fit width zoom rate is unknow at this stage since the image is NOT loaded yet.
ZoomList.Add(new ZoomItem(Common.FITWIDTHMENUITEMNAME));
/// Fit height zoom rate is unknow at this stage since the image is NOT loaded yet.
ZoomList.Add(new ZoomItem(Common.FITHEIGHTMENUITEMNAME));
// Add a separator
ZoomList.Add(new ZoomItem("-"));
ZoomList.Add(new ZoomItem(Common.MINSCALEPERCENT));
for (int scale = 25; scale <= 150; scale += 25)
{
ZoomList.Add(new ZoomItem(scale));
}
for (int scale = 200; scale <= Common.MAXSCALEPERCENT; scale += 200)
{
ZoomList.Add(new ZoomItem(scale));
}
}
关注点
调整原始图像
此版本的一个主要改进是,原始图像可以被修改,而不仅仅是被缩小,这是在 ReDrawPicture()
函数中完成的。
首先,我们创建一个名为 pictureWithFrame
的新 Bitmap。其想法是将其用作画布,并分几步在上面绘制修改后的图像。目前,我们所做的是将缩放后的图像定位在 pictureWithFrame
的中心,并在其周围添加四条条带作为图片框。
为了添加背景条带,我们在 FillBitMapRegion()
中使用了一些不安全的代码,以便用特定颜色填充区域内的像素。Bitmap.LockBits()
和 Bitmap.UnlockBits()
用于将 Bitmap 锁定到系统内存,以便可以根据其在内存中的地址快速设置 Bitmap 像素。同时,我们定义并使用了一个新的结构 Overlay
来将 32 位颜色填充到 4 个字节中。填充几个矩形区域作为图片框没什么值得大惊小怪的,但重要的是将 Bitmap 变量用作画布的想法。
/// <summary>
/// Generic function to fill BitMap region on screen or in PictureBox control
/// </summary>
/// <param name="bmap"></param>
/// <param name="colour"></param>
/// <param name="leftX"></param>
/// <param name="upperY"></param>
/// <param name="rightX"></param>
/// <param name="lowerY"></param>
/// <returns></returns>
unsafe private bool FillBitMapRegion(ref Bitmap bmap,
Color colour, int leftX, int upperY, int rightX, int lowerY)
{
leftX--; upperY--; rightX--; lowerY--;
BitmapData bmd = bmap.LockBits(new Rectangle(leftX,
upperY, rightX - leftX + 1, lowerY - upperY + 1),
ImageLockMode.ReadOnly, bmap.PixelFormat);
int PixelSize = 4;
Overlay overlay = new Overlay();
for (int y = 0; y < bmd.Height; y++)
{
byte* row = (byte*)bmd.Scan0 + (y * bmd.Stride);
for (int x = 0; x < bmd.Width; x++)
{
overlay.u32 = (uint)colour.ToArgb();
row[x * PixelSize] = overlay.u8_0;
row[x * PixelSize + 1] = overlay.u8_1;
row[x * PixelSize + 2] = overlay.u8_2;
row[x * PixelSize + 3] = overlay.u8_3;
}
}
bmap.UnlockBits(bmd);
return true;
}
第二步是用缩放后的图像填充 pictureWithFrame
的中心。我们使用 System.Drawing.Graphics.DrawImage()
将 OriginalPicture
绘制到 pictureWithFrame
中的指定区域,而不是绘制前一个缩放后的图像。正如我们之前提到的,这是为了提高图像质量。同时,将 Graphics.InterpolationMode
设置为 InterpolationMode.HighQualityBilinear
,以便在放大 OriginalPicture
时在像素之间进行插值。
最后一步是将这个调整过的图像设置为 pictureBox.Image
并刷新弹出菜单。
/// <summary>
/// Reraw the changed image in memory and assign its value to Picture
/// </summary>
/// <returns></returns>
private bool ReDrawPicture()
{
Bitmap pictureWithFrame;
// Get the four corner point coordinates of the scaled image
switch (GetZoomedPicturePosition(ref leftX, ref upperY, ref rightX, ref lowerY))
{
case Common.SUCCESSBUTNOTDONE:
return true;
case Common.GENERALERROR:
return false;
case Common.GENERALSUCCESS:
break;
default:
throw new NotImplementedException();
}
// The background frames need to be filled every time the foreground is zoomed
if (!FillBackground(out pictureWithFrame, leftX, upperY, rightX, lowerY))
return false;
// Draw the zoomed image
System.Drawing.Graphics g = System.Drawing.Graphics.FromImage(pictureWithFrame);
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBilinear;
g.DrawImage(OriginalPicture, leftX, upperY, rightX, lowerY);
// Set the picture dimensions
PictureBox.Width = ScaledPictureWidth;
PictureBox.Height = ScaledPictureHeight;
// Set the picture
Picture = pictureWithFrame;
return true;
}
我们包含了一个简单的演示项目来展示如何使用此控件。如果您在使用控件本身或演示过程中遇到任何问题,请告知我们。