Introduction
I have always been impressed by the Mac OS X GUI. It is very neat and elegant. In this article, I will show you how to create a user control using GDI+ similar to the Asynchronous Circular Progress Indicator in Mac OS X.
Based on the comments that I received from BillWoodRuff and dequadin, I have refined my code further to create two new Circular Progress Controls
- Optimized Circular Progress Control
- Matrix Circular Progress Control
I will go through their details one by one...
The Circular Progress Control
In the attached project, I have created a User Control, called CircularProgressControl
, which encapsulates the rendering of the progress control. It also provides certain extra properties like the color of the control, the speed of the progress, and the starting angle of the control. Here is the class diagram of the CircularProgressControl
:
The Start
and Stop
APIs let the user start the animation and stop it, respectively.
Using the Control
In order to use this control, you can add a reference to this project and drag and drop the CircularProgressControl
from the ToolBox onto your Form
. You can set the color, speed, and starting angle. The starting angle is specified in degrees, and it increases in clockwise direction.
You can also set the direction of the rotation: CLOCKWISE
or ANTICLOCKWISE
. For that, you need to set the Rotation
property to one of the values of the Direction
enum.
public enum Direction
{
CLOCKWISE,
ANTICLOCKWISE
}
Circular Progress Control Demystified
In order to render the spokes of the control, first the control calculates two circles - inner circle and outer circle. These two circles are concentric. The radii of these two circles are dependent on the size of the CircularProgressControl
. The start point (X1, Y1) and the end point (X2, Y2) are calculated based on the angle of the spoke. The angle between two adjacent spokes (m_AngleIncrement
) is based on the number of spokes (m_SpokesCount
), and it is calculated as:
m_AngleIncrement = (int)(360/m_SpokesCount);
The Alpha value of the color of the first spoke is 255. After each spoke is rendered, the alpha value of the next spoke's color is reduced by a fixed amount (m_AlphaDecrement
).
The thickness of the spoke also varies with the size of the CircularProgressControl
.
The calculation and rendering are done in the PaintEventHandler
of the control.
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.InterpolationMode =
System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
e.Graphics.SmoothingMode =
System.Drawing.Drawing2D.SmoothingMode.HighQuality;
int alpha = m_AlphaStartValue;
int angle = m_StartAngle;
int width = (this.Width < this.Height) ? this.Width : this.Height;
m_CentrePt = new PointF(this.Width / 2, this.Height / 2);
m_Pen.Width = (int)(width / 15);
if (m_Pen.Width < MINIMUM_PEN_WIDTH)
m_Pen.Width = MINIMUM_PEN_WIDTH;
m_InnerRadius = (int)(width * (140 / (float)800));
if (m_InnerRadius < MINIMUM_INNER_RADIUS)
m_InnerRadius = MINIMUM_INNER_RADIUS;
m_OuterRadius = (int)(width * (250 / (float)800));
if (m_OuterRadius < MINIMUM_OUTER_RADIUS)
m_OuterRadius = MINIMUM_OUTER_RADIUS;
for (int i = 0; i < m_SpokesCount; i++)
{
PointF pt1 = new PointF(m_InnerRadius *
(float)Math.Cos(ConvertDegreesToRadians(angle)),
m_InnerRadius * (float)Math.Sin(ConvertDegreesToRadians(angle)));
PointF pt2 = new PointF(m_OuterRadius *
(float)Math.Cos(ConvertDegreesToRadians(angle)),
m_OuterRadius * (float)Math.Sin(ConvertDegreesToRadians(angle)));
pt1.X += m_CentrePt.X;
pt1.Y += m_CentrePt.Y;
pt2.X += m_CentrePt.X;
pt2.Y += m_CentrePt.Y;
m_Pen.Color = Color.FromArgb(alpha, this.TickColor);
e.Graphics.DrawLine(m_Pen, pt1, pt2);
if (Rotation == Direction.CLOCKWISE)
{
angle -= m_AngleIncrement;
}
else if (Rotation == Direction.ANTICLOCKWISE)
{
angle += m_AngleIncrement;
}
alpha -= m_AlphaDecrement;
}
}
When the Start
API is called, a Timer
, whose TickInterval
is equal to the value of the Interval
property of the CircularProgressControl
, is started. Upon each Tick
of the timer, the angle of the first spoke is increased or decreased based on the Rotation
property, and then the Invalidate()
method is called which forces the repaint of the control. The Stop
API stops the timer.
Optimized Circular Progress Control
In the Optimized Circular Progress Control, I have taken out the points calculation part from the OnPaint
method in order to improve the control's performance and moved it to another method called CalculateSpokePoints
. This method will be called in the constructor, when the Rotation property is changed and also when the size of the control is changed either at runtime or at design time (this is handled by subscribing to the ClientSizeChanged
event). I have also defined a data structure to store the start point and end point of each spoke.
struct Spoke
{
public PointF StartPoint;
public PointF EndPoint;
public Spoke(PointF pt1, PointF pt2)
{
StartPoint = pt1;
EndPoint = pt2;
}
}
In the CalculateSpokePoints
method, the points are calculated and encapsulated in the Spoke
structure and stored in the m_SpokePoints
.
private void CalculateSpokesPoints()
{
m_Spokes = new List<Spoke>();
m_AngleIncrement = (360 / (float)m_SpokesCount);
m_AlphaChange = (int)((255 - m_AlphaLowerLimit) / m_SpokesCount);
int width = (this.Width < this.Height) ? this.Width : this.Height;
m_CentrePt = new PointF(this.Width / 2, this.Height / 2);
m_Pen.Width = (int)(width / 15);
if (m_Pen.Width < MINIMUM_PEN_WIDTH)
m_Pen.Width = MINIMUM_PEN_WIDTH;
m_InnerRadius = (int)(width * INNER_RADIUS_FACTOR);
if (m_InnerRadius < MINIMUM_INNER_RADIUS)
m_InnerRadius = MINIMUM_INNER_RADIUS;
m_OuterRadius = (int)(width * OUTER_RADIUS_FACTOR);
if (m_OuterRadius < MINIMUM_OUTER_RADIUS)
m_OuterRadius = MINIMUM_OUTER_RADIUS;
float angle = 0;
for (int i = 0; i < m_SpokesCount; i++)
{
PointF pt1 = new PointF(m_InnerRadius * (float)Math.Cos(
ConvertDegreesToRadians(angle)), m_InnerRadius * (float)Math.Sin(
ConvertDegreesToRadians(angle)));
PointF pt2 = new PointF(m_OuterRadius * (float)Math.Cos(
ConvertDegreesToRadians(angle)), m_OuterRadius * (float)Math.Sin(
ConvertDegreesToRadians(angle)));
pt1.X += m_CentrePt.X;
pt1.Y += m_CentrePt.Y;
pt2.X += m_CentrePt.X;
pt2.Y += m_CentrePt.Y;
Spoke spoke = new Spoke(pt1, pt2);
m_Spokes.Add(spoke);
if (Rotation == Direction.CLOCKWISE)
{
angle -= m_AngleIncrement;
}
else if (Rotation == Direction.ANTICLOCKWISE)
{
angle += m_AngleIncrement;
}
}
}
The Alpha value of the spoke drawn at 0 degree angle is calculated as follows
private void CalculateAlpha()
{
if (this.Rotation == Direction.CLOCKWISE)
{
if (m_StartAngle >= 0)
{
m_AlphaStartValue = 255 - (((int)((
m_StartAngle % 360) / m_AngleIncrement) + 1) *
m_AlphaChange);
}
else
{
m_AlphaStartValue = 255 - (((int)((360 +
(m_StartAngle % 360)) / m_AngleIncrement) + 1) *
m_AlphaChange);
}
}
else
{
if (m_StartAngle >= 0)
{
m_AlphaStartValue = 255 - (((int)((360 - (
m_StartAngle % 360)) / m_AngleIncrement) + 1) *
m_AlphaChange);
}
else
{
m_AlphaStartValue = 255 - (((int)(((
360 - m_StartAngle) % 360) / m_AngleIncrement) +
1) * m_AlphaChange);
}
}
}
Now the OnPaint
method looks like this
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.InterpolationMode =
System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
int alpha = m_AlphaStartValue;
for (int i = 0; i < m_SpokesCount; i++)
{
m_Pen.Color = Color.FromArgb(alpha, this.TickColor);
e.Graphics.DrawLine(m_Pen, m_Spokes[i].StartPoint,
m_Spokes[i].EndPoint);
alpha -= m_AlphaChange;
if (alpha < m_AlphaLowerLimit)
alpha = 255 - m_AlphaChange;
}
}
Each time the Timer.Elapsed
occurs, I am manipulating the m_AlphaStartValue
so that the spokes' alpha value keeps changing.
Matrix Circular Progress Control
I have further modified the OptimizedCircularProgressControl
to create this control. In the Paint method, I am employing the Translation and Rotation transformations.
protected override void OnPaint(PaintEventArgs e)
{
e.Graphics.InterpolationMode =
System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
e.Graphics.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
e.Graphics.TranslateTransform(m_CentrePt.X, m_CentrePt.Y,
System.Drawing.Drawing2D.MatrixOrder.Prepend);
e.Graphics.RotateTransform(m_StartAngle,
System.Drawing.Drawing2D.MatrixOrder.Prepend);
int alpha = 255;
for (int i = 0; i < m_SpokesCount; i++)
{
m_Pen.Color = Color.FromArgb(alpha, this.TickColor);
e.Graphics.DrawLine(m_Pen, m_Spokes[i].StartPoint,
m_Spokes[i].EndPoint);
alpha -= m_AlphaChange;
if (alpha < m_AlphaLowerLimit)
alpha = 255 - m_AlphaChange;
}
e.Graphics.RotateTransform(-m_StartAngle,
System.Drawing.Drawing2D.MatrixOrder.Append);
e.Graphics.TranslateTransform(-m_CentrePt.X, -m_CentrePt.Y,
System.Drawing.Drawing2D.MatrixOrder.Append);
}
The angle of rotation is changed in the Timer.Elapsed
event handler.
When you compile the attached source code (Optimized Progress Control) and execute it, you will see a Form in which I have place the 3 Controls side by side.
Note
While using OptimizedCircularControl, if you are setting the StartAngle by yourself, make sure that you set the Rotation property first.