Tailor Your Application with a Custom Forms Designer in .NET 参考:http://msdn.microsoft.com/en-us/magazine/cc163871.aspx
Tailor Your Application by Building a Custom Forms Designer with .NET
Sayed Y. Hashimi
This article discusses: Design-time environment fundamentals Forms designer architecture The implementation of the forms designer in Visual Studio .NET Services you need to implement to write a forms designer for your own application
Code download available at: CustomFormsDesigner.exe (157 KB) Browse the Code Online
Contents Some Windows Forms Basics Services and Containers Let's Build a Forms Designer Implementing Services Designer Host Designer Transactions Interfaces Tracking Designer Verbs and Showing Context Menus ITypeDescriptorFilterService Putting It All Together Debugging the Project Conclusion
For many years, MFC has been a popular framework for building Windows®-based applications. MFC includes a forms designer to make form building, event wiring, and other form-based programming tasks easier. Even though MFC is widely used, it has often been criticized for its shortcomings, most in areas that the Microsoft® .NET Framework addressed. As a matter of fact, the extensible and pluggable design-time architecture of Windows Forms in the .NET Framework has made development a whole lot more flexible than it had been with MFC.
With Windows Forms, for example, you can drag one of your custom controls from the toolbox and drop it onto the Visual Studio® design surface. Amazingly, Windows Forms knows nothing about the control, yet it's able to host it and let you manipulate its properties. You couldn't do any of that in MFC.
In this article, I'll discuss what's happening under the covers as you design your forms. Then I'll show you how to build your own bare-bones forms designer that allows your users to create a form in a manner similar to the way they do it using the forms designer in Visual Studio. In order to do that, you'll need to understand exactly what functionality is provided by the .NET Framework.
Some Windows Forms Basics
Before I get underway with the project, there are several basic concepts that are important to understand. Let's begin with the definition of a designer. A designer provides the design-mode UI and behavior of a component. For example, when you drop a button on a form, the button's designer is the entity that determines the appearance and behavior of the button. The design-time environment provides a forms designer and a properties editor to allow you to manipulate components and build user interfaces. The design-time environment also provides services that can be used to interact with, customize, and extend design-time support.
A forms designer provides design-time services and a facility for developers to design forms. The designer host works with the design-time environment to manage designer state, activities (such as transactions), and components. In addition, there are several concepts relating to the components themselves that are important to comprehend. For example, a component is disposable, can be managed by a container, and offers a Site property. It obtains these characteristics by implementing IComponent, as shown here:
public interface System.ComponentModel.IComponent : IDisposable
{
ISite Site { get; set; }
public event EventHandler Disposed;
}
The IComponent interface is the fundamental contract between the design-time environment and an element to be hosted on a design surface (such as the Visual Studio forms designer). For example, a button can be hosted on the Windows Forms designer because it implements IComponent.
The .NET Framework implements two types of components: visual and nonvisual. A visual component is a user interface element, such as a Control, and a nonvisual component is one without a user interface, such as one that creates a SQL Server™ database connection. The Visual Studio .NET forms designer distinguishes between visual and nonvisual components when you drag and drop components onto the design surface. Figure 1 shows an example of this distinction.
Figure 1 Visual and Nonvisual Components
A container contains components and allows contained components to access each other. When a container manages a component, the container is responsible for disposing the component when the container gets disposed—a good idea because a component may use unmanaged resources, which are not automatically disposed of by the garbage collector. Container implements IContainer, which is nothing more than a few methods that let you add and remove components from the container:
public interface IContainer : IDisposable
{
ComponentCollection Components { get; }
void Add(IComponent component);
void Add(IComponent component, string name);
void Remove(IComponent component);
}
Don't let the simplicity of the interface fool you. The concept of a container is critical at design time and is useful in other situations as well. For example, you've certainly written business logic that instantiates several disposable components. This generally takes the following form:
using(MyComponent a = new MyComponent())
{
// a.do();
}
using(MyComponent b = new MyComponent())
{
// b.do();
}
using(MyComponent c = new MyComponent())
{
// c.do();
}
Using a Container object, these lines are reduced to the following:
using(Container cont = new Container())
{
MyComponent a = new MyComponent(cont);
MyComponent b = new MyComponent(cont);
MyComponent c = new MyComponent(cont);
// a.do();
// b.do();
// c.do();
}
There is more to a container than the automatic disposal of its components. The .NET Framework defines what is called a site, which is related to a container and a component. The relationship between the three is shown in Figure 2. As you can see, a component is managed by exactly one container and each component has exactly one site. When building a forms designer, the same component cannot appear on more than one design surface. However, multiple components can be associated with the same container.
Figure 2 Relationships
A component's lifecycle can be controlled by its container. In return for lifetime management, a component gains access to the services provided by the container. This relationship is analogous to a COM+ component living inside the COM+ container. By allowing the COM+ container to manage it, the COM+ component can participate in transactions and use other services provided by the COM+ container. In a design-time context, the relationship between a component and its container is established through a site. When you place a component on a form, the designer-host creates a site instance for the component and its container. When this relationship is established, the component has been "sited" and uses its ISite property to get to the services provided by its container.
Services and Containers
When a component allows a container to take ownership of it, the component gains access to the services provided by that container. A service, in this context, can be thought of as a function that has a well-known interface, can be obtained from a service provider, is stored in service containers, and is addressable by its type.
Service providers implement IServiceProvider as shown here:
public interface IServiceProvider
{
object GetService(Type serviceType);
}
Clients obtain a service by supplying to the service provider's GetService method the type of the service they want. Service containers act as a repository for services and implement IServiceContainer, thus providing a means of adding and removing services. The following code shows the definition of IServiceContainer. Note that the service definition simply contains methods to add and remove a service.
public interface IServiceContainer : IServiceProvider
{
void AddService(Type serviceType,ServiceCreatorCallback callback);
void AddService(Type serviceType,ServiceCreatorCallback callback,
bool promote);
void AddService(Type serviceType, object serviceInstance);
void AddService(Type serviceType, object serviceInstance,
bool promote);
void RemoveService(Type serviceType);
void RemoveService(Type serviceType, bool promote);
}
Because a service container can store and retrieve services, they are also considered service providers and as such implement IServiceProvider. The combination of services, service providers, and service containers form a simple design pattern that has many advantages. For example, the pattern:
- Creates loose couplings between the client components and the services they use.
- Creates a simple service repository and discovery mechanism, which allows an application (or parts of applications) to scale well. You can build your application with exactly the necessary parts, then add additional services later without making any drastic changes to your application or module.
- Provides the facility to implement lazy loading of services. The AddService method is overloaded to create services the first time they are queried.
- Can be used as an alternative to static classes.
- Promotes contract-based programming.
- Can be used to implement a factory service.
- Can be used to implement a pluggable architecture. You can use this simple pattern to load plug-ins and provide services to plug-ins (such as logging and configuration).
The design-time infrastructure uses this pattern quite extensively so it's important to understand it thoroughly.
Let's Build a Forms Designer
Now that you understand the basic concepts behind the design-time environment, I'll build upon them by examining the architecture of the forms designer (see Figure 3).
Figure 3 Forms Designer Architecture
At the heart of the architecture sits the component. All other entities, directly or indirectly, work with components. The forms designer is the glue that connects the other entities. Forms designers use a designer host to obtain access to the design-time infrastructure. The designer host uses design-time services and provides some of services of its own. Services can, and often do, use other services.
The .NET Framework does not expose the forms designer in Visual Studio .NET because that implementation is application-specific. Even though the actual interfaces are not exposed, the design-time framework is there. All you have to do is provide implementations specific to your forms designer and then submit your version to the design-time environment to use.
My sample forms designer is shown in Figure 4. Like every forms designer, it has a toolbox for users to select tools or controls, a design surface to build forms, and a properties grid to manipulate component properties.
Figure 4 Custom Forms Designer Sample
First I'll build the toolbox. Before I do, however, I need to decide how I want to present tools to the user. Visual Studio .NET has a navigation bar with several groups, each of which contains tools. To build the toolbox, you must do the following:
- Create a user interface that displays tools to the user
- Implement the IToolboxService
- Plug the IToolboxService implementation into the design-time environment
- Handle events, such as tool selection and drag and drop
For any real-world application, building the toolbox user interface can be quite time consuming. The first design decision you have to make is how to discover and load the tools, and there are several viable approaches. With the first method, you can hardcode the tools to be displayed. This is not recommended unless your application is extremely simple and very little maintenance will be required in the future.
The second method involves reading the tools from a configuration file. For example, the tools could be defined as follows:
<Toolbox>
<ToolboxItems>
<ToolboxItem DisplayName="Label"
Image="ResourceAssembly,Resources.LabelImage.gif"/>
<ToolboxItem DisplayName="Button"
Image="ResourceAssembly,Resources.ButtonImage.gif"/>
<ToolboxItem DisplayName="Textbox"
Image="ResourceAssembly,Resources.TextboxImage.gif"/>
</ToolboxItems>
</Toolbox>
This method has the advantage that you can add or subtract tools and not have to recompile code to alter the tools shown in the toolbox. Additionally, the implementation is fairly simple. You implement a section handler to read the Toolbox section and return a list of ToolboxItems.
The third approach is to create a class for each tool and decorate the class with an attribute that encapsulates things like display name, group, and bitmap. At startup, the application loads a set of assemblies (from a well-known location, as specified in a configuration file, or something similar), and then looks for types with a particular decoration (such as ToolboxAttribute). Types that have this decoration are loaded into the toolbox. This method is probably the most flexible and allows for great tool discovery through reflection, but it also requires a bit more work. In my sample application, I use the second method.
The next important step is to obtain toolbox images. You could spend days trying to create your own toolbox images, but it would be very handy to somehow access the toolbox images in the Visual Studio .NET toolbox. Luckily, there is a way to do that. Internally, the Visual Studio .NET toolbox is loaded using a variation of the third method. This means that the components and controls are decorated with an attribute (ToolboxBitmapAttribute) that defines where to obtain an image for the component or control.
In the sample application, the toolbox contents (groups and items) are defined in the application configuration file. To load the toolbox, a custom section handler reads the Toolbox section and returns a binding class. The binding class is then passed to the LoadToolbox method of the TreeView control that represents the toolbox, as shown in Figure 5.
Figure 5 Loading the Toolbox
public void LoadToolbox(FDToolbox tools)
{
// Clear out existing nodes and images associated with the tree
toolboxView.Nodes.Clear();
if (treeViewImgList != null) treeViewImgList.Dispose();
treeViewImgList = new ImageList(this.components);
// Two images are always used for category nodes and the pointer node
treeViewImgList.Images.Add(requiredImgList.Images[0]);
treeViewImgList.Images.Add(requiredImgList.Images[1]);
// Assign ImageList to the TreeView
toolboxView.ImageList = treeViewImgList;
// If I have categories...
if(tools!=null && tools.FDToolboxCategories!=null &&
tools.FDToolboxCategories.Length>0)
{
foreach(Category cat in tools.FDToolboxCategories)
{
LoadCategory(cat);
}
}
}
private void LoadCategory(Category cat)
{
// If I have items in the category...
if(cat!=null && cat.FDToolboxItem!=null && cat.FDToolboxItem.Length>0)
{
// Create a node for the category
TreeNode catNode = new TreeNode(cat.DisplayName);
catNode.ImageIndex = 0;
catNode.SelectedImageIndex = 0;
// Add this category to the tree
toolboxView.Nodes.Add(catNode);
// Every category gets the selection tool node
AddSelectionNode(catNode);
foreach(FDToolboxItem item in cat.FDToolboxItem)
{
LoadItem(item,catNode);
}
}
}
private void LoadItem(FDToolboxItem item,TreeNode cat)
{
if(item!=null && item.Type!=null && cat!=null)
{
// Load the type
Type toolboxItemType = Type.GetType(item.Type);
ToolboxItem toolItem = new ToolboxItem(toolboxItemType);
// Get the image for the item and create a node for it
Image img = GetItemImage(toolboxItemType);
TreeNode nd = new TreeNode(toolItem.DisplayName);
nd.Tag = toolItem;
// Add the item's bitmap to the image list
if(img!=null)
{
treeViewImgList.Images.Add(img);
nd.ImageIndex = treeViewImgList.Images.Count-1;
nd.SelectedImageIndex = treeViewImgList.Images.Count-1;
}
// Add this node to the category node
cat.Nodes.Add(nd);
}
}
private Image GetItemImage(Type type)
{
// Get the AttributeCollection for the given type and
// find the ToolboxBitmap attribute
AttributeCollection attrCol=TypeDescriptor.GetAttributes(type,true);
if(attrCol!=null)
{
ToolboxBitmapAttribute toolboxBitmapAttr =
(ToolboxBitmapAttribute)attrCol[typeof(ToolboxBitmapAttribute)];
if(toolboxBitmapAttr!=null)
{
return toolboxBitmapAttr.GetImage(type);
}
}
return null;
}
The LoadItem method creates a ToolboxItem instance for the given type and then calls GetItemImage to get the image associated with that type. The method gets the attribute collection for the type to find the ToolboxBitmapAttribute. If it finds the attribute, it returns the image so it can be associated with the newly created ToolboxItem. Note that the method uses the TypeDescriptor class, a utility class in the System.ComponentModel namespace that is used to obtain attribute and event information for a given type.
Now that you know how to build the toolbox user interface, the next step is to implement IToolboxService. Since this interface is tied directly to the toolbox, it's handy to just implement this interface in the TreeView-derived class. Most of the implementation is straightforward, but you do need to pay special attention to how you handle drag and drop operations and to how you serialize toolbox items (see the toolboxView_MouseDown method in the ToolboxService implementation in the code download for this article, available from the MSDN®Magazine Web site). The final step in the process is to hook the service implementation into the design-time environment, which I'll demonstrate how to do after I discuss how to implement the designer host.
Implementing Services
The forms designer infrastructure is built on top of services. There are a set of services that you're required to implement, and then there are some that simply enhance the functionality of the forms designer if you implement them. This is an important aspect of the services pattern I talked about earlier, as well as of the forms designer. You can start by implementing the base set and then add additional services later.
The designer host is the hook into the design-time environment. The design-time environment uses the host service to create new components when users drag and drop components from the toolbox, to manage designer transactions, to look up services when users manipulate components, and so on. The host service definition, IDesignerHost, defines methods and events. In the host implementation, you provide implementations for the host service along with several other services. These should include IContainer, IComponentChangeService, IExtenderProviderService, ITypeDescriptionFilterService, and IDesignerEventService.
Designer Host
The designer host is the nucleus of the forms designer. When the constructor of the host is called, the host uses the parent service provider (IServiceProvider) to construct its service container. It is very common to chain providers in this manner in order to achieve a trickle-down effect. After the service container is created, the host adds its own services to the provider, as shown in Figure 6.
Figure 6 Adding Services to the Provider
public DesignerHostImpl(IServiceProvider parentProvider)
{
// Append to the parentProvider...
serviceContainer = new ServiceContainer(parentProvider);
loggerService = parentProvider.GetService(
typeof(IServiceRequestLogger)) as IServiceRequestLogger;
// Site name to ISite mapping
sites = new Hashtable(CaseInsensitiveHashCodeProvider.Default,
CaseInsensitiveComparer.Default);
// Component to designer mapping
designers = new Hashtable();
// List of extender providers
extenderProviders = new ArrayList();
// Create transaction stack
transactions = new Stack();
// Services
serviceContainer.AddService(typeof(IDesignerHost), this);
serviceContainer.AddService(typeof(IContainer), this);
serviceContainer.AddService(typeof(IComponentChangeService), this);
serviceContainer.AddService(typeof(IExtenderProviderService), this);
serviceContainer.AddService(typeof(IDesignerEventService), this);
serviceContainer.AddService(
typeof(INameCreationService), new NameCreationServiceImpl(this));
serviceContainer.AddService(
typeof(ISelectionService), new SelectionServiceImpl(this));
serviceContainer.AddService(
typeof(IMenuCommandService), new MenuCommandServiceImpl(this));
serviceContainer.AddService(
typeof(ITypeDescriptorFilterService),
new TypeDescriptorFilterServiceImpl(this));
}
When a component is dropped onto the design surface, it needs to be added to the host's container. Adding a new component is a fairly involved operation because you have to perform several checks and fire off some events as well (see Figure 7).
Figure 7 Add Components
public void Add(IComponent component, string name)
{
if (component == null) throw new ArgumentException("component");
// If I don't have a name, create one
if (name == null || name.Trim().Length == 0)
{
// I need the naming service
INameCreationService nameCreationService = GetService(
typeof(INameCreationService)) as INameCreationService;
if(nameCreationService==null)
throw new Exception("Failed to get INameCreationService.");
name = nameCreationService.CreateName(this,component.GetType());
}
// If I own the component and the name has changed, rename it
if (component.Site != null && component.Site.Container == this &&
name != null && string.Compare(name,component.Site.Name,true)!=0)
{
component.Site.Name=name;
return;
}
// Create a site for the component and associate it
ISite site = new SiteImpl(component, name, this);
component.Site = site;
// Fire necessary events
ComponentEventArgs evtArgs = new ComponentEventArgs(component);
ComponentEventHandler compAdding = ComponentAdding;
if (compAdding != null)
{
try
{
compAdding(this, evtArgs);
}
catch{}
}
// if this is the root component
IDesigner designer = null;
if(rootComponent == null)
{
// Set the root component
rootComponent = component;
// Create the root designer
rootDesigner = (IRootDesigner)TypeDescriptor.CreateDesigner(
component,typeof(IRootDesigner));
designer = rootDesigner;
}
else
{
designer = TypeDescriptor.CreateDesigner(
component,typeof(IDesigner));
}
// Add the designer to the list and initialize it
designers.Add(component,designer);
designer.Initialize(component);
// Add to container component list
sites.Add(site.Name,site);
compAdding = ComponentAdding;
if (compAdding != null)
{
try
{
compAdding(this, evtArgs);
}
catch{}
}
}
If you overlook the checks and events, you can summarize the addition algorithm as follows. First, a new IComponent is created for the type and a new ISite is created for the component. This establishes the site-to-component association. Note that the site's constructor accepts a designer host instance. The site constructor takes the designer host and the component so that it can establish the component-container relationship shown in Figure 2. Then the component designer is created, initialized, and added to the component-to-designer dictionary. Finally, the new component is added to the designer host container.
Removing a component requires a bit of cleanup. Again, overlooking simple checks and validation, the remove operation amounts to removing the designer, disposing the designer, removing the component's site, and then disposing the component.
Designer Transactions
The concept of designer transactions is analogous to database transactions since they both group a sequence of operations in order to treat the group as a unit of work and enable a commit/abort mechanism. Designer transactions are used throughout the design-time infrastructure to support the canceling of operations and to enable views to delay updates of their displays until the entire transaction is completed. The designer host provides the facility to manage designer transactions through the IDesignerHost interface. Managing transactions is not very difficult (see DesignerTransactionImpl.cs in the sample application).
DesignerTransactionImpl represents a single operation in a transaction. When the host is asked to create a transaction, it creates an instance of DesignerTransactionImpl to manage the single change. The host tracks the transactions while instances of DesignerTransactionImpl manage each change. If you don't implement transaction management, you will get some interesting exceptions while you work with the forms designer.
Interfaces
As I've said, components are put into containers for the purpose of lifetime management and to provide them with services. The designer host interface, IDesignerHost, defines methods to create and remove components, so you shouldn't be surprised if the host offers this service. Again, the container service defines methods to add and remove components and these overlap with the CreateComponent and DestroyComponent methods of IDesignerHost. Therefore, most of the heavy lifting is done in the add and remove methods of the container, and the create and destroy methods simply forward the calls to these methods.
IComponentChangeService defines component change, add, remove, and rename events. It also defines methods for component changed and changing events, which are called by the design-time environment when a component is changing or has changed (such as when a property is changed). This service is offered by the designer host because components get created and destroyed through the host. In addition to creating and destroying components, the host also handles component rename operations through the create method. The rename logic is simple, yet interesting:
// If I own the component and the name has changed, rename the component
if (component.Site != null && component.Site.Container == this &&
name != null && string.Compare(name,component.Site.Name,true) != 0)
{
// name validation and component changing/changed events are
// fired in the Site.Name property so I don't have
// to do it here...
component.Site.Name=name;
return;
}
The implementation of this interface is simple enough that you can defer the rest to the sample application.
ISelectionService deals with component selections on the design surface. When users select components, the SetSelectedComponents method is called by the design-time environment with the selected components. The implementation of SetSelectedComponents is shown in Figure 8.
Figure 8 SetSelectedComponents
public void SetSelectedComponents(
ICollection components, SelectionTypes selectionType)
{
// fire changing event
EventHandler selChange = SelectionChanging;
if (selChange != null)
{
try
{
selChange(this, EventArgs.Empty);
}
catch{}
}
if (components == null) components = new ArrayList();
bool ctrlDown=false,shiftDown=false;
// I need to know if shift or ctrl is down on clicks
if ((selectionType & SelectionTypes.Click) == SelectionTypes.Click)
{
ctrlDown = ((Control.ModifierKeys & Keys.Control)==Keys.Control);
shiftDown = ((Control.ModifierKeys & Keys.Shift)==Keys.Shift);
}
if (selectionType == SelectionTypes.Replace)
{
// Discard the hold list and go with this one
selectedComponents = new ArrayList(components);
}
else
{
if (!shiftDown && !ctrlDown && components.Count == 1 &&
!selectedComponents.Contains(components))
{
selectedComponents.Clear();
}
// Something was either added to the selection or removed
IEnumerator ie = components.GetEnumerator();
while(ie.MoveNext())
{
IComponent comp = ie.Current as IComponent;
if(comp!=null)
{
if (ctrlDown || shiftDown)
{
if (selectedComponents.Contains(comp))
{
selectedComponents.Remove(comp);
}
else
{
// Put it back into the front because it was
// the last one selected
selectedComponents.Insert(0,comp);
}
}
else
{
if (!selectedComponents.Contains(comp))
{
selectedComponents.Add(comp);
}
else
{
selectedComponents.Remove(comp);
selectedComponents.Insert(0,comp);
}
}
}
}
}
// fire changed event
selChange = SelectionChanging;
if (selChange != null)
{
try
{
selChange(this, EventArgs.Empty);
}
catch{}
}
}
The selection service tracks component selections on the designer surface. Other services, such as IMenuCommandService, use this service when they need to obtain information about selected components. In order to provide this information, the service maintains an internal list that represents the currently selected components. The design-time environment calls SetSelectedComponents with a collection of components when a change has been made to the selection of components. For example, if a user selects one component and then holds down the shift key and selects another three, the method is called for each addition to the selection list. Each time the method is called, the design-time environment tells us which components are affected and how (via the SelectionTypes enumeration). The implementation looks at how the components were changed in order to determine whether components need to be added to or removed from the internal selected list. After modifying the internal selection list, I fire the Selection Changed event (see method selectionService_SelectionChanged in SelectionServiceImpl.cs) so that the properties grid can be updated with the new selections. The application's main form, MainWindow, subscribes to the selection service's selection changed events in order to update the properties grid with the selected component(s).
Also note that the selection service defines a PrimarySelection property. The primary selection is always set to the last item selected. I'll use this property in my discussion of IMenuCommandService when I talk about showing the correct designer context menu.
The selection service is one of the more difficult services to implement correctly because it has some valuable features that complicate the implementation. For example, in a real-world application it makes sense to handle keyboard events, such as Ctrl+A, and also manage issues around handling a large selection list.
The ISite implementation is one of the more important implementations and is shown in Figure 9.
Figure 9 The ISite Implementation
public class SiteImpl : ISite, IDictionaryService
{
private IComponent component;
private string name;
private DesignerHostImpl host;
private DictionaryServiceImpl dictionaryService;
public SiteImpl(IComponent comp, string name, DesignerHostImpl host)
{
if(comp==null) throw new ArgumentException("comp");
if(host==null) throw new ArgumentException("host");
if(name==null || name.Trim().Length==0)
throw new ArgumentException("name");
component = comp;
this.host = host;
this.name = name;
// Create a dictionary service for this site
dictionaryService = new DictionaryServiceImpl();
}
public object GetService(Type service)
{
// Forward request to the host
if (service == typeof(IDictionaryService)) return this;
return host.GetService(service);
}
public object GetKey(object value)
{
return dictionaryService.GetKey(value);
}
public object GetValue(object key)
{
return dictionaryService.GetValue(key);
}
public void SetValue(object key, object value)
{
dictionaryService.SetValue(key,value);
}
}
You'll notice that the SiteImpl also implements IDictionaryService, which is a bit unusual because all of the other services I implemented were tied to the designer host. It turns out that the design-time environment requires you to implement IDictionaryService for every sited component. The design-time environment uses the IDictionaryService on every site to maintain a data table that is used throughout the designer framework. Another thing to note about the site implementation is that since ISite extends IServiceProvider, the class provides an implementation for GetService. The designer framework calls this method when it's looking for a service implementation on a site. If the service request is for IDictionaryService, the implementation simply returns itself, the SiteImpl. For all other services, the request is forwarded to the site's container (for example, the host).
Every component has to have a unique name. As you drag and drop components from the toolbox onto the design surface, the design-time environment uses an implementation of INameCreationService to generate each component's name. The component's name is the Name property that is displayed in the properties window when the component is selected. The definition of the INameCreationService interface is shown here:
public interface INameCreationService
{
string CreateName(IContainer container, Type dataType);
bool IsValidName(string name);
void ValidateName(string name);
}
In the sample application, the CreateName implementation uses the container and dataType to calculate a new name. In a nutshell, the method counts the number of components whose type is equivalent to dataType and then uses that count with the dataType to come up with a unique name.
The services discussed so far have all dealt with components, either directly or indirectly. The menu command service, on the other hand, is specific to designers. It is responsible for tracking menu commands and designer verbs (actions), and showing the correct context menu when a user chooses a particular designer.
The menu command service handles the tasks of adding, removing, finding, and executing menu commands. In addition, it defines methods to track designer verbs and to show designer context menus for designers that support them. The core of this implementation lies in showing the correct context menu. As such, I'll defer what little implementation is left to the sample application and instead focus on how to show context menus.
Tracking Designer Verbs and Showing Context Menus
There are two types of designer verbs: global and local. Global verbs exist for all designers and local verbs are specific to each designer. An example of a local verb can be seen when you right-click a tab control on the design surface (see Figure 10).
Figure 10 The Design Surface
Right-clicking a tab control adds local verbs that allow you to add and remove tabs on the control. An example of a global verb can be seen in the Visual Studio forms designer when you right-click anywhere on the design surface. Regardless of where and on what object you click, you always see two menu items: View Code and Properties. Each designer has a Verbs property that contains the verbs representing functionality specific to that designer. For the tab control designer, for example, the verbs collection contains two members: Add Tab and Remove Tab.
When a user right-clicks a tab control on the design surface, the design-time environment calls the ShowContextMenu method on the IMenuCommandService (see Figure 11).
Figure 11 Handling Context Menus on the Design Surface
public void ShowContextMenu(
System.ComponentModel.Design.CommandID menuID, int x, int y)
{
ISelectionService selectionService = host.GetService(
typeof(ISelectionService)) as ISelectionService;
// Get the primary component
IComponent primarySelection =
selectionService.PrimarySelection as IComponent;
if (lastSelectedComponent != primarySelection)
{
// Remove all non-global menu items from the context menu
ResetContextMenu();
// Get the designer
IDesigner designer = host.GetDesigner(primarySelection);
// Not all controls need a designer
if(designer!=null)
{
// Add menu items for designer's verbs
DesignerVerbCollection verbs = designer.Verbs;
foreach (DesignerVerb verb in verbs)
{
CreateAndAddLocalVerb(verb);
}
}
}
// I only show designer context menus for controls
if(primarySelection is Control)
{
Control comp = primarySelection as Control;
Point pt = comp.PointToScreen(new Point(0, 0));
contextMenu.Show(comp, new Point(x - pt.X, y - pt.Y));
}
// Keep the selected component for next time
lastSelectedComponent = primarySelection;
}
This method is responsible for showing the context menu for the selected object's designer. As you can see in Figure 11, the method gets the selected component from the selection service, obtains its designer from the host, gets the verbs collection from the designer, and then adds a menu item to the context menu for each verb. After the verbs have been added, the context menu is shown. Note that when you create new menu items for designer verbs, you also attach a click handler for the menu item. The custom click handler handles clicking events for all menu items (see the method MenuItemClickHandler in the sample application).
When a user selects a menu item from a designer context menu, the custom handler is called to execute the verb associated with the menu item. In the handler, you retrieve the verb associated with the menu item and invoke it.
ITypeDescriptorFilterService
I mentioned earlier that the TypeDescriptor class is a utility class that is used to obtain information about a type's properties, attributes, and events. The ITypeDescriptorFilterService can filter this information for sited components. The TypeDescriptor class uses ITypeDescriptorFilterService when it attempts to return a sited component's properties, attributes, and/or events. Designers who want to modify the metadata available to the design-time environment for the component they are designing can do so by implementing IDesignerFilter. The ITypeDescriptorFilterService defines three methods that allow designer filters to hook into and modify a sited component's metadata. Implementing ITypeDescriptorFilterService is simple and intuitive (see TypeDescriptorFilterService.cs in the sample application).
Putting It All Together
If you've taken a look at the sample application and run the forms designer, you may be wondering how all of the services have come together. You can't build the forms designer incrementally—that is, you can't implement one service, test the application, and then write another service. You have to implement all of the required services, build the user interface, and tie them all together before you can test the application. That's the bad news. The good news is that I have done most of the work in the services I have implemented. All that's left to do is a bit of crafting.
To start, take a look at the CreateComponent method of the designer host. When creating a new component, it's important to see if it's the first component (if rootComponent is null). If it is the first component, you have to create a specialized designer for the component. The specialized base designer is an IRootDesigner because the top most designer in the designer hierarchy has to be an IRootDesigner (see Figure 12).
Figure 12 Creating the Root Designer
// If this is the root component
IDesigner designer = null;
if(rootComponent==null)
{
// set the root component and create the root designer
rootComponent = component;
rootDesigner = (IRootDesigner)TypeDescriptor.CreateDesigner(
component,typeof(IRootDesigner));
designer = rootDesigner;
}
else designer = TypeDescriptor.CreateDesigner(
component,typeof(IDesigner));
designers.Add(component,designer);
designer.Initialize(component);
Now that you know that the first component has to be the root component, how do you ensure that the right component is the first? The answer is that the design surface ends up being the first component because you create this control, as a Form, during the main window initialization routine (see Figure 13).
Figure 13 Initializing the Forms Designer
private void InitWindow()
{
serviceContainer = new ServiceContainer();
// create host
host = new DesignerHostImpl(serviceContainer);
AddBaseServices();
Form designSurfaceForm = host.CreateComponent(
typeof(Form),null) as Form;
// Create the forms designer now that I have the root designer
FormDesignerDocumentCtrl formDesigner = new FormDesignerDocumentCtrl(
this.GetService(typeof(IDesignerHost))as IDesignerHost,
host.GetDesigner(designSurfaceForm) as IRootDesigner);
formDesigner.InitializeDocument();
formDesigner.Dock=DockStyle.Fill;
formDesigner.Visible=true;
formDesigner.Focus();
designSurfacePanel.Controls.Add(formDesigner);
// I need to subscribe to selection changed events so
// that I can update our properties grid
ISelectionService selectionService = host.GetService(
typeof(ISelectionService)) as ISelectionService;
selectionService.SelectionChanged += new EventHandler(
selectionService_SelectionChanged);
// Activate the host
host.Activate();
}
Handling the root component is the only tricky part of the adhesive between the designer host, the design-time environment, and the user interface. The rest is easily understood by spending a bit of time reading the code.
Debugging the Project
Implementing a forms designer is not a trivial exercise. There is little existing documentation on the subject. And once you figure out where to start and what services to implement, debugging the project is going to be painful because you have to implement a set of required services and plug them in before you can start to debug any of it. Finally, once you implement the required services, the error messages that you get are not very helpful. For example, you may get a NullReferenceException on a line that calls the internal design-time assemblies, which you can't debug, so you're left wondering which service failed where.
Additionally, because the design-time infrastructure is built on top of the service pattern I discussed earlier, debugging services can be a problem. A technique to mitigate debugging pains is to log service requests. Logging which service request was queried, whether it passed or failed, and where it was called from (taking advantage of Environment.StackTrace) within the framework can be a very useful debugging tool to add to your arsenal.
Conclusion
I've provided an overview of the base services that you need to implement in order to get your forms designer up and running. In addition, you've seen how to configure the toolbox based on the needs of your application by changing the configuration file. What remains is to tweak the existing services and to implement a few others depending on your needs.
Sayed Y. Hashimi, a consultant based in Jacksonville, Florida, specializes in technologies related to the .NET Framework. Get in touch with him at hashimi_sayed@hotmail.com.
|