Introduction
I have been spending a lot of time writing business and data layer components
for a number of different purposes lately - and to be honest, I was sick of
looking at reams of code and no eye candy! So, I decided to turn my hand to a
bit of control creation goodness.
I have put together a few UIs in VS.NET before, and I was a little
disappointed by the lack of advanced XP-style controls in the toolbox. So I
decided, I would try to create a version of the ubiquitous collapsing/expanding
group-box you see in every XP explorer window. A brief search for code
(Stealing from one source is plagiarism. Stealing from many sources is
research - Laurendo Almeida) turned up a number of basic examples that had
rectangular captions and "snapped" shut immediately, but none had all of the
following:
- A curved caption box
- An animated collapse/expand (with that funky gradual collapse/expand that
gives the feeling of mass to the control)
- A gradual fade to/from transparent during the animation
- A graduated fill left to right across the caption
- A graduated fill from top left to bottom right within the group-box pane
- A graduated fill from top to bottom for the group-box container
- Full control of all color parameters
- Design time support (properties and the ability to drop and arrange child
controls)
The rest of the article represents the journey that has resulted in the
controls shown above. I don't for a moment suggest that they are complete
examples, but they are usable. Any tips on improvements are welcome! :-)
Creating the code
As I sat down to design the group-box control (XPGroupBox
) I soon realized that a collapsing XPGroupBox
on its own is pretty useless - the neat thing is ther
XPGroupBox
moving in relation to the expansion/contraction
and to do that, I needed a container - hence the XPGroupBoxContainer
was born.
Both controls inherit from UserControl
.
Using the code
The controls are contained within the DarenM.Controls
assembly. Add the assembly to your project and compile, and the controls should
appear in the toolbox's "My User Controls" area. Just drag the XPGroupContainer
(dock it to the left if you like) and then drag
XPGroupBox
controls into it. You can then add child
controls to the XPGroupBox
s as required.
XPGroupBox
This is actually the expanding/contracting group-box. There was a steep
learning curve to move from my idea to having a control sitting on a form that
expanded and contracted as I hoped.
XPGroupBox - Design-time support
In order to support design-time use of the control, I added properties:
[Description("Determines the radius of the curves
at the top-left and top-right of the control caption."),
DefaultValue(7),
Category("Appearance")]
public int CaptionCurveRadius
{
get { return captionCurveRadius; }
set { captionCurveRadius = value; Invalidate(); }
}
I also added this code to specify that the UserControl
used the ParentControlDesigner
designer, allowing child
controls to be dropped onto the XPGroupBox
control.
[Designer("System.Windows.Forms.Design.ParentControlDesigner,System.Design",
typeof(System.ComponentModel.Design.IDesigner))]
public class XPGroupBox : System.Windows.Forms.UserControl
{
...
XPGroupBox - The caption box
As I mentioned above, I wanted my controls to have nice rounded captions (the
radius for the curves are exposed by the property CaptionCurveRadius
) and to look something like this:
In order to achieve this look, I needed to perform the following:
- Enable transparent color support
- Override the
OnPaint
event
- Use a path to define the curved outline
- Use a gradient brush to fill the path
In order to support all the transparency and to perform all the custom
drawing I needed, I set the following styles in the constructor:
SetStyle(ControlStyles.ResizeRedraw, true);
SetStyle(ControlStyles.AllPaintingInWmPaint, true);
SetStyle(ControlStyles.UserPaint, true);
SetStyle(ControlStyles.DoubleBuffer, true);
SetStyle(ControlStyles.SupportsTransparentBackColor, true);
SetStyle(ControlStyles.ContainerControl, true);
this.BackColor = Color.Transparent;
To actually draw the caption box:
protected override void OnPaint(PaintEventArgs e)
{
Rectangle rc = new Rectangle(0, 0, this.Width, captionHeight);
LinearGradientBrush b = new LinearGradientBrush(rc,
captionLeftColor, captionRightColor,
LinearGradientMode.Horizontal);
GraphicsPath path = new GraphicsPath();
path.AddLine(captionCurveRadius, 0, this.Width -
(captionCurveRadius*2), 0);
path.AddArc(this.Width - (captionCurveRadius*2)-1, 0,
(captionCurveRadius*2), (captionCurveRadius*2), 270, 90);
path.AddLine(this.Width, captionCurveRadius,
this.Width-1 , captionHeight);
path.AddLine(this.Width , captionHeight, 0, captionHeight);
path.AddLine(0 , captionHeight, 0, captionCurveRadius);
path.AddArc(0, 0, (captionCurveRadius*2),
(captionCurveRadius*2), 180, 90);
e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
e.Graphics.FillPath(b, path);
...
You may note the inclusion of e.Graphics.SmoothingMode =
SmoothingMode.AntiAlias;
which removes jaggies from the curves.
Note: I have enhanced the caption to include a pseudo-button with
chevrons:
I added this code to the OnPaint
event handler:
int psuedoButtonDiameter = 14;
Point psuedoButtonorigin = new
Point(this.Width - psuedoButtonDiameter - 5, 5);
Size psuedoButtonSize = new
Size(psuedoButtonDiameter, psuedoButtonDiameter);
Rectangle psuedoButtonRect = new
Rectangle(psuedoButtonorigin, psuedoButtonSize);
e.Graphics.DrawEllipse(new Pen(CaptionFontColor), psuedoButtonRect);
DrawChevrons(e.Graphics, psuedoButtonRect.X,
psuedoButtonRect.Y, psuedoButtonRect.Width/4);
I then added the following support methods:
private void DrawChevrons(Graphics g, int x, int y, int offset)
{
if (expanded)
{
DrawChevron(g, x + offset + 1, y + 1*offset, -offset);
DrawChevron(g, x + offset + 1, y + 2*offset, -offset);
}
else
{
DrawChevron(g, x + offset + 1, y + 2*offset, offset);
DrawChevron(g, x + offset + 1, y + 3*offset, offset);
}
}
private void DrawChevron(Graphics g, int x, int y, int offset)
{
Pen p;
if (captionHighlighted)
{
p = new Pen(captionFontHighLightColor);
}
else
{
p = new Pen(CaptionFontColor);
}
Point[] points = { new Point(x, y),
new Point(x+Math.Abs(offset), y-offset),
new Point(x+2*Math.Abs(offset), y)
};
g.DrawLines(p, points);
}
XPGroupBox - The pane
To draw the outline of the pane (body) for the control, I use the following
code, again in the OnPaint
method:
if ( this.Height > captionHeight)
{
e.Graphics.DrawLine(new Pen(paneOutlineColor),
0, this.captionHeight, 0, this.Height);
e.Graphics.DrawLine(new Pen(paneOutlineColor),
this.Width -1, this.captionHeight, this.Width-1, this.Height);
e.Graphics.DrawLine(new Pen(paneOutlineColor),
0, this.Height - 1, this.Width-1 , this.Height - 1);
}
To fill in the background, I used OnBackgroundPaint
:
protected override void OnPaintBackground(PaintEventArgs pevent)
{
if (this.Height > captionHeight)
{
Rectangle rect = new Rectangle(0, captionHeight,
this.Width, this.Height - captionHeight);
LinearGradientBrush b = new LinearGradientBrush(rect,
paneTopLeftColor, paneBottomRightColor,
LinearGradientMode.ForwardDiagonal);
pevent.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
pevent.Graphics.FillRectangle(b, rect);
}
}
XPGroupBox - collapsing/ expanding
In order to implement the "weighted" collapsing look that I wanted, I used a
timer control and "slowly" (hey we are talking milliseconds here...) decreased
the period. For each tick, I reduce the size of the control, increase the
transparency, etc. This is the code that handles the tick:
private void timer1_Elapsed(object sender,
System.Timers.ElapsedEventArgs e)
{
if (transitionSizeDelta == 0)
{
transitionSizeDelta = 1;
}
if (timer1.Interval > 20)
{
timer1.Interval -= 20;
}
else
{
transitionSizeDelta+=2;
}
if (transitionAlphaChannel == 0)
{
transitionAlphaChannel = 10;
}
else
{
if ( transitionAlphaChannel + 10 < 255)
{
transitionAlphaChannel += 10;
}
}
switch (groupState)
{
case GroupState.Expanding:
if ((this.Height + transitionSizeDelta)< controlHeight )
{
SetControlsOpacity(transitionAlphaChannel);
paneBottomRightColor =
Color.FromArgb(transitionAlphaChannel,
paneBottomRightColor);
paneTopLeftColor =
Color.FromArgb(transitionAlphaChannel,
paneTopLeftColor);
paneOutlineColor =
Color.FromArgb(transitionAlphaChannel,
paneOutlineColor);
this.Height += transitionSizeDelta;
SetControlsVisible();
}
else
{
SetControlsOpacity(255);
paneBottomRightColor = Color.FromArgb(255 ,
paneBottomRightColor);
paneTopLeftColor = Color.FromArgb(255 ,
paneTopLeftColor);
paneOutlineColor = Color.FromArgb(255 ,
paneOutlineColor);
transitionAlphaChannel = 0;
this.Height = controlHeight;
expanded = true;
groupState = GroupState.Static;
SetControlsVisible();
}
break;
case GroupState.Collapsing:
if ((this.Height - transitionSizeDelta) > captionHeight )
{
SetControlsOpacity(transitionAlphaChannel);
this.Height -= transitionSizeDelta;
paneBottomRightColor = Color.FromArgb(255 -
transitionAlphaChannel, paneBottomRightColor);
paneTopLeftColor = Color.FromArgb(255 -
transitionAlphaChannel, paneTopLeftColor);
paneOutlineColor = Color.FromArgb(255 -
transitionAlphaChannel, paneOutlineColor);
SetControlsVisible();
}
else
{
transitionAlphaChannel = 0;
SetControlsOpacity(0);
paneBottomRightColor = Color.FromArgb(0,
paneBottomRightColor);
paneTopLeftColor = Color.FromArgb(0,
paneTopLeftColor);
paneOutlineColor = Color.FromArgb(0,
paneOutlineColor);
this.Height = captionHeight;
expanded = false;
groupState = GroupState.Static;
SetControlsVisible();
}
break;
case GroupState.Static:
timer1.Enabled = false;
transitionSizeDelta = 0;
break;
default:
throw new
InvalidExpressionException(
"groupState variable set to incorrect value");
}
Invalidate();
OnSizeChanging(new EventArgs());
}
You will note that the code fires an event on each tick - OnSizeChanging
. The event is defined as:
public delegate void SizeChangingHandler(Object sender, EventArgs e);
...
public event SizeChangingHandler SizeChanging;
protected virtual void OnSizeChanging(EventArgs e)
{
if (SizeChanging != null)
{
SizeChanging(this, e);
}
}
...
The event is handled by the XPGroupBoxContainer
to move
other controls as it collapses/ expands.
XPGroupBoxContainer
The XPGroupBoxContainer
is fairly simple (a lot less
code than I imagined - .NET is a wonderful thing!), but there were a number of
nasty issues I stumbled into and had to solve to get it working acceptably.
XPGroupBoxContainer - Design-time support
In order to support design-time use of the control, I added properties:
[Description("Determines the starting
(light) color of the pane gradient fill."),
Category("Appearance")]
public Color PaneTopLeftColor
{
get { return paneTopLeftColor; }
set { paneTopLeftColor = value; Invalidate(); }
}
I also added this code to specify that the UserControl
used the ParentControlDesigner
designer, allowing child
controls to be dropped onto the XPGroupBoxContainer
control.
[Designer("System.Windows.Forms.Design.ParentControlDesigner,System.Design",
typeof(System.ComponentModel.Design.IDesigner))]
public class XPGroupBoxContainer : System.Windows.Forms.UserControl
In order to auto-position the XPGroupBox
controls when
they are added, I implemented the following override:
protected override void OnControlAdded(ControlEventArgs e)
{
base.OnControlAdded (e);
if (e.Control is XPGroupBox)
{
RepositionControls();
((XPGroupBox)e.Control).SizeChanging +=
new SizeChangingHandler(XPGroupBoxContainer_SizeChanging);
}
else
{
throw new InvalidOperationException("Can only add XPGroupBoxControls");
}
}
Note that the XPGroupBoxContainer
registers itself to
handle each XPGroupBox
OnSizeChanging
event.
The following code is called to actually position the controls vertically
within the control. Note that this method is also invoked in response to the
OnSizeChanging
event from any of the controls. You will
also note the use of AutoScrollPosition.Y
to provide the
control positioning offset.
public void RepositionControls()
{
int lastVerticalPosition = AutoScrollPosition.Y;
foreach (Control c in this.Controls)
{
XPGroupBox xpg = c as XPGroupBox;
if (xpg != null)
{
xpg.Left = groupBoxSpacing;
xpg.Top = lastVerticalPosition + groupBoxSpacing;
lastVerticalPosition += xpg.Height + groupBoxSpacing ;
xpg.Width = this.Width - 2 * groupBoxSpacing - 16;
}
}
}
XPGroupBoxContainer - painting and scrolling
The background painting of the gradient is virtually a replica of that used
by the XpGroupBox
, except that the gradient direction is
vertical.
I ran into a lot of issues relating to positioning of controls on a scrolling
control, performing gradient fills, etc. I have noted them in the section -
Points of interest. The OnBackgroundPaint
method below
shows how I overcame the issues. Note the use of AutoScrollPosition
and DisplayRectangle
:
protected override void OnPaintBackground(PaintEventArgs pevent)
{
Rectangle rect = new Rectangle(0, AutoScrollPosition.Y, this.Width,
this.Height);
LinearGradientBrush b = new
LinearGradientBrush(this.DisplayRectangle,
paneTopLeftColor, paneBottomRightColor,
LinearGradientMode.Vertical);
pevent.Graphics.FillRectangle(b, this.DisplayRectangle);
}
Points of interest
A list of problems I encountered and the solutions in the order I remember
them, not the order they occurred:
- Not using a transparent background (or enabling its support) during the
early stages left false-background color displayed by my nice curves - very
disappointing! Took me a while to figure out what was glaringly obvious -
groan!
- In order for the child controls to move up when the control collapses -
remember to anchor them to the bottom!
- When I first implemented collapsing (and remembered to anchor the controls
to the bottom...), the child controls moved over the caption - UGLY! I spent a
long time trying to see if I could offset the client area so that child
controls would not draw over the caption. But I could not work out how to do
this. (Any tips on this will be hugely appreciated!) I implemented a
"kludge" that sets
control.Visible = false;
whenever
control.TOP < captionHeight;
. Not great, but it works!
- I ran into a large number of problems associated with scrolling. The
solutions to all my woes occurred once I realized a few fundamental truths
about GDI+:
- The drawing origin of the viewable area of any scrolled control is
always (0,0) - i.e. the top-left of the
ClientRectangle
. If you mean to position a control at the
top-left of the entire surface (the DisplayRectangle
)
you must add AutoScrollPosition.X
and AutoScrollPosition.Y
to the control's X,Y coordinates. I had
a nasty case of scrollbar confusion until I sorted this out.
- Perform the paint of the graduated fill background over the
DisplayRectangle
, as otherwise the fill looks naff and you
get the incorrect background appearing on the "transparent" corners of the
caption where the curves are.
- Is it just me or do
ClientRectangle
and DisplayRectangle
seem to be inappropriately named?
- Occasionally, the visual designer loses child controls - why?!
History
- Version 2.0 - 05/25/04
- Substantial update!
- Uploaded latest source.
- Updated screenshot
- Version 1.4 - 08/18/03
- Version 1.3 - 08/08/03
- Updated the document to include code for a pseudo-button I have added to
the caption, which enhances the look (I think :P). Modified source projects
will follow shortly.
- Version 1.2 - 07/29/03
- Uploaded new project containing fixes for:
- Top-most control not being set to visible, after control expanded.
- Multiple clicks on caption during expansion and collapse caused
incorrect control height to be set.
- Version 1.1 - 07/24/03
- Version 1.0 - 07/23/03