Wednesday, May 02, 2007

.NET CF Custom Control: RoundedGroupBox

The .NET Compact Framework provides an excellent abstracted interface to the Graphics Device Interface (GDI) subsystem in the same manner as the full .NET Framework. However, there are still quite a few drawing methods which are not part of the CF which if included would make the design of custom controls easier (and sometimes more fun). One of the methods missing from the Compact Framework is the RoundRect GDI function. According to MSDN, "This function draws a rectangle with rounded corners. The current pen outlines the rectangle and the current brush fills it."

In this entry, the RoundRect function is used to create a new custom control called a RoundedGroupBox. The standard GroupBox control exists on the full framework and provides a panel with a rounded outline and a title. It logically groups controls into a designated area of the screen. An image of the GroupBox from the full framework is displayed in Image 1. The Compact Framework does not contain a GroupBox control, so this custom control, RoundedGroupBox, serves as a stand-in with a little more flare.

Image 1 - GroupBox from the full .NET framework


To create the RoundedGroupBox using C# and the .NET Compact Framework, aside from the RoundRect GDI function, there are three other GDI functions which need to be P/Invoked:


  • CreateSolidBrush - This function is used to create colored brushes used to fill in the round rectangles.

  • SelectObject - This function is used to select a brush to use when drawing.

  • DeleteObject - This function deletes a drawing object and frees its associated memory.



A fourth function, GetStockObject is also contained within the source code but not used within this example. GDI contains some predefined pens, brushes and fonts. This function can retrieve any of those.


One very important point when P/Invoking these functions is that they need to be accessed at both design time and runtime. On Windows Mobile, these functions are all found in the coredll native assembly. Trying to use that assembly during design time is not going to work however, because Visual Studio will try to access a coredll.dll file on the local desktop. To work in the designer on the desktop, these functions need to be accessed in the gdi32 assembly found in the \Windows\System32 folder. A simple wrapper function has been written that checks the current environment and decides which version of the function to use -- gdi32 on the desktop and coredll on Windows Mobile. The source code for accessing these functions is listed in Code Listing 1. Image 2 shows the RoundedGroupBox control displayed in the Visual Studio designer.

Code Listing 1 - NativeMethods.cs


#region Using Directives

using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;

#endregion

