by Francesco Balena
Reprinted with permission from Visual Basic Programmer's Journal, 1/98, Volume 8, Issue 1, Copyright 1998, Fawcette Technical Publications, Palo Alto, CA, USA. To subscribe, call 1-800-848-5523, 650-833-7100, visit www.vbpj.com, or visit The Development Exchange at www.devx.com
VB5 has so many new features that it's hard to decide which is my favorite. The native compiler is hot, as is the capability to build ActiveX controls. However, one of the features that intrigues me most is the new AddressOf keyword, which opens an entirely unexplored world of callback and subclassing techniques to all VB programmers working on 32-bit Windows platforms.
Subclassing lets you change the default behavior and appearance of VB forms and controls, and accomplish tasks that would otherwise require you to purchase a third-party ActiveX control. Once you understand the subclassing mechanism, you can add icons to menus, gradients to window captions, and new items to text-box control context menus, to name a few possibilities. Essentially, you have nearly no limits in manipulating Windows and VB objects.
First, I'm going to describe basic subclassing techniques and the things you can accomplish with them. Then I'll introduce CFormEvents, a class that relies on these techniques to add new custom events to VB's forms. The source of the class module is available on the free, Registered Level of The Development Exchange (see the Code Online box at the end of the article for more details).
The Windows operating system is heavily based on messages. When you click on a window or a control, Windows sends it a WM_LBUTTONDOWN message, with additional information about where the mouse cursor is and which control keys are currently pressed. Similarly, Windows sends a WM_SIZE message when you resize a window, and a WM_PAINT message when the window needs refreshing.
Messages are not the exclusive domain of the operating system, and you can send a message to a window or a control yourself, using the SendMessage or the PostMessage API function. This technique often offers you new capabilities not directly supported by VB's controls and forms. For instance, you can set the internal left margin of a multiline text-box control to 20 pixels by issuing this command:
SendMessage Text1.Hwnd,EM_SETMARGINS, _
EC_LEFTMARGIN, 20
The first argument is the handle of the window to which the message is addressed; the second argument-usually a symbolic constant-is the numeric value of the message; the third and fourth arguments, traditionally named wParam and lParam, carry any additional information needed by the message-in this case, which margin should be set and its new width, respectively. When more than two values are needed, they are usually gathered in a structure and its address is sent in the lParam argument.
You can also use SendMessage to retrieve a value, such as the number of the first visible line in a multiline text box:
lineIndex = SendMessage(Text1.hWnd, _
EM_GETFIRSTVISIBLELINE, 0, 0)
Although it's easy to send messages, VB applications have never been able to detect when a message is being sent to them. If Windows sends a WM_SIZE message, the VB runtime intercepts it and exposes it to your program in the form of a Form_Resize event. VB deals with many other Windows messages in this way. For instance, the WM_ACTIVATE message fires a Form_Activate event, and WM_PAINT invokes a Form_Paint event.
VB does not transform all messages into events, though. When a window moves, Windows sends it a WM_MOVE message, but VB doesn't offer any Form_Move event (see Figure 1). Many useful Windows messages are processed internally by VB or exposed as events, but many others are not. Those that are not deprive VB programmers of several interesting opportunities.
If you're working with a previous version of VB, you can't do anything but buy a VBX or OCX that supplies these missing capabilities, such as the popular SpyWorks by Desaware. VB5, however, gives you the choice of processing all incoming messages using a subclassing technique.
Subclassing a window or a control simply means intercepting all the messages that are sent to it. Subclassing has several variants:
When you issue a SendMessage or PostMessage API function, Windows calls the so-called window procedure of the target window. This routine is associated with each window and processes all incoming messages. If this window procedure were written in VB, it would look like this:
Function WndProc (ByVal hWnd As Long, _
ByVal uMsg As Long, _
ByVal wParam As Long, _
ByVal lParam As Long) As Long
Select Case uMsg
Case WM_PAINT
' redraw the window
Case WM_ACTIVATE
' activate the window
....
End Select
' return a value to the caller
WndProc = result
End Function
The window procedure receives the same arguments that you specify when you fire the message. Subclassing a window means substituting the original procedure with your own: you then receive all the messages that were directed to the window and decide what to do with each one of them.
Implement Subclassing with AddressOf
VB5 does not offer a direct implementation of subclassing techniques. Instead, it offers only the AddressOf keyword, which returns the address of a VB procedure. Incidentally, this keyword lets you implement callback techniques easily, and you may exploit the many API functions that require an address as one of their arguments, such as EnumWindows or EnumFonts.
It's less apparent how you can make use of the AddressOf keyword to implement subclassing. The trick is to prepare a substitute window procedure in your VB program, then ask Windows to call it. You can achieve this by using the SetWindowLong API function, which sets a value in the internal table related to the specified window:
oldAddress = SetWindowLong(hWnd, _
GWL_WNDPROC, AddressOf MyWndProc)
SetWindowLong not only enforces a new value in the GWL_WNDPROC slot in the internal table, but it also returns the previous value of that item. In this case, this value is the address of the original window procedure. You must store this value in a variable, because you absolutely must restore it before the application ends.
You also have to prepare your modified window procedure. Fortunately, it doesn't have to deal with all the messages sent to the window, because it can pass the messages you aren't interested in to the original window procedure, using the CallWindowProc API function:
Function MyWndProc (_
ByVal hWnd As Long, _
ByVal uMsg As Long, _
ByVal wParam As Long, _
ByVal lParam As Long) As Long
' call the original window procedure
MyProc = CallWindowProc(oldAddress, _
hWnd, uMsg, wParam, lParam)
' detect window movement
If uMsg = WM_MOVE Then
' the window has moved !
End If
End Function
You may take appropriate actions both before and after the call to the original window procedure, and you may even inhibit standard processing by simply omitting the call to CallWindowProc. However, be aware that this action is likely to cause GPFs and system crashes.
As a well-behaved Windows programmer, you should restore everything as it was before you subclassed the window. This means you have to store the address of the original window procedure back in the window's internal table, which you do with another call to the SetWindowLong API function:
SetWindowLong hwnd, GWL_WNDPROC, _
oldAddress
This step is important because if it's omitted, your application might crash unexpectedly. The best place to perform this step is in the Form_Unload event. A word of caution: VB calls this event when the user closes the form using the mouse and when the program issues an Unload command, but does not invoke it when you execute an End statement or the Run-End command in the VB environment. Therefore, you should never quit a program using the End statement, the Run-End menu command, or the corresponding button on the toolbar.
When writing a subclassing procedure, pay attention to your parameters. They must match exactly what Windows expects to find. This includes using the correct value type, using ByVal where required, and returning the expected value. Any error at this point makes the application hang.
An annoying detail is that your custom window procedure must be located in a BAS module, because you can apply the AddressOf operator only to this kind of procedure. This minor issue severely impacts code reuse, because the code in the form module depends on a BAS module, so you can't build self-sufficient FRM modules. Also, you need to ensure you don't accidentally reuse the same custom window procedure in the BAS module for multiple subclassed windows. I'll return to this issue later.
Finally, consider performance. The more code you put into a subclassing routine, the longer it takes to execute. Because Windows calls your custom window procedure every time a single message is sent to the window, you may easily slow down your user interface to an unacceptable degree. Always use native-compiled code for programs that implement subclassing.
Using the CFormEvents class
From this introductory description, you might get the impression that subclassing is an advanced and potentially dangerous technique, and you would be completely right. That's why I decided to encapsulate all the nitty-gritty details of form subclassing into a well-designed class that you can reuse in your applications immediately. My explanation of the CFormEvents class's functioning refers to Windows messages trapped by the class (see Table 1). (You can download a more detailed version of this table on the free, Registered Level of The Development Exchange.)
Message | Hex Value | Description | ||||||||||||||||||||||||||||||
WM_ACTIVATEAPP | &H1C | This message is sent to the active window when the application is being activated (wParam = True) or deactivated (wParam = False).
WM_COMPACTING | &b1 | This message is sent to all top-level windows when the operating system is low on memory.
| WM_DISPLAYCHANGE | &H7E | This message is sent to all top-level windows after the display resolution has changed.
| WM_GETMINMAXINFO | &b4 | This message is sent just before a user operation that affects the
window's size.
| WM_MOVE | &b | This message is sent to a window or a control that has been moved.
| WM_MOVING | &b16 | This message is sent to a window or control while it is being moved.
| WM_NCHITTEST | &H84 | This message is sent to a window (or control) whenever a Mouse
event occurs.
| WM_PAINT | &HF | This message is sent to a window or control to request an update to its client area (similar to the Form_Paint event).
| WM_SETCURSOR | &b0 | This message is sent to a window when a mouse action is performed while the cursor is over it or one of its child windows.
| WM_SIZING | &b14 | This message is sent to a window or control when it is being resized.
| Table 1 Create Better Programs by Handling Messages. The CFormEvents class intercepts these messages and exposes them as regular form events to VB applications. You can download a more detailed version of this table from the free, Registered Level of The Development Exchange.
| |
Dim WithEvents FormX As CFormEvents
You can't use the As New clause in the declaration of a WithEvents object variable, so you must explicitly create the object somewhere else in the code. In this case, the most appropriate place is the Form_Event procedure. After creating the object, you assign the form to be subclassed to its HookedForm property:
Private Sub Form_Load( )
Set FormX = New CFormEvents
Set FormX.HookedForm = Me
End Sub
This assignment starts the subclassing of the current form. Similarly, you can stop the subclassing by assigning Nothing to the HookedForm property. You usually stop the subclassing activity in the Form_Unload event. This step is optional, however, because when the FormX object goes out of scope-when the current form is unloaded-its Class_Terminate event stops the subclassing automatically. This is one of the advantages of using a class to encapsulate the subclassing mechanism.
The CFormEvents class exposes 12 new form events, which you can pick up from the right-most combo box above your code window (see Figure 2). Using these events is as easy as using the standard VB form events, once you know the meaning of each one.
The AppActivate and AppDeactivate events resemble the standard Activate and Deactivate events, but they fire if the current form loses the input focus because the user switched to another application. They're handy when you're working with external ActiveX EXE components and you want to keep them in sync with your main application.
The Move event fires after the user has moved the form. You can use it to move a companion form, or to ensure that the form is always completely visible on the screen. The Sizing and Moving events have similar syntax. They resemble the standard Resize and the custom Move events, respectively, but occur while the program is resizing or moving the form. They give you the opportunity to modify the size and position of the tracking rectangle-the contour of the window that appears when you drag a window's border. Here's the code for the Sizing event:
Private Sub FormX_Resizing(X1 As Long, _
Y1 As Long, X2 As Long, Y2 As Long, _
ByVal draggedBorder As Integer)
End Sub
The last argument passed to the event is a constant whose value tells the program which border or corner is being dragged (see Listing 1). The first four parameters are in pixels and are passed by reference, so you can modify them according to your needs. For instance, you can force the user to preserve a given ratio between the width and the height of the form:
Listing 1
|
Listing 1 Passing Constants. The CFormEvents class passes the WMSZ_* constants to Sizing and Moving custom events to indicate which border size or corner is being dragged. The HT* constants are passed to NCHitTest custom events to indicate which portion of the window has been detected under the mouse cursor. All these symbolic constants are declared in the APIDECLARE.BAS module. |
Private Sub FormX_Resizing(X1 As _
Long, Y1 As Long, X2 As Long, Y2 As _
Long, ByVal draggedBorder As Integer)
' width is twice the height
X2 = X1 + 2 * (Y2 - Y1)
End Sub
Whenever the user starts a move or a size operation, Windows sends the form a WM_GETMINMAXINFO message. The form should respond by stating how large you can resize it, and other related information. VB replies to this message by stating that you can maximize the form to full screen, and that its tracking rectangle can be made arbitrarily small or large. The CFormEvents class exposes a GetMinMaxInfo custom event that lets you override VB's standard behavior:
Private Sub FormX_GetMinMaxInfo(_
MaxSizeX As Long, MaxSizeY As Long, _
MaxPosX As Long, MaxPosY As Long, _
MinTrackSizeX As Long, _
MinTrackSizeY As Long, _
MaxTrackSizeX As Long, _
MaxTrackSizeY As Long)
End Sub
The first four parameters are the size and the position of the maximized form (see Figure 3). The fifth and sixth parameters are the minimum size of the tracking rectangle. The last two parameters are the maximum size of the tracking rectangle. All parameters are passed by reference, so you can modify them as you wish. The CFormEvents class tells the operating system about your changes. For instance, you can prevent the user from shrinking the form smaller than a given size:
Private Sub FormX_GetMinMaxInfo(...
MinTrackSizeX = 300
MinTrackSizeY = 200
End Sub
The Paint custom event cures one severe deficiency of the standard event with the same name: when the operating system sends a WM_PAINT message to a window, the application can call the GetUpdateRect API function to learn the coordinates of the area of the window that must be updated. Unfortunately, this precious information is not available inside the regular Paint event, and VB programs are forced to repaint the entire window. The CFormEvents class solves this problem by passing such coordinates as parameters to the custom Paint event:
Private Sub FormX_Paint(_
X1 As Single, Y1 As Single, _
X2 As Single, Y2 As Single)
' do your optimized repainting
End Sub
Many programmers would like to offer a short description of the control or button under the mouse button. Although it's easy to implement tooltips using VB5's new ToolTipText property, showing a longer description on the status bar is more difficult and requires an iteration on the Controls collection and a lot of code. Instead, you can use the new MouseEnter and MouseExit custom events:
Private Sub FormX_MouseEnter(_
ByVal ctrl As Control)
lblStatus = ctrl.Tag
End Sub
Private Sub FormX_MouseExit(_
ByVal ctrl As Control)
lblStatus = ""
End Sub
The CompactingMemory event informs your application that Windows is short on memory, and that you are kindly requested to release all the memory and other system resources that are not strictly necessary. For instance, you can react to this message by unloading all forms that were loaded in memory but hidden:
Private Sub FormX_CompactingMemory()
Dim frm As Form
For Each frm In Forms
If frm.Visible = False Then
Unload frm
Set frm = Nothing
End If
Next
End Sub
You can also get a notification when the user changes the screen resolution or the number of available colors:
Private Sub FormX_DisplayChanged(_
ByVal newWidth As Integer, _
ByVal newHeight As Integer, _
ByVal numberOfColors As Long)
End Sub
You can respond to this event by resizing the form, reloading other versions of your bitmaps that conform to the new color depth, and so forth.
As you know, users can close a window by clicking on the "x" icon in the upper-right corner, move it by dragging its title bar, and show the system menu by clicking on its upper-left corner. You might believe the operating system knows where these "hot spots" are located, but it doesn't. In fact, it's possible to create windows with a nonstandard title bar and even nonrectangular forms. In this case, how could Windows know where the borders and the corners of the window are?
What actually happens is that the operating system sends a WM_NCHITTEST message to the window, together with the mouse coordinates, and the window returns a code that states which portion of itself has been hit. VB handles this message in the standard way, but you may intercept it and change your window's behavior. This customization is possible thanks to the NCHitTest custom event:
Private Sub FormX_NCHitTest(_
ByVal X As Integer, _
ByVal Y As Integer, hitCode As Long)
End Sub
The first two values are the mouse coordinates in pixels, whereas hitCode is one of the symbolic constants (see Listing 1). This event fires immediately after VB has processed the message, so you can inspect the hitCode value about to be returned to Windows, and modify it if you wish. For instance, you can create forms that cannot be closed with a click on their Close buttons:
If hitCode = HTCLOSE Then
hitCode = NTNOWHERE
End If
Another useful application of this technique lets you provide dragging capabilities for forms without a caption, so the user can move them by clicking anywhere on their client area:
' let the user move this form by
' dragging any point in its client
' area
If hitCode = HTCLIENT Then
hitCode = HTCAPTION
End If
The CFormEvents class is useful in its present form, but you can also expand it to support additional messages and expose even more custom events. However, you must understand how it works.
Identify the Class Instance
Any class that subclasses multiple windows must solve this problem: the actual subclassing code-that is, the custom window procedure that replaces the original one-must be located in a BAS form because only those procedures can be used as arguments to the AddressOf operator. When a message arrives, the window procedure calls a method in the class, and the class decides what to do with the message. The problem is: how can the custom window procedure know which particular instance of the CFormEvents class should be called?
The obvious solution is to maintain an array of user-defined Type structures in the BAS module, where each item stores-among other information-the handle of the window being subclassed, and a reference to the CFormEvents object that must receive the notification when the procedure receives a message:
Private Type TWndInfo
hwnd As Long
objRef As CFormEvents
...
End Type
Dim WinInfo(1 To MAX_WNDPROC) _
As TWndInfo
For each incoming message, the window procedure must scan the WndInfo( ) array looking for the window handle, and then call a method of the corresponding CFormEvents object.
Unfortunately, this simple solution has a couple drawbacks. First, it's inefficient because every message requires a complete scan of the array. To solve this problem, the CFormEvents class uses a pool of custom window procedures:
Function WndProc1(ByVal hWnd As Long, _
ByVal uMsg As Long, _
ByVal wParam As Long, _
ByVal lParam As Long) As Long
WndProc1 = WndProc(1, hWnd, _
uMsg, wParam, lParam)
End Function
Function WndProc2(...) As Long
WndProc2 = WndProc(2, ...)
End Function
' and so on....
All the procedures in the pool call a centralized, generic window procedure that does the actual subclassing job. When your program creates a new CFormEvents instance, that object looks for the first "available" window procedure-the first window procedure that isn't currently allocated to another instance of the class. When the program destroys an instance of the CFormEvents class, that object stops subclassing and marks the corresponding window procedure as "available."
The approach based on multiple window procedures prevents a time-consuming search on the items of the array. Each window procedure "knows" its index and, consequently, knows which object it needs to notify. In fact, the CFormEvents class performs fast and doesn't add sensible overhead to VB forms.
As provided, CFormEvents includes 10 available window procedures, which means you can subclass up to 10 forms at the same time. This should be enough in most cases, but you increase this limit by editing the source code of the HookEvents BAS module.
There's one more problem. Your program destroys a VB object only when you explicitly set all the variables that reference it to Nothing, or when the variables go out of scope. This means that the reference to the object stored in the WndInfo( ) array prevents the termination of the object when the program unloads the subclassed form. Because the Class_Terminate event is where the object restores the address of the original window procedures and deletes the entry in the WndInfo( ) array, this additional reference might become a serious obstacle to the correct usage of the CFormEvents class.
To summarize, the HookEvents BAS module must hold a reference to the CFormEvents object in order to call its methods when the window procedure receives the messages. On the other hand, this second reference prevents the correct termination of the object when the main program destroys its own reference.
Surprisingly, there is no solution to this dilemma if you use a "pure" VB approach, so you're forced to resort to an unorthodox and undocumented technique. This technique is based on object pointers, which are sometimes known as weak object references.
The HookEvent BAS module doesn't store a reference to the class instance into an Object variable; instead, it stores a pointer to the object in a Long variable. The module obtains this address using the undocumented ObjPtr function:
wndInfo(Index).obj_ptr = ObjPtr(obj)
(For more information on the ObjPtr keyword, see Black Belt Programming, "Override ActiveX Controls," in the December 1997 issue of VBPJ.)
Because the BAS module stores a pointer to the object rather than a reference to it, the module doesn't use a Set command, and the object's reference counter is not incremented. As far as VB is concerned, the main program holds the only reference to the object, so when that reference is set to Nothing, VB correctly fires its Class_Terminate event. This is an important step, because in this event procedure, the CFormEvents class clears the entry in the WndInfo( ) array and returns the allocated window procedure back to the pool of available procedures.
However, at some point in the WndProc procedure, you must turn this object pointer into an actual reference to the CFormEvents object. Do this by using a direct memory copy that stores the value of the pointer into an object variable:
Dim obj As CFormEvents
...
CopyMemory obj, wndInfo(ndx).obj_ptr, 4
At this point, the obj variable contains a valid object reference, even if this assignment didn't increment the reference counter of the object it points to. Only Set commands increment and decrement the internal reference counter managed by VB. The obj variable can call methods of the object and query its properties. However, before the object variable goes out of scope, the routine must reset it by manually storing a null value into it using a direct memory copy:
CopyMemory obj, 0&, 4
If you omit this last step, VB finds a non-null value in the object variable, and executes an implicit Set to Nothing command. This operation decreases the reference counter from one to zero, which informs VB that it's time to destroy the CFormEvents object. If this happens, a GPF occurs when the main program eventually references the released instance through its object variable. So it's crucial that the WndProc routine resets the obj local variable before exiting. That's why the routine is protected by an On Error statement.
Because subclassing is powerful and complex, I have provided you with a tool to perform more efficient and safer subclassing of VB forms. But I have just scratched the surface of possibilities for this technique. Experiment with subclassing using the Microsoft Developer Network, or a good reference book for Windows messages, such as Daniel Appleman's excellent book, Visual Basic Programmer's Guide to the Win32 API (Ziff-Davis Press).
And one word of caution: remember to save your code often. Windows is friendly to its end users, but it does not forgive when you play in the minefield of its internal message architecture.
Francesco Balena is editor-in-chief of Visual Basic Journal, the Italian licensee of VBPJ, and cofounder of Software Design, a software firm specializing in VB and VC++ add-ons, training, and consulting. He has written several books in Italian on DOS, QuickBasic, and VB, and is a coauthor of Platinum Edition Using Visual Basic 5 (Que). Contact Francesco at