适用于 .NET Compact Framework 的自定义进度条






4.90/5 (42投票s)
一篇关于为Windows Mobile 5创建更好看的ProgressBar的文章。

引言
本文介绍了如何创建一个自定义外观的 ProgressBar 控件,该控件比 Windows Mobile 5 平台提供的标准进度条更美观且(在某种程度上)功能更强大。关于创建美观进度条的优秀文章已经有很多(例如这篇文章),但本文将重点介绍如何创建能够呈现几乎任何外观并在移动设备上运行的进度条。我还将提供一些关于如何设置 Visual Studio 项目以缩短 Windows Mobile 5 开发时间的技巧。
更新:此更新包含一个性能修复。修复内容将在“性能”章节中介绍。
Using the Code
本文可下载的源代码 ZIP 文件包含一个 Visual Studio 解决方案,位于名为 Bornander UI 的文件夹中。该解决方案包含进度条的代码以及一些测试代码;所有代码的目标平台均为 Windows。可以使用此项目在未安装 .NET Compact Framework 的情况下,在桌面环境中尝试此进度条。下载的 ZIP 文件还包含一个名为 Bornander UI (Cross platform).zip 的 ZIP 文件,其中包含我在构建此进度条时使用的解决方案。其中还包含用于为设备环境构建源代码的项目。
进度条控件的代码全部在 ProgressBar.cs 文件中。该文件包含一个名为 ProgressBar
的类,该类继承自 System.Windows.Panel
。由于它继承自标准的 Windows Forms 控件,因此可以使用可视化设计器将其放置在窗体或面板上。
要求
在创建此控件时,我确定了一组控件应实现的要求:
- 进度条的外观应该是可配置的。
- 进度条应该能够模仿已有的进度条,例如 XP、Vista 和 Mac 上找到的进度条。
- 进度条必须像正常的 Windows Forms 控件一样运行,这意味着可以使用窗体设计器将其添加到面板或窗体中。
- 进度条的源代码必须能在 Windows 环境和 Windows Mobile 环境之间完全移植,这意味着源代码无需更改即可为任一平台进行编译。
因此,有四个相当直接的要求需要实现。那么,情况如何呢?
满足自定义外观
使用图元进行渲染
自定义控件渲染的一种方法是重写绘制方法,并调用 DrawRect
或 FillRect
等方法来绘制所需的图形,例如:
...
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.FillRectangle(new SolidBrush(backgroundColor), 0, 0,
this.Width, this.Height);
e.Graphics.FillRectangle(new SolidBrush(foregroundColor), 0, 0,
scrollbarValue, this.Height);
}
...
这首先会用 backgroundColor
颜色绘制一个实心矩形背景,然后在其上方绘制另一个可能更短的矩形,颜色为 foregroundColor
。这基本上是我认为 .NET Compact Framework 中标准进度条的实现方式。
然而,使用这种方法绘制 Windows Vista 风格的进度条将非常困难(或至少非常耗时),因为该进度条使用了颜色之间的渐变过渡。幸运的是,System.Drawing.Drawing2D
提供了渐变填充的画笔(例如 System.Drawing.Drawing2D.LinearGradientBrush
)。太好了!我们有了完成这项工作所需的工具。或者没有?
做过一点 .NET 编程然后开始做 .NET Compact Framework 编程的人都意识到了 Compact Framework 到底有多么“紧凑”。不仅一些类的方法缺失,连整个类也消失了。例如,Compact Framework 中没有 System.Drawing.Drawing2D.LinearGradientBrush
。
使用图像进行渲染
在考虑了我要达到的自定义程度后,我决定,即使 Compact Framework 中存在 System.Drawing.Drawing2D.LinearGradientBrush
,它仍然无法满足我的要求。我希望我的进度条能够实现更高的自定义程度。我希望能够支持背景中的文本或图像,这有点像你在游戏中看到的进度条。