namespace Rhinomobile.ControlLibrary
{
/// <summary>
/// Platform Invocation Methods
/// </summary>
class NativeMethods
{
#region Windows CE Methods

[DllImport("coredll", EntryPoint = "RoundRect")]
private static extern bool RoundRectCE(IntPtr hdc, int nLeftRect, int nTopRect,
int nRightRect, int nBottomRect, int nWidth, int nHeight);

[DllImport("coredll", EntryPoint = "CreateSolidBrush")]
private static extern IntPtr CreateSolidBrushCE(int crColor);

[DllImport("coredll", EntryPoint = "SelectObject")]
private static extern IntPtr SelectObjectCE(IntPtr hdc, IntPtr hgdiobj);

[DllImport("coredll", EntryPoint = "DeleteObject")]
private static extern bool DeleteObjectCE(IntPtr hObject);

[DllImport("coredll", EntryPoint = "GetStockObject")]
private static extern IntPtr GetStockObjectCE(int fnObject);

#endregion

#region Windows Methods

[DllImport("gdi32", EntryPoint = "RoundRect")]
private static extern bool RoundRectWin(IntPtr hdc, int nLeftRect, int nTopRect,
int nRightRect, int nBottomRect, int nWidth, int nHeight);

[DllImport("gdi32", EntryPoint = "CreateSolidBrush")]
private static extern IntPtr CreateSolidBrushWin(int crColor);

[DllImport("gdi32", EntryPoint = "SelectObject")]
private static extern IntPtr SelectObjectWin(IntPtr hdc, IntPtr hgdiobj);

[DllImport("gdi32", EntryPoint = "DeleteObject")]
private static extern bool DeleteObjectWin(IntPtr hObject);

[DllImport("gdi32", EntryPoint = "GetStockObject")]
private static extern IntPtr GetStockObjectWin(int fnObject);

#endregion

#region Platform Agnostic Methods

/// <summary>
/// This function draws a rectangle with rounded corners. The current pen outlines the
/// rectangle and the current brush fills it.
/// </summary>
/// <param name="hdc">Handle to the device context.</param>
/// <param name="nLeftRect">Specifies the x-coordinate of the upper left corner of
/// the rectangle.</param>
/// <param name="nTopRect">Specifies the y-coordinate of the upper left corner of
/// the rectangle.</param>
/// <param name="nRightRect">Specifies the x-coordinate of the lower right corner
/// of the rectangle.</param>
/// <param name="nBottomRect">Specifies the y-coordinate of the lower right corner
/// of the rectangle.</param>
/// <param name="nWidth">Specifies the width of the ellipse used to draw the
/// rounded corners.</param>
/// <param name="nHeight">Specifies the height of the ellipese used to draw the
/// rounded corners.</param>
/// <returns>
/// Nonzero indicates success. Zero indicates failure. To get extended
/// error information, call GetLastError.
/// </returns>
internal static bool RoundRect(IntPtr hdc, int nLeftRect, int nTopRect,
int nRightRect, int nBottomRect, int nWidth, int nHeight)
{
bool result = false;
if (Environment.OSVersion.Platform == PlatformID.WinCE)
{
result = RoundRectCE(hdc, nLeftRect, nTopRect, nRightRect,
nBottomRect, nWidth, nHeight);
}
else
{
result = RoundRectWin(hdc, nLeftRect, nTopRect, nRightRect,
nBottomRect, nWidth, nHeight);
}
return result;
}

/// <summary>
/// This function creates a logical brush that has the specified solid color.
/// </summary>
/// <param name="crColor">Specifies the color of the brush.</param>
/// <returns>
/// A handle that identifies a logical brush indicates success. NULL
/// indicates failure. To get extended error information, call GetLastError.
/// </returns>
internal static IntPtr CreateSolidBrush(int crColor)
{
IntPtr result = IntPtr.Zero;
if (Environment.OSVersion.Platform == PlatformID.WinCE)
{
result = CreateSolidBrushCE(crColor);
}
else
{
result = CreateSolidBrushWin(crColor);
}
return result;
}

/// <summary>
/// This function selects an object into a specified device context. The new object
/// replaces the previous object of the same type.
/// </summary>
/// <param name="hdc">Handle to the device context.</param>
/// <param name="hgdiobj">Handle to the object to be selected.</param>
/// <returns>
/// If the selected object is not a region, the handle of the object being
/// replaced indicates success.
/// </returns>
internal static IntPtr SelectObject(IntPtr hdc, IntPtr hgdiobj)
{
IntPtr result = IntPtr.Zero;
if (Environment.OSVersion.Platform == PlatformID.WinCE)
{
result = SelectObjectCE(hdc, hgdiobj);
}
else
{
result = SelectObjectWin(hdc, hgdiobj);
}
return result;
}

/// <summary>
/// This function deletes a logical pen, brush, font, bitmap, region, or palette,
/// freeing all system resources associated with the object. After the object is
/// deleted, the specified handle is no longer valid.
/// </summary>
/// <param name="hObject">Handle to a logical pen, brush, font, bitmap, region,
/// or palette.</param>
/// <returns>Nonzero indicates success.</returns>
internal static bool DeleteObject(IntPtr hObject)
{
bool result = false;
if (Environment.OSVersion.Platform == PlatformID.WinCE)
{
result = DeleteObjectCE(hObject);
}
else
{
result = DeleteObjectWin(hObject);
}
return result;
}

/// <summary>
/// This function retrieves a handle to one of the predefined stock pens,
/// brushes or fonts.
/// </summary>
/// <param name="fnObject">Specifies the type of stock object.</param>
/// <returns>If the function succeeds, the return value identifies the
/// logical object requested.</returns>
internal static IntPtr GetStockObject(int fnObject)
{
IntPtr result = IntPtr.Zero;
if (Environment.OSVersion.Platform == PlatformID.WinCE)
{
result = GetStockObjectCE(fnObject);
}
else
{
result = GetStockObjectWin(fnObject);
}
return result;
}

#endregion
}
}



Image 2 - RoundedGroupBox control displayed in the designer


By default, when a P/Invoke call is made inside a .NET CF custom control, the Visual Studio designer will not attempt to draw the control properly because it believes it cannot. The attribute which controls whether Visual Studio will attempt to draw the control or not is the DesktopCompatible attribute which is placed in the DesignTimeAttributes.xmta xml file. Code Listing 2 shows the DesignTimeAttributes.xmta file. Image 3 displays two versions of the control - one with the DesktopCompatible attribute set to true in the xmta file and the other without it.

Code Listing 2 - DesignTimeAttributes.xmta


