Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A Better Line Control

0.00/5 (No votes)
2 Feb 2013 1  
A WinForms line control that works how you would expect one to.

Line Control

Introduction 

Evaluating the .NET architecture when it first came out, I found one of the biggest complaints for developers (mostly VB guys) was that there was no more line control. Why would Microsoft leave out such a basic and useful control as a line? It's all speculation, but I believe that Microsoft left out some of the simpler controls to focus on the basic ones that should be included. They left a lot off the table in the standard toolbox so that extensions developers could create and sell their own controls. 

Background 

Until this day, I've yet to find a really good line control. What makes up a really good line control? First, the control must behave on the designer like I would expect it to. It's a line, why would it have more than 2 drag handles? Why is the line anchored to each side of the control? Why can't I drag an edge of the line anywhere I want it on the form? 

Searching around on the internet yielded some various results from "use a panel set to a height/width of 1 and border style set to fixed single", to "override the OnPaint of the form and draw your line". The former does not allow lines other than horizontal ones, and is odd to work with on the screen. The latter is certainly not a control that some code-impaired GUI developer would attempt. 

Design 

After a lot of research on the subject, I came to the conclusion that I would have to design a custom control designer and provide my own Adorner and Glyphs for display when the designer is active. I also wanted to be able to extend this to more shapes and not have to redesign the glyphs for each type of shape when it essentially does the same thing. My design requirements were as follows:

  • Be able to move the control anywhere on the form by dragging like any other control.
  • Be able to move any point of the line anywhere on the form by dragging that point, the other point would not move from its position. These points can be arbitrary, the start point of the line can be on the right of the end point for example.
  • Do not show the normal resize grips in the designer, the only way the line control is resized is by dragging one of the end points.
  • The line should be transparent so that you can draw the line over any other controls on the form and those controls would show through the line.

Starting with a Glyph

A glyph is a visual item that is painted on an Adorner window. There isn't a whole lot of information out there on how to make custom glyphs, so I spent a lot of time looking through the one article I could find on MSDN (here) and got to work.

The first part of creating a glyph is to define it's behavior. A behavior is basically how the glyph interacts with the control when the mouse is interacting with the glyph. For example, the smart tag menu is a special type of glyph that displays the controls context menu when it is clicked. The 8 resize handles around a normal control are a ResizeBehavior on the glyph (ResizeBehavior is an internal class to the .NET Framework). 

The behavior we are designing needs to do only one thing, drag a point around. In order to do that, when we create the behavior we have to get a reference to the control and the point that the glyph is responsible for.  There really isn't a lot to the class, so I'll show the meat of it here:

#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 

The code above is very basic drag behavior. The thing to note about designing your own and using the mouse location is that the location is in the coordinates of the adorner window, not the control. Since we are only calculating an offset here, I don't convert the adorner window coordinates to the control coordinates. It is something to keep in mind though since it can mess your calculations up.

After we've created the behavior, we can then define the glyph. Again the glyph is actually pretty simple, its simply a bounds, some painting, and what kind of cursor to display:

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

}

You can change the shape of the glyph to be whatever you want by overriding the Paint code. A glyph only tells the adorner what to paint, where to paint it, and what behavior is associated with the glyph when the mouse is over it.

Custom Shape Designer

The next thing we have to make is the designer for the shape. The designer derives from ControlDesigner and basically defines the appearance and behavior of a control that is in design mode on a design surface.  

The first method we are going to override is the Initialize method. This is called when the designer is initialized for a particular control.

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();
} 

As you can see from above, we call the base initialize to let the base class do what it needs to do first. Then we get a reference to the selection service which tells us what control is selected and when that selection changes (will show why this is important later). We hook the resize event on the control so we can tell the behavior service to synchronize the adorners, we hook the point count changed event of the IShape so that we can tell when points are added (for future use, like creating polygons), then we create the 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));
    }

} 

Here, if the adorner exists, we clear out the glyphs so we can add new ones. If it doesn't exist, we create it and add it to the BehaviorService so that the designer knows to use it. We then iterate through all the points in the shape and add a glyph for each one. This function is also called when the number of points changes in the control to recreate the glyphs.

In order to keep the designer from showing the resize grips and the snap lines, we need to override two properties:

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;
    }
} 

The first property tells the designer we do not want to participate in snap lines. The second property tells the designer that the control is only movable and visible, so it will draw a selection rectangle around it and allow it to be dragged, but no resize handles are shown. 

It is very important that you override the Dispose method on the designer. You do this so that you remove the adorner you created when the designer is removed. If you don't do this when you attempt to close the designer you will get some design time errors displayed. Here is how you implement the Dispose method:

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

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

    base.Dispose(disposing);
} 

The last thing I want to point out about the designer is the selection service event.

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

If the control we are designing is the primary selection and there is only one selection, we enable (show) our shape adorner. Otherwise we disable (hide) it. If we don't override this and enable/disable the adorner, the adorner elements will show all the time even if the control is not selected.

Line Control 

Alright, almost done. The last thing we have to implement is the line control. This derives from Control, IShape, and INotifyPropertyChanged. There are only a couple things I want to point out here, if you want to see how to make it transparent please look at the source code or other references available on the net and here on CodeProject. 

First are the two point properties:

[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();
        }
    }
} 

These are pretty basic but notice how they call the RecalcSize function at the end. This was the hardest thing to implement, here's why:

When any point changes, we need to find the new size/location of the control. We need to change the size/location of the control without changing how the line looks on the screen. For example, if the user drags one point to the left, the control must move left, then increase the width to appear not to move the other point. Since the points are arbitrary (even though named StartPoint and EndPoint), it does not matter which one is where.

Here is the entire size calculation routine:

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;
} 

Basically whenever a point changes, the size needs to be calculated again. It goes through and adjusts each edge of the control based on where the min/max of the points are. The right and bottom edges are easy since they are _edgeOffset away from the edge and we only need to adjust the width. The left and top edges were more difficult since an adjustment to the edge means an adjustment to the width or height. Depending on what point is the max/min, then we have to also adjust the other point position as well.

The effect is the image at the top, and when a line is selected, you see only two resize handles on each edge of the line:

Glyph View 

Points of Interest 

No matter what I did, I could not get the selection rectangle to disappear. Removing the adorners with that functionality not only removed it from my control, but all controls which made the resize/move handles disappear on other controls like buttons. This is the best I could get it, I would have preferred that the rectangle was not there, if anybody has a suggestion to remove it let me know and I'll update this. 

Known Issues

  • When moving a point around, the control flickers. This is due to the transparent nature of the control and enabling double buffering removes the transparency. This may be fixed with some manual double buffering in the paint routine but I haven't tried it yet.
  • On my system when moving a point around the mouse has artifacts that make it look like its blinking back and forth between the arrow and the hand. I don't have a fix or know why this is happening. 
  • If the glyph loses capture of the mouse when dragging it will stop moving the line point. This can be annoying for those with quick hands and I haven't found a good way around it. Microsoft uses a very complicated drag system that I wasn't really wanting to put that much time/effort into. If anybody does this let me know and I'll update the code and give major credit where due. 

History

Feb 2, 2013 - Initial article.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here