Introduction
Sunday, April 27, 2003
Here I'll talk about how to implement some sort of (or alternative to) the "command" pattern in VB.NET. It's similiar to Delphi's Action lists, but it's going to be "code-only" (of course you can make the IDE work with the classes here, but that's another story).
So why use action lists? Well, a modern UI gives the user several ways of doing things, for example, click a toolbar button, select a command from the menu, or use a context menu, and so on. So it's about saving work: define your actions/commands once, and have consistent names and tooltips across the application. This approach also allows for easy customization by the user. If certain actions should be disabled, you just have to set a property once, and the UI will update automatically. The same goes for captions and tooltips.
On the download page, get the UICore project, which contains everything discussed in this and the next topics.
General design
We'll create a custom collection class called "NActions", which derives from the class NameObjectCollectionBase in the System.Collections namespace (see "Collections" for more info on this approach). This allows us to access actions either by key or by index.
Then there is "CAction", a class that encapsulates about everything related to commands at the user level: captions, tooltips, invokeability (Enabled property), delegates for executing the command and updating UI cues, and potentially icon information (which is discussed in the topic Menues as far as menues are concerned; sure you'll know how to apply that to a toolbar).
That's the most generic part. Then follow several classes that cooperate with CAction, all of which represent such parts of the user interface as can trigger commands (controls mostly), for example menu items, toolbar buttons, and so on. Consequently, these classes derive from respective control classes. I'll talk about the trade-offs between this method and interfaces later. These classes are responsible for reacting to events and mapping them to actions (invoking delegates). They'll also listen to action objects for status changes. Anyway, I'll show you three classes of that sort (CActionMenu, CActionToolbarButton, and CActionButton), but you can create other classes that do the same for other controls, or create controls that deal with CAction objects as well.
When you create an application, you first fill an action list with appropriate actions. These actions are assigned handlers for executing the command, and for updating UI cues. Then you create, for example, menu items and assign actions to them. The same goes for toolbar buttons. That's it.
Implementation
NActions collection
NActions is fairly straightforward. Just let me mention that the base class doesn't ensure unique keys, so you'll want to do that in the add method. I'll just use the actions' captions as keys, which implies you cannot have several commands with the same caption. Mostly, that's OK; otherwise you need to change the Add methods to take something else as a key, or not use keys alltogether. Also have a look at the secondary constructor:
Public Class NActions Inherits System.Collections.NameObjectCollectionBase Sub New MyBase.New End Sub Sub New(ParamArray ByVal actions() As CAction) MyBase.New Dim i As Integer For i = 0 To UBound(actions) MyClass.Add(actions(i) Next i End Sub Public Overloads Sub Add(ByVal action As CAction) If MyBase.BaseGet(action.Caption) Is Nothing Then MyBase.BaseAdd(action.Caption, action) Else Throw New ArgumentException End If End Sub Public Overloads Function Add(ByVal sText As String, ByVal sHint As String) As CAction ' ... Public Overloads Function Add(ByVal sText As String, ByVal execHandler As DExecHandler) As CAction ' ... Public Overloads Function Add(ByVal sText As String, ByVal sHint As String, ByVal execHandler As DExecHandler, ByVal hintHandler As DHintHandler) As CAction ' ... ' Don't forget the Item method (overloaded - both key and index) End Class
The second through fourth Add methods map to CAction's constructors. Note that when a new CAction object is created, the Add methods return a reference to it (when an existing one is added, there's no need). Of course, there's more to CAction:
CAction class
The Delegates
CAction defines three delegate types (see "Delegates" for more info), and one reference of each type per object. The m_ExecHandler(a DExecHandler) and m_HintHandler (a DHintHandler) delegates will reference procedures provided by the application, whereas m_StatusHandler (a DStatusHandler, actually a multicast delegate) will point to methods provided by the above-mentioned cooperating control classes (like CActionMenu).
The purpose of the delegate referenced by m_ExecHandler is to execute the command; all CAction objects can talk to the same handler, but the point about using delegates is to map each action to a different target in the programm. Using delegates, there is no need to create a custom class for each action (which would implement an "ICommand" interface, or even derive from a "CommandBase" class). The m_ExecHandler is usually assigned when a CAction object is created. You can also switch handlers at runtime.
The m_HintHandler delegate updates the user interface for cues about the action. Mostly, this will update the statusbar's text. It is not necessary to provide a hint handler; cooperating control classes can also display the hint as a tooltip (as shown later). It's up to those classes (like CActionMenu) to decide whether to call the DHintHandler delegate at all. Again, this delegate is usually assigned at creation.
The m_StatusHandler delegate should be assigned when an instance of a cooperating class (like CActionMenu) is assigned a reference to a CAction object. The point is to update the user interface (disable a menu item, change a button's caption, etc.) when the programm changes a property of an CAction object (like disabling a command).
Here are the delegates:
Public Delegate Function DExecHandler(ByVal action As CAction) As Boolean Public Delegate Sub DHintHandler(ByVal action As CAction) Public Delegate Sub DStatusHandler(ByVal action As CAction) Private m_ExecHandler As DExecHandler Private m_HintHandler As DHintHandler Private m_StatusHandler As DStatusHandler
Both m_ExecHandler and m_HintHandler can be changed by property procedures. As the m_StatusHandler delegate can actually invoke several procedures (as one action can be invoked from different point in the UI), it is a multicast delegate. Therefore, two methods are provided to assign and remove handlers. These methods will be called by cooperating classes like CActionMenu:
Public Sub AddStatusHandler(ByVal statusHandler As DStatusHandler) m_StatusHandler = DirectCast(System.Delegate.Combine(m_StatusHandler, statusHandler), DStatusHandler) End Sub Public Sub RemoveStatusHandler(ByVal statusHandler As DStatusHandler) m_StatusHandler = DirectCast(System.Delegate.Remove(m_StatusHandler, statusHandler), DStatusHandler) End Sub
CAction properties
CAction encapsulates the following fields, all of which are accessible by property procedures:
Private m_Text As String Private m_Hint As String Private m_Enabled As Boolean
When one of these properties changes, the m_StatusHandler multicast delegate is invoked:
Public Property Enabled As Boolean Get Return m_Enabled End Get Set m_Enabled = value If Not m_StatusHandler Is Nothing Then m_StatusHandler.Invoke(Me) End Set End Property
This will notify, for example, CActionMenu objects that have registered a DStatusHandler delegate with this action. Therefore, when, somewhere in the programm, an action is disabled, all UI elements will react and take steps defined in classes like CActionMenu.
CAction methods
Classes like CActionMenu react to user input; they will call CAction's methods as needed. The assigned CAction object will then invoke either m_ExecHandler or m_HintHandler:
Public Function Execute As Boolean If Not m_ExecHandler Is Nothing Then Return m_ExecHandler.Invoke(Me) End Function Public Sub ShowHint If Not m_HintHandler Is Nothing Then m_HintHandler.Invoke(Me) End Sub
Of course, the ShowHint method will be called on certain event (like rollover events in menu items).
CAction constructors
CAction has several constructors; I'll just show the most complicated one (the signature for the others is shown above with the NActions collection):
Public Overloads Sub New(ByVal sText As String, ByVal sHint As String, ByVal execHandler As DExecHandler, ByVal hintHandler As DHintHandler) MyBase.New m_Enabled = True m_Text = sText m_Hint = sHint m_ExecHandler = execHandler m_HintHandler = hintHandler End Sub
Customizing CAction
CAction objects invoke functions in your program whose signatures match that of the DExecHandler delegate. However, the methods and functions actually called might not comply with that, unless you design everything to be consistent with this pattern here. But this is hardly always possible, nor is it desirable.
In any case, to address the signature problem, there are several options:
- Inheritance: CAction inheritors add other delegates that match the signature. But try not to create a new class for every command (see the summary to this topic for some comments); rather, adopt delegate types for typical situations.
- Set up a centralized handler, and use Select-Case. Altough this isn't so elegant, it's still somewhat centralized (one handler for all actions, regardless of which controls have triggered them). While it's suboptimal, you might consider using this for a group of related commands. A Select-Case isn't the end of the world.
- Call by name, that is, use reflection. Enhance CAction with properties that hold values for parameters, and invoke methods via the CallByName function (or the stuff found in the System.Reflection namespace). Consider this if you're working in an extremly late-bound scenario. I list it for the sake of completeness.
- One event handling procedure per action. Although this means having more procedures around, this avoids both Select-Case and inheritance, and allows having all the information relevant to one action visible in one logical line of code.
Cooperating classes
Now that we've got the CAction class, we need some classes that talk with it. The idea is to derive from existing controls and handle the events in the subclass, then call CAction methods as needed. CActionMenu derives from MenuItem; when you instantiate it, you pass an instance of CAction (the action is exposed via a property, too; not shown):
Public Class CActionMenu Inherits System.WinForms.MenuItem Private m_Action As CAction Public Overloads Sub New MyBase.New End Sub Public Overloads Sub New(ByVal action As CAction) MyBase.New MyBase.Text = action.Caption MyBase.Enabled = action.Enabled m_Action = action m_Action.AddStatusHandler(AddressOf Me.HandleStatus) End Sub Protected Overrides Sub OnClick(ByVal e As System.EventArgs) MyBase.OnClick(e) m_Action.Execute End Sub Protected Overrides Sub OnSelect(ByVal e As System.EventArgs) MyBase.OnSelect(e) m_Action.ShowHint End Sub Private Sub HandleStatus(ByVal action As CAction) MyBase.Text = action.Caption MyBase.Enabled = action.Enabled End Sub End Class
On creation, a reference to the action is saved. Then, a DStatusHandler delegate is registered with the action, so the object can react to status changes (disabling etc.). Also, you can see how event handlers for menu clicks aren't needed in the programm, as here the protected (event-raiser) subs "OnClick" and "OnSelect" are overridden. So menu items of the CActionMenu class that are connected to CAction objects take care of event handling themselves. The CAction objects invoke delegates in the program.
Other implementations
Here is how you can use ordinary buttons with CAction objects. The class, CActionButton, differs from CActionMenu in that the hint (via DHintHandler) is shown when the user requests help (i.e., presses F1):
Protected Overrides Sub OnHelpRequested(ByVal hevent As System.WinForms.HelpEventArgs) MyBase.OnHelpRequested(hevent) m_Action.ShowHint End Sub
A little different still is the CActionToolbarButton class. It uses the Hint text from the action as a tooltip:
Public Overloads Sub New(ByVal action As CAction) MyBase.New MyBase.Text = action.Caption MyBase.ToolTipText = action.Hint MyBase.Enabled = action.Enabled m_Action = action m_Action.AddStatusHandler(AddressOf Me.HandleStatus) End Sub Private Sub HandleStatus(ByVal action As CAction) MyBase.Text = action.Caption MyBase.ToolTipText = action.Hint MyBase.Enabled = action.Enabled End Sub
A drawback here is that the ToolBarButton class does not allow us to sink events or overwrite protected event-raiser subs (which it doesn't have). Rather, the containing ToolBar class fires all the events. To work arround this issue, the client could write a handler and call the actions via the button references received. But it's better to create our own descendent from ToolBar that calls the actions associated with the buttons:
Class CActionToolbar Public Sub New() MyBase.New End Sub Protected Overrides Sub OnButtonClick(ByVal e As ToolBarButtonClickEventArgs) DirectCast(e.button, CActionToolbarButton).Action.Execute End Sub End Class
Note that there is no Select-Case here. The EventArgs instance lets us access the button and thus the action, so we can call the Execute method, which will do the right thing and call the appropriate handler.
Using these classes
The following code shows how to use these classes in your program. Start by creating actions, then creating menu items and toolbar butttons that reference these actions. Also create handlers to update the UI for cues (statusbar), and the actual handlers for the commands:
Private m_Actions As NActions Private m_AcsRecent As NActions Private m_Toolbar As CActionToolbar Private m_Status As StatusBar Private Sub InitializeComponent() ' create two action lists m_Actions = New NActions m_AcsRecent = New NActions m_Actions.Add("File", "Contains commands for working with files", Nothing, AddressOf Me.HandleHint) m_Actions.Add("Open ...", "Opens a file", AddressOf Me.HandleAction, AddressOf Me.HandleHint) m_Actions.Add("Save", "Saves the file", AddressOf Me.HandleAction, AddressOf Me.HandleHint) m_Actions.Add("Recent", "List of recently used files", Nothing, AddressOf Me.HandleHint) m_Actions.Add("Edit", "Contains editing commands", Nothing, AddressOf Me.HandleHint) m_Actions.Add("Undo", "Undoes the last editing command", AddressOf Me.HandleUndo, AddressOf Me.HandleHint) m_Actions.Add("Cut", "Cuts the selection into the clipboard", AddressOf Me.HandleCutCopy, AddressOf Me.HandleHint) m_Actions.Add("Copy", "Copies the selection into the clipboard", AddressOf Me.HandleCutCopy, AddressOf Me.HandleHint) m_Actions.Add("Paste", "Pastes the clipboard's contents into the selection", AddressOf Me.HandleAction, AddressOf Me.HandleHint) m_Actions("Paste").Enabled = False m_AcsRecent.Add("Tips.txt", "C:\My Documents\Tips.txt", AddressOf Me.HandleAction, AddressOf Me.HandleHint) m_AcsRecent.Add("ReadMe.txt", "C:\Windows\ReadMe.txt", AddressOf Me.HandleAction, AddressOf Me.HandleHint) m_AcsRecent.Add("AutoExec.bat", "C:\AutoExec.bat", AddressOf Me.HandleAction, AddressOf Me.HandleHint) ' menu Me.Menu = New MainMenu With Me.Menu.MenuItems .Add(New CActionMenu(m_Actions("File"))) With .Item(.Count - 1).MenuItems .Add(New CActionMenu(m_Actions("Open ..."))) ' access action by key ... .Add(New CActionMenu(m_Actions("Save"))) .Add(New CActionMenu(m_Actions("Recent"))) With .Item(.Count - 1).MenuItems .Add(New CActionMenu(m_AcsRecent(0))) ' ... or by index .Add(New CActionMenu(m_AcsRecent(1))) .Add(New CActionMenu(m_AcsRecent(2))) End With .Add(New CActionMenu(New CAction("Exit", "Exits the program", AddressOf Me.HandleExit, AddressOf Me.HandleHint))) End With .Add(New CActionMenu(m_Actions("Edit"))) With .Item(.Count - 1).MenuItems .Add(New CActionMenu(m_Actions("Undo"))) .Add(New CActionMenu(New CAction("-"))) .Add(New CActionMenu(m_Actions("Cut"))) .Add(New CActionMenu(m_Actions("Copy"))) .Add(New CActionMenu(m_Actions("Paste"))) End With End With ' context menu Me.ContextMenu = New ContextMenu With Me.ContextMenu.MenuItems .Add(New CActionMenu(m_Actions("Undo"))) .Add(New CActionMenu(New CAction("-"))) .Add(New CActionMenu(m_Actions("Cut"))) .Add(New CActionMenu(m_Actions("Copy"))) .Add(New CActionMenu(m_Actions("Paste"))) .Add(New CActionMenu(New CAction("-"))) .Add(New CActionMenu(m_Actions(1))) .Add(New CActionMenu(m_Actions(2))) End With ' toolbar Dim tbn As CActionToolbarButton m_Toolbar = New CActionToolbar m_Toolbar.Dock = DockStyle.Top m_Toolbar.BorderStyle = BorderStyle.None m_Toolbar.Appearance = ToolBarAppearance.Flat Me.Controls.Add(m_Toolbar) tbn = New CActionToolbarButton(m_Actions(1)) tbn.Style = ToolBarButtonStyle.DropDownButton tbn.DropDownMenu = New ContextMenu With tbn.DropDownMenu.MenuItems .Add(New CActionMenu(m_Actions("Open ..."))) .Item(0).DefaultItem = True .Add(New CActionMenu(m_Actions("Recent"))) With .Item(.Count - 1).MenuItems .Add(New CActionMenu(m_AcsRecent(0))) .Add(New CActionMenu(m_AcsRecent(1))) .Add(New CActionMenu(m_AcsRecent(2))) End With End With m_Toolbar.Buttons.Add(tbn) tbn = New CActionToolbarButton(m_Actions("Undo")) m_Toolbar.Buttons.Add(tbn) ' buttons Dim bn As CActionButton bn = New CActionButton(m_Actions(1)) bn.Visible = True: bn.Left = 10: bn.Top = 40: bn.Width = 60: bn.Height = 20 Me.Controls.Add(bn) bn = New CActionButton(m_Actions(2)) bn.Visible = True: bn.Left = 70: bn.Top = 40: bn.Width = 60: bn.Height = 20 Me.Controls.Add(bn) bn = New CActionButton(m_Actions("Undo")) bn.Visible = True: bn.Left = 130: bn.Top = 40: bn.Width = 60: bn.Height = 20 Me.Controls.Add(bn) ' create statusbar m_Status = New StatusBar m_Status.Dock = DockStyle.Bottom Me.Controls.Add(m_Status) ' form stuff Me.Text = "FCommand" End Sub ' generic execute handler (for testing) Private Function HandleAction(ByVal action As CAction) As Boolean MsgBox(action.Caption) m_Status.Text = Nothing Return True End Function ' handle hints Private Sub HandleHint(ByVal action As CAction) m_Status.Text = action.Hint End Sub ' clear statusbar Protected Overrides Sub OnMenuComplete(ByVal e As System.EventArgs) m_Status.Text = Nothing End Sub ' special handlers Private Function HandleExit(ByVal action As CAction) As Boolean Application.Exit Return True End Function ' these will change several controls Private Function HandleUndo(ByVal action As CAction) As Boolean With action .Caption = "(Undo)" .Hint = "Can't undo right now" .Enabled = False End With Return True End Function Private Function HandleCutCopy(ByVal action As CAction) As Boolean With m_Actions("Paste") .Enabled = True End With End Function
Summary
This pattern, as implemented by NActions, CAction as most generic classes and CActionMenu, CActionButton, and CActionToolbarButton as intermediate classes lets you easily create a user interface where commands are consistent and are guaranteed to reflect status changes. You can extend these classes to take care of icons, too.
Note that the actions here are objects that describe commands in a prototypical way: one action is something that can be executed many times; it's not something like an event or even a transaction. If you need data pertaining to a single invocation of a command, use a different class (derived from System.EventArgs), and pass an instance of it to the Exec event handler (this isn't compatible with the expamle here, but it's shown in the UICore project in the Gregor.NET series).
For the user interface problems discussed here, it is often suggested to employ the command pattern. I disagree with that. The advantages of the command pattern, like undo, are of little relevance to a typical Windows application. The commands one would undo are better handled by the RichTextBox control. You wouldn't want to undo most UI commands if you think about it (Tools/Settings, View/StatusBar, Help/About, File/Exit, etc.).
What's more important, is the problem of inheritance, bloated namespaces, and twisting the problem to fit the solution instead of vice versa. I'm talking about the idea of implementing virtually everything that has to do with the actual execution of a command in another class specific to it, that is, having one class per command. An application's logic should not be twisted because of a narrow-minded hold-on to the choice of a particular design pattern. What this comes down to, practically, is a berserk after-the-fact justification of having created new classes, by throwing into them all sorts of tasks (like UI state updates, which should never be designed top-down), flagrantly ignoring all the rules of event-driven programming. Yes, that is as though developers still got paid by the number of lines of code they stomp out.
Just like an object can representing anything, there's a design pattern for everything, and the command pattern used for user interface commands is a telling (though not the worst) example of object orientation out of control. If the command objects provide the actual implementation (as actual as implementation can get in a high-level language), it really comes down to OO, functional style: different command objects scattered in many files working on the same objects in the program. If we end up modelling functional units (read: functions) with classes, we might as well just use - functions.
There is, though, the event handler signature problem discussed above. And the good news is that neither a Select-Case statement, nor its OO solution (implicit dynamic dispatch over virtual methods), which I don't think is suitable here, are necessary. Delegates provide the flexibility that allows us to follow the most important rule for writing maintainable code: specify things only once, and place what's related togethter. All that's needed is creating an action object, specifying text, icon, hint, and an event handler, which simply forwards a call.
If you need special action objects, you can still inherit from CAction, because you have thus far avoided forking the class. In other words, CAction can be specialized in a way that cannot yet be foreseen. Inheritance is a powerful, but precious tool. All in all, the new Delegate feature in .NET enables a more streamlined approach that doesn't bloat the project namespace. Because delegates - ultimately - use the same technique as virtual methods (that would be function pointers), they can add flexibility at a much cheaper cost.