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

一个更好的线条控件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (16投票s)

2013年2月1日

CPOL

9分钟阅读

viewsIcon

44494

一个行为符合预期的 WinForms 线条控件。

Line Control

介绍 

评估 .NET 架构刚推出时,我发现开发人员(主要是 VB 开发者)最大的抱怨之一是,不再有线条控件了。为什么微软要放弃这样一个基本且有用的控件?这只能猜测,但我认为微软之所以省略一些简单的控件,是为了专注于应该包含的基础控件。他们在标准工具箱中省略了很多内容,以便扩展开发人员可以创建和销售自己的控件。 

背景 

直到今天,我还没有找到一个真正好的线条控件。一个好的线条控件应该是什么样的?首先,控件在设计器中的行为应该符合我的预期。它是一条线,为什么会有超过 2 个拖动手柄?为什么线会固定在控件的每一侧?为什么我不能随意拖动线的边缘到窗体上的任何位置? 

在网上搜索得到了一些不同的结果,有人建议“使用一个高度/宽度设置为 1 并将边框样式设置为固定单线的面板”,也有人建议“重写窗体的 OnPaint 方法来绘制你的线条”。前者不允许绘制非水平线,并且在屏幕上操作起来很奇怪。后者肯定不是一个对代码不熟悉的 GUI 开发者能够尝试的。 

设计 

经过大量研究,我得出结论,我必须设计一个自定义控件设计器,并提供我自己的 Adorner 和 Glyphs,以便在设计器激活时显示。我还希望能够将这一点扩展到更多形状,而无需为每种形状重新设计 Glyphs,因为它们基本上做的是同样的事情。我的设计要求如下:

  • 能够像其他控件一样,通过拖动将控件移动到窗体上的任何位置。
  • 能够通过拖动该点来移动线条的任意点,另一个点不会移动。这些点可以是任意的,例如,线的起点可以在终点的右侧。
  • 在设计器中不显示正常的调整大小手柄,调整线条控件大小的唯一方法是拖动其中一个端点。
  • 线条应该是透明的,这样你就可以在窗体上的任何其他控件上绘制线条,并且那些控件会透过线条显示出来。

从一个 Glyph 开始

Glyph 是绘制在 Adorner 窗口上的可视化项。关于如何创建自定义 Glyphs 的信息并不多,所以我花了很多时间查阅 MSDN 上我能找到的一篇文章(这里),然后开始着手。

创建 Glyph 的第一部分是定义其行为。行为基本上是如何在鼠标与 Glyph 交互时,Glyph 与控件进行交互。例如,智能标签菜单是一种特殊的 Glyph,在点击时会显示控件的上下文菜单。正常控件周围的 8 个调整大小手柄是 Glyph 的一个 ResizeBehaviorResizeBehavior 是 .NET Framework 的一个内部类)。 

我们需要设计的行为只需要做一件事,就是拖动一个点。为了做到这一点,当我们创建行为时,我们必须获得对控件和 Glyph 所负责的点的引用。这个类其实并不复杂,所以我在这里展示其核心部分。

#region Construction / Deconstruction

public ShapeGlyphBehavior(IShape shape, int pointIdx)
{
    _shape = shape;
    _pointIdx = pointIdx;
}

#endregion

#region Public Methods

public override bool OnMouseDown(Glyph g, System.Windows.Forms.MouseButtons button, Point mouseLoc)
{
    if ((button & System.Windows.Forms.MouseButtons.Left) == System.Windows.Forms.MouseButtons.Left)
    {
        _dragStart = mouseLoc;
        _dragging = true;
    }
    return true;
}

public override bool OnMouseUp(Glyph g, System.Windows.Forms.MouseButtons button)
{
    if ((button & System.Windows.Forms.MouseButtons.Left) == System.Windows.Forms.MouseButtons.Left)
    {
        _dragging = false;
    }
    return true;
}