<?xml version="1.0" encoding="utf-16"?>
<Classes xmlns="http://schemas.microsoft.com/VisualStudio/2004/03/SmartDevices/XMTA.xsd">
<Class Name="Rhinomobile.ControlLibrary.BorderPanel">
<DesktopCompatible>true</DesktopCompatible>
<Property Name="BorderColor">
<Category>Appearance</Category>
<Description>The color used to draw the border of the panel.</Description>
</Property>
<Property Name="BorderWidth">
<Category>Layout</Category>
<Description>The width of the panel border.</Description>
</Property>
</Class>
<Class Name="Rhinomobile.ControlLibrary.RoundedGroupBox">
<DesktopCompatible>true</DesktopCompatible>
<Property Name="OuterColor">
<Category>Appearance</Category>
<Description>The color user to fill the background of the title of the rounded group box.</Description>
</Property>
<Property Name="RoundedGroupBoxText">
<Category>Appearance</Category>
<Description>The text displayed in the title of the group box panel.</Description>
</Property>
</Class>
</Classes>



Image 3 - Two versions of RoundedGroupBox displayed - One with the DesktopCompatible attribute set to true in the DesignTimeAttributes.xmta file, the other without


The RoundedGroupBox control itself is comprised of two round rectangles drawn with different colored brushes. The top is slightly offset on the inner polygon which leaves an area for the title to be written. This control is derived from a standard .NET Compact Framework System.Windows.Forms.Panel, so out of the box it provides the logical grouping functionality inherent in a panel.

RoundedGroupBox provides two additional properties, OuterColor and RoundedGroupBoxText. OuterColor is the color used for the title area and the outline. The standard BackColor property is used for the area where controls are placed. RoundedGroupBoxText is a property which contains the text displayed in the title area.

RoundedGroupBox also overrides two inherited methods, OnPaintBackground and OnPaint. In OnPaintBackground, the background color of the parent control is used to fill the RoundedGroupBox control. Doing this fills in the gaps left by the rounded corners of the control so that seemingly the parent control is showing through. OnPaint is more complicated and a snippet of code is displayed inline below.


protected override void OnPaint(PaintEventArgs e)
{
int outerBrushColor = HelperMethods.ColorToWin32(m_outerColor);
int innerBrushColor = HelperMethods.ColorToWin32(this.BackColor);

IntPtr hdc = e.Graphics.GetHdc();
try
{
IntPtr hbrOuter = NativeMethods.CreateSolidBrush(outerBrushColor);
IntPtr hOldBrush = NativeMethods.SelectObject(hdc, hbrOuter);
NativeMethods.RoundRect(hdc, 0, 0, this.Width, this.Height, DIAMETER, DIAMETER);
IntPtr hbrInner = NativeMethods.CreateSolidBrush(innerBrushColor);
NativeMethods.SelectObject(hdc, hbrInner);
NativeMethods.RoundRect(hdc, 0, 18, this.Width, this.Height, DIAMETER, DIAMETER);
NativeMethods.SelectObject(hdc, hOldBrush);
NativeMethods.DeleteObject(hbrOuter);
NativeMethods.DeleteObject(hbrInner);
}
finally
{
e.Graphics.ReleaseHdc(hdc);
}

if (!string.IsNullOrEmpty(m_roundedGroupBoxText))
{
Font titleFont = new Font("Tahoma", 9.0F, FontStyle.Bold);
Brush titleBrush = new SolidBrush(this.BackColor);
try
{
e.Graphics.DrawString(m_roundedGroupBoxText, titleFont, titleBrush, 14.0F, 2.0F);
}
finally
{
titleFont.Dispose();
titleBrush.Dispose();
}
}

base.OnPaint(e);
}


The first two function calls are to the ColorToWin32 method. This method converts a System.Drawing.Color value into an integer used with Windows 32 functions. It is contained in the HelperMethods class. This class can be found in Code Listing 3.

Following the color conversions, a handle to the underlying GDI device context is retrieved using the GetHdc method. A solid brush of the outer color is then created using CreateSolidBrush. This brush is then selected using the SelectObject function. This is analagous to selecting a type of brush within a graphics program when drawing shapes. A handle to the previously selected brush is saved so that it can be the selected brush again when the control is finished drawing. The outer round rectangle is drawn with a call to the RoundRect function. The same steps are repeated for the inner round rectangle: creation, selection, and drawing. Both the outer and inner round rectangles are drawn using a constant value for nWidth and nHeight giving the control a circular corner. When both round rectangles have been drawn, the previously selected brush is set as the selected brush again. The two created brushes are then deleted. Finally, the handle to the device context is released. The remaining part of the OnPaint method is responsible for writing the title text in the space between the two round rectangles. The source code for RoundedGroupBox.cs is found in Code Listing 4. Image 4 shows a screenshot of a sample application which uses the RoundedGroupBox control to ask a question where a singular answer can be chosen.

