带边框效果的简单标签控件






4.61/5 (30投票s)
介绍一个能够为任何所需文本添加类似边框效果的
 
 
引言
有一次,在尝试用户界面设计时,我需要显示一些带有类似边框效果的文本,以提高文本在透明背景上的可读性。不幸的是,不仅 Framework 没有提供这样的功能,而且我在网上也找不到任何免费的实现。考虑到这种情况,我决定开始创建自己的带有文本边框功能的标签控件。
背景
起初,我曾认为可以通过将文本绘制两次到屏幕上来模拟边框,但使用不同的尺寸。这样,较大的文本(外部)将模拟边框,较小的文本(绘制在较大的内部)将作为前景。
后来,当我第一次将这篇文章提交到 The Code Project 时,我希望有人能提出一种更好的方法来实现这种效果。而事实也正是如此。
由于我对 GDI+ 和直接在屏幕上绘制图形的经验很少,我遵循了 Bob Powell 的优秀指南,该指南位于 此处,这是 CodeProject 会员 fwsouthern 的建议。基本思想保持不变,只是我们现在将使用 GraphicPaths、Brushes 和其他效果,而不是仅仅重叠文本,这确实是 更好 的处理方式。
创建代码
我开始编写这个控件,首先创建了一个派生自标准 System.Windows.Forms.Control 类的新组件,但后来我决定直接继承自 System.Windows.Forms.Label。然后我重写了 OnPaint 方法以添加我自己的绘制逻辑,并添加了几个额外的属性来设置控件的“边框部分”。
创建我们的控件属性
由于我希望获得最大的标签式体验,因此实现 Text、TextAlign 和 AutoSize 等属性是很自然的。但更重要的是,我需要属性来控制文本的边框方面,我称之为 BorderColor 和 BorderSize。此时,继承自 Windows.Forms.Label 似乎是个不错的选择,因为我可以免费获得一些想要的属性,而无需担心,例如 AutoSizing 控件。
然而,为了正确实现这一点,此控件需要一些我花了一些时间才学会的技巧。
那么,让我们来看看必须做什么,从控件的构造函数、属性和重写事件开始。
/// <summary>
///   Represents a Bordered label.
/// </summary>
public partial class BorderLabel : Label
{
    private float borderSize;
    private Color borderColor;
    private PointF point;
    private SizeF drawSize;
    private Pen drawPen;
    private GraphicsPath drawPath;
    private SolidBrush forecolorBrush;
    // Constructor
    //-----------------------------------------------------
    #region Constructor
    public BorderLabel()
    {
        this.borderSize = 1f;
        this.borderColor = Color.White;
        this.drawPath = new GraphicsPath();
        this.drawPen = new Pen(new SolidBrush(this.borderColor), borderSize);
        this.forecolorBrush = new SolidBrush(this.ForeColor);
    
        this.Invalidate();
    }
    #endregion
    // Public Properties
    //-----------------------------------------------------
    #region Public Properties
    /// <summary>
    ///   The border's thickness
    /// </summary>
    [Browsable(true)]
    [Category("Appearance")]
    [Description("The border's thickness")]
    [DefaultValue(1f)]
    public float BorderSize
    {
        get { return this.borderSize; }
        set
        {
            this.borderSize = value;
            if (value == 0)
            {
                //If border size equals zero, disable the
                // border by setting it as transparent
                this.drawPen.Color = Color.Transparent;
            }
            else
            {
                this.drawPen.Color = this.BorderColor;
                this.drawPen.Width = value;
            }
            this.OnTextChanged(EventArgs.Empty);
        }
    }
    /// <summary>
    ///   The border color of this component
    /// </summary>
    [Browsable(true)]
    [Category("Appearance")]
    [DefaultValue(typeof(Color), "White")]
    [Description("The border color of this component")]
    public Color BorderColor
    {
        get { return this.borderColor; }
        set
        {
            this.borderColor = value;
            
            if (this.BorderSize != 0)
                this.drawPen.Color = value;
        
            this.Invalidate();
        }
    }
    #endregion
    // Event Handling
    //-----------------------------------------------------
    