public override bool OnMouseMove(Glyph g, System.Windows.Forms.MouseButtons button, Point mouseLoc)
{
    if (_dragging)
    {
        int xDiff = mouseLoc.X - _dragStart.X;
        int yDiff = mouseLoc.Y - _dragStart.Y;

        Point p = _shape.GetPoint(_pointIdx);

        if (xDiff == 0 && yDiff == 0)
            return true;

        p.X += xDiff;
        p.Y += yDiff;
        _dragStart = mouseLoc;

        _shape.SetPoint(_pointIdx, p);
    }

    return true;
}

public override bool OnMouseLeave(Glyph g)
{
    _dragging = false;
    return true;
}

#endregion 

上面的代码是非常基础的拖动行为。设计自己的行为并使用鼠标位置时需要注意的是,位置是在 Adorner 窗口的坐标系中,而不是在控件的坐标系中。由于我们只计算偏移量,所以我没有将 Adorner 窗口的坐标转换为控件的坐标。不过,这是需要注意的,因为它可能会影响你的计算。

创建好行为后,我们就可以定义 Glyph 了。同样,Glyph 其实很简单,它只有边界、一些绘制代码以及鼠标悬停时要显示的游标类型。

class PointGlyph : Glyph
{

    #region Fields

    private IShape _shape;
    private int _pointIdx;
    private BehaviorService _behaviorSvc;
    private Control _baseControl;
    private int _glyphSize = 10;
    private Color _glyphFillColor = Color.White;
    private Color _glyphOutlineColor = Color.Black;
    private int _glyphCornerRadius = 4;

    #endregion

    #region Properties

    public override Rectangle Bounds
    {
        get
        {
            Point p = _shape.GetPoint(_pointIdx);

            p = _behaviorSvc.MapAdornerWindowPoint(_baseControl.Handle, p);

            int x = p.X - (_glyphSize / 2);
            int y = p.Y - (_glyphSize / 2);

            return new Rectangle(x, y, _glyphSize, _glyphSize);
        }
    }

    #endregion

    #region Construction / Deconstruction

    public PointGlyph(BehaviorService behaviorSvc, IShape shape, int pointIdx, Control baseControl)
        : base(new ShapeGlyphBehavior(shape, pointIdx))
    {
        _shape = shape;
        _pointIdx = pointIdx;
        _behaviorSvc = behaviorSvc;
        _baseControl = baseControl;
    }

    #endregion

    #region Public Methods

    public override Cursor GetHitTest(Point p)
    {
        Rectangle hitBounds = Bounds;
        hitBounds.Inflate(4, 4);

        if (hitBounds.Contains(p))
            return Cursors.Hand;

        return null;
    }

    public override void Paint(PaintEventArgs pe)
    {
        Rectangle glyphRect = Bounds;

        pe.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

        //First draw the fill...
        using (SolidBrush sb = new SolidBrush(_glyphFillColor))
        {
            pe.Graphics.FillRoundedRectangle(sb, glyphRect, _glyphCornerRadius);
        }

        //And then  the outline
        using (Pen p = new Pen(_glyphOutlineColor))
        {
            pe.Graphics.DrawRoundedRectangle(p, glyphRect, _glyphCornerRadius);
        }
    }

    #endregion

    #region Private Methods



    #endregion

}

你可以通过重写 Paint 代码来改变 Glyph 的形状,使其成为你想要的任何样子。Glyph 只告诉 Adorner 要在何处绘制什么,以及鼠标悬停在其上时的行为。

自定义形状设计器

接下来我们要创建的是形状的设计器。设计器派生自 ControlDesigner,它基本上定义了一个控件在设计表面的设计模式下的外观和行为。 

我们要重写的第一个方法是 Initialize 方法。当为特定控件初始化设计器时会调用此方法。

public override void Initialize(IComponent component)
{
    base.Initialize(component);

    _selectionSvc = GetService(typeof(ISelectionService)) as ISelectionService;

    _selectionSvc.SelectionChanged += new EventHandler(SelectionSvc_SelectionChanged);

    Control.Resize += new EventHandler(Control_Resize);

    IShape shape = Control as IShape;

    if (shape != null)
        shape.PointCountChanged += new EventHandler(Shape_PointCountChanged);

    RecreateAdorner();
} 

