New version 1.5: Little animation and snap feature
Old version 1.0
The first link contains the binaries to watch a quick demo. The second link contains the Range control source code and a test application source code that uses this control.
Introduction
It's a fine morning. Your boss Alice says, "Hey Bob! It will be great if we can have a unique control matching our business needs instead of presenting a legacy one. bla bla bla....". And you think, "Well, it *was* a fine morning ... ". However, now you have to design a unique custom control matching your unique business needs.
This article details how to write your own unique custom control. Also, this article includes a sample animated range selection control (both binary and source code included). The initial sections of this article explains how to use the range control. If you are an advanced programmer, you may directly go to the 'Control code explained' section after seeing the demo.
Background
Business needs are not always very generic. Most of the time, business needs are very unique and require authoring a custom control.
Using the code
Steps to see a demo:
Step 1: Copy the binaries (download using the link from the top of this article) to a separate folder.
Step 2: It is a compressed file. Choose a temporary folder and copy all the files into the folder.
Step 3: Run TestApplication.exe.
Step 4: You should see an application as in the image above.
You can slide the slider thumb in both directions using your mouse. I chose to have a smooth scroll slider instead of a block move slider. However, the code can be modified to support both. Also, I added a little animation for good user experience.
Steps to use the assembly:
Consider you are done seeing the demo and would like to use it. The following steps may be followed to use the Range Selector custom control in your project:
Step 1: Create a Windows Forms application using Visual Studio 2005 or above. The VS2005 requirement is because, I compiled the assembly in VS2005. However, you can easily move it to any version of Visual Studio with very little effort
Step 2: In the Toolbox, right click, then select 'Choose Items'. Then, click the Browse button to choose the assembly. Click the OK button after selecting the assembly.
Step 3: Now, you will see an entry in the Toolbox with the name 'RangeSelectorControl'. Drag and drop it to any part of your Windows form to use the control.
Step 4: Select the control. Then, right click and select the Properties menu option.
Step 5: Add/ modify/ delete the required properties to match your requirements.
Step 6: Now, it's coding time. But, it's very minimal. All you have to do is register your method with the control assembly -- so that the control assembly will call your method when the user changes his choice (by sliding the range control). Otherwise, you can also query the control method QueryRange
to find out the user choice. Both the choices are explained with a simple code snippet below.
private void button1_Click(object sender, System.EventArgs e)
{
string strRange1;
string strRange2;
rangeSelectorControl1.QueryRange(out strRange1 , out strRange2);
textBox1.Text = strRange1 + " - " + strRange2;
textBox1.Update();
object obj = strRange1;
strRange1 = "100";
}
In the above code snippet, the first line declares two string variables strRange1
and strRange2
. The next line queries the control assembly to get the current user choice. After which, the code updates a TextBox
control in the form to showcase the user choice.
Now, another option is to get continuous update from the control whenever a user modifies his option. Look at the code snippet below:
private CustomRangeSelectorControl.NotifyClient objNotifyClient;
private void Form1_Load(object sender, System.EventArgs e)
{
objNotifyClient = new CustomRangeSelectorControl.NotifyClient();
rangeSelectorControl1.RegisterForChangeEvent(ref objNotifyClient);
}
In the above code, the first line declares a class level variable objNotifyClient
. This variable is initialized in the Form_Load
event. Next, this object is registered with the control assembly for continuous notifications.
However, I included a detailed sample test application for those who use this control assembly extensively. The sample source code and the control source code can be downloaded from the link at the top of this article.
As always, this is not a professional level code. Hence, I have not done extreme validation checks in the code. However, the code can be brushed up quickly.
Points of interest
Okay, now, about writing a control assembly. It's very simple. All you have to do is create a new control project. If you have created the project correctly, your main class will derive from System.Windows.Forms.UserControl
. All you have to do then is manage your control's visual activity by overriding the void OnPaint(PaintEventArgs e)
method. You can look at the source code of range selector control. It's very simple with a few math calculations. Based on the comments I have received, I will explain this in further detail.
These are a few interesting things I did in the range selector control:
- The range selector control exposes as much properties as possible to give users an opportunity to see the control at design time rather than at runtime.
- Ranges can be input using an XML file. This will help to load the control in a much generic way.
- The range selector control accepts bitmaps.
- The range selector control avoids any flicker while moving the slider using the mouse.
- The range selector control automatically displays the range values at the bottom.
- I have also added a little animation, which is explained in the next section (Control code explained - seventh section/region).
Control code explained
Refer to the range selector control source code (and the demo) that can be downloaded from the links at the top of this article.
I divided the control source code in the namespace CustomRangeSelectorControl.RangeSelectorControl
into different regions. Let’s walk through the regions one by one.
The first region is 'Design Time Control Variables...'. These are private variables that will hold the values of the exposed properties to the users. For example, strXMLFileName
is used to store the value of the exposed property XMLFileName
.
#region Design Time Control Variables -- Private Variables
private string strXMLFileName;
private string strRangeString;
private string strRange;
private Font fntLabelFont;
private FontStyle fntLabelFontStyle;
private float fLabelFontSize;
private FontFamily fntLabelFontFamily;
private string strLeftImagePath;
private string strRightImagePath;
private float fHeightOfThumb;
private float fWidthOfThumb;
private Color clrThumbColor;
private Color clrInFocusBarColor;
private Color clrDisabledBarColor;
private Color clrInFocusRangeLabelColor;
private Color clrDisabledRangeLabelColor;
private uint unSizeOfMiddleBar;
private uint unGapFromLeftMargin;
private uint unGapFromRightMargin;
private string strDelimiter;
private string strRange1;
private string strRange2;
private Font fntRangeOutputStringFont;
private float fStringOutputFontSize;
private Color clrStringOutputFontColor;
private FontFamily fntStringOutputFontFamily;
#endregion
The second region is 'Design Time Control Properties...'. These are the properties exposed to the user of the control at design time. For example, have a look at the first property XMLFileName
. Open the demo in Visual Studio. Right click to see the properties of this control. If you browse through entry by entry, you will find the XMLFileName
property exposed to the user. The user can type in the value using Visual Studio designer's property window. You can see all the properties such as RangeString
, RangeValues
, LabelFont
etc., in this section. The code below is just a small snippet from this region.
#region Design Time Control Properties -- Public --
Design Time User properites - Can also be changed runtime
public string XMLFileName
{
set
{
try
{
strXMLFileName = value;
if (null != strXMLFileName)
{
xmlTextReader =
new System.Xml.XmlTextReader(strXMLFileName);
strRange = null;
while(xmlTextReader.Read())
{
switch(xmlTextReader.NodeType)
{
case System.Xml.XmlNodeType.Text:
strRange += xmlTextReader.Value.Trim();
strRange += strDelimiter;
break;
}
}
strRange = strRange.Remove(strRange.Length -
strDelimiter.Length, strDelimiter.Length);
CalculateValues();
this.Refresh();
OnPaint(ePaintArgs);
}
}
catch
{
strXMLFileName = null;
System.Windows.Forms.MessageBox.Show("The XML Path entered" +
" may be invalid (or) The XML file is not well formed",
"Error!");
}
}
get
{
return strXMLFileName;
}
}
#endregion
The third region is 'Variables used for computation'. These are the variables used to do the math calculations when the user moves the slider. Also, these variables are used to draw the bitmap, font, line, slider, and the bar at the right locations at the right time.
The fourth region is the constructor for this control. This constructor contains a region 'Initialization of variables'. This region contains code that initializes all the declared variables with default values for this class.
Fifth region is 'Methods exposed to client at runtime'. The users of this range control needs to receive feedback from the control about user activity. Consider that an end user of the application slides a bar in the range selector control, then the application needs to receive input to these events to act on. There are two methods exposed for this purpose. One is passive and the other is active. The passive one is a query method. The user of this control can query the control at any given time to get the current range values selected by the application's end user. Otherwise, the application can register for a notification to be sent out when the user slides the control. The method QueryRange
does the passive job and the RegisterForChangeEvent
method does the active job.
#region Methods Exposed to client at runtime
public void QueryRange(out string strGetRange1, out string strGetRange2)
{
strGetRange1 = strRange1.ToString();
strGetRange2 = strRange2.ToString();
}
public void RegisterForChangeEvent(ref NotifyClient refNotifyClient)
{
try
{
if (null != refNotifyClient)
{
objNotifyClient = refNotifyClient;
objNotifyClient.Range1 = strRange1;
objNotifyClient.Range2 = strRange2;
}
}
catch
{
System.Windows.Forms.MessageBox.Show("The Registered Event object has " +
"a Bad memory. Please correct it", "Error!");
}
}
#endregion
The sixth region is 'This is a Private method that calculates the values...". This section has only one private method CalculateValues
. This method calculates the values to draw the various components of the control. The various components of the control are the bar, slider, range values etc. The code is well documented here. It gets the Graphics
object, then calculates the positions of each component.
#region This is a Private method that calculates
the values to be placed while painting
private void CalculateValues()
{
try
{
System.Drawing.Graphics myGraphics = this.CreateGraphics();
strSplitLabels = strRange.Split(strDelimiter.ToCharArray(), 1024);
nNumberOfLabels = strSplitLabels.Length;
if (null != strLeftImagePath)
{
imImageLeft = System.Drawing.Image.FromFile(strLeftImagePath);
}
if (null != strRightImagePath)
{
imImageRight = System.Drawing.Image.FromFile(strRightImagePath);
}
RectangleF recRegion = myGraphics.VisibleClipBounds;
fLeftCol = unGapFromLeftMargin;
fLeftRow = recRegion.Height / 2.0f;
fRightCol = recRegion.Width - unGapFromRightMargin;
fRightRow = fLeftRow;
fThumb1Point = fLeftCol;
fThumb2Point = fRightCol;
fTotalWidth = recRegion.Width - (unGapFromRightMargin + unGapFromLeftMargin);
fDividedWidth = fTotalWidth / (float)(nNumberOfLabels - 1);
for(int nIndexer = 0;nIndexer < nNumberOfLabels;nIndexer++)
{
if (strRange1.Equals(strSplitLabels[nIndexer]))
{
fThumb1Point = fLeftCol + fDividedWidth * nIndexer;
}
else if (strRange2.Equals(strSplitLabels[nIndexer]))
{
fThumb2Point = fLeftCol + fDividedWidth * nIndexer;
}
}
ptThumbPoints1[0].X = fThumb1Point;
ptThumbPoints1[0].Y = fLeftRow - 3.0f;
ptThumbPoints1[1].X = fThumb1Point;
ptThumbPoints1[1].Y = fLeftRow - 3.0f - fHeightOfThumb;
ptThumbPoints1[2].X = (fThumb1Point + fWidthOfThumb);
ptThumbPoints1[2].Y = fLeftRow - 3.0f - fHeightOfThumb/2.0f;
ptThumbPoints2[0].X = fThumb2Point;
ptThumbPoints2[0].Y = fRightRow - 3.0f;
ptThumbPoints2[1].X = fThumb2Point;
ptThumbPoints2[1].Y = fRightRow - 3.0f - fHeightOfThumb;
ptThumbPoints2[2].X = fThumb2Point - fWidthOfThumb;
ptThumbPoints2[2].Y = fRightRow - 3.0f - fHeightOfThumb/2.0f;
}
catch
{
}
}
#endregion
The seventh region is 'Paint Method override'. This is the important one that draws the control on the screen. The code is very simple here. The first for
loop draws the Label
s on the screen. The next one draws the range values on the screen. The next draws Slider1
(mentioned in the code as Thumb1
) and Slider2
. The following lines in this method are to draw the in-focus and disabled colors properly based on the calculated values using the above section.
I implemented a very simple animation in this paint method. This is not a very professional one, but provides a basic understanding of the animation. Once the mouse is up, we set the bAnimateSlider
to true
. This is used in the paint method to repeatedly call the Draw
method to animate. However, if the animation takes longer than a second (1000 ms), then we discard the animation.
private void OnPaintDrawSliderAndBar(System.Drawing.Graphics myGraphics, PaintEventArgs e)
{
System.Drawing.Brush brSolidBrush;
System.Drawing.Pen myPen;
if (bMouseEventThumb1)
{
brSolidBrush = new System.Drawing.SolidBrush(this.BackColor);
if (null != strLeftImagePath)
{
myGraphics.FillRectangle(brSolidBrush, ptThumbPoints1[0].X,
ptThumbPoints1[1].Y, fWidthOfThumb, fHeightOfThumb);
}
else
{
myGraphics.FillClosedCurve(brSolidBrush, ptThumbPoints1,
System.Drawing.Drawing2D.FillMode.Winding, 0f);
}
}
if (bMouseEventThumb2)
{
brSolidBrush = new System.Drawing.SolidBrush(this.BackColor);
if (null != strRightImagePath)
{
myGraphics.FillRectangle(brSolidBrush, ptThumbPoints2[2].X,
ptThumbPoints2[1].Y, fWidthOfThumb, fHeightOfThumb);
}
else
{
myGraphics.FillClosedCurve(brSolidBrush, ptThumbPoints2,
System.Drawing.Drawing2D.FillMode.Winding, 0f);
}
}
brSolidBrush = new System.Drawing.SolidBrush(clrInFocusRangeLabelColor);
myPen = new System.Drawing.Pen(clrInFocusRangeLabelColor, unSizeOfMiddleBar);
ptThumbPoints1[0].X = fThumb1Point;
ptThumbPoints1[1].X = fThumb1Point;
ptThumbPoints1[2].X = fThumb1Point + fWidthOfThumb;
ptThumbPoints2[0].X = fThumb2Point;
ptThumbPoints2[1].X = fThumb2Point;
ptThumbPoints2[2].X = fThumb2Point - fWidthOfThumb;
myPen = new System.Drawing.Pen(clrDisabledBarColor, unSizeOfMiddleBar);
myGraphics.DrawLine(myPen, fLeftCol, ptThumbPoints1[2].Y,
fThumb1Point, ptThumbPoints1[2].Y);
myGraphics.DrawLine(myPen, fLeftCol, ptThumbPoints1[2].Y, fLeftCol,
ptThumbPoints1[2].Y + fntLabelFont.SizeInPoints);
myGraphics.DrawLine(myPen, fRightCol, ptThumbPoints1[2].Y, fRightCol,
ptThumbPoints1[2].Y + fntLabelFont.SizeInPoints);
brSolidBrush = new System.Drawing.SolidBrush(clrStringOutputFontColor);
myGraphics.DrawString(strRangeString, fntRangeOutputStringFont,
brSolidBrush, fLeftCol,
fLeftRow * 2 - fntRangeOutputStringFont.Size - 3);
myPen = new System.Drawing.Pen(clrInFocusBarColor, unSizeOfMiddleBar);
myGraphics.DrawLine(myPen, ptThumbPoints1[2].X, ptThumbPoints1[2].Y,
fThumb2Point,ptThumbPoints1[2].Y);
myPen = new System.Drawing.Pen(clrDisabledBarColor, unSizeOfMiddleBar);
myGraphic s.DrawLine(myPen, fThumb2Point, ptThumbPoints2[2].Y, fRightCol,
ptThumbPoints2[2].Y);
if (null != strLeftImagePath)
{
myGraphics.DrawImage(imImageLeft, ptThumbPoints1[0].X,
ptThumbPoints1[1].Y, fWidthOfThumb, fHeightOfThumb);
}
else
{
brSolidBrush = new System.Drawing.SolidBrush(clrThumbColor);
myGraphics.FillClosedCurve(brSolidBrush, ptThumbPoints1,
System.Drawing.Drawing2D.FillMode.Winding, 0f);
}
if (null != strRightImagePath)
{
myGraphics.DrawImage(imImageRight, ptThumbPoints2[2].X, ptThumbPoints2[1].Y,
fWidthOfThumb, fHeightOfThumb);
}
else
{
brSolidBrush = new System.Drawing.SolidBrush(clrThumbColor);
myGraphics.FillClosedCurve(brSolidBrush, ptThumbPoints2,
System.Drawing.Drawing2D.FillMode.Winding, 0f);
}
}
protected override void OnPaint(PaintEventArgs e)
{
try
{
System.Drawing.Brush brSolidBrush;
float fDividerCounter;
float fIsThumb1Crossed, fIsThumb2Crossed;
string strRangeOutput;
string strNewRange1, strNewRange2;
System.Drawing.Graphics myGraphics = this.CreateGraphics();
ePaintArgs = e;
fDividerCounter = 0;
brSolidBrush = new System.Drawing.SolidBrush(clrDisabledRangeLabelColor);
strNewRange1 = null;
strNewRange2 = null;
for(int nIndexer = 0;nIndexer < nNumberOfLabels;nIndexer++)
{
fDividerCounter = fLeftCol + fDividedWidth * nIndexer ;
fIsThumb1Crossed = fDividerCounter + strSplitLabels[nIndexer].Length *
fntLabelFont.SizeInPoints/2;
fIsThumb2Crossed = fDividerCounter -
(strSplitLabels[nIndexer].Length - 1) * fntLabelFont.SizeInPoints/2;
if (fIsThumb1Crossed >= fThumb1Point && strNewRange1 == null)
{
brSolidBrush =
new System.Drawing.SolidBrush(clrInFocusRangeLabelColor);
strNewRange1 = strSplitLabels[nIndexer];
}
if (fIsThumb2Crossed > fThumb2Point)
{
brSolidBrush =
new System.Drawing.SolidBrush(clrDisabledRangeLabelColor);
}
else
{
strNewRange2 = strSplitLabels[nIndexer];
}
myGraphics.DrawString(strSplitLabels[nIndexer], fntLabelFont, brSolidBrush,
fDividerCounter - ((fntLabelFont.SizeInPoints) *
strSplitLabels[nIndexer].Length)/2, fLeftRow);
}
if (strNewRange1 != null && strNewRange2 != null &&
(!strRange1.Equals(strNewRange1) || !strRange2.Equals(strNewRange2)) ||
(!bMouseEventThumb1 && !bMouseEventThumb2))
{
brSolidBrush = new System.Drawing.SolidBrush(this.BackColor);
strRangeOutput = strRange1 + " - " + strRange2;
myGraphics.DrawString(strRangeOutput , fntRangeOutputStringFont, brSolidBrush,
fLeftCol + fntRangeOutputStringFont.Size *
strRangeString.Length ,
fLeftRow * 2 - fntRangeOutputStringFont.Size - 3);
brSolidBrush = new System.Drawing.SolidBrush(clrStringOutputFontColor);
strRangeOutput = strNewRange1 + " - " + strNewRange2;
myGraphics.DrawString(strRangeOutput , fntRangeOutputStringFont, brSolidBrush,
fLeftCol + fntRangeOutputStringFont.Size * strRangeString.Length ,
fLeftRow * 2 - fntRangeOutputStringFont.Size - 3);
strRange1 = strNewRange1;
strRange2 = strNewRange2;
}
if (bAnimateTheSlider)
{
float fTempThumb1Point = fThumb1Point;
float fTempThumb2Point = fThumb2Point;
int nToMakeItTimely = System.Environment.TickCount;
for (fThumb1Point = fThumbPoint1Prev, fThumb2Point = fThumbPoint2Prev;
fThumb1Point <= fTempThumb1Point || fThumb2Point >= fTempThumb2Point;
fThumb1Point += 3.0f, fThumb2Point -= 3.0f)
{
bMouseEventThumb1 = true;
bMouseEventThumb2 = true;
if (fThumb1Point > fTempThumb1Point)
{
fThumb1Point = fTempThumb1Point;
}
if (fThumb2Point < fTempThumb2Point)
{
fThumb2Point = fTempThumb2Point;
}
OnPaintDrawSliderAndBar(myGraphics, e);
if (System.Environment.TickCount - nToMakeItTimely >= 1000)
{
break;
}
System.Threading.Thread.Sleep(1);
}
fThumb1Point = fTempThumb1Point;
fThumb2Point = fTempThumb2Point;
bMouseEventThumb1 = true;
bMouseEventThumb2 = true;
OnPaintDrawSliderAndBar(myGraphics, e);
bAnimateTheSlider = false;
bMouseEventThumb1 = false;
bMouseEventThumb2 = false;
OnPaintDrawSliderAndBar(myGraphics, e);
}
else
{
OnPaintDrawSliderAndBar(myGraphics, e);
}
base.OnPaint (e);
}
catch
{
}
}
}
#endregion
The eighth region is 'Methods used for handling mouse events'. This section has methods to capture the mouse events up, down, and move. These methods are used to see if the mouse activity by the end user is interesting enough.
#region Methods used for handling Mouse Events
private void RangeSelectorControl_MouseUp(object sender,
System.Windows.Forms.MouseEventArgs e)
{
bMouseEventThumb1 = false;
bMouseEventThumb2 = false;
fThumbPoint1Prev = fThumb1Point;
fThumbPoint2Prev = fThumb2Point;
CalculateValues();
bAnimateTheSlider = true;
this.Refresh();
}
private void RangeSelectorControl_MouseDown(object sender,
System.Windows.Forms.MouseEventArgs e)
{
if (e.X >= ptThumbPoints1[0].X && e.X <= ptThumbPoints1[2].X &&
e.Y >= ptThumbPoints1[1].Y && e.Y <= ptThumbPoints1[0].Y)
{
bMouseEventThumb1 = true;
}
else if (e.X >= ptThumbPoints2[2].X && e.X <= ptThumbPoints2[0].X &&
e.Y >= ptThumbPoints2[1].Y && e.Y <= ptThumbPoints2[0].Y)
{
bMouseEventThumb2 = true;
}
}
private void RangeSelectorControl_MouseMove(object sender,
System.Windows.Forms.MouseEventArgs e)
{
if (bMouseEventThumb1 && e.Button ==
System.Windows.Forms.MouseButtons.Left && e.X >= fLeftCol )
{
if (strRange1.Equals(strRange2))
{
if (e.X < fThumb1Point)
{
fThumb1Point = e.X;
OnPaint(ePaintArgs);
}
}
else if (fThumb2Point - fWidthOfThumb > e.X)
{
fThumb1Point = e.X;
OnPaint(ePaintArgs);
}
else
{
bMouseEventThumb1 = false;
}
}
else if (bMouseEventThumb2 && e.Button ==
System.Windows.Forms.MouseButtons.Left && e.X <= fRightCol)
{
if (strRange1.Equals(strRange2))
{
if (e.X > fThumb2Point)
{
fThumb2Point = e.X;
OnPaint(ePaintArgs);
}
}
else if (fThumb1Point + fWidthOfThumb < e.X)
{
fThumb2Point = e.X;
OnPaint(ePaintArgs);
}
else
{
bMouseEventThumb2 = false;
}
}
if (null != objNotifyClient)
{
objNotifyClient.Range1 = strRange1;
objNotifyClient.Range2 = strRange2;
}
}
#endregion
The last region is 'Notification class...'. This section contains a small class that the user may pass to get an active event notification.
#region Notification class for client to register with the control for changes
public class NotifyClient
{
private string strRange1, strRange2;
public string Range1
{
set
{
strRange1 = value;
}
get
{
return strRange1;
}
}
public string Range2
{
set
{
strRange2 = value;
}
get
{
return strRange2;
}
}
}
#endregion
History
- 21 Aug 2008 - First version. Added source snippets in the 'Control code explained' section. Added snap feature as suggested by a reviewer. Added another version with a little animation.