Code Listing 3 - HelperMethods.cs


#region Using Directives

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Text;

#endregion

namespace Rhinomobile.ControlLibrary
{
/// <summary>
/// Useful methods
/// </summary>
static class HelperMethods
{
/// <summary>
/// Converts a System.Drawing.Color value into a Windows 32 color value.
/// </summary>
/// <param name="color">System.Drawing.Color value</param>
/// <returns>Windows 32 integer representation of a color</returns>
public static int ColorToWin32(Color color)
{
return ((color.R | (color.G << 8)) | (color.B << 16));
}
}
}



Code Listing 4 - RoundedGroupBox.cs


#region Using Directives

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Windows.Forms;
using System.Text;

#endregion

namespace Rhinomobile.ControlLibrary
{
/// <summary>
/// A group box panel with rounded corners and a title
/// </summary>
public class RoundedGroupBox : Panel
{
#region Constants

private const int DIAMETER = 32;

#endregion

#region Fields

private Color m_outerColor = SystemColors.ActiveCaption;
private string m_roundedGroupBoxText = string.Empty;

#endregion

#region Protected Methods

protected override void OnPaintBackground(PaintEventArgs e)
{
if (this.Parent != null)
{
SolidBrush backBrush = new SolidBrush(this.Parent.BackColor);
try
{
e.Graphics.FillRectangle(backBrush, 0, 0, this.Width, this.Height);
}
finally
{
backBrush.Dispose();
}
}
}

protected override void OnPaint(PaintEventArgs e)
{
int outerBrushColor = HelperMethods.ColorToWin32(m_outerColor);
int innerBrushColor = HelperMethods.ColorToWin32(this.BackColor);

IntPtr hdc = e.Graphics.GetHdc();
try
{
IntPtr hbrOuter = NativeMethods.CreateSolidBrush(outerBrushColor);
IntPtr hOldBrush = NativeMethods.SelectObject(hdc, hbrOuter);
NativeMethods.RoundRect(hdc, 0, 0, this.Width, this.Height, DIAMETER, DIAMETER);
IntPtr hbrInner = NativeMethods.CreateSolidBrush(innerBrushColor);
NativeMethods.SelectObject(hdc, hbrInner);
NativeMethods.RoundRect(hdc, 0, 18, this.Width, this.Height, DIAMETER, DIAMETER);
NativeMethods.SelectObject(hdc, hOldBrush);
NativeMethods.DeleteObject(hbrOuter);
NativeMethods.DeleteObject(hbrInner);
}
finally
{
e.Graphics.ReleaseHdc(hdc);
}

if (!string.IsNullOrEmpty(m_roundedGroupBoxText))
{
Font titleFont = new Font("Tahoma", 9.0F, FontStyle.Bold);
Brush titleBrush = new SolidBrush(this.BackColor);
try
{
e.Graphics.DrawString(m_roundedGroupBoxText, titleFont, titleBrush, 14.0F, 2.0F);
}
finally
{
titleFont.Dispose();
titleBrush.Dispose();
}
}

base.OnPaint(e);
}

#endregion

#region Properties

/// <summary>
/// The color user to fill the background of the title of the rounded group box.
/// </summary>
public Color OuterColor
{
get
{
return m_outerColor;
}
set
{
m_outerColor = value;
this.Invalidate();
}
}

/// <summary>
/// The text displayed in the title of the group box panel.
/// </summary>
public string RoundedGroupBoxText
{
get
{
return this.m_roundedGroupBoxText;
}
set
{
m_roundedGroupBoxText = value;
this.Invalidate();
}
}

#endregion
}
}



Image 4 - RoundedGroupBox example application

2 comments:

Shaps said...

How can I use this technique to draw a rounded rectangle as opposed to filling one. Excellent article btw, heaps of help.

Oscar Alarcón said...

Hello!

Thanyou very much for your post. I've learned a lot reading it. I have a question:

In the example the outside's backcolor of the roundedgroupbox is white. Is there any way to draw it transparent? I'm talking about the space outside of the roundedgroupbox, around the four corners.

Thank you!