从上面的代码可以看出,我们调用了基类的 Initialize 方法,让基类先完成其需要做的事情。然后,我们获取 SelectionService 的引用,它会告诉我们哪个控件被选中以及何时发生选择变化(稍后将展示其重要性)。我们订阅了控件的 resize 事件,以便通知 behavior service 同步 adorners;我们订阅了 IShape 的 point count changed 事件,以便在添加点时(用于创建多边形等)得到通知;然后我们创建了 Adorner。

private void RecreateAdorner()
{
    if (_shapeAdorner != null)
    {
        _shapeAdorner.Glyphs.Clear();
    }
    else
    {
        _shapeAdorner = new Adorner();
        BehaviorService.Adorners.Add(_shapeAdorner);
    }

    IShape shape = Control as IShape;

    if (shape == null)
        return;

    for (int i = 0; i < shape.PointCount; i++)
    {
        _shapeAdorner.Glyphs.Add(new PointGlyph(BehaviorService, shape, i, Control));
    }

} 

在这里,如果 Adorner 存在,我们会清除现有的 Glyphs,以便添加新的。如果不存在,我们会创建它并将其添加到 BehaviorService 中,以便设计器知道使用它。然后,我们遍历形状中的所有点,并为每个点添加一个 Glyph。当控件中的点数发生变化时,也会调用此函数来重新创建 Glyphs。

为了阻止设计器显示调整大小手柄和吸附线,我们需要重写两个属性。

public override bool ParticipatesWithSnapLines
{
    get
    {
        return false;
    }
}

public override SelectionRules SelectionRules
{
    get
    {
        return System.Windows.Forms.Design.SelectionRules.Moveable |
            System.Windows.Forms.Design.SelectionRules.Visible;
    }
} 

第一个属性告诉设计器我们不希望参与吸附线。第二个属性告诉设计器该控件只能移动和可见,因此它会围绕它绘制一个选择矩形并允许拖动,但不会显示调整大小手柄。 

重写设计器上的 Dispose 方法是**非常**重要的。这样做是为了在设计器被移除时移除你创建的 Adorner。如果你不这样做,在尝试关闭设计器时会显示一些设计时错误。以下是如何实现 Dispose 方法。

protected override void Dispose(bool disposing)
{
    BehaviorService b = BehaviorService;

    if (b != null && b.Adorners.Contains(_shapeAdorner))
        b.Adorners.Remove(_shapeAdorner);

    base.Dispose(disposing);
} 

关于设计器,我最后想指出的是 SelectionService 事件。 

private void SelectionSvc_SelectionChanged(object sender, EventArgs e)
{
    if (_selectionSvc.PrimarySelection == Control && _selectionSvc.SelectionCount == 1)
        _shapeAdorner.Enabled = true;
    else
        _shapeAdorner.Enabled = false;
} 

如果我们要设计的控件是主要选中的,并且只有一个选中项,我们则启用(显示)我们的形状 Adorner。否则,我们禁用(隐藏)它。如果我们不重写此方法并启用/禁用 Adorner,那么即使控件未被选中,Adorner 元素也会一直显示。

线条控件 

好了,差不多完成了。我们最后需要实现的是线条控件。它派生自 ControlIShapeINotifyPropertyChanged。这里只有几点我想指出,如果你想知道如何使其透明,请查看源代码或网络上及 CodeProject 上的其他参考资料。 

首先是两个点属性。

[Browsable(false), Category("Layout"), Description("Start point of the line in control coordinates.")]
public Point StartPoint
{
    get { return _p1; }
    set
    {
        if (value != _p1)
        {
            _p1 = value;
            OnPropertyChanged("StartPoint");
            RecalcSize();
        }
    }
}

[Browsable(false), Category("Layout"), Description("End point of the line in control coordinates.")]
public Point EndPoint
{
    get { return _p2; }
    set
    {
        if (value != _p2)
        {
            _p2 = value;
            OnPropertyChanged("EndPoint");
            RecalcSize();
        }
    }
} 

