带尺寸填充的图像绘制
类似视觉样式的图像拉伸,用于自定义皮肤
引言
在本教程中,我将向您展示如何像 Windows 中绘制视觉样式那样拉伸图像。它完全由 GDI+ 和 C# 完成,因此无需进行互操作(我也从未找到能够为我完成这种常见皮肤任务的互操作)。
背景
视觉样式元素的组成
Windows(以及大多数其他操作系统)中的视觉样式使用位图来绘制其元素。这种位图当然具有固定大小,但仍必须非常灵活——几乎每个控件都可以是任何大小,因此位图必须以一种使结果看起来仍然很好的方式进行拉伸。
简单地将图像拉伸到控件的新大小看起来不好——想象一下典型的按钮设计,它有一个边框,然后中间有一个渐变。
这是官方的 Windows 7 风格按钮。是的,它实际上很小!但是 Windows 是如何绘制比这大得多的按钮的呢?例如,一个宽度为 75 像素、高度为 50 像素的按钮?
使用典型方法 DrawImage
拉伸图像也会重新调整边框的大小,输出会变得模糊,而且真的,真的很难看。
private void _panel_Paint(object sender, PaintEventArgs e)
{
e.Graphics.DrawImage(Resources.Button, _panel.ClientRectangle);
}
呃,这不是我们想要的。边框只是像图像的其他部分一样被拉伸了。Windows 不能这样做。
大小填充
这就是所谓的“大小填充”的作用。它们也经常被称为“大小边距”,但我坚持使用填充。你很快就会明白为什么。
所以,我们不希望边框区域被拉伸。我们必须告诉 Windows 这个边框有多大,以及内部内容(灰色渐变部分)有多大。我们可以将位图图像分为以下几个部分。
事情变得越来越小,是吧?如果你仔细看,你会发现我们现在有按钮的 9 个部分。
- 看起来有4 个角部,在绘制按钮的调整大小版本时,它们不应该向任何方向拉伸。
- 然后是中间左侧和中间右侧的2 个高部。这些部分应该只垂直拉伸,但宽度应保持不变。
- 在顶部中间和底部中间有2 个宽部。这些部分必须水平拉伸,但高度保持不变。
- 最后但并非最不重要的一点是,最中间的部分,它会向水平和垂直两个方向拉伸。
为了使事情更清楚,这里有一个按上面项目符号列表着色区域的放大版本。
要定义这些区域,4 个值就足够了!您可以将它们想象成位图中的填充,其中左边值决定左边部分的宽度,上边值决定上边部分的高度,依此类推。在上面的图像中,如果您再次仔细观察,您会注意到这里的填充是 (3, 3, 3, 3),因为所有部分要么宽 3 像素,要么高 3 像素(当然除了向各个方向拉伸的中间部分)。
这也是我为什么不称它们为大小边距(例如在 WindowBlinds 及其 SkinStudio 中)的原因,因为这些区域是在位图内部,而不是外部。
Using the Code
编写一个根据这些填充绘制图像的方法可能很繁琐。GDI+ 最终需要源和目标部分的矩形(计算这些可能非常麻烦),如果方法不够优化,起初会重复很多代码。此外,绘制 GUI 元素应该很快,所以我们需要一个轻量级的方法。
这就是为什么我想分享我经过一段时间的调试和测试后想出的方法。由于它是 Graphics
对象的扩展方法,您可以像调用 DrawImage()
一样调用它。它还支持剪裁源,稍后会详细介绍。这是完整的类。
public static class GraphicsExtensions
{
/// <summary>
/// Draws the image into the specified destination rectangle with the specified sizing
/// padding for stretched drawing.
/// </summary>
/// <param name="gr">The extended Graphics object.</param>
/// <param name="image">The image which will be drawn.</param>
/// <param name="destination">
/// The destination rectangle in which the image will be drawn.</param>
/// <param name="padding">
/// The padding specifying the image parts which won't be stretched.</param>
public static void DrawImageWithPadding(this Graphics gr, Image image,
Rectangle destination, Padding padding)
{
if (image == null)
{
throw new ArgumentNullException("image");
}
DrawImageWithPadding(gr, image, destination, new Rectangle(Point.Empty, image.Size),
padding);
}
/// <summary>
/// Draws the part of the image defined by the source rectangle into the specified
/// destination rectangle with the specified sizing padding for stretched drawing.
/// </summary>
/// <param name="gr">The extended Graphics object.</param>
/// <param name="image">The image which will be drawn.</param>
/// <param name="destination">
/// The destination rectangle in which the image will be drawn.</param>
/// <param name="source">
/// The source rectangle in the image used for clipping parts of it.</param>
/// <param name="padding">
/// The padding specifying the image parts which won't be stretched.</param>
public static void DrawImageWithPadding(this Graphics gr, Image image,
Rectangle destination, Rectangle source, Padding padding)
{
if (gr == null)
{
throw new ArgumentNullException("gr");
}
if (image == null)
{
throw new ArgumentNullException("image");
}
Rectangle[] destinations = GetSizingRectangles(destination, padding);
Rectangle[] sources = GetSizingRectangles(source, padding);
for (int i = 0; i < 9; i++)
{
gr.DrawImage(image, destinations[i], sources[i], GraphicsUnit.Pixel);
}
}
private static Rectangle[] GetSizingRectangles(Rectangle rectangle, Padding padding)
{
int leftV = rectangle.X + padding.Left;
int rightV = rectangle.X + rectangle.Width - padding.Right;
int topH = rectangle.Y + padding.Top;
int bottomH = rectangle.Y + rectangle.Height - padding.Bottom;
int innerW = rectangle.Width - padding.Horizontal;
int innerH = rectangle.Height - padding.Vertical;
// Set parts in descending order to draw upper left tiles over bottom right ones
Rectangle[] rectangles = new Rectangle[9];
rectangles[8] = new Rectangle(rectangle.X, rectangle.Y, padding.Left, padding.Top);
rectangles[7] = new Rectangle(leftV, rectangle.Y, innerW, padding.Top);
rectangles[6] = new Rectangle(rightV, rectangle.Y, padding.Right, padding.Top);
rectangles[5] = new Rectangle(rectangle.X, topH, padding.Left, innerH);
rectangles[4] = new Rectangle(leftV, topH, innerW, innerH);
rectangles[3] = new Rectangle(rightV, topH, padding.Right, innerH);
rectangles[2] = new Rectangle(rectangle.X, bottomH, padding.Left, padding.Bottom);
rectangles[1] = new Rectangle(leftV, bottomH, innerW, padding.Bottom);
rectangles[0] = new Rectangle(rightV, bottomH, padding.Right, padding.Bottom);
return rectangles;
}
}
对您来说最重要的方法是 DrawImageWithPadding
。它的更简单的重载需要绘制的图像、图像将被绘制到的目标矩形(正确缩放!)以及——当然——我们刚刚谈到的尺寸填充。
结果非常……漂亮
这正是我们想要的1!
剪裁区域和不同的按钮状态
DrawImageWithPadding
还有一个稍微复杂一点的版本,它实际上做了真正的工作。它有一个额外的源参数。
其背后的想法是,大多数视觉样式位图都包含不止一个按钮状态。Windows 7 按钮位图实际上看起来像这样2。
您可以识别以下状态:正常、热点(悬停)、按下、禁用、焦点和焦点热点。
您不能将此位图与 DrawImageWithPadding
的简单重载一起使用,但可以使用更复杂的重载——它具有所需的参数来仅提取其中一个状态!为此,您需要定义状态所在的区域。在此示例中,热点状态将是矩形 (0, 21, 11, 21)
。
private void _panel_Paint(object sender, PaintEventArgs e)
{
e.Graphics.DrawImageWithPadding(Resources.Button, _panel.ClientRectangle,
new Rectangle(0, 21, 11, 21), new Padding(3, 3, 3, 3));
}
GetSizingRectangles 内部
GetSizingRectangles
方法似乎执行了所有必要的计算,以获取 GDI+ 绘制图像所需的矩形。您说得对!
首先,它计算了上面彩色图像中黑线的位置,因为在下面的矩形中需要多次用到这些位置。它还获取了向两个方向拉伸的内部部分的宽度和高度。
然后,计算所有 9 个区域。它们被反向分配给矩形数组。这看起来很奇怪,但实际上是有道理的:如果实际控件比所用的实际位图小,那么左上角的部分会绘制在右下角的部分之上。即使右下角的边框开始消失,这也比左上角的边框消失看起来好得多。为了获得这个结果,矩形被反向分配给数组,所以右下角的部分首先被绘制(当然,你也可以反转 DrawImageWithPadding
中的 for
循环,但我讨厌反向循环,而且也认为它们会慢一点?)。
关注点
1:眼尖的人会注意到内部的灰色渐变有点模糊。Windows 不会对输出图像使用任何插值或双线性过滤,这保持了渐变的锐利边缘。然而,GDI+ 默认情况下会这样做,为了简单起见,我不想改变这一点。您必须关闭 Graphics 对象的插值属性。它还会稍微加快图像的绘制速度,但在特定图像上可能会导致像素化(就像在 Windows 中一样——非常大的按钮真的很难看)。
gr.InterpolationMode = InterpolationMode.NearestNeighbor;
gr.PixelOffsetMode = PixelOffsetMode.Half;
PixelOffsetMode
也必须设置。如果不设置,图像拉伸得越多,图像底部或右侧就会出现越多完全透明的像素。看起来图像没有拉伸得足够宽或足够高。
2:如果您以前使用过 Windows 7 风格,您会发现我有点作弊。在原始位图中,每个状态周围都有透明像素。Windows 按钮实际上比屏幕上实际看到的大一个像素。但是,位图在那里是完全透明的,所以按钮看起来更小。尝试点击其可见边框外一个像素的按钮,您就会看到这个事实!我移除了这些透明区域以简化文章并避免混淆读者。
源代码和示例程序
我附上了一个示例程序,您可以使用它来修改传递给函数的参数。其中包含两个示例图像,包括一个 Windows XP 风格的按钮。调整窗口大小,实时查看拉伸效果!
您还可以在本文中找到程序的完整源代码。它包括上面展示的 GraphicsExtensions.cs,这是您自己程序中开始使用这些功能所需的全部内容。
历史
- 2014年7月22日 - 更新了示例应用程序
- 2014年1月31日 - 添加了示例程序及其源代码
- 2013年10月27日 - 首次发布