我决定采用一种解决方案,即我的进度条不使用图元进行渲染,而是使用一套图像,我可以在 Visual Studio 的可视化窗体设计器中提供这些图像。这有三个主要好处:
- 这是一种足够通用的解决方案,可以实现我想到的几乎所有外观。
- 它将允许我“复制”其他进度条,因为我可以截屏然后剪切并粘贴我想要的部分。这对我很重要,因为我根本没有艺术才能。
- 实现时间将只是基于图元的渲染方法的一小部分。
所以,决定使用图像了。
图像
图像类型
我最初尝试重现 XP 风格,并意识到我需要三种图像来实现这一点:
- 背景:这当然是背景图像。首先绘制它。
- 前景:这是当进度条的值达到最大值时“填充”进度条的部分。这是第二个绘制项。
- 叠加层:理论上,这个不是必需的,但使用它比跳过它要容易得多。最后绘制它,并且这个图像大多数情况下应该是完全透明的。我用它来添加漂亮的边框。
图像尺寸
图像应该有多大?它们都需要一样大吗?如果我想让我的进度条比使用的图像更宽怎么办?显然,我希望进度条能够具有或多或少的任意尺寸,而不管使用的图像如何,那么进度条应该如何渲染背景,例如,如果背景图像不够宽以匹配我想要的进度条宽度?有两种方法可以解决这种情况:
- 多次绘制图像,一个接一个地直到进度条的宽度被填满,平铺图像。
- 在绘制时拉伸图像,使其宽度与进度条相同。
我意识到这两种方法都有优缺点。你不能拉伸图像来创建 XP 风格的进度条,其中进度由绿色方块表示。在这种情况下,你必须平铺图像。另一方面,对于 Vista 风格,直接拉伸图像更方便。最终,我决定这两种方法都能带来酷炫的效果,因此我将选择权留给了使用该控件的开发人员。我是通过公开一个名为 [imageType]DrawMethod
的属性来实现的。我想选择平铺或拉伸图像,为背景、前景和叠加层分别设置。
public class ProgressBar : Panel
{
public enum DrawMethod
{
Tile,
Stretch
}
private DrawMethod foregroundDrawMethod = DrawMethod.Stretch;
...
public DrawMethod ForegroundDrawMethod
{
get { return foregroundDrawMethod; }
set { foregroundDrawMethod = value; }
}
...
}
通过将其公开为公共属性,可视化窗体设计器将允许我在控件的属性页上更改它。完美。
图像段
好的,我们有了所需的图像类型,并且有了确保任何长度的图像都能覆盖进度条整个宽度的方法。太好了,这意味着我们可以使用小型图像并以此节省内存和资源。我上面例子中的 XP 风格进度条由这三张图像组成:
背景图像
前景图像
叠加层图像(大部分透明)
但是,如果我们想将背景图像拉伸到 200 像素,会发生什么?这会破坏角落图像的比例,如下所示:
背景图像(从 19 像素拉伸到 200 像素宽度)
这样看起来不好,所以我提出了图像段的概念。我为每张图像公开三个段相关的属性,然后只拉伸或平铺中间段。三个段由 ProgressBar
类的两个属性定义:
[imageType]LeadingSize
:定义不会被拉伸的最左侧区域。[imageType]TrailingSize
:定义不会被拉伸的最右侧区域。
中间段隐式定义为领先段和尾随段之间的段。同样,公共属性将段值公开给窗体设计器。
public class ProgressBar : Panel
{
...
private int backgroundLeadingSize = 0;
private int backgroundTrailingSize = 0;
...
public int BackgroundLeadingSize
{
get { return backgroundLeadingSize; }
set { backgroundLeadingSize = value; }
}
public int BackgroundTrailingSize
{
get { return backgroundTrailingSize; }
set { backgroundTrailingSize = value; }
}
}
现在,终于定义好了所有必需的属性。
渲染
现在是时候渲染图像了。我们通过重写 OnPaint
来实现这一点。
protected override void OnPaintBackground(PaintEventArgs e)
{
// Do nothing in here as all the painting is done in OnPaint
}
protected override void OnPaint(PaintEventArgs e)
{
//
// An offscreen is a must have so we make sure one is always
// created if it does not exists,
// a resize of the progressbar sets offscreenImage to null and
// this will then automatically
// create a new one with the correct dimensions.
//
if (offscreenImage == null)
CreateOffscreen();
// Render the background first, here we pass the entire width of
// the progressbar as the distance value because we
// always want the entire background to be drawn.
Render(offscreen,
backgroundImage,
backgroundDrawMethod,
backgroundLeadingSize,
backgroundTrailingSize,
this.Width);
// We only need to render the foreground if the
// current value is above the minimum
if (value > minimum)
{
// Calculate the amount of pixels (the distance) to draw.
int distance =
(int)(((float)this.Width) * ((float)(value - minimum)) /
((float)(maximum - minimum)));
Render(offscreen,
foregroundImage,
foregroundDrawMethod,
foregroundLeadingSize,
foregroundTrailingSize,
distance);
}
// Render the overlay, this way we can get neat border
// on our progress bar (for example)
Render(offscreen,
overlayImage,
overlayDrawMethod,
overlayLeadingSize,
overlayTrailingSize,
this.Width);
// Finally, draw we our offscreen onto the Graphics in the event.
e.Graphics.DrawImage(offscreenImage, 0, 0);
}
这里有几点需要注意:首先,我们不仅重写了 OnPaint
,还重写了 OnPaintBackground
,以确保面板不会尝试渲染其默认背景。这很重要,因为如果不这样做可能会导致进度条闪烁。
此外,我不会直接绘制到传递给 OnPaint
方法的 Graphics
对象,因为这也会导致闪烁。相反,我在内存中创建一个图像(在 CreateOffscreen
方法中完成),并将进度条渲染到其中。然后,在方法末尾,我将屏幕外图像绘制到 e.Graphics
。这样,屏幕上可见的 Graphics
对象只更新一次。不会闪烁!
进度条类中的一个名为 Render
的方法用于将图像渲染到指定宽度。这个方法被调用三次,分别对应背景、前景和叠加层。对于背景和叠加层,控件的宽度作为宽度参数传递,使得背景和叠加层始终绘制以填充整个进度条。前景则使用表示进度条“进度”量的宽度参数进行绘制。Render
方法如下所示:
protected void Render(Graphics graphics,
Image sourceImage,
DrawMethod drawMethod,
int leadingSize,
int trailingSize,
int distance)
{
// If we don't have an image to render just bug out, this allows us
// to call Render without checking sourceImage first.
if (sourceImage == null)
return;
//
// Draw the first segment of the image as defined by leadingSize,
// this is always drawn at (0, 0).
//
ProgressBar.DrawImage(
graphics,
sourceImage,
new Rectangle(0, 0, leadingSize, this.Height),
new Rectangle(0, 0, leadingSize, sourceImage.Height));
//
// Figure out where the last segment of the image should be drawn,
// this is always to the right of the first segment
// and then at the given distance minus the width of the last segment.
//
int trailerLeftPosition = Math.Max(leadingSize, distance - trailingSize);
ProgressBar.DrawImage(
graphics,
sourceImage,
new Rectangle(trailerLeftPosition, 0, trailingSize, this.Height),
new Rectangle(sourceImage.Width - trailingSize,
0,
trailingSize,
sourceImage.Height));
//
// We only draw the middle segment if the width of the first and last
// are less than what we need to display.
//
if (distance > leadingSize + trailingSize)
{
RenderCenterSegment(graphics,
sourceImage,
drawMethod,
leadingSize,
trailingSize,
distance,
trailerLeftPosition);
}
}
通过传入一个源矩形(指定要绘制的图像区域)和目标矩形(图像绘制到的图形对象区域),可以轻松地绘制领先段、尾随段和中间段。在 Render
的末尾,并且只有当宽度参数大于领先段宽度加上尾随段宽度时(此检查是为了确保进度条的“端点”始终被绘制),中间段才会被渲染。这一部分考虑了当前的 DrawMode
。
private void RenderCenterSegment(Graphics graphics,
Image sourceImage,
DrawMethod drawMethod,
int leadingSize,
int trailingSize,
int distance,
int trailerLeftPosition)
{
switch (drawMethod)
{
// This draws the middle segment stretched to fill the area
// between the first and last segment.
case DrawMethod.Stretch:
ProgressBar.DrawImage(
graphics,
sourceImage,
new Rectangle(leadingSize,
0,
distance - (leadingSize + trailingSize),
this.Height),
new Rectangle(leadingSize,
0,
sourceImage.Width -
(
leadingSize + trailingSize
),
sourceImage.Height));
break;
// This draws the middle segment un-stretched as many times
// as required to fill the area between the first and last segment.
case DrawMethod.Tile:
{
Region clipRegion = graphics.Clip;
int tileLeft = leadingSize;
int tileWidth = sourceImage.Width -
(leadingSize + trailingSize);
// By setting clip we don't have to change the size
// of either the source rectangle or the destination
// rectangle, the clip will make sure the
//overflow is cropped away.
graphics.Clip = new Region(
new Rectangle(tileLeft,
0,
trailerLeftPosition - tileLeft,
this.Height + 1));
while (tileLeft < trailerLeftPosition)
{
ProgressBar.DrawImage(
graphics,
sourceImage,
new Rectangle(tileLeft,
0,
tileWidth,
this.Height),
new Rectangle(leadingSize,
0,
tileWidth,
sourceImage.Height));
tileLeft += tileWidth;
}
graphics.Clip = clipRegion;
}
break;
}
}
细心的读者可能会注意到使用了 ProgressBar.DrawImage
而不是 graphics.DrawImage
。这是因为 .NET 和 .NET Compact Framework 之间存在兼容性问题。我希望同一代码库能够在桌面和设备平台上运行。这导致使用一些预处理器指令,因为我们必须在不同平台之间稍微更改代码。.NET Compact Framework 会忽略 PNG 文件中的透明像素,并将它们渲染为白色。这样不行,所以我使用 ImageAttribute
类中定义的“色键”来实现透明度。
这也能在桌面上正常工作,但无需使用“绿色屏幕”颜色就能以我想要的外观绘制图像要好得多。因此,我决定在桌面上保留原始行为。
protected static void DrawImage(Graphics graphics,
Image image,
Rectangle destinationRectangle,
Rectangle sourceRectangle)
{
/*
* The only place where some porting issues arises in when
* drawing images, because of this the ProgressBar code does not
* draw using Graphics.DrawImage directly. It instead uses this
* wrapper method that takes care of any porting issues using pre-
* processor directives.
*/
#if PocketPC
//
// The .NET Compact Framework can not handle transparent pngs
// (or any images), so to achieve transparancy we need to set
// the image attributes when drawing the image.
// I've decided to hard code the "chroma key" value to
// Color.Magenta but that can easily
// be set by a property instead.
//
if (imageAttributes == null)
{
imageAttributes = new ImageAttributes();
imageAttributes.SetColorKey(Color.Magenta, Color.Magenta);
}
graphics.DrawImage(image,
destinationRectangle,
sourceRectangle.X,
sourceRectangle.Y,
sourceRectangle.Width,
sourceRectangle.Height,
GraphicsUnit.Pixel,
imageAttributes);
#else
graphics.DrawImage(image,
destinationRectangle,
sourceRectangle,
GraphicsUnit.Pixel);
#endif
}
我选择洋红色作为硬编码的色键,这使得无法在进度条中使用该颜色,因为它不会被渲染。这是一件好事,因为洋红色是一种丑陋的颜色。就是这样。自定义进度条可以呈现任何外观!
渲染走马灯条
(本章节在本文第三版中添加。)
正如我在本文的讨论中被正确指出的那样,我的实现缺少一个走马灯模式。我决定添加它并实现相同类型的自定义可能性。对于那些不熟悉走马灯进度条的人来说,当进度条用于指示处理过程而非进度时,就称为走马灯进度条。它通常用于告知用户应用程序正在执行某些操作,但不知道还有多少工作要做。我的进度条首先需要一种方式来指示条的类型,所以我添加了一个枚举:
public class ProgressBar : Panel
{
...
public enum BarType
{
Progress,
Marquee
}
...
}
通过使用此枚举的成员,该成员通过 get/set 属性公开,可以使用可视化设计器中的属性页轻松设置条的类型:
public class ProgressBar : Panel
{
...
public enum BarType
{
Progress,
Marquee
}
private BarType barType = BarType.Progress;
#if !PocketPC
[Category("Progressbar")]
#endif
public BarType Type
{
get { return barType; }
set { barType = value; }
}
...
}
你可能会想,属性上的预处理器指令有什么用?
#if !PocketPC
[Category("Progressbar")]
#endif
public BarType Type
通过向属性添加 Category
属性,可视化设计器中的属性页可以将相关属性分组在一起。然而,.NET Compact Framework 不包含 Category
属性,因此在没有预处理器指令的情况下代码将无法编译。这意味着,如果我们在桌面环境中使用进度条,属性将整齐地分组在一起。不幸的是,在设备版本上不会这样。我还添加了另一个枚举和成员/属性对:
public class ProgressBar : Panel
{
...
public enum MarqueeStyle
{
TileWrap,
BlockWrap,
Wave
}
...
}
这提供了选择走马灯渲染类型的选项。在我的实现中有三种不同的渲染类型可供选择:
- TileWrap:渲染一个单独的平铺块,该块在条上移动,并在到达末尾时从头开始。
- BlockWrap:有点像 TileWrap,但移动的块的宽度是可配置的。
- Wave:渲染一个类似 BlockWrap 的块,但它以波浪形模式在条上来回移动。
由于进度条的背景和叠加层部分不随 BarType
改变,因此 ProgressBar.OnPaint
方法的大部分内容与之前的实现相同。唯一的改动是添加了一个 switch 语句,根据 BarType
渲染前景。走马灯前景的渲染被委托给另一个方法,因为有三种选项,我想保持 ProgressBar.OnPaint
方法的整洁:
protected override void OnPaint(PaintEventArgs e)
{
// An offscreen is a must have so we make sure one is always created
// if it does not exists, a resize of the progressbar sets
// offscreenImage to null and this will then automatically
// create a new one with the correct dimensions.
if (offscreenImage == null)
CreateOffscreen();
// Render the background first, here we pass the entire width of the
// progressbar as the distance value because we always want the entire
// background to be drawn.
Render(offscreen, backgroundImage, backgroundDrawMethod,
backgroundLeadingSize, backgroundTrailingSize, this.Width);
switch (barType)
{
case BarType.Progress:
// We only need to render the foreground if the current value
// is above the minimum
if (value > minimum)
{
// Calculate the amount of pixels (the distance) to draw.
int distance = (int)(((float)this.Width) * ((float)(
value - minimum)) / ((float)(maximum - minimum)));
Render(offscreen, foregroundImage, foregroundDrawMethod,
foregroundLeadingSize, foregroundTrailingSize, distance);
}
break;
case BarType.Marquee:
// There are a couple of ways to render the marquee foreground
// so this is delegated to a method
RenderMarqueeForeground();
break;
}
// Render the overlay, this way we can get neat border on our progress
// bar (for example)
Render(offscreen, overlayImage, overlayDrawMethod, overlayLeadingSize,
overlayTrailingSize, this.Width);
// Finally, draw we our offscreen onto the Graphics in the event.
e.Graphics.DrawImage(offscreenImage, 0, 0);
}
ProgressBar.RenderMarqueeForeground()
负责使用正确的方法渲染前景:
private void RenderMarqueeForeground()
{
switch (marqueeStyle)
{
case MarqueeStyle.TileWrap:
RenderMarqueeTileWrap();
break;
case MarqueeStyle.Wave:
RenderMaqueeWave();
break;
case MarqueeStyle.BlockWrap:
RenderMarqueeBlockWrap();
break;
}
}
这三个方法随后用于渲染前景。这些方法基本上都是相同的:它们都渲染前景的领先部分、中间部分,最后是尾随部分。它们之间的唯一区别在于它们如何计算绘图的起始位置。
性能
由于 Windows Mobile 设备的功能远不如桌面计算机强大,我发现我需要提高进度条的渲染性能,以便在进度值频繁更新的情况下运行得更流畅。
在这种情况下,找到可以节省时间的区域并不困难。我知道进度条基本上只是在渲染自身。因此,为了优化它,我需要优化渲染背景、前景和叠加层的方法。当我说不难时,我的意思是很容易找到一个折衷点,即在牺牲其他东西的情况下获得速度。优化通常就是这样,除非代码一开始就写得很差,在这种情况下,可以通过删除冗余项或正确执行操作来优化。
我决定以牺牲内存为代价来提高速度,方法是存储“计算好的”图形到缓存图像中。“计算好的”指的是背景、前景和叠加层在屏幕上的显示方式是通过它们的领先、尾随属性计算出来的。通过在调整大小时将它们渲染到缓存图像,然后在进度条需要重绘时使用缓存图像,正常的重绘将无需进行任何计算。
这种方法对背景和叠加层来说是可以的,但对于前景(它会随着进度值的变化而变化)来说,情况就变得复杂了。我可以创建一个完整的前景缓存图像集,为每个进度量生成一个,然后根据进度值渲染正确的图像。如果使用的缓存图像太少,这可能会导致进度条看起来像是在值之间跳跃,或者如果我为每个可能的状态(等于进度条的像素宽度)创建一个缓存图像,则会消耗过多的内存。
我决定只优化背景和叠加层。然后需要做的第一件事是有一个方法,将图像渲染到正确的尺寸,而不是渲染到屏幕外,而是渲染到它们的缓存图像。这个方法是从处理大小调整的方法中调用的:
protected void RenderCacheImages()
{
ProgressBar.DisposeToNull(backgroundCacheImage);
ProgressBar.DisposeToNull(overlayCacheImage);
backgroundCacheImage = new Bitmap(Width, Height);
Graphics backgroundCacheGraphics = Graphics.FromImage(backgroundCacheImage);
// Render the background, here we pass the entire
// width of the progressbar as the distance value because we
// always want the entire background to be drawn.
Render(backgroundCacheGraphics, backgroundImage, backgroundDrawMethod,
backgroundLeadingSize, backgroundTrailingSize, this.Width);
overlayCacheImage = new Bitmap(Width, Height);
Graphics overlayCacheGraphics = Graphics.FromImage(overlayCacheImage);
// Make sure that we retain our chroma key value by starting with a
// fully transparent overlay cache image
overlayCacheGraphics.FillRectangle(new SolidBrush(Color.Magenta),
ClientRectangle);
// Render the overlay, this way we can get neat border on our
// progress bar (for example)
Render(overlayCacheGraphics, overlayImage, overlayDrawMethod,
overlayLeadingSize, overlayTrailingSize, this.Width);
}
我仍然可以重用现有的 ProgressBar.Render
方法,因为它被创建为渲染到屏幕外。然而,不是将屏幕外图形作为第一个参数,而是提供缓存图像的图形。请注意,为了保持任何透明度,叠加层图像被绘制在洋红色区域上,因为在这一步中,透明(洋红色)像素会留下该区域,并在渲染到屏幕外时使其透明。下一步是用对 ProgressBar.Render
的调用替换 OnPaint
方法中背景和叠加层的调用,改用 ProgressBar.DrawImage
。
protected override void OnPaint(PaintEventArgs e)
{
//
// An offscreen is a must have so we make sure one is
// always created if it does not exists,
// a resize of the progressbar sets offscreenImage to
// null and this will then automatically
// create a new one with the correct dimensions.
//
if (offscreenImage == null)
CreateOffscreen();
// Render the background first using the cached image
ProgressBar.DrawImage(offscreen, backgroundCacheImage,
ClientRectangle, ClientRectangle);
switch (barType)
{
// Render foreground here...
...
}
// Render the overlay using the cached image
ProgressBar.DrawImage(offscreen, overlayCacheImage,
ClientRectangle, ClientRectangle);
// Finally, draw we our offscreen onto the Graphics in the event.
e.Graphics.DrawImage(offscreenImage, 0, 0);
}
性能修复就到这里。在运行应用程序时,在 Windows Mobile 5 设备上很难看到差异,但在 PocketPC 2003 设备上则可见。尽管此实现旨在用于 Mobile 5 或更高版本,但我仍然尝试让我的代码在尽可能多的平台上工作。
最终结果
本文开头定义的所有要求都已实现,唯一需要改进的是在 Mobile 5 设备上运行时性能。总的来说,我对结果相当满意。
关注点
下载文件中的 ZIP 文件展示了如何设置一个解决方案,以便两个项目可以引用相同的文件。这对于开发 .NET Compact Framework 应用程序很有用,因为在模拟器上启动测试应用程序可能需要一些时间。因此,在桌面环境中进行尝试很方便,但同时,我希望能够立即获得 API 兼容性的反馈。这样,我就不会花费数天时间实现一些因为使用了 Compact Framework 不支持的内容而无用的功能。
欢迎对代码和文章提出任何评论。
历史
- 2007-11-14:初始版本
- 2007-11-15:第二版(修复了一些拼写和语法错误)
- 2007-11-26:添加了对走马灯进度条的支持
- 2007-12-14:更新了性能修复