这些非常基础,但请注意它们如何在最后调用 RecalcSize 函数。这是最难实现的部分,原因如下:

当任何一个点发生变化时,我们需要找到控件的新大小/位置。我们需要改变控件的大小/位置,而不改变线条在屏幕上的样子。例如,如果用户将一个点向左拖动,控件也必须向左移动,然后增加宽度,以显示另一个点似乎没有移动。由于点是任意的(即使被命名为 StartPointEndPoint),哪个点在哪里并不重要。

这是完整的尺寸计算例程。

private void RecalcSize()
{
    AdjustTopEdge();
    AdjustBottomEdge();
    AdjustLeftEdge();
    AdjustRightEdge();

    InvokeInvalidate();

    base.OnResize(EventArgs.Empty);
}

private void AdjustTopEdge()
{
    //Find the top most point
    int minY = Math.Min(_p1.Y, _p2.Y);
    bool useP1 = false;

    if (_p1.Y < _p2.Y)
        useP1 = true;

    int adjust = minY - _edgeOffset;

    Top += adjust;

    if (useP1)
    {
        _p1.Y = _edgeOffset;
        _p2.Y -= adjust;
        Height -= adjust;
    }
    else
    {
        _p2.Y = _edgeOffset;
        _p1.Y -= adjust;
        Height -= adjust;
    }
}

private void AdjustBottomEdge()
{
    int maxY = Math.Max(_p1.Y, _p2.Y);

    int height = maxY + _edgeOffset;

    Height = height;
}

private void AdjustLeftEdge()
{
    int minX = Math.Min(_p1.X, _p2.X);
    bool useP1 = false;

    if (_p1.X < _p2.X)
        useP1 = true;

    int adjust = minX - _edgeOffset;

    Left += adjust;

    if (useP1)
    {
        _p1.X = _edgeOffset;
        _p2.X -= adjust;
        Width -= adjust;
    }
    else
    {
        _p2.X = _edgeOffset;
        _p1.X -= adjust;
        Width -= adjust;
    }
}

private void AdjustRightEdge()
{
    int maxX = Math.Max(_p1.X, _p2.X);

    int width = maxX + _edgeOffset;

    Width = width;
} 

基本上,每当一个点发生变化时,就需要重新计算尺寸。它会根据点的最小/最大值来调整控件的每个边缘。右侧和底部边缘很容易,因为它们距离边缘有 _edgeOffset,我们只需要调整宽度。左侧和顶部边缘更难,因为调整边缘意味着调整宽度或高度。根据哪个点是最大/最小值,我们也必须调整另一个点的位置。

效果如顶部的图片所示,当线条被选中时,你只会看到线条的两端各有两个调整大小手柄。

Glyph View 

兴趣点 

无论我怎么做,都无法让选择矩形消失。移除具有此功能的 Adorner 不仅移除了我控件的选择矩形,也移除了所有控件的选择矩形,导致按钮等其他控件的调整大小/移动手柄也消失了。这是我能做到的最好效果了,我本来希望矩形不存在,如果有人有移除它的建议,请告诉我,我会更新这篇文章。 

已知问题

  • 拖动点时,控件会闪烁。这是由于控件的透明特性,启用双缓冲会破坏透明度。这或许可以通过在绘制例程中进行一些手动双缓冲来修复,但我还没有尝试过。
  • 在我的系统上,拖动点时鼠标会出现伪影,使其看起来像在箭头和手之间来回闪烁。我没有修复方法,也不知道为什么会发生这种情况。 
  • 如果 Glyph 在拖动时失去了鼠标捕获,它将停止移动线条的起点。这对于手速较快的人来说可能很烦人,而且我还没有找到一个好的解决办法。微软使用了一个非常复杂的拖动系统,我不想花费太多时间和精力去研究。如果有人解决了这个问题,请告诉我,我会更新代码并给予极高的荣誉。 

历史

2013 年 2 月 2 日 - 初始文章。

© . All rights reserved.