Home > Net >  Reducing Decoupling in VBA Object Event Wrapper
Reducing Decoupling in VBA Object Event Wrapper

Time:01-08

tl;dr Is there a way to enable events for built-in objects without coupling the event to the original object's parent, assuming the event interacts with the parent?

Disclaimer 1: I don't have access to MS Office on my home machine and therefore type all code from memory. I'm sorry if something's incorrect.

Disclaimer 2: This post is incredibly lengthy because I've been trying to figure out how to do this process for several years but never quite hit the correct Google terms to figure it out. I do a lot of explaining in the hopes that it might help someone with the same issues.

The Original Problem

I've had this longstanding issue of having Userforms with near-identical event handling but no way to compact the code into a generic solution. For example, let's say I have a Userform with a bunch of Command Buttons that all do the same thing when clicked. Traditionally, you would have to include something like the following in Userform1

Private Sub CommandButton1_Click()
    Me.DoSomething CommandButton1.Name
End Sub

Private Sub CommandButton2_Click()
    Me.DoSomething CommandButton2.Name
End Sub

 '...a bunch more of these...'

Private Sub CommandButtonN_Click()
    Me.DoSomething CommandButtonN.Name
End Sub

This is annoying to setup and hurts readability for a large number of buttons.

The Naive Solution

I recently discovered that wrapper classes can be utilized to make a generic WithEvents handler for built-in objects. Applying this to our previous example, we create an EventCommandButton.cls Class with the following code

Private WithEvents mCommandButton as MSForms.CommandButton

Private Sub mCommandButton_Click()
    mCommandButton.Parent.DoSomething(mCommandButton.Name) 
End Sub

Property Get CommandButton() as MSForms.CommandButton
    Set CommandButton = mCommandButton
End Property

Property Set CommandButton(cmdBtn as MSForms.CommandButton)
    Set mCommandButton = cmdBtn
End Property

And Userform1 turns into

Private EventCommandButtons() as New EventCommandButton

Private Sub Userform1_Initialize()
    For Each ctl in Me.Controls
        If TypeName(ctl) = "CommandButton" Then
            i = i   1
            ReDim Preserve EventCommandButtons(1 to i) 
            Set EventCommandButtons(i).CommandButton = ctl
        End If
    Next
End Sub

This approach saves space and looks comparatively nice, but it presents (at least) 2 major issues:

  1. All of Userform1's control events are no longer housed in its own code
  2. Our EventCommandButton requires a specific procedure (DoSomething(str)) to exist in its parent or else we'll get an error.

A Slight Refinement

The solution I'm currently implementing is to take a more intuitive approach that returns control of the event handling back to where you'd expect it to be. In EventCommandButton.cls we add a new property to specify where we expect to find the return code:

Private mCommandButton as MSForms.CommandButton
Private mCallback as Object

Private Sub mCommandButton_Click()
    'Some error handling should be here to check that mCallback is set
    mCallback.EventCommandButton_Click(mCommandButton) 
End Sub

Property Get Callback() as Object
    Set Callback = mCallback
End Property

Property Set Callback(ParentObject as Object)
    'Let's not assume it's always the .Parent
    Set mCallback = ParentObject
End Property 

Property Get CommandButton() as MSForms.CommandButton
    Set CommandButton = mCommandButton
End Property

Property Set CommandButton(cmdBtn as MSForms.CommandButton)
    Set mCommandButton = cmdBtn
End Property

And in Userform1

Private EventCommandButtons() as New EventCommandButton

Public Sub EventCommandButton_Click(cmdBtn as MSForms.CommandButton)
    Me.DoSomething cmdBtn.name
End Sub

Private Sub Userform1_Initialize()
    For Each ctl in Me.Controls
        If TypeName(ctl) = "CommandButton" Then
            i = i   1
            ReDim Preserve EventCommandButtons(1 to i)
            Set EventCommandButtons(i).CommandButton = ctl
            Set EventCommandButtons(i).Callback = Me         'Set new property
        End If
    Next
End Sub

This approach feels close to the intuitive solution of the original problem (with some extra steps involved) and resolves issue #1 from the previous, but we still have issues:

  1. There's still coupling between the Class and Userform, now requiring that each parent object must have corresponding pseudo-event procedures of the form Public Sub [ClassName]_[EventName]([OriginalObject], Optional [EventParams]), which isn't intuitive and looks weird amongst the sea of Private Event Subs.
  2. The coupling now depends on the class name, which may not always be ideal. Renaming the class will require editing the events to reflect that.
  3. For the wrapper to be "complete", it must include all events and error handling to ignore the ones that aren't setup on the Parent side. At some point I'd think having all these On Error GoTo EoF statements in each class instance will have a performance impact.

The Question

Is there a way that this process can be further improved to reduce the coupling between (in this case) the Class and Form code? With VBIDE we could detect the classname and generate the pseudo-events, but without VBIDE access it seems like it requires some upkeeping and instruction to properly utilize the class.

In Python (and I'm sure other languages), you could just pass a reference to a function to direct the event returns; however, VBA doesn't seem to support this.

CodePudding user response:

If you can pass the method name from the parent as a string you could use something like CallByName mCallback, vbMethod, mProcName, mCommandButton from within the class instance, to call the method mProcName on the parent, passing the clicked-on button.

For example:

Event class (properties changed to public fields for brevity)

Option Explicit

Public WithEvents mCommandButton As MSForms.CommandButton
Public mCallback As Object  '<< object on which the callback method is to be called
Public mProcName As String  '<< name of the callback method

Private Sub mCommandButton_Click()
    CallByName mCallback, mProcName, VbMethod, mCommandButton
End Sub

Form code:

Private EventCommandButtons  As Collection

Public Sub ButtonClick(cmdBtn As MSForms.CommandButton)
    MsgBox "clicked on button " & cmdBtn.Caption
End Sub

Private Sub Userform_Initialize()
    Dim ctl As Object
    Set EventCommandButtons = New Collection
    For Each ctl In Me.Controls
        If TypeName(ctl) = "CommandButton" Then
            EventCommandButtons.Add NewClickHandler(ctl)
        End If
    Next
End Sub

Function NewClickHandler(btn As Object) As EventCommandButton
    Set NewClickHandler = New EventCommandButton
    Set NewClickHandler.mCommandButton = btn
    Set NewClickHandler.mCallback = Me
    NewClickHandler.mProcName = "ButtonClick"
End Function
  • Related