What you need:
Visual Basic 6.0 |
|
The DynamicMDI sample application I'll show you how to build incorporates all these features into a working framework. The framework is built on a standard Multiple Document Interface (MDI) created originally using VB's application wizard. You'll see how the user can select different document types when creating new windows, and save and load MDI documents representing the different document types.
| |
Figure 1. Three Approaches to a Multiple Document Interface (MDI) Application Click here.
|
The MDI represents one of the most common architectures for Windows applications. An MDI application contains a single MDIForm object, and one or more regular forms that have their MDIChild property set to True (see Figure 1a). Each of these
MDI child forms typically represents a different type of document, and more than one of each document type can be visible at a given time. For example, the Excel MDI environment can manage spreadsheet, chart, and macro sheets. The application's main menu can change depending on the document type, and the main application can load or save individual documents.
Based on the many questions I've received on the subject, it has become apparent to me that many VB programmers want to place the MDI child forms into individual ActiveX DLL servers (see Figure 1b). Such an architecture would offer a number of major benefits. You could update individual documents or document handlers without updating the entire application. You could modify the types of documents the main application could support by adding or removing ActiveX server DLLs from the system without modifying the main application. You could modify the types of documents available to people using the program depending on licensing, user attributes, or any other criteria. You could incrementally add new features to an application by simply shipping out a new ActiveX DLL server with support for a new document type. The only difference is that the individual MDI child forms exist in separate ActiveX DLL servers.
The major flaw of this architecture is simple: It doesn't work. VB doesn't allow MDI child forms
contained inside ActiveX DLLs to load. It reports an MDIForm is unavailable because it expects the MDI-Form to be in the same application as the child forms.
|
Resources |
|
Dan Appleman's Developing COM/ActiveX Components with Visual Basic 6.0:
A Guide to the Perplexed, Sams, ISBN 1-56276-576-0. Includes
in-depth explanations of
all the technologies discussed in this article.
|
So how about standard forms? Is it possible to somehow place a standard form implemented within an ActiveX DLL into a regular MDI child form? Many VB programmers have tried to do so using the SetParent API function, which forces one window to be the parent of another. This approach does work somewhat, but in every case I've heard of, programmers have run into difficulties-regular MDI keyboard processing stops working correctly and sporadic crashes occur. The truth is VB is not designed with this possibility in mind.
I've always been skeptical of techniques not based on documented VB features, or not clearly supported by the underlying operating system. It's difficult enough to make applications work if you follow all the rules. The risks of breaking the rules are too great to contemplate for any serious professional work.
Nevertheless, the idea of dynamic configuration of MDI clients is too good to pass up. Fortunately, VB offers two approaches: a somewhat awkward approach compatible with VB5, and a safe and efficient approach that takes advantage of two of VB6's new features.
A New Use for ActiveX Controls
An awkward, yet fairly reliable, approach is available under VB5. In this design, the main application contains one type of MDI child form used for every type of document. This MDI child form contains a single Internet Explorer (IE) ActiveX control. The control is loaded with ActiveX documents that represent each type of document. While effective, this approach does have serious disadvantages.
The approach imposes all the IE control's overhead on each document window (not to mention the application as a whole). It uses ActiveX documents, which have not been widely adopted. When a technology is not widely adopted, the level of support available for that technology in the form of books, articles, information, and support from Microsoft is relatively poor. A significant chance also exists that the technology will be completely abandoned one day, placing the long-term viability of any application adopting that technology at risk.
The existence of an additional layer (the IE control) between your ActiveX document and the application adds complexity to the entire application, especially when it comes to communication between the main application and your client window software. Nevertheless, this approach points the way to a new strategy that does not suffer from any of these disadvantages. VB6 allows you to dynamically load an ActiveX control onto a form. You can use a generic MDI child form that dynamically loads ActiveX controls that take over the entire client area of the window (see Figure 1c).
This architecture is safe, efficient, and uses widely supported technology. But an architecture is only a start. As you'll see, this approach has far-reaching ramifications on the design of the application, and ultimately demands the use of VB6 features that are still new to many VB programmers.
Loading ActiveX Controls Dynamically
Every VB form contains a Controls property that is a collection of the controls present on the form. VB6 allows you to add new controls to the control collection using its Add method:
Set EmbeddedControl = _
Me.Controls.Add(ControlName, _
NameOfControl)
The full programmatic ID (ProgID) of the control is the first parameter to the Add method. The second parameter contains a string that specifies the value of the Name property for the new control. You can use the Name property later to remove the control from the collection. The Add method returns a reference to the Extender object of the new control. The Extender object contains two sets of properties: the control's properties, and those that VB adds to every control (such as the Left, Top, and Name properties).
Use the Extender object not only to access the properties of the control, but to receive events as well. You should declare this object at the module level of the form:
Dim WithEvents EmbeddedControl As _
VBControlExtender
You'll use a slightly different approach to dynamically load VB intrinsic controls such as the standard Edit or List control. (You can find information on dynamically loading intrinsic controls in the VB documentation.)
The Extender object's ObjectEvent event processes the control's events, as shown here for the DblClick event:
Private Sub _
EmbeddedControl_ObjectEvent( _
Info As EventInfo)
If Info.Name = "DblClick" Then
MsgBox _
"Control was double-clicked"
End If
End Sub
How would you apply the ability to
add controls dynamically to the dynamic MDI architecture? Each MDI child form contains a single dynamically loaded ActiveX control. To keep things simple, the form uses a single module-level VBControlExtender object called "EmbeddedControl." In this example, the control always has the name "Embedded." The MDI child form has a public method called "CreateControl," which takes the control's programmatic ID as a parameter. The top-level MDIForm creates a new MDI child, and the MDIForm passes the MDI child the name of the control that implements the desired document type and passes it to the CreateControl method (see Listing 1).
The CreateControl method first destroys any existing ActiveX control on the form. This allows you to change a given MDI child form's ActiveX control. At first glance, you might think this serves no purpose, but in fact it corresponds to the user opening a new document in an existing window. The CreateControl method next creates the control, makes it visible, and forces a form resize event. The method raises an error if the specified control does not exist, and the parent MDIForm handles this error.
The form resize event resizes the embedded control so it fills the entire client area of the MDIChild form. The On Error statement eliminates errors that occur if the resize event occurs before a control is loaded, or if a control fails to load due to an error.
Designing Private Controls
Developing custom ActiveX controls to handle each document type is a key part of the architecture explained in this article. ActiveX controls are by nature general-
purpose components you can use in multiple applications. This raises two important questions: Are there features you normally implement in an ActiveX control that a control developed for this architecture doesn't require? Can you restrict your ActiveX control so you can't use it outside your MDIForm application? The answer to both questions is, fortunately, yes.
The solution to the first problem follows from one of the limitations of ActiveX controls that are dynamically loaded into a form. ActiveX controls can exist in two modes: design mode and run mode. A control exists in design mode when you place it on a VB form in the design-time environment. Design-mode support typically differs from runtime mode in several ways. The control usually displays different information in design mode-often the name of the control. Controls usually support property pages accessible only in design mode. The code used to implement properties within a control usually handles design and run modes differently. In some cases, you can only set properties in design mode.
When you load a control dynamically, it is loaded in run mode. A control designed for this architecture will be loaded only dynamically, so you don't need to implement many features common to controls. You don't have to implement property
pages, design-time display, or any design-time-related code. In fact, you don't have to implement properties at all. How can a useful control have no properties? The answer to that question follows from the solution to making a control private.
Enforcing Privacy
The controls you use with the approach explained in this article are private in the sense that only your MDI application can use them. But this architecture does require that you implement the controls in separate OCX files, which means they must be public from Windows' point of view-in other words, registered publicly in the system registry. Therefore, you can load the controls into other applications. The trick is to disable all functionality when the controls are loaded in other applications and perhaps display a message stating that the control is not supported in that client. One approach is to disable the control by default and require that the client explicitly turn on the control using some mechanism that only your application knows about.
You could expose a method that the client could call, but methods are visible through the object browser, and the last thing you want is for people to be able to tinker with your control's properties and methods. A better approach is to have your control implement a private interface that only your application and controls know about. This private interface won't appear in the object browser.
Creating a new ActiveX DLL server that contains a class whose properties and methods describe the interface is the easiest way to create a private interface with VB. Don't add any code to the class besides the property and method declarations. You will never ship the objects in this server, or even create objects with this server. The idea is to define an interface that your control can implement.
It's important to think carefully about the properties and methods you wish to include in this interface. It is undesirable, and in most cases dangerous, to change the interface after you deploy a component that uses it. Under this architecture, this private interface is the standard mechanism by which the main MDI application identifies and communicates with the private controls compatible with the application. So be sure you pay adequate attention to the interface design ahead of time (see Listing 2).
The parent application calls the Enable method to tell the control it should enable itself. Only your main MDI application can access this interface and call the Enable property because you never distribute the private interface DLL. The interface also includes methods to help store and retrieve the document data managed by the control, and to allow the control to display a custom menu.
After you finish defining your interface, you should first compile it and set the project to binary compatibility with the newly compiled DLL. This ensures that the low-level class and interface identifiers do not change. You can easily find yourself creating con-trols incompatible with one another and your parent application if you allow these identifiers to change.
What do you do if you discover later that you must make a change to your private interface? Don't. The solution in this case is to create a new class in the private interface project that contains the interface you need. Then modify your MDIForm application to check for both interfaces, supporting older controls on the first interface, and newer controls on the newer interface. But how do the MDIForm and controls use the private interface in the first place?
Most of the communication between your main MDI application and the control will be through the private interface. First, support the new interface in your control by adding a reference to the PrivateInterface DLL that contains the PrivateMDI class object and using the Implements statement:
Implements PrivateInterface.PrivateMDI
A control can restrict its functionality outside your main MDI application (see Listing 3). The Enable method of the PrivateMDI interface sets the m_Enabled variable to True and can only be called from your MDI application. The control can have a separate Enabled property on its default interface, but it is independent of
the one on the private interface. The m_Enabled variable is False and the control's Show event code hides any constituent
controls if the control is placed on a form or container that does not call the private inter-face's Enable method. The control then shows a Label control that contains a warning that the control is not enabled in the environment (see Figure 2).
You must modify the MDI child form when using a private interface. Add a reference in your main MDI project to the PrivateInterface DLL so it too can reference the PrivateMDI interface. A variable that
is declared as the PrivateMDI type lets
you access the control through its PrivateMDI interface. In the form's CreateControl method, add this code
immediately after loading the control onto the form:
Set EmbeddedControl = _
Me.Controls.Add(ControlName, _
"Embedded")
EmbeddedControl.Visible = True
Set PrivateInt = _
EmbeddedControl.object
PrivateInt.Enable
Form_Resize
The Object property of the Embed-dedControl object references the actual control you created instead of the extender. You ask the control for a reference to its PrivateMDI interface when you assign the control to the PrivateInt variable (which is defined as a PrivateMDI type in a module- or function-level Dim statement). Add error handling to deal with this situation in a real application because any attempt to load a control that does not support this interface fails at this point. Once you have that reference, you can call methods and access properties of the interface, effectively using a "back door" to the control that is unique to your application.
Managing Document Types Dynamically
Each control you create corresponds to a document type. Your application needs some method of determining which document types it will support. A simple approach is to store the list of supported document types and associated control programmatic IDs in the system registry or a text file. You can update this information any time you add a new document type to the application. The File | New menu command brings up a list of available documents. The MDI app can call its CreateControl method, passing it the programmatic ID of the selected control when it opens a new MDI child window:
Set frmD = New frmDocument
frmDocList.Show vbModal
On Error GoTo nocontrol
If frmDocList.SelectedProgID <> " _
Then frmD.CreateControl _
frmDocList.SelectedProgID
End If
The frmD variable is loaded with a new MDI child form. The frmDocList form represents a form that displays a list of document types from which the user can choose. Use the child form's CreateControl method to load the appropriate control. You need error handling to handle the case where a control fails to load for a given document type.
Loading and Saving
Documents
Creating new documents is simple. Loading and saving documents is where things get truly interesting. You can choose to store each document type in a separate file type with its own extension, or to use a single file type for all documents and store information in the file to distinguish between document types. The latter approach is easier than it sounds and is used in the DynamicMDI example, which uses files with the extension ".mdi".
The key to making document storage work lies in the way property persistence works in VB6. Visual Basic ActiveX controls have always had the ability to save their state into a PropertyBag. VB6 adds the ability to create PropertyBags and convert them to and from byte arrays.
You might recall that the private interface defined earlier includes its own LoadProperties and SaveProperties
methods. You'll soon see why this is
important. Meanwhile, the UserControl ReadProperties and WriteProperties events are delegated to the private interface implementation (see Listing 4). Why do you need this unusual approach for persisting properties? Why not rely just on the UserControl events? Be patient-this will become clear shortly.
The MDI child form has a method called SaveDocument that obtains a byte array that contains the document data stored with the control. The method creates an empty PropertyBag and uses its WriteProperty method to store the control's object. This causes a single property to be written into the PropertyBag containing a property named "Embedded." This property contains a subobject that consists of the control data written by the control's WriteProperties event, which is raised during the execution of the method:
Public Function SaveDocument() As _
Variant
Dim p As New PropertyBag
p.WriteProperty "EmbeddedControl", _
EmbeddedControl.object, Nothing
SaveDocument = p.Contents
End Function
The PropertyBag's Contents property allows you to retrieve the byte array containing the document information. This array is stored directly into a file by the parent application. When you look into a text version of this information as it is stored in a VB form file, it looks something like this:
Begin FirstMDI.FirstMDIControl Embedded
FirstNumber = 1
SecondNumber = 2
End
Re-Creating Controls
Loading a document is, unfortunately, more complex. The MDI parent application passes a byte array containing the file data to the LoadDocument function on the MDI child form (see Listing 5). The MDI child creates a PropertyBag and loads it with the data using the Contents property. The function then calls the ReadProperty event and reads the Embedded property into an object that references the PrivateMDI interface (the private interface defined earlier). This object references a newly created control object with all its properties loaded through its internal ReadProperties event. What type of control is it? The same type you originally stored in the file! The definition of control type (and thus document type) was stored in the PropertyBag during the original WriteProperty call.
Now, here's a question for you: Can you use this newly created control? The answer is no! Remember, you dynamically load a control onto a form by using the Add method of the Controls collection. This method requires the programmatic ID of the control, but has no provision for adding an existing control object. You must retrieve the programmatic ID of the control and create a new control of the correct type. You can retrieve the programmatic ID of a control using the ProgID method of the control that you loaded from the PropertyBag (the ProgID function is part of the PrivateMDI interface). You transfer information from the existing control into a newly created empty control by creating a new PropertyBag and storing the data from the existing control into the PropertyBag, then reading the data into the new control. In this case, you use the StoreProperties and LoadProperties methods of the private interface for each control. This way, you only transfer the control's internal data, bypassing the mechanism by which a control stores its type information. This also explains why it is necessary to support two different, but related, property storage mechanisms (see Figure 3). The approach is somewhat inefficient, but it does work.
Menu Management and Other Extensions
Using a private interface allows you to
define functionality common to all the
controls your application supports. For example, your document can define custom menus. The MDI child form calls the
GetMenuEntry function to obtain a list of menu strings (see Listing 6). The MDI parent form obtains this information from the MDI child form and places it on the menu. When a user clicks on a custom menu item, the command is sent to the MDI child form and then to the MenuClicked method of the private interface. The MDI child form notifies the parent form when it is activated so the MDI form can update its menus based on the current document type. You can extend this simple example to build more complex menus.
You can also use the private interface to send common data to the control. For example, the PrivateMDI interface includes the SetUser method, which allows the parent application to define a user name. For example, the user name can be Reader, and can be displayed in two radically different document types (see Figure 4).
When you combine the ability to load controls dynamically, the ability to create PropertyBags, and the Implements statement, entire new application architectures become possible.
Dan Appleman is the president of Desaware Inc., a developer of software components and add-on products for Visual Basic and other tools. He is the best-selling author of Dan Appleman's Visual Basic Programmer's Guide to the Win32 API, as well as a cofounder and editorial director of APress, a computer book publishing company. You can reach Dan by e-mail at dan@desaware.com.
|