    #region Event Handling
    protected override void OnFontChanged(EventArgs e)
    {
        base.OnFontChanged(e);
        this.Invalidate();
    }
    protected override void OnTextAlignChanged(EventArgs e)
    {
        base.OnTextAlignChanged(e);
        this.Invalidate();
    }
    protected override void OnTextChanged(EventArgs e)
    {
        base.OnTextChanged(e);
    }
    protected override void OnForeColorChanged(EventArgs e)
    {
        this.forecolorBrush.Color = base.ForeColor;
        base.OnForeColorChanged(e);
        this.Invalidate();
    }
    #endregion
简单,不是吗?
好吧,实际上不是。我最初试图尽可能地保持与原始标签控件的最大相似性。这包括编写每个属性,定义正确的设计器特性和标志,并确保在修改属性后调用 this.Invalidate() 方法,以便在设计模式下立即反映更改。
现在,我们可以继续讨论这个组件最有趣的部分了。
重写 OnPaint
正如我之前所说,重写 OnPaint 方法似乎是解决我问题的唯一合适方法。但是,由于我们直接继承自 System.Windows.Forms.Label,我们必须手动添加大部分绘制逻辑。这包括绘制适当大小的文本,以及确定文本应在控件区域的哪个位置绘制。
但是,出现了一个问题。由于我们将绘制到一个 GraphicsPath 而不是直接绘制到 Graphics 对象本身,因此出现了许多尺寸问题。显然,在屏幕上绘制的相同字体和在 GraphicsPath 内部绘制的字体不一定能产生相同的结果。因此,我无法让 AutoSize 按我想要的方式工作,甚至在控件内正确对齐文本也似乎是一项复杂的任务。
最后,经过大量的阅读(以及一些运气),我找到了一些关于如何正确处理这些问题的线索。最终的重写方法如下所示。
// Drawning Events
//-----------------------------------------------------
#region Drawning
protected override void OnPaint(PaintEventArgs e)
{
    // First let's check if we indeed have text to draw.
    //  if we have no text, then we have nothing to do.
    if (this.Text.Length == 0)
        return;
    // Secondly, let's begin setting the smoothing mode to AntiAlias, to
    // reduce image sharpening and compositing quality to HighQuality,
    // to improve our drawnings and produce a better looking image.
    e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
    e.Graphics.CompositingQuality = CompositingQuality.HighQuality;
    // Next, we measure how much space our drawning will use on the control.
    //  this is important so we can determine the correct position for our text.
    this.drawSize = e.Graphics.MeasureString(this.Text, this.Font, new PointF(), 
                StringFormat.GenericTypographic);
         
    // Now, we can determine how we should align our text in the control
    //  area, both horizontally and vertically. If the control is set to auto
    //  size itself, then it should be automatically drawn to the standard position.
            
    if (this.AutoSize)
    {
        this.point.X = this.Padding.Left;
        this.point.Y = this.Padding.Top;
    }
    else
    {
        // Text is Left-Aligned:
        if (this.TextAlign == ContentAlignment.TopLeft ||
            this.TextAlign == ContentAlignment.MiddleLeft ||
            this.TextAlign == ContentAlignment.BottomLeft)
            this.point.X = this.Padding.Left;
    
        // Text is Center-Aligned
        else if (this.TextAlign == ContentAlignment.TopCenter ||
            this.TextAlign == ContentAlignment.MiddleCenter ||
            this.TextAlign == ContentAlignment.BottomCenter)
            point.X = (this.Width - this.drawSize.Width) / 2;
        // Text is Right-Aligned
        else point.X = this.Width - (this.Padding.Right + this.drawSize.Width);
        
        // Text is Top-Aligned
        if (this.TextAlign == ContentAlignment.TopLeft ||
            this.TextAlign == ContentAlignment.TopCenter ||
            this.TextAlign == ContentAlignment.TopRight)
            point.Y = this.Padding.Top;
        // Text is Middle-Aligned
        else if (this.TextAlign == ContentAlignment.MiddleLeft ||
            this.TextAlign == ContentAlignment.MiddleCenter ||
            this.TextAlign == ContentAlignment.MiddleRight)
            point.Y = (this.Height - this.drawSize.Height) / 2;
        // Text is Bottom-Aligned
        else point.Y = this.Height - (this.Padding.Bottom + this.drawSize.Height);
    }
    // Now we can draw our text to a graphics path.
    //  
    //   PS: this is a tricky part: AddString() expects float emSize in pixel, 
    //   but Font.Size measures it as points.
    //   So, we need to convert between points and pixels, which in
    //   turn requires detailed knowledge of the DPI of the device we are drawing on. 
    //
    //   The solution was to get the last value returned by the 
    //   Graphics.DpiY property and
    //   divide by 72, since point is 1/72 of an inch, 
    //   no matter on what device we draw.
    //
    //   The source of this solution can be seen on CodeProject's article
    //   'OSD window with animation effect' - 
    //   https://codeproject.org.cn/csharp/OSDwindow.asp
    
    float fontSize = e.Graphics.DpiY * this.Font.SizeInPoints / 72;
                
    this.drawPath.Reset();                           
    this.drawPath.AddString(this.Text, this.Font.FontFamily, 
                    (int)this.Font.Style, fontSize,
                        point, StringFormat.GenericTypographic);
    // And finally, using our pen, all we have to do now
    //  is draw our graphics path to the screen. Voila!
    e.Graphics.FillPath(this.forecolorBrush, this.drawPath);
    e.Graphics.DrawPath(this.drawPen, this.drawPath);
}
现在,最后但也许最重要的方法是(我最初忘记了 - 感谢 martin 的提示)Dispose 方法。我说这可能是最重要的方法,因为大多数 GDI+ 资源(如笔和画笔)不会被垃圾回收器自动收集,需要手动处理。否则,控件可能会导致内存泄漏,迟早会导致崩溃,因为 GDI 对象将一直保留在内存中。
/// <summary>
///   Releases all resources used by this control
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources.
/// </param>
protected override void Dispose(bool disposing)
{
    if (disposing)
    {
        if (this.forecolorBrush != null)
            this.forecolorBrush.Dispose();
        if (this.drawPath != null)
            this.drawPath.Dispose();
        if (this.drawPen != null)
            this.drawPen.Dispose();
    }
    base.Dispose(disposing);
}
好了,就这样。
如果您对代码的工作原理仍有任何疑问,请随时下载并尝试源代码,/或在文章讨论板上发布消息。我已经尽我所能记录了我的代码。
Using the Code
要使用此代码,只需将此组件添加到您的项目中,打开窗体设计器,然后将 BorderLabel 拖放到您的窗体中。您可以通过“设计器属性工具箱”定义 Text、TextAlign、BorderSize 和 BorderColor,就像使用任何控件一样。
关注点
在创建此控件时,我首先尝试通过重叠不同大小的字符串来产生类似边框的效果。然而,这导致了非常不匹配的字符串,根本无法组合在一起。我不得不逐个字母绘制字符串,以保持文本从开始到结束的同步,这导致了与我预期的效果略有不同的效果。
在尝试实现 AutoSize 属性时,我也遇到了许多问题,最终导致了比好处更多的头痛。我删除了那段糟糕的代码,并找到了一种更好的解决方案,即直接继承自 Windows.Forms.Label,并尝试将控件显示的字体大小调整为字符串的实际大小。
现在,如果您有任何更好的建议、批评,或者只是想告诉我我的代码很糟糕,请回复您的想法,以便我能了解更多关于这个主题的信息并继续改进我的控件。但请善良一些,因为这是我第一次提交文章!
历史
- 14/09/2007 
- 首次提交版本
 
- 14/09/2007 
- 感谢 The Code Project 的反馈,代码得到了很大改进
 
- 03/10/2007 
- 已调整以显示正确的字体大小
- 添加了完全工作的 AutoSize属性
 
- 22/06/2008 
- 简化了绘制例程
- 文本放置也得到了改进
 


