Creating an Extensible User Interface with .NET, Part 1






4.68/5 (32 votes)
Nov 18, 2002
4 min read

208044

728
An architecture for extending the User Interface of a program via plug-in components.
Introduction
Many times it can be desirable to extend or enhance the User Interface (UI) of a program after it has been deployed. Usually this means redeploying the entire application. This document describes a “plug in” architecture that allows for the UI to be extended at any time. An example of a program with an extensible UI is the Microsoft Management Console (MMC) and its associated snap-ins.
Overview
There is one main requirement that your program must meet before this architecture can be considered.
- There should be NO interaction between UI plug-ins. This does not mean they cannot share a common data structure or business objects, but each UI plug-in should not try to make direct calls to other plug-ins.
In this architecture all UI elements are contained in a set of plug-ins based upon the System.Windows.Forms.UserControl
class. All plug-ins are described in a configuration file, and loaded at runtime. Extending the UI is accomplished by creating a new plug-in and adding the appropriate entries to the config file.
Some of the advantages of this architecture are:
- Independent development of different UI elements. For example, if you are developing a Personal Information Manager (PIM), one person could be working on the "Appointments/Calender" UI, while another works on the "Contacts" UI.
- Application control. You could restrict the functionality of the application based upon the user's name or the user's roll or purchased options.
- You can add new UI elements at any time. In the PIM example above, you could add a "Diary" UI after the application has been distributed.
The architecture consists of 3 parts:
- A “shell” application that handles the loading and navigation between the plug-ins.
- A base class that supplies all communications between the “shell” and the plug-ins.
- The individual UI plug-ins themselves.
The shell
At startup the shell application reads a configuration file to get the names and locations for each UI plug-in. It then uses reflection to load each plug-in. In the screen shots below, the Shell application contains a ListBox
used to navigate between plug-ins, and a Panel
in which the plug-ins are loaded.
Here is an example of a “shell” with 2 plug-ins loaded. The ListBox
on the left is used to select between each plug-in, while the panel on the right will display the plug-in when it is made visible.
Clicking on "PlugIn1" made the plug-in visible.
And then clicking on "PlugIn Number 2" makes it visible in the panel.
The Tabbed Shell application shows another way of navigation:
Here is the Tabbed Shell after it has been loaded.
And here it is after "PlugIn Number 2" has been selected.
How the Shell finds the plug-ins.
The plug-ins to be loaded at runtime are listed in an XML file named config.xml.
<?xml version="1.0" encoding="utf-8" ?>
<PlugIns>
<PlugIn Location="E:\ExtensibleUI\OurControls\bin\Debug\OurControls.dll"
Name="OurControls.PlugIn1"></PlugIn>
<PlugIn Location="E:\ExtensibleUI\OurControls\bin\Debug\OurControls.dll"
Name="OurControls.PlugIn2"></PlugIn>
</PlugIns>
In the form load event, the config.xml file is loaded into a DataSet
via ReadXml
. Then it iterates each DataRow
calling AddPlugin
with the “location” and “name” of the plug-in.
private void Form1_Load(object sender, System.EventArgs e)
{
DataSet ds = new DataSet();
ds.ReadXml("Config.xml");
foreach(DataRow dr in ds.Tables["Plug-In"].Rows)
{
AddPlugIn(dr["Location"].ToString(),
dr["Name"].ToString());
}
}
Two examples of the AddPlugIn code.
The AddPlugIn
loads the assembly containing the plug-in and creates an instance of it. It also adds the plug-in to the ListBox
. When a new item in the list box is selected, we need to hide the current plug-in, and show the new selection.
// Load and add a plug-in to the panel1 control
// Also set the list box to navigate between plugins.
private void AddPlugIn(string Location, string ControlName)
{
Assembly ControlLib;
PlugIn NewPlugIn;
// Load the assembly.
ControlLib = Assembly.LoadFrom(Location);
// Now create the plugin.
NewPlugIn = (PlugIn)ControlLib.CreateInstance(ControlName);
NewPlugIn.Location = new System.Drawing.Point(0, 0);
NewPlugIn.Dock = DockStyle.Fill;
NewPlugIn.Visible = false;
// Add it to the panel, note that its Visible property is false.
panel1.Controls.Add(NewPlugIn);
// Set up the ClickHandler
NewPlugIn.Clicked += new PlugInLib.ClickHandler(Control_Clicked);
// Add the plugin to the listBox, listBox will use ToString to
// get the text to display.
listBox1.Items.Add(NewPlugIn);
}
private PlugIn CurrentPlugIn;
// When a new item in the listBox is selected,
// hide the current plugin and show the new.
private void listBox1_SelectedIndexChanged(object sender,
System.EventArgs e)
{
if(CurrentPlugIn!=null)
{
CurrentPlugIn.Visible = false;
}
CurrentPlugIn = (PlugIn)listBox1.SelectedItem;
CurrentPlugIn.Visible = true;
}
The AddPlugIn
for the Tabbed Shell application is only slightly different. The Tabbed Shell application needs no navigation code because it is handled by the TabControl
.
// Load and add a plug-in to the TabControl1 control
private void AddPlugIn(string Location, string ControlName)
{
Assembly ControlLib;
PlugIn NewPlugIn;
// Load the assembly.
ControlLib = Assembly.LoadFrom(Location);
// Now create the plugin.
NewPlugIn = (PlugIn)ControlLib.CreateInstance(ControlName);
NewPlugIn.Location = new System.Drawing.Point(0, 0);
NewPlugIn.Dock = DockStyle.Fill;
NewPlugIn.Visible = true;
// Create a new TabPage.
TabPage newPage = new TabPage();
// Set the text on the tabPage with the PlugIn Caption.
newPage.Text = NewPlugIn.Caption;
// Add the PlugIn to the TabPage.
newPage.Controls.Add(NewPlugIn);
// Add the page to the tabControl.
tabControl1.TabPages.Add(newPage);
// Set up the ClickHandler
NewPlugIn.Clicked += new PlugInLib.ClickHandler(Control_Clicked);
}
The PlugIn base blass
The PlugIn
base class is based upon the System.Windows.Forms.UserControl
class and extends it to provide predefined events, methods, and properties that each plug-in can use to communicate with the shell applications. In this example a Clicked
event, a Caption
property, and a TestFunction
method are predefined. In addition, ToString
is overridden to return the Caption
instead of the object name.
using System;
using System.Windows.Forms;
namespace PlugInLib
{
/// <summary>
/// A delegate type for hooking up notifications.
/// </summary>
public delegate void ClickHandler(object sender, EventArgs e);
/// <summary>
/// Summary description for PlugIn.
/// </summary>
public class PlugIn : System.Windows.Forms.UserControl
{
// The following provides "Clicked" event back to the container.
public event ClickHandler Clicked;
protected void DoClick(EventArgs e)
{
if (Clicked != null)
Clicked(this, e);
}
// Provide a "Caption" that the container can display.
protected string m_Caption = "PlugIn";
public string Caption
{
get
{
return m_Caption;
}
set
{
m_Caption = value;
}
}
public override string ToString()
{
return m_Caption;
}
// Provide a method "TestFunction" that the container can call.
public virtual void TestFunction()
{
}
}
}
Creating a UI plug-in.
- Use Visual Studio to create a new “Windows Control Library”.
- Add a reference to the
PlugInLib
that contains the plug-in base class. - Change the name of the user control from
UserControl1
to something more descriptive. - Add the
using
directive for thePlugInLib
. - Change the base class for the user control from
System.Windows.Forms.UserControl
toPlugIn
. - Connect any events that you want to send to the shell applications.
- Add the necessary overrides for calls from the shell to the plug-in.
- Construct your UI as you would for any other
UserControl
.
using System;
using System.Collections;
using System.ComponentModel;
using System.Drawing;
using System.Data;
using System.Windows.Forms;
using PlugInLib; // <---Add using for the plug-In base class
namespace OurControls
{
/// <summary>
/// Summary description for PlugIn3.
/// </summary>
public class PlugIn3 : PlugIn // <---Change base class to PlugIn
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.Container components = null;
public PlugIn3()
{
// This call is required by the Windows.Forms Form Designer.
InitializeComponent();
// TODO: Add any initialization after the InitForm call
}
/// <summary>
/// Clean up any resources being used.
/// </summary>
protected override void Dispose( bool disposing )
{
if( disposing )
{
if(components != null)
{
components.Dispose();
}
}
base.Dispose( disposing );
}
#region Component Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
//
// PlugIn3
//
this.Caption = "PlugIn 3";
this.Name = "PlugIn3";
this.Click += new System.EventHandler(this.PlugIn3_Click);
}
#endregion
// Override Base class to receive call from the shell.
public override void TestFunction()
{
Console.WriteLine("TestFunction called by the shell.");
}
// Send clicks to the shell, just because we can.
private void PlugIn3_Click(object sender, System.EventArgs e)
{
DoClick(e);
}
}
}
Conclusions
With this architecture, the interaction between the plug-ins and the shell should be well defined and limited. In the PlugIn
base class example shown above, the only real interaction between the shell and plug-in is the Caption
property. Another possible interaction would be for the shell to load a common data structure that is passed to each plug-in when it is loaded.
You can add new plug-ins at any time, simply by creating a new plug-in and adding the appropriate entries to the config.xml file.
Notes
In the demo zip available in the downloads section, config.xml file is located in both the Release and Debug directories of the two "Shell" applications. These files contain the absolute pathname to the OurControls.dll including a drive letter. You will need to modify these paths for your local system.