Preamble
For the past couple months I’ve been pretty (very) busy with my Rubberduck project, an open-source add-in for the VBE (the VBA IDE) packed with cool features that I encourage you to explore (see the “Features” page on the official website) – but I’m not here to talk about RD, otherwise I would have posted on Rubberduck News.
I’m here to blog about the last piece of VBA code I’ve written, in response to a recent tweet from @ExcelEasy to which I replied:
@ExcelEasy well done! #Challenge: make a more #OOP one without using the form’s default/global instance! #becausewhynot
So, building on this article, I went and implemented my own – this post is essentially a walkthrough for the ProgressIndicator class, the most recent addition to my VBTools GitHub repository.
ProgressView
Designer
First step is to create the UserForm that will be used for displaying the progress – I made mine 255×78, called it ProgessView. Then I added a 228×12 Label at (12,6) and called it ProgressLabel; following @ExcelEasy’s neat UI, I added a 228×24 frame at (12,24), cleared its caption and called it DecorativeFrame; the blue highlight is a 10×20 label at (0,0); its backcolor is the system highlight color. The result looks like this at run time:

Code
Here’s the code-behind:
Option Explicit Private Const PROGRESSBAR_MAXWIDTH As Integer = 224 Public Event Activated() Public Event Cancelled() Private Sub UserForm_Activate() ProgressBar.Width = 0 RaiseEvent Activated End Sub Public Sub Update(ByVal percentValue As Single, Optional ByVal labelValue As String, Optional ByVal captionValue As String) If labelValue <> vbNullString Then ProgressLabel.Caption = labelValue End If If captionValue <> vbNullString Then Me.Caption = captionValue End If ProgressBar.Width = percentValue * PROGRESSBAR_MAXWIDTH DoEvents End Sub Private Sub UserForm_QueryClose(Cancel As Integer, CloseMode As Integer) If CloseMode = VbQueryClose.vbFormControlMenu Then Cancel = True RaiseEvent Cancelled End If End Sub
As you can see, the form isn’t responsible for anything other than updating itself, and notifying someone when it’s ready to start reporting progress, or when the “X” button is clicked – in which case whoever is handling these events can decide what to do.
Of course, that form alone doesn’t do much. It’s just a view. We need something else to implement the logic for it. Enter the ProgressIndicator class.
ProgressIndicator
I gave the class a default instance – for this to work you need to edit the class module outside the VBE (until a Rubberduck release lets you tweak module and procedure attributes at will, that is!), and import it back into your project. Here’s the listing, including a Win32API function declaration for 32-bit Office; 64-bit Office requires a PtrSafe keyword to compile them:
VERSION 1.0 CLASS BEGIN MultiUse = -1 'True END Attribute VB_Name = "ProgressIndicator" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = True Attribute VB_Exposed = True Option Explicit Private Declare Sub Sleep Lib "kernel32" (ByVal dwMilliseconds As Long) Private Const DEFAULT_CAPTION As String = "Progress" Private Const DEFAULT_LABEL As String = "Please wait..." Private Const ERR_NOT_INITIALIZED As String = "ProgressIndicator is not initialized." Private Const ERR_PROC_NOT_FOUND As String = "Specified macro or object member was not found." Private Const ERR_OPERATION_CANCELLED As String = "Operation was cancelled by the user." Public Enum ProgressIndicatorError Error_NotInitialized = vbObjectError + 1001 Error_ProcedureNotFound Error_OperationCancelled End Enum Private Type TProgressIndicator procedure As String instance As Object sleepDelay As Long canCancel As Boolean currentProgressValue As Double End Type Public Event BeforeCancel(ByRef throw As Boolean) Private this As TProgressIndicator Private WithEvents view As ProgressView Private Sub Class_Initialize() Set view = New ProgressView view.Caption = DEFAULT_CAPTION view.ProgressLabel = DEFAULT_LABEL End Sub Private Sub Class_Terminate() Set view = Nothing Set this.instance = Nothing End Sub Private Function QualifyMacroName(ByVal book As Workbook, ByVal procedure As String) As String QualifyMacroName = "'" & book.FullName & "'!" & procedure End Function Public Function Create(ByVal procedure As String, Optional instance As Object = Nothing, Optional ByVal initialLabelValue As String, Optional ByVal initialCaptionValue As String, Optional ByVal completedSleepMilliseconds As Long = 1000, Optional canCancel As Boolean = False) As ProgressIndicator Dim result As New ProgressIndicator result.Cancellable = canCancel result.SleepMilliseconds = completedSleepMilliseconds If Not instance Is Nothing Then Set result.OwnerInstance = instance ElseIf Not Framework.Strings.Contains(procedure, "'!") Then procedure = QualifyMacroName(Application.ActiveWorkbook, procedure) End If result.ProcedureName = procedure If initialLabelValue <> vbNullString Then result.ProgressView.ProgressLabel = initialLabelValue End If If initialCaptionValue <> vbNullString Then result.ProgressView.Caption = initialCaptionValue End If Set Create = result End Function Friend Property Get ProgressView() As ProgressView Set ProgressView = view End Property Friend Property Get ProcedureName() As String ProcedureName = this.procedure End Property Friend Property Let ProcedureName(ByVal value As String) this.procedure = value End Property Friend Property Get OwnerInstance() As Object Set OwnerInstance = this.instance End Property Friend Property Set OwnerInstance(ByVal value As Object) Set this.instance = value End Property Friend Property Get SleepMilliseconds() As Long SleepMilliseconds = this.sleepDelay End Property Friend Property Let SleepMilliseconds(ByVal value As Long) this.sleepDelay = value End Property Public Property Get CurrentProgress() As Double CurrentProgress = this.currentProgressValue End Property Public Property Get Cancellable() As Boolean Cancellable = this.canCancel End Property Friend Property Let Cancellable(ByVal value As Boolean) this.canCancel = value End Property Public Sub Execute() view.Show vbModal End Sub Public Sub Update(ByVal percentValue As Double, Optional ByVal labelValue As String, Optional ByVal captionValue As String) On Error GoTo CleanFail ThrowIfNotInitialized ValidatePercentValue percentValue this.currentProgressValue = percentValue view.Update this.currentProgressValue, labelValue CleanExit: If percentValue = 1 Then Sleep 1000 Exit Sub CleanFail: MsgBox Err.Number & vbTab & Err.Description, vbCritical, "Error" Resume CleanExit End Sub Public Sub UpdatePercent(ByVal percentValue As Double, Optional ByVal captionValue As String) ValidatePercentValue percentValue Update percentValue, Format(percentValue, "0.0% Completed") End Sub Private Sub ValidatePercentValue(ByRef percentValue As Double) If percentValue > 1 Then percentValue = percentValue / 100 End If End Sub Private Sub ThrowIfNotInitialized() If this.procedure = vbNullString Then Err.Raise ProgressIndicatorError.Error_NotInitialized, TypeName(Me), ERR_NOT_INITIALIZED End If End Sub Private Sub view_Activated() On Error GoTo CleanFail ThrowIfNotInitialized If Not this.instance Is Nothing Then ExecuteInstanceMethod Else ExecuteMacro End If CleanExit: view.Hide Exit Sub CleanFail: MsgBox Err.Number & vbTab & Err.Description, vbCritical, "Error" Resume CleanExit End Sub Private Sub ExecuteMacro() On Error GoTo CleanFail Application.Run this.procedure, Me CleanExit: Exit Sub CleanFail: If Err.Number = 438 Then Err.Raise ProgressIndicatorError.Error_ProcedureNotFound, TypeName(Me), ERR_PROC_NOT_FOUND Else Err.Raise Err.Number, Err.source, Err.Description, Err.HelpFile, Err.HelpContext End If Resume CleanExit End Sub Private Sub ExecuteInstanceMethod() On Error GoTo CleanFail Dim parameter As ProgressIndicator Set parameter = Me 'Me cannot be passed to CallByName directly CallByName this.instance, this.procedure, VbMethod, parameter CleanExit: Exit Sub CleanFail: If Err.Number = 438 Then Err.Raise ProgressIndicatorError.Error_ProcedureNotFound, TypeName(Me), ERR_PROC_NOT_FOUND Else Err.Raise Err.Number, Err.source, Err.Description, Err.HelpFile, Err.HelpContext End If Resume CleanExit End Sub Private Sub view_Cancelled() If Not this.canCancel Then Exit Sub Dim throw As Boolean throw = True RaiseEvent BeforeCancel(throw) 'this error isn't trappable, but not raising it wouldn't cancel anything: If throw Then OnCancelledError End Sub Private Sub OnCancelledError() Err.Raise ProgressIndicatorError.Error_OperationCancelled, TypeName(Me), ERR_OPERATION_CANCELLED End Sub
The class module must have its Instancing property set to PublicNotCreatable, which makes it impossible to instantiate directly from within another VBA project – that’s why there’s this Create factory method to initialize and return an instance.
Here’s the simplest use case, to illustrate – we have a DoSomething procedure attached to some command button on a worksheet; that procedure calls the ProgressIndicator.Create factory method to return an instance of a ProgressIndicator that will call the DoWork procedure:
Public Sub DoSomething() With ProgressIndicator.Create("DoWork") .Execute End With End Sub This DoWork procedure is located in a standard module and must take a ProgressIndicator parameter:
Public Sub DoWork(ByVal progress As ProgressIndicator) Dim i As Long For i = 1 To 1000 Cells(1, 1) = i progress.UpdatePercent i / 1000 Next End Sub When the Execute method is called, the ProgressView is displayed and fires up its Activated event, to which the ProgressIndicator responds by running the DoWork procedure, passing itself as a parameter – so all DoWork needs to care about, is the work it’s responsible for… and when to update progress.
The user simply can’t “X-out” of the view, which closes itself automatically a whole second after DoWork completes.
Of course, there’s more to it.
If you read the ProgressIndicator code, you know it’s way more flexible than that.
Here’s a more meaty (yet very simplified) use case: here the work is encapsulated in a class module.
Option Explicit Private WithEvents indicator As ProgressIndicator Private cancelling As Boolean Public Sub DoWork(ByVal progress As ProgressIndicator) On Error GoTo CleanFail Dim i As Long For i = 1 To 10000 If cancelling Then ThrowOnCancel Cells(1, 1) = i progress.UpdatePercent i / 10000 Next CleanExit: Exit Sub CleanFail: MsgBox Err.Description, vbExclamation Resume CleanExit End Sub Private Sub indicator_BeforeCancel(throw As Boolean) throw = False cancelling = True End Sub Private Sub ThrowOnCancel() Err.Raise ProgressIndicatorError.Error_OperationCancelled, TypeName(Me), "Operation was cancelled." End Sub
Using a private field to hold the cancelling state of the ProgressIndicator, our DoWork method can now decide to act accordingly – if this method was executing a stored procedure using an ADODB database connection, we could handle Error_OperationCancelled by rolling back a transaction and properly cleaning up before we gracefully cancel the long-running task.
In this case (assuming DoWork is located in a Class1 class module), the macro that’s attached to a worksheet button would look something like this:
Public Sub DoSomething() Dim progress As ProgressIndicator Set progress = ProgressIndicator.Create("DoWork", New Class1) progress.Execute End Sub And now the user can “X-out” and cancel the task, and nothing blows up.
This ProgressIndicator is highly reusable, so I’ve included it in an Excel add-in that I always have open – any new VBA project that needs this can then simply reference my add-in project and use everything I’ve packed into that library. If you want to use this code as is, you’re going to need this handy function that I’ve put in my Framework.Strings module:
Public Function Contains(ByVal string_source As String, ByVal find_text As String, Optional ByVal caseSensitive As Boolean = False) As Boolean Dim compareMethod As VbCompareMethod If caseSensitive Then compareMethod = vbBinaryCompare Else compareMethod = vbTextCompare End If Contains = (InStr(1, string_source, find_text, compareMethod) <> 0) End Function
Enjoy!


