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

带尺寸填充的图像绘制

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.86/5 (22投票s)

2013年10月27日

CPOL

7分钟阅读

viewsIcon

46937

downloadIcon

1502

类似视觉样式的图像拉伸,用于自定义皮肤

引言

在本教程中,我将向您展示如何像 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日 - 首次发布
© . All rights reserved.