Share via


Creating Document-Centric Applications in Windows Forms, Part 3

 

Chris Sells
Microsoft Corporation

November 12, 2003

Summary: In this final installment, Chris Sells refactors his original application and splits the application-neutral functionality out into a component that allows for both ease of maintenance and reuse of the document-management functionality in other applications. (12 printed pages)

Download the wfdocs3src.msi sample file.

Recall from the first two parts of this article that we've been building an application for calculating average and annualized rates of return, shown in Figure 1 as an SDI application and in Figure 2 as an MDI application.

Figure 1. The SDI version of the RatesOfReturn application

Figure 2. The MDI version of the RatesOfReturn application

As I mentioned in part 1, building the main functionality of the application was mostly a matter of making use of the DataSet and Windows Forms designer, with the code boiling down into a single, small event handler method. However, saving the user's data to a file and allowing them to open it again, something supported thoroughly in the Microsoft Foundation Classes (MFC), isn't directly supported in .NET. So, I spent the last two articles of my column building this functionality, first for SDI applications, then for MDI application, throwing in some basic shell integration along the way. What I ended up with is a bunch of code that is chiefly concerned with the standard document-management behavior that users are accustomed to seeing in file-based applications. Because this code isn't specific to just this application, but should be packaged to be reusable across applications, it really belongs in a component.

Components

A component is a .NET class that integrates with a design-time environment (like Visual Studio® .NET). A component can show up on the Toolbox along with controls and can be dropped onto any design surface. Dropping a component onto a design surface makes it available to set the property or handle the events in the Designer, just like a control. Figure 3 shows the difference between a hosted control and a hosted component.

Figure 3. Where components and controls are hosted on a form

What makes components useful is that while they're non-visual at run time, they can be manipulated by the developer at design time. For example, dropping a PrintDocument component onto a form allows you to use the Property Browser to set the DocumentName property and handle the PrintPage event, which will cause the Designer to generate code like the following in the form's InitializeComponent method:

void InitializeComponent() {
  this.components = new System.ComponentModel.Container();
  this.printDocument1 = new System.Drawing.Printing.PrintDocument();
  ...
  this.printDocument1.DocumentName = "Rates of Return";
  this.printDocument1.PrintPage +=
    new PrintPageEventHandler(this.printDocument1_PrintPage);
  ...
}

Using a pre-built component is a productivity boost. Likewise, building your own code into a component allows you give yourself the same productivity boost the next time you or someone you love needs to reuse your code. This is exactly the right way for us to gather together the application-neutral portions of the document-management code we've been building. And, because our component bundles up file operations in the same way that the PrintDocument component bundles up print operations, we'll call our component "FileDocument."

The FileDocument Component

For maximum reuse, you'll want to build your components into assemblies that are separate from any one application that makes use of them. Visual Studio .NET facilitates this with the Windows Control Library project template, which creates a DLL assembly that we can reference in our application projects. This project template generates a class file that isn't much use for our needs and can be deleted. Providing a much more helpful start on the road to the FileDocument component is adding a Component from the Project menu, which produces a class that derives from the System.ComponentModel.Component class, which is all we need to integrate with the Visual Studio .NET Designers.

After a component assembly is compiled, it can be added to the Toolbox by right-clicking on the Toolbox, choosing Add/Remove Items (or Customize Toolbox in Visual Studio .NET 2002), browsing to the assembly in the file system and making sure that the checkbox next to the component that you'd like to pull in is checked, as shown in Figure 4.

Figure 4. Adding a component to the Toolbox

Once it's there, you can drag it from the Toolbox, drop it onto a form, and use the Property Browser to set the properties as appropriate, as shown in Figure 5.

click for larger image

Figure 5. Managing a FileDocument component in the Property Browser

Figure 5 shows the completed FileDocument component after I migrated the code out of the form and into the component. Now let's see how to put the component to use.

Using the FileDocument Component

Before dropping the FileDocument component onto the main form in the SDI version of the RatesOfReturn application, I first stripped it back to the application-specific code, which consisted of a bunch of Designer-generated code and my single event handler:

public class RatesOfReturnForm : Form {
  ...
  void dataView1_ListChanged(object s, ListChangedEventArgs e) {...}
}

With the application-neutral code out of there, I dropped an instance of the FileDocument component onto the main form to implement the dirty bit and file name management.

Dirty Bit Management

The file name is managed as part of the implementation of the File menu items, but first the document needs to know when the data has changed so that it can prompt the user about saving at various times (that is, creating a new document, opening an existing one, and closing the window). For this to happen properly, we need to track when the data has changed in our data set and set the document's dirty bit appropriately. We can do this in the data set's ListChanged event:

public class RatesOfReturnForm : Form {
  ...
  void dataView1_ListChanged(object s, ListChangedEventArgs e) {
    ...
    // Update the dirty bit
    fileDocument1.Dirty = true;
    ...
  }
}

Since the FileDocument component knows about the hosting form (using a component trick discussed in the writings listed in the References section), it can update the caption text appropriately to reflect the dirty bit.

File Management

Next, I went back to the Property Browser to set the FileDocument's DefaultExt, DefaultExtDescription, and RegisterDefaultExtension properties. This information is used to set the file dialog properties appropriately for my custom file extension, but also to register it with the shell so that double-clicking on one of the application's custom files causes the application to be launched by the shell. Since a double-click launches a new instance of my application, passing the name of the file to open to Main through the string array arguments, I still need the code in Main that opens an initial file if one is passed in:

static void Main(string[] args) {
  // Load main form, taking command line into account
  RatesOfReturnForm form = new RatesOfReturnForm();
  if( args.Length == 1 ) {
    form.OpenDocument(Path.GetFullPath(args[0]));
  }

  Application.Run(form);
}

The main form still needs to expose a public OpenDocument method as it did before, but it can just pass the file name along to the FileDocument component:

public class RatesOfReturnForm : Form {
  ...
  // For opening document from command line arguments
  public bool OpenDocument(string fileName) {
    return fileDocument1.Open(fileName);
  }
  ...
}

When the FileDocument object is first created or it's reused through the New method (as the implementation of the File->New menu item), it fires the NewDocument event, which we can use to set the initial seed data on our form:

public class RatesOfReturnForm : Form {
  ...
  void fileDocument1_NewDocument(object sender, EventArgs e) {
    // Clear existing data
    ClearDataSet();

    // Set initial document data and state
    this.periodReturnsSet1.PeriodReturn.AddPeriodReturnRow(
      "start", 0M, 1000M);
  }
  ...
}

If a file is passed through the command line or we're implementing the File->Open menu item, we'll get the ReadDocument event:

public class RatesOfReturnForm : Form {
  ...
  void fileDocument1_ReadDocument(
    object sender, SerializeDocumentEventArgs e) {

    // Deserialize object from text format
    IFormatter formatter = new SoapFormatter();
    PeriodReturnsSet
      ds = (PeriodReturnsSet)formatter.Deserialize(e.Stream);

    // Clear existing data
    ClearDataSet();

    // Merge in new data, keeping data bindings intact
    this.periodReturnsSet1.Merge(ds);
  }
  ...
}

Notice that the FileDocument component's ReadDocument event passes along an object of the custom type SerializeDocumentEventArgs, which contains the file name of the document to be read and a stream already opened on that file. This is where our FileDocument component shines. All we need to do is ask the FileDocument to Open and it checks the dirty bit to see if the current document needs to be saved first, prompts the user, saves the document as necessary, uses the DefaultExt to show the file open dialog, gets the file name, updates the hosting form's caption with the new file name, and even puts the newly opened file into the Start->Documents menu. The FileDocument component will ask us to do the small application-specific part (reading the data from the stream) by firing the ReadDocument event at the right time in the process.

In the same way, implementing the File->Save family of menu items is a matter of handling the WriteDocument event:

public class RatesOfReturnForm : Form {
  ...
  void fileDocument1_WriteDocument(
    object sender, SerializeDocumentEventArgs e) {

    // Serialize object to text format
    IFormatter formatter = new SoapFormatter();
    formatter.Serialize(e.Stream, this.periodReturnsSet1);
  }
  ...
}

Just like Open, the FileDocument component handles all of the chores of the Save family of operations, including the slightly different semantics of Save, Save As, and Save Copy As. The component also makes sure to change the current file and dirty bit (or not) as appropriate.

Handling the Menu Items

The NewDocument, ReadDocument, and WriteDocument events are called as part of the implementation of the File menu items. You can implement those menu items by handling the menu items in your form and calling the corresponding FileDocument methods:

public class RatesOfReturnForm : Form {
  ...
  void fileNewMenuItem_Click(object sender, EventArgs e) {
    fileDocument1.New();
  }

  void fileOpenMenuItem_Click(object sender, EventArgs e) {
    fileDocument1.Open();
  }

  void fileSaveMenuItem_Click(object sender, EventArgs e) {
    fileDocument1.Save();
  }

  void fileSaveAsMenuItem_Click(object sender, EventArgs e) {
    fileDocument1.SaveAs();
  }

  void fileSaveCopyAsMenuItem_Click(object sender, EventArgs e) {
    fileDocument1.SaveCopyAs();
  }
  ...
}

However, because this code will be the same for most applications, there's no reason for everyone to write it when we've got the magic of components combined with the smarts of Visual Studio .NET. By exposing a MenuItem property for each of the File menu items that the FileDocument component supports, we can simply select the appropriate menu item in the Property Browser from a drop-down list and let the FileDocument component itself handle the menu items, as shown in Figure 6.

Figure 6. Letting the FileDocument handle the File menu items

Notice that File->Exit isn't on this list. That's up to the form to implement:

public class RatesOfReturnForm : Form {
  ...
  void fileExitMenuItem_Click(object sender, EventArgs e) {
    // Let FileDocument component decide if this is OK
    this.Close();
  }
  ...
}

All the main form has to do to implement File->Exit is to do what it normally would in any application—close itself. Since the FileDocument knows which form is hosting it, it can handle the main form's Closing event and let it close or not based on the dirty bit and what the user has to say about whether they want to save their data or not. You don't need to write any special code to make this happen.

Simplified Serialization

In the spirit of further code reduction, let's take another quick look at the ReadDocument and WriteDocument event handlers:

public class RatesOfReturnForm : Form {
  ...
  void fileDocument1_ReadDocument(
    object sender, SerializeDocumentEventArgs e) {

    // Deserialize object from text format
    IFormatter formatter = new SoapFormatter();
    PeriodReturnsSet
      ds = (PeriodReturnsSet)formatter.Deserialize(e.Stream);

    // Clear existing data
    ClearDataSet();

    // Merge in new data, keeping data bindings intact
    this.periodReturnsSet1.Merge(ds);
  }

  void fileDocument1_WriteDocument(
    object sender, SerializeDocumentEventArgs e) {

    // Serialize object to text format
    IFormatter formatter = new SoapFormatter();
    formatter.Serialize(e.Stream, this.periodReturnsSet1);
  }
  ...
}

You'll notice that in addition to deserializing the data from the stream in the ReadDocument event handler, we need to do some special things to clear the existing data set and merge the new data in. This is necessary to preserve the data binding settings. However, it's often the case that we don't need to do anything to an object except deserialize it, just like we don't have to do anything special in our case when we're serializing. Towards that end, the FileDocument component exposes the Data and Formatter properties to be used if one or both of the ReadDocument and WriteDocument events aren't implemented.

For our application, this means we need to set the Data property of the FileDocument to the typed data set that we've been serializing by hand and make sure that we're using the same formatter between the FileDocument and the ReadDocument event handlers:

public class RatesOfReturnForm : Form {
  ...
  public RatesOfReturnForm() {
    // Required for Windows Form Designer support
    InitializeComponent();

    // Set up the data and the formatter
    // so that the FileDocument can write the data
    // by itself (we still need to read, though)
    fileDocument1.Data = this.periodReturnsSet1;
    fileDocument1.Formatter = new SoapFormatter();
  }

  void fileDocument1_ReadDocument(
    object sender, SerializeDocumentEventArgs e) {

    // Deserialize object from text format
    // using the FileDocument's formatter
    IFormatter formatter = this.fileDocument1.Formatter;
    PeriodReturnsSet
      ds = (PeriodReturnsSet)formatter.Deserialize(e.Stream);

    // Clear existing data
    ClearDataSet();

    // Merge in new data, keeping data bindings intact
    this.periodReturnsSet1.Merge(ds);
  }

  // Let FileDocument component handle WriteDocument itself
  ...
}

The flexibility to handle the serialization events or not allows you to handle a full range of cases, ranging from where the data is completely managed by the "view" of the data (for example, a text editor that manages all of its data in an instance of the TextBox control) to where the data is completely separate from the view a la the classic MFC document-view scenario. In fact, ex-MFC programmers that are so inclined could derive a custom document type from the FileDocument component, put the custom data into this new document type and handle the serialization events itself, providing a good start on duplicating the MFC Document/View model of programming. Once the basics are in place, all kinds of things are possible.

MDI and the FileDocument Component

The MDI usage of the FileDocument component is just as easy as the SDI usage. An MDI child form implements the File->Save family of menu items like the SDI main form does, with the addition of the File->Close menu item, which can also be handled directly by the FileDocument component through a menu property.

The MDI parent form creates new instances of the MDI child form to implement File->New and File->Open, as it did in part 2 of this series:

class MdiParentForm : Form {
  ...
  void fileNewMenuItem_Click(object sender, EventArgs e) {
    // Create and show a new child
    RatesOfReturnForm child = new RatesOfReturnForm();
    child.MdiParent = this;
    child.Show();
  }

  void fileOpenMenuItem_Click(object sender, EventArgs e) {
    OpenDocument(null);
  }

  // For use by Main in processing a file passed via the command line
  public void OpenDocument(string fileName) {
    // Let child do the opening
    RatesOfReturnForm child = new RatesOfReturnForm();
    if( child.OpenDocument(fileName) ) {
      child.MdiParent = this;
      child.Show();
    }
    else {
      child.Close();
    }
  }

  void fileExitMenuItem_Click(object sender, EventArgs e) {
    // Children will decide if this is allowed or not
    this.Close();
  }
  ...
}

Likewise, File->Exit is implemented in the MDI parent as it was in the SDI main form by closing the form and letting the FileDocument component judge whether the MDI children can be closed based on the dirty bit and the user's input.

Genghis

As handy as the FileDocument is, it's not quite done yet. For example, I haven't found a model that I like for multiple views on the same data. Likewise, I'm not handling the case where an application understands several file extensions. I find these rare enough in my own world that I haven't needed this support, but other folks, especially those bringing their MFC applications forward, might need it. Towards that end, the FileDocument component is part of the Genghis shared source project (as listed in the References section). You can always get the latest version of the FileDocument from the Genghis project, which may have this functionality added by some kind soul in the future. Even better, you may decide to join the project and be that kind soul.

Where Are We?

I started this series very happy to use the Windows Forms and Visual Studio .NET support for data binding, letting it generate most of the code to provide the main functionality. I then progressed to writing all kinds of code that wasn't specific to my application, but that I needed to get a full-featured application. From there, I progressed to refactoring my application, splitting the application-neutral functionality out into a component so that I could both cleanup my custom code for ease of maintenance and reuse the document-management functionality in other applications. This is a model that I favor heavily. I find that while refactoring in this manner takes some extra time up front, it always give me better, clearer, more maintainable, more robust, and more reusable code. Further, this increases the overall quality of my applications and ultimately saves me time. If that's not magic, I don't know what is.

References

There are a lot of component-specific features that I don't discuss in this article, like hiding some of the properties, and the need for the ISupportInitialize interface and the implementation of the HostForm property. You can learn more about these and other nifty component tricks in the following sources:

Some of this material is excerpted from the forthcoming Addison-Wesley title Windows Forms Programming in C# by Chris Sells (0321116208). Please note the material presented here is an initial DRAFT of what will appear in the published book.

Chris Sells is a Content Strategist for MSDN Online, currently focused on Longhorn, Microsoft's next operating system. He's written several books, including Mastering Visual Studio .NET and Windows Forms for C# Programmers. In his free time, Chris hosts various conferences, directs the Genghis source-available project, plays with Rotor and, in general, makes a pest of himself in the blogsphere. More information about Chris, and his various projects, is available at http://www.sellsbrothers.com.