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

Just another C# Collapsing Group Control

0.00/5 (No votes)
30 May 2004 7  
An article on building an XP-style collapsing group box in C# with transparency.

Sample Image - XPGroupImage.gif

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 XPGroupBoxs 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.

/// <SUMMARY>
/// Summary description for XPGroupBox.
/// </SUMMARY>
[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:

Caption Image

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

   // Now draw the caption areas with the rounded corners at the top
   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);
   
   // Remove jaggies
   e.Graphics.SmoothingMode = SmoothingMode.AntiAlias;
   
   // Smooooth fill
   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:

Caption with pseudo button

I added this code to the OnPaint event handler:

// Draw the pseudo button indicating caption state
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:

// Draw the outline around the work area if expanded
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)
{
   // Initializes the transition delta
   if (transitionSizeDelta == 0)
   {
      transitionSizeDelta = 1;
   }

   // Reduces the interval between timer events - 
   // this gives the visual effect of the 
   // control slowly starting to collapse/expand then accelertaing
   if (timer1.Interval > 20)
   {
      timer1.Interval -= 20;
   }
   else
   {
      transitionSizeDelta+=2;
   }

   // Initialises the control transparency
   if (transitionAlphaChannel == 0)
   {
      transitionAlphaChannel = 10;
   }
   else
   {
      if ( transitionAlphaChannel + 10 < 255)
      {
         // Increase control transparency as it collapses
         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:

// Defined outside the class
public delegate void SizeChangingHandler(Object sender, EventArgs e);

   ...
   
// Defined within the class
public event SizeChangingHandler SizeChanging;

protected virtual void OnSizeChanging(EventArgs e)
{
   // Only fires event if something is handling the event
   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.

/// <SUMMARY>
/// Summary description for XPGroupBoxContainer.
/// </SUMMARY>
[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)
{
   //base.OnPaintBackground (pevent);
   Rectangle rect = new Rectangle(0, AutoScrollPosition.Y, this.Width, 
      this.Height); 
   //Rectangle rect = new Rectangle(0, 0, 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
    • Uploaded latest source.
  • 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
    • Fixed some typos.
  • Version 1.0 - 07/23/03
    • Initial version.

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