Advanced Basics

Building a Progress Bar that Doesn't Progress

Duncan Mackenzie

Code download available at:AdvancedBasics0410.exe(151 KB)

Contents

What Type of Control Should You Use?
Adding Configuration Properties
Handling Animation
Finally, It is Time to Draw the Control
The Standard Demo Application
Running a Process in the Background
Displaying Updates from Your Background Process
I Just Want It to Stop!
Conclusion

In many situations, accurately estimating the length of a certain process (copying a large file, loading data from a server, retrieving files from the Internet) would be both difficult and inefficient. What you end up with is a process that is going to take long enough to make the user wait, yet you have no easy way to indicate the percentage of the task that has completed. A regular progress bar would be rather meaningless, so you need some form of "Working..." indicator.

Windows® often uses the animated Windows icon in the upper-right corner of Microsoft® Internet Explorer while a page is loading, and you could certainly create and display your own AVI file or animated GIF if you wanted to customize it a bit. Personally, I prefer to use a more flexible approach—a repeating animated progress bar control. By using a control, I can configure the appearance of the animation for different applications, and it is a lot easier to incorporate into a Windows Forms UI.

Figure 1 My Progress Bar

Figure 1** My Progress Bar **

In this column, I am going to walk you through the implementation of a custom owner-drawn control—an animated progress bar that will cycle continuously. The particular style of this control was inspired by a recent evening spent staring at the installation UI for Windows XP. By the end of the install, the hypnotic little green squares were burned into my brain, so I just had to try to recreate something similar in my own control. Of course, not everyone likes green squares, so I will try to make the control's appearance as configurable as possible. The result will be a progress bar that displays either squares or circles and exposes properties to control the speed of animation, the size of the graphical elements, and more (see Figure 1). The actual progress indicator will be a single shape (circle or square) drawn a bit larger than the rest, which will appear to move across the control at a steady rate.

What Type of Control Should You Use?

When developing with Windows Forms, I like to classify control development into three categories: user controls, inherited controls, and custom controls. User controls are the traditional (pre-.NET) style of Visual Basic® control, a design surface on which you can combine a variety of other controls. Inherited controls allow you to build on existing controls such as TextBox, writing only the code you need and getting all of the existing functionality for free. Finally, custom controls inherit only from the base class of Control and are perfect for when you want to handle all of the graphics work on your own. Here I'm using a custom control because I'm not going to be hosting any other controls and I'll be doing all of the drawing on my own.

Whenever I'm starting a custom control, I create a Class Library project and then start a control class with the code shown in Figure 2. The class shown inherits from Control, which makes it a Windows Forms control and allows it to be hosted correctly within a Form or other container. The rest of the code just configures the control as owner-drawn, telling the control that my code will be handling all of the drawing, that it should be redrawn whenever it is resized, and that it should use double buffering to reduce flicker. I have added the "DesignerCategory" attribute at the top of the class just for my own ease of use; it makes Visual Studio® open this class into code view by default, instead of into the design view (thanks to Daniel Cazzulino for posting this tip on his blog at https://weblogs.asp.net/cazzu).

Figure 2 Writing a New Custom Control

Imports System.Windows.Forms Imports System.Drawing <System.ComponentModel.DesignerCategory("Code")> _ Public Class NoProgressBar Inherits Control Public Sub New() 'I put these at the top of every owner-drawn control... Me.SetStyle(ControlStyles.AllPaintingInWmPaint, True) Me.SetStyle(ControlStyles.DoubleBuffer, True) Me.SetStyle(ControlStyles.SupportsTransparentBackColor, True) Me.SetStyle(ControlStyles.UserPaint, True) Me.SetStyle(ControlStyles.ResizeRedraw) End Sub End Class

Adding Configuration Properties

From that generic beginning, you are ready to start writing the specific code that your control needs. While the graphics work might seem like the logical next step, I prefer to create all of my supporting Properties, Constants, and Enums at this point. If you leave writing the necessary code for these features until the end, then you will find yourself using placeholders and hardcoded values in your graphic routines, which is just more code that you have to edit. As with any development, this is an iterative process, which means you may need to add or edit one or more properties once you dig into the graphics code. For starters though, let's consider the basic functionality that I want this control to have. I want any developer using it to be able to configure:

  • The shape to be drawn (squares, circles)
  • The size (diameter) of shape to draw
  • The spacing between each shape
  • The background and foreground colors
  • The duration of a complete cycle through the progress bar's items

Given that set of requirements, I am going to create one Enum for the graphic shape, like this

Public Enum ElementStyle As Byte Square = 0 Circle = 1 End Enum

along with properties for ShapeToDraw, ShapeSize, ShapeCount, ShapeSpacing, and CycleSpeed which you can take a look at in the code download for this column, which is available at the link at the top of this article.

The property routines are very simple; they just set and retrieve the value of some private member variables, but each one is marked with the System.ComponentModel.Category attribute to indicate the section under which it should appear in the Visual Studio property window. Note that I don't need to add any properties for the background and foreground colors because the base Control class already provides ForeColor and BackColor.

If you browse through the source for these property routines, you will notice that when a new value is being provided many of these properties call RecalcCountAndInterval and/or force a redraw of the control by calling Me.Invalidate. These calls, along with some basic validation included in these property routines, will be explained a bit later in this column, so for now I will just say that whenever a property change could affect the rest of your control, you will often need to recalculate some layout information or force a redraw of the control.

Handling Animation

To animate the control, which means redrawing all or part of it for every frame or step of the desired animation, I've created an instance of the System.Windows.Forms.Timer class. I create that timer in the constructor of my control, using the WithEvents and Handles keywords together to respond to the Timer class's Tick event, as shown in Figure 3.

Figure 3 Animating a Control with a Timer

Dim WithEvents cycle As Timer Dim currentActiveItem As Integer = 0 Private Sub cycle_Tick(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles cycle.Tick Dim oldActiveItem As Integer = Me.currentActiveItem If Me.currentActiveItem >= m_ShapeCount Then Me.currentActiveItem = 1 Else Me.currentActiveItem += 1 End If Me.Invalidate(CalcItemRectangle(oldActiveItem)) Me.Invalidate(CalcItemRectangle(Me.currentActiveItem)) End Sub

CalcItemRectangle is used to determine the bounds of a single shape, wherever it was drawn on the control, and allows me to invalidate only those areas of the control that need redrawing rather than everything. Reducing the area that is redrawn helps to improve performance and to reduce flicker, although with the built-in double buffering you may not notice a difference either way. The animation and the drawing routines are all dependent on knowing the number of shapes to draw, which is in turn dependent on the shape size, spacing, and the width of the control. Several additional routines are used to keep that information up to date as you can see by taking a look at Figure 4.

Figure 4 Calculating Shape Sizes and Spacing

Protected Overrides Sub OnResize(ByVal e As System.EventArgs) RecalcCountAndInterval() MyBase.OnResize(e) End Sub Private Const MIN_INTERVAL As Integer = 100 Private Sub RecalcCountAndInterval() Dim w As Integer = Me.Width Dim newShapeCount As Integer newShapeCount = Math.Floor((w - m_ShapeSpacing) / _ (m_ShapeSize + m_ShapeSpacing)) If newShapeCount <> m_ShapeCount AndAlso newShapeCount > 0 Then Dim interval As Integer = Me.m_CycleSpeed \ newShapeCount If interval >= MIN_INTERVAL Then cycle.Interval = interval Else cycle.Interval = MIN_INTERVAL End If m_ShapeCount = newShapeCount End If End Sub

As I mentioned earlier, several of the property routines call RecalcCountAndInterval when they are modified because those values are somehow involved in the calculation. Of course, if you look carefully at the calculations involved in the RecalcCountAndInterval routine, you will see that certain property values will cause an error, specifically any combination of values such that (m_ShapeSize + m_ShapeSpacing) = 0. You could just add a check in the Recalc routine to avoid this issue, but the cleaner solution is to require ShapeSize and ShapeSpacing to be greater than or equal to zero and then check this rule in both the property routines and in RecalcCountAndInterval.

In my property routines, I call Me.Invalidate after calling the RecalcCountAndInterval routine, which forces a redraw of the entire control. It is possible (depending on what has been changed) that only a small area of the control needs to be invalidated, but these property changes should be relatively rare so the code required to determine the affected area wasn't worth the effort.

Finally, It is Time to Draw the Control

As I mentioned earlier, overriding OnPaint and drawing the control might seem like the logical first step, but it is really much easier to work on after you have your configuration options set up. Within the OnPaint routine, you need to draw out every one of the shapes that will appear across the entire control, drawing one shape a bit bigger to indicate that it is the active one. That active shape will appear to be moving across the control, giving the desired appearance of progress (Figure 5 shows the code).

Figure 5 Drawing the Custom Control

Private Const SIZE_INCR As Integer = 2 Protected Overrides Sub OnPaint( _ ByVal e As System.Windows.Forms.PaintEventArgs) Dim g As Graphics = e.Graphics ControlPaint.DrawBorder3D(g, Me.ClientRectangle, Me.BorderStyle) For i As Integer = 1 To m_ShapeCount Dim pos As Point Dim x, y As Integer pos = CalculateItemPosition(i) x = pos.X y = pos.Y If i = currentActiveItem Then DrawShape(g, Me.m_ShapeToDraw, _ x - SIZE_INCR, y - SIZE_INCR, _ m_ShapeSize + (SIZE_INCR * 2)) Else DrawShape(g, Me.m_ShapeToDraw, x, y, m_ShapeSize) End If Next End Sub Private Function CalculateItemPosition(ByVal index As Integer) As Point Dim pos As Point pos.X = (m_ShapeSpacing * index) + (m_ShapeSize * (index - 1)) pos.Y = (Me.Height \ 2) - (m_ShapeSize \ 2) Return pos End Function

Figure 6 Drawing the Elements

Figure 6** Drawing the Elements **

OnPaint is essentially nothing but a loop, but (via the CalculateItemPosition function) it calculates the size and position of the next shape (based on a combination of the ShapeSpacing and ShapeSize properties, as shown in Figure 6) and it then calls the DrawShape method to handle drawing out each individual element with a specific position and size:

Private Sub DrawShape(ByVal g As Graphics, _ ByVal shape As ElementStyle, ByVal x As Integer, _ ByVal y As Integer, ByVal size As Integer) Dim shapeBrush As New SolidBrush(Me.ForeColor) Select Case shape Case ElementStyle.Circle g.FillEllipse(shapeBrush, x, y, size, size) Case ElementStyle.Square g.FillRectangle(shapeBrush, x, y, size, size) End Select End Sub

You should note that the control's ForeColor property is used in order to create the brush, thus making the color of these shapes configurable by the developer.

With the completion of the graphics code, the control is ready for use, although I expect you will want to add to and modify it to suit your own needs (say, for example, you really need it to draw triangles instead of circles or squares).

The Standard Demo Application

Included with the source of this application are two demos, basic and advanced. The first demo is nothing more than a sample of the control in action, complete with an interface for manipulating the properties of the progress bar. The second demo, on the other hand, illustrates how you might use this progress bar in a real program, executing a task on a background thread and putting this control up as the "Working..." indicator. I won't go into great detail on the concepts behind this second demo as Ted Pattison covered it in the May 2004 installment of Basic Instincts, but here is a quick discussion and a walkthrough of the code involved.

Running a Process in the Background

No one likes the "Not Responding" message or, as Mark Boulter calls it (Mark is a Lead Program Manager on the Windows Forms team), the "White Screen of Impatience," but the alternative almost always involves threading. Working with multiple threads is much simpler in the Microsoft .NET Framework than in previous versions of Visual Basic, but that doesn't mean it is easy, so most applications ignore it and the user has no choice but to wait and hope that the application hasn't crashed completely. Using the new progress bar I just created and the simple background process of searching the file system, I will show you a relatively simple implementation of a background thread, then I'll make it more complicated by passing information from that background process to the foreground user interface.

The first step is to encapsulate your background process into a routine that either takes no parameters or accepts parameters through properties because when you start a new thread, you need to give it the address of a "Sub" routine with no parameters. I rarely find a long-running process that requires no information, so I create a class for my process, turn the parameters into properties, and create a "Start" method (see Figure 7).

Figure 7 State Class to Use with Background Thread

Imports System.IO Public Class FileSystemScan Dim m_StartingDirectory As String = "C:\" Public Property StartingDirectory() As String Get Return m_StartingDirectory End Get Set(ByVal Value As String) m_StartingDirectory = Value End Set End Property Public Sub StartScan() ScanDirectory(Me.m_StartingDirectory) End Sub Private Sub ScanDirectory(ByVal dir As String) Try Dim files As String() files = Directory.GetFiles(dir) For Each f As String In files ' do something with the file path Next Catch ex As Exception End Try Try Dim directories As String() directories = Directory.GetDirectories(dir) For Each d As String In directories ScanDirectory(d) Next Catch ex As Exception End Try End Sub End Class

Then, in my main application, I create a working form that contains my new progress bar. When it is time to call the long-running process, I pop up the form, create the new thread and execute it, and then wait for that thread to exit (see Figure 8).

Figure 8 Waiting for a Task to Complete

Dim activeThread As Threading.Thread Private Sub startScan_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles startScan.Click Me.Enabled = False Dim progress As New ActivityIndicator progress.Show() Dim myFSS As New FileSystemScan myFSS.StartingDirectory = "C:\" Dim activeThread As New _ Threading.Thread(AddressOf myFSS.StartScan) activeThread.Start() Do While activeThread.IsAlive Application.DoEvents() Thread.Sleep(50) Loop progress.Close() Me.Enabled = True End Sub

That is, of course, the simplest case. What if you want to get feedback from the background process, such as the current position in the file system in my example?

Displaying Updates from Your Background Process

Receiving information from the background process is one problem, but the other problem revolves around the use of multiple threads; you can't reliably modify any property of a Form from a different thread than the one the Form is running on (although it can appear to work fine during casual testing). I'll show you what this means to the code in just a moment. First, let's modify the FileSystemScan class to report its progress through a few events. I'll add declarations for two events and then call them during my file-scanning loops, as shown here:

'at the top of the FileSystemScan class Public Event FileFound(ByVal sender As Object, _ ByVal e As FileFoundEventArgs) Public Event ErrorEncountered(ByVal sender As Object, _ ByVal e As ErrorEventArgs)

I use two custom EventArg-derived classes, one to pass back a message for errors and another to return a file name to indicate progress. Now, back in the calling form, I am going to add a label to the Activity Indicator form (the one with the new progress bar on it) and add a public method to that form to allow the label's text to be set, as shown in the following:

Public Class ActivityIndicator Inherits System.Windows.Forms.Form ... Public Sub SetMessageText(ByVal message As String) Me.lblMessage.Text = message End Sub ... End Class

Next, I'll rewrite the code in the Start Scan button click event to use a form-level variable (myScanner) that includes event handling and to create and use a delegate pointing at the ActivityIndicator Form to pass along new messages whenever they appear:

Dim WithEvents myScanner As FileSystemScan Private Delegate Sub MessageHandler(ByVal message As String) Dim myHandler As MessageHandler Dim activeThread As Threading.Thread Private Sub startScan_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles startScan.Click ... myHandler = New MessageHandler(AddressOf progress.SetMessageText) ... End Sub

With those changes made, I now have a window that displays a nice graphic and shows a continuously changing file path to indicate the current progress.

I Just Want It to Stop!

I need to add a Cancel button to the window and then the sample will be complete (see Figure 9). There are many ways to do this, but the simplest and most generic from my point of view is to interrupt the background thread whenever the Cancel button is pressed. That will cause an exception in the FileSystemScan class, which we can handle by just exiting our long-running process. To accommodate this change, I added a handler for the Cancel button (which calls activeThread.Interrupt, as shown in Figure 10) and error handling to the code in FileSystemScan (see Figure 11).

Figure 11 Handling Thread Interrupts

Public Sub StartScan() Try ScanDirectory(Me.m_StartingDirectory) Catch ex As Threading.ThreadInterruptedException 'thread interrupted, get out of here! Exit Sub End Try End Sub Private Sub ScanDirectory(ByVal dir As String) Try ••• Catch ex As Threading.ThreadInterruptedException 'thread interrupted, fire it back up the 'call stack... should go up until it 'hits "StartScan" Throw ex Catch ex As Exception RaiseEvent ErrorEncountered(Me, _ New ErrorEventArgs(ex.Message)) End Try ••• End Sub

Figure 10 Starting and Stopping a Background Thread

Dim activeThread As Threading.Thread Private Sub startScan_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles startScan.Click Me.Enabled = False Dim progress As New ActivityIndicator AddHandler progress.CancelButtonPushed, _ AddressOf Me.CancelButtonPushed progress.Show() ••• End Sub Private Sub CancelButtonPushed(ByVal sender As Object, _ ByVal e As System.EventArgs) Me.activeThread.Interrupt() End Sub

Figure 9 Adding a Cancel Button

Figure 9** Adding a Cancel Button **

While it is centered on only one sample background process, I think that this demo incorporates most of the things I would like to see in an "in progress" indicator for a long-running background process. The sample code included with this column is the culmination of all of these little adventures, and includes the status text area and the Cancel button. Check it out to see the full example running on your own machine.

Conclusion

Sometimes you just need to show that you are doing something, even if you can't say how long it is going to take. One effective way of illustrating this state is to display a cyclic animation. Building your own progress bar is probably the easiest part of this column; understanding how to use it in your own applications is the real trick. I hope that my example helped explain at least some of the issues involved in running a process on a background thread.

Send your questions and comments to  basics@microsoft.com.

Duncan Mackenzie is the MSDN Content Strategist for Visual Basic and C# and the author of the Coding 4 Fun column on MSDN online. He can be reached through his personal site at https://www.duncanmackenzie.net.