Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

WTL for MFC Programmers, Part VIII - Property Sheets and Wizards

0.00/5 (No votes)
13 Jan 2006 17  
A guide to creating property sheets and wizards in WTL

Contents

Introduction

Property sheets have been a popular way of presenting options, even before Windows 95 introduced the sheet as a common control. Wizards are often used to guide users through installing software or other complex tasks. WTL provides good support for creating both of these types of property sheets, and lets you use all of the dialog-related features that we've covered earlier, like DDX and DDV. In this article, I'll demonstrate creating a basic property sheet and a wizard, and how to handle events and notification messages sent by the sheet.

WTL Property Sheet Classes

There are two classes that combine to implement a property sheet, CPropertySheetWindow and CPropertySheetImpl. Both are defined in the atldlgs.h header file. CPropertySheetWindow is a window interface class (that is, it derives from CWindow), and CPropertySheetImpl has a message map and actually implements the window. This is similar to the basic ATL windowing classes, where CWindow and CWindowImpl are used together.

CPropertySheetWindow contains wrappers for the various PSM_* messages, such as SetActivePageByID() which wraps PSM_SETCURSELID. CPropertySheetImpl manages a PROPSHEETHEADER struct and an array of HPROPSHEETPAGEs. CPropertySheetImpl also has methods for setting some PROPSHEETHEADER fields, and adding and removing pages. You can access the PROPSHEETHEADER directly by accessing the m_psh member variable.

Finally, CPropertySheet is a specialization of CPropertySheetImpl that you can use if you don't need to customize the sheet at all.

CPropertySheetImpl methods

Here are some of the important methods of CPropertySheetImpl. Since many methods are just wrappers around window messages, I won't present an exhaustive list here, but you can check out atldlgs.h to see the complete list of methods.

CPropertySheetImpl(_U_STRINGorID title = (LPCTSTR) NULL,
                   UINT uStartPage = 0, HWND hWndParent = NULL)

The CPropertySheetImpl constructor lets you specify some common properties right away, so you don't have to call other methods later to set them. title specifies the text to use in the property sheet's caption. _U_STRINGorID is a WTL utility class that lets you pass either an LPCTSTR or string resource ID. For example, both of these lines will work:

  CPropertySheet mySheet ( IDS_SHEET_TITLE );
  CPropertySheet mySheet ( _T("My prop sheet") );

if IDS_SHEET_TITLE is the ID of a string in the string table. uStartPage is the zero-based index of the page that should be active when the sheet is first made visible. hWndParent sets the sheet's parent window.

BOOL AddPage(HPROPSHEETPAGE hPage)
BOOL AddPage(LPCPROPSHEETPAGE pPage)

Adds a property page to the sheet. If the page is already created, you can pass its handle (an HPROPSHEETPAGE) to the first overload. The more common way is to use the second overload. With that version, you set up a PROPSHEETPAGE struct (which you can do with CPropertyPageImpl, covered later) and CPropertySheetImpl will create and manage the page for you.

BOOL RemovePage(HPROPSHEETPAGE hPage)
BOOL RemovePage(int nPageIndex)

Removes a page from the sheet. You can pass either the page's handle or its zero-based index.

BOOL SetActivePage(HPROPSHEETPAGE hPage)
BOOL SetActivePage(int nPageIndex)

Sets the active page in the sheet. You can pass either the handle or zero-based index of the page to be made active. You can call this method before showing the property sheet to set which page will be active when the sheet is first made visible.

void SetTitle(LPCTSTR lpszText, UINT nStyle = 0)

Sets the text to be used in the caption of the property sheet. nStyle can be either 0 or PSH_PROPTITLE. If it is PSH_PROPTITLE, then that style bit is added to the sheet, which causes the words "Properties for" to be prepended to the text you pass in lpszText.

void SetWizardMode()

Sets the PSH_WIZARD style, which changes the sheet into a wizard. You must call this method before showing the sheet.

void EnableHelp()

Sets the PSH_HASHELP style, which adds a Help button to the sheet. Note that you also need to enable help in each page that provides help for this to take effect.

INT_PTR DoModal(HWND hWndParent = ::GetActiveWindow())

Creates and shows a modal property sheet. The return value is positive to indicate success, see the docs on the PropertySheet() API for a full description of the return value. If an error occurs and the sheet can't be created, DoModal() returns -1.

HWND Create(HWND hWndParent = NULL)

Creates and shows a modeless property sheet, and returns its window handle. If an error occurs and the sheet can't be created, Create() returns NULL.

WTL Property Page Classes

The WTL classes that encapsulate property pages work similarly to the property sheet classes. There is a window interface class, CPropertyPageWindow, and an implementation class, CPropertyPageImpl. CPropertyPageWindow is very small, and contains mostly utility functions that call methods in the parent sheet.

CPropertyPageImpl derives from the ATL class CDialogImplBaseT, since a page is built from a dialog resource. That means that all the WTL features we've used in dialogs are also available in property sheets, like DDX and DDV. CPropertyPageImpl has two main purposes: it manages a PROPSHEETPAGE struct (kept in the member variable m_psp), and handles PSN_* notification messages. For very simple property pages, you can use the CPropertyPage class. This is only suitable for pages that do not interact with the user at all, for example an About page, or the introduction page in a wizard.

You can also create pages that host ActiveX controls. You first include atlhost.h in stdafx.h. For the page, you use CAxPropertyPageImpl instead of CPropertyPageImpl. For simple pages that host ActiveX controls, you can use CAxPropertyPage instead of CPropertyPage.

CPropertyPageWindow methods

CPropertyPageWindow's most important method is GetPropertySheet():

CPropertySheetWindow GetPropertySheet()

This method gets the HWND of the page's parent window (the sheet) and attaches a CPropertySheetWindow to that HWND. The new CPropertySheetWindow is then returned to the caller. Note that this only creates a temporary object; it does not return a pointer or reference to the actual CPropertySheet or CPropertySheetImpl object used to create the sheet. This is important if you are using your own CPropertySheetImpl-derived class and need to access data members in the sheet object.

The remaining members just call through to CPropertySheetWindow functions that wrap PSM_* messages:

BOOL Apply()
void CancelToClose()
void SetModified(BOOL bChanged = TRUE)
LRESULT QuerySiblings(WPARAM wParam, LPARAM lParam)
void RebootSystem()
void RestartWindows()
void SetWizardButtons(DWORD dwFlags)

For example, in a CPropertyPageImpl-derived class, you can call:

  SetWizardButtons ( PSWIZB_BACK | PSWIZB_FINISH );

instead of:

CPropertySheetWindow wndSheet;
 
  wndSheet = GetPropertySheet();
  wndSheet.SetWizardButtons ( PSWIZB_BACK | PSWIZB_FINISH );

CPropertyPageImpl methods

CPropertyPageImpl manages a PROPSHEETPAGE struct, the public member m_psp. CPropertyPageImpl also has an operator PROPSHEETPAGE* converter, so you can pass a CPropertyPageImpl to a method that takes an LPPROPSHEETPAGE or LPCPROPSHEETPAGE, such as CPropertySheetImpl::AddPage().

The CPropertyPageImpl constructor lets you set the page title, which is the text that appears on the page's tab:

CPropertyPageImpl(_U_STRINGorID title = (LPCTSTR) NULL)

If you ever need to create a page manually, instead of letting the sheet do it, you can call Create():

HPROPSHEETPAGE Create()

Create() just calls the Win32 API CreatePropertySheetPage() with m_psp as the parameter. You would only need to call Create() if you are adding pages to a sheet after the sheet is created, or if you are creating a page to be passed to some other sheet not under your control (for example, a property sheet handler shell extension).

There are three methods for setting various title text on the page:

void SetTitle(_U_STRINGorID title)
void SetHeaderTitle(LPCTSTR lpstrHeaderTitle)
void SetHeaderSubTitle(LPCTSTR lpstrHeaderSubTitle)

The first changes the text on the page's tab. The other two are used in Wizard97-style wizards to set the text in the header above the property page area.

void EnableHelp()

Sets the PSP_HASHELP flag in m_psp to enable the Help button when the page is active.

Handling notification messages

CPropertyPageImpl has a message map that handles WM_NOTIFY. If the notification code is a PSN_* value, OnNotify() calls a handler for that particular notification. This is done using the compile-time virtual function technique, so the handlers can be easily overridden in derived classes.

There are two sets of notification handlers, due to a design change between WTL 3 and 7. In WTL 3, the notification handlers had return values that differ from the return values for the PSN_* messages. For example, the WTL 3 handler for PSN_WIZFINISH is:

  case PSN_WIZFINISH:
    lResult = !pT->OnWizardFinish();
  break;

OnWizardFinish() was expected to return TRUE to let the wizard finish, or FALSE to prevent the wizard from closing. This code broke when the IE 5 common controls added the ability to return a window handle from the PSN_WIZFINISH handler to give that window the focus. WTL 3 apps could not use this feature because all non-zero values were treated the same.

In WTL 7, OnNotify() does not change any value returned from a PSN_* handler. The handlers can return any documented legal value and the behavior will be correct. However, for backwards compatibility, the WTL 3 handlers are still present and are used by default. To use the WTL 7 handlers, you must add this line to stdafx.h before including atldlgs.h:

#define _WTL_NEW_PAGE_NOTIFY_HANDLERS

When writing new code, there is no reason not to use the WTL 7 handlers, so the WTL 3 handlers will not be covered here.

CPropertyPageImpl has default handlers for all notifications, so you can override only the handlers that are relevant to your program. The default handlers and their actions are:

int OnSetActive() - allows the page to become active

BOOL OnKillActive() - allows the page to become inactive

int OnApply() - returns PSNRET_NOERROR to indicate the apply operation succeeded

void OnReset() - no action

BOOL OnQueryCancel() - allows the cancel operation

int OnWizardBack() - goes to the previous page

int OnWizardNext() - goes to the next page

INT_PTR OnWizardFinish() - allows the wizard to finish

void OnHelp() - no action

BOOL OnGetObject(LPNMOBJECTNOTIFY lpObjectNotify) - no action

int OnTranslateAccelerator(LPMSG lpMsg) - returns PSNRET_NOERROR to indicate that the message was not handled

HWND OnQueryInitialFocus(HWND hWndFocus) - returns NULL to set the focus to the first control in the tab order

Creating a Property Sheet

Now that our tour of the classes is complete, we need a program to illustrate how to use them. The sample project for this article is a simple SDI app that shows a picture in the client area, and fills the background with a color. The picture and color can be changed via an options dialog (a property sheet), and a wizard (which I'll describe later).

The simplest property sheet, ever

After making an SDI project with the WTL AppWizard, we can start by creating a sheet to use for our about box. Let's start with the About dialog the wizard creates for us, and change the styles so it will work as a property page.

The first step is to remove the OK button, since that doesn't make sense in a sheet. In the dialog's properties, change the Style to Child, the Border to Thin, and set Disabled to checked.

The second (and final) step is to create a property sheet in the OnAppAbout() handler. We can do this with CPropertySheet and CPropertyPage:

void CMainFrame::OnAppAbout(...)
{
CPropertySheet sheet ( _T("About PSheets") );
CPropertyPage<IDD_ABOUTBOX> pgAbout;
 
  sheet.AddPage ( pgAbout );
  sheet.DoModal ( *this );
}

The result looks like this:

 [Simple sheet - 27K]

Creating a useful property page

Since not every page in every sheet is as simple as an about box, most pages will require a CPropertyPageImpl-derived class, so we'll take a look at such a class now. We'll make a new property page that contains the settings for the graphics shown in the background of the client area. Here's the dialog:

 [Background options - 4K]

This dialog has the same styles as the About page. We'll need a new class to go along with the page, called CBackgroundOptsPage. This class derives from CPropertyPageImpl since it's a property page, and CWinDataExchange to enable DDX.

class CBackgroundOptsPage :
  public CPropertyPageImpl<CBackgroundOptsPage>,
  public CWinDataExchange<CBackgroundOptsPage>
{
public:
  enum { IDD = IDD_BACKGROUND_OPTS };
 
  // Construction

  CBackgroundOptsPage();
  ~CBackgroundOptsPage();
 
  // Maps

  BEGIN_MSG_MAP(CBackgroundOptsPage)
    MSG_WM_INITDIALOG(OnInitDialog)
    CHAIN_MSG_MAP(CPropertyPageImpl<CBackgroundOptsPage>)
  END_MSG_MAP()
 
  BEGIN_DDX_MAP(CBackgroundOptsPage)
    DDX_RADIO(IDC_BLUE, m_nColor)
    DDX_RADIO(IDC_ALYSON, m_nPicture)
  END_DDX_MAP()
 
  // Message handlers

  BOOL OnInitDialog ( HWND hwndFocus, LPARAM lParam );
 
  // Property page notification handlers

  int OnApply();
 
  // DDX variables

  int m_nColor, m_nPicture;
};

Things to note in this class:

  • There is a public member named IDD that holds the associated dialog resource ID.
  • The message map resembles that of a CDialogImpl class.
  • The message map chains messages to CPropertyPageImpl so that property sheet-related notification messages are handled.
  • There is an OnApply() handler to save the user's choices when he clicks OK in the sheet.

OnApply() is pretty simple, it calls DoDataExchange() to update the DDX variables, then returns a code indicating whether the sheet should close or not:

int CBackgroundOptsPage::OnApply()
{
  return DoDataExchange(true) ? PSNRET_NOERROR : PSNRET_INVALID;
}

We'll add a Tools|Options menu item that brings up the property sheet, and put add handler for this command to the view class. The handler creates the property sheet as before, but with the new CBackgroundOptsPage added to the sheet.

void CPSheetsView::OnOptions ( UINT uCode, int nID, HWND hwndCtrl )
{
CPropertySheet sheet ( _T("PSheets Options"), 0 );
CBackgroundOptsPage pgBackground;
CPropertyPage<IDD_ABOUTBOX> pgAbout;
 
  pgBackground.m_nColor = m_nColor;
  pgBackground.m_nPicture = m_nPicture;
 
  sheet.m_psh.dwFlags |= PSH_NOAPPLYNOW|PSH_NOCONTEXTHELP;
 
  sheet.AddPage ( pgBackground );
  sheet.AddPage ( pgAbout );
 
  if ( IDOK == sheet.DoModal() )
    SetBackgroundOptions ( pgBackground.m_nColor,
                           pgBackground.m_nPicture );
}

The sheet constructor now has a second parameter of 0, meaning that the page at index 0 should be visible initially. You could change that to 1 to have the About page be visible when the sheet first appears. Since this is just demo code, I'm going to be lazy and make the CBackgroundOptsPage variables connected to the radio buttons public. The view stores the current options in those variables, and saves the new values if the user clicks OK in the sheet.

If the user clicks OK, DoModal() returns IDOK, and the view redraws itself using the new picture and color. Here are some screen shots to show the different views:

 [Alyson background - 35K]  [Strong Bad background - 9K]

Creating a better property sheet class

The OnOptions() handler creates the sheet just fine, but there's an awful lot of setup and initialization code there, which really shouldn't be CMainFrame's responsibility. A better way is to make a class derived from CPropertySheetImpl that handles those tasks.

#include "BackgroundOptsPage.h"

 
class COptionsSheet : public CPropertySheetImpl<COptionsSheet>
{
public:
  // Construction

  COptionsSheet(_U_STRINGorID title = (LPCTSTR) NULL, 
                UINT uStartPage = 0, HWND hWndParent = NULL);
 
  // Maps

  BEGIN_MSG_MAP(COptionsSheet)
    CHAIN_MSG_MAP(CPropertySheetImpl<COptionsSheet>)
  END_MSG_MAP()
 
  // Property pages

  CBackgroundOptsPage         m_pgBackground;
  CPropertyPage<IDD_ABOUTBOX> m_pgAbout;
};

With this class, we've encapsulated the details of what pages are in the sheet, and moved them into the sheet class itself. The constructor handles adding the pages to the sheet, and setting any other necessary flags:

COptionsSheet::COptionsSheet (
  _U_STRINGorID title, UINT uStartPage, HWND hWndParent ) :
    CPropertySheetImpl<COptionsSheet>(title, uStartPage, hWndParent)
{
  m_psh.dwFlags |= PSH_NOAPPLYNOW|PSH_NOCONTEXTHELP;
 
  AddPage ( m_pgBackground );
  AddPage ( m_pgAbout );
}

As a result, the OnOptions() handler becomes a bit simpler:

void CPSheetsView::OnOptions ( UINT uCode, int nID, HWND hwndCtrl )
{
COptionsSheet sheet ( _T("PSheets Options"), 0 );
 
  sheet.m_pgBackground.m_nColor = m_nColor;
  sheet.m_pgBackground.m_nPicture = m_nPicture;
 
  if ( IDOK == sheet.DoModal() )
    SetBackgroundOptions ( sheet.m_pgBackground.m_nColor,
                           sheet.m_pgBackground.m_nPicture );
}

Creating a Wizard

Creating a wizard is, not surprisingly, similar to creating a property sheet. A little more work is required to enable the Back and Next buttons; just as in MFC property pages, you override OnSetActive() and call SetWizardButtons() to enable the appropriate buttons. We'll start with a simple introduction page, with ID IDD_WIZARD_INTRO:

 [Intro page - 3K]

Notice that the page has no caption text. Since every page in a wizard usually has the same title, I prefer to set the text in the CPropertySheetImpl constructor, and have every page use the same string resource. That way, I can just change that one string and every page will reflect the change.

The implementation of this page is done in the CWizIntroPage class:

class CWizIntroPage : public CPropertyPageImpl<CWizIntroPage>
{
public:
  enum { IDD = IDD_WIZARD_INTRO };
 
  // Construction

  CWizIntroPage();
 
  // Maps

  BEGIN_MSG_MAP(COptionsWizard)
    CHAIN_MSG_MAP(CPropertyPageImpl<CWizIntroPage>)
  END_MSG_MAP()
 
  // Notification handlers

  int OnSetActive();
};

The constructor sets the page's text by referencing a string resource ID:

CWizIntroPage::CWizIntroPage() :
  CPropertyPageImpl<CWizIntroPage>(IDS_WIZARD_TITLE)
{
}

The string IDS_WIZARD_TITLE ("PSheets Options Wizard") will appear in the wizard's caption bar when this page is the current page. OnSetActive() enables just the Next button:

int CWizIntroPage::OnSetActive()
{
  SetWizardButtons ( PSWIZB_NEXT );
  return 0;
}

To implement the wizard, we'll create a class COptionsWizard, and a Tools|Wizard menu option in the app's menu. The COptionsWizard constructor is pretty similar to COptionsSheet's, in that it sets any necessary style bits or flags, and adds pages to the sheet.

class COptionsWizard : public CPropertySheetImpl<COptionsWizard>
{
public:
  // Construction

  COptionsWizard ( HWND hWndParent = NULL );
 
  // Maps

  BEGIN_MSG_MAP(COptionsWizard)
    CHAIN_MSG_MAP(CPropertySheetImpl<COptionsWizard>)
  END_MSG_MAP()
 
  // Property pages

  CWizIntroPage m_pgIntro;
};
 
COptionsWizard::COptionsWizard ( HWND hWndParent ) :
  CPropertySheetImpl<COptionsWizard> ( 0U, 0, hWndParent )
{
  SetWizardMode();
  AddPage ( m_pgIntro );
}

Then the handler for the Tools|Wizard menu looks like this:

void CPSheetsView::OnOptionsWizard ( UINT uCode, int nID, HWND hwndCtrl )
{
COptionsWizard wizard;
 
  wizard.DoModal ( GetTopLevelParent() );
}

And here's the wizard in action:

 [Wizard on intro page - 10K]

Adding More Pages, Handling DDV

To make this a useful wizard, we'll add a new page for setting the view's background color. This page will also have a checkbox for demonstrating handling a DDV failure and preventing the user from continuing on with the wizard. Here's the new page, whose ID is IDD_WIZARD_BKCOLOR:

 [Color selection wizard page - 4K]

The implementation for this page is in the class CWizBkColorPage. Here are the parts relating to

class CWizBkColorPage :
  public CPropertyPageImpl<CWizBkColorPage>,
  public CWinDataExchange<CWizBkColorPage>
{
public:
    //...

  BEGIN_DDX_MAP(CWizBkColorPage)
    DDX_RADIO(IDC_BLUE, m_nColor)
    DDX_CHECK(IDC_FAIL_DDV, m_bFailDDV)
  END_DDX_MAP()
 
  // Notification handlers

  int OnSetActive();
  BOOL OnKillActive();
 
  // DDX vars

  int m_nColor;
 
protected:
  bool m_bFailDDV;
};

OnSetActive() works similarly to the intro page, it enables both the Back and Next buttons. OnKillActive() is a new handler, it invokes DDX then checks the value of m_bFailDDV. If that variable is true, meaning the checkbox was checked, OnKillActive() prevents the wizard from going to the next page.

int CWizBkColorPage::OnSetActive()
{
  SetWizardButtons ( PSWIZB_BACK | PSWIZB_NEXT );
  return 0;
}
 
int CWizBkColorPage::OnKillActive()
{
  if ( !DoDataExchange(true) )
    return TRUE;    // prevent deactivation

 
  if ( m_bFailDDV )
    {
    MessageBox (
        _T("Error box checked, wizard will stay on this page."),
        _T("PSheets"), MB_ICONERROR );
 
    return TRUE;    // prevent deactivation

    }
 
  return FALSE;       // allow deactivation

}

Note that the logic in OnKillActive() could certainly have been put in OnWizardNext() instead, since both handlers have the ability to keep the wizard on the current page. The difference is that OnKillActive() is called when the user clicks Back or Next, while OnWizardNext() is only called when the user clicks Next (as the name signifies). OnWizardNext() is also used for other purposes; it can direct the wizard to a different page instead of the next one in order, if some pages need to be skipped.

The wizard in the sample project has two more pages, CWizBkPicturePage and CWizFinishPage. Since they are similar to the two pages above, I won't cover them in detail here, but you can check out the sample code to get all the details.

Other UI Considerations

Centering a sheet

The default behavior of sheets and wizards is to appear near the upper-left corner of their parent window:

 [sheet position - 19K]

This looks rather sloppy, but fortunately we can remedy it. Thanks to the folks on the forum who provided the code to do this; the previous version of the article did it in a much more complicated way.

The property sheet or wizard class can handle the WM_SHOWWINDOW message. The wParam sent with WM_SHOWWINDOW is a boolean indicating whether the window is being shown. If wParam is true, and it's the first time the window is being shown, then it can call CenterWindow().

Here is the code that we can add to COptionsSheet to center the sheet. The m_bCentered member is how we keep track of whether the sheet has already been centered.

class COptionsSheet : public CPropertySheetImpl<COptionsSheet>
{
//...

  BEGIN_MSG_MAP(COptionsSheet)
    MSG_WM_SHOWWINDOW(OnShowWindow)
    CHAIN_MSG_MAP(CPropertySheetImpl<COptionsSheet>)
  END_MSG_MAP()
 
  // Message handlers

  void OnShowWindow(BOOL bShowing, int nReason);
 
protected:
  bool m_bCentered;  // set to false in the ctor

};
 
void COptionsSheet::OnShowWindow(BOOL bShowing, int nReason)
{
  if ( bShowing && !m_bCentered )
    {
    m_bCentered = true;
    CenterWindow ( m_psh.hwndParent );
    }
}

Adding icons to pages

To use other features of sheets and pages that are not already wrapped by member functions, you'll need to access the relevant structures directly: the PROPSHEETHEADER member m_psh in CPropertySheetImpl, or the PROPSHEETPAGE member m_psp in CPropertyPageImpl.

For example, to add an icon to the Background page of the options property sheet, we need to add a flag and set a couple other members in the page's PROPSHEETPAGE struct:

CBackgroundOptsPage::CBackgroundOptsPage()
{
  m_psp.dwFlags |= PSP_USEICONID;
  m_psp.pszIcon = MAKEINTRESOURCE(IDI_TABICON);
  m_psp.hInstance = _Module.GetResourceInstance();
}

And here's the result:

 [Tab icon - 5K]

Up Next

In Part 9, I'll cover WTL's utility classes, and its wrappers for GDI object and common dialogs.

Copyright and license

This article is copyrighted material, (c)2003-2006 by Michael Dunn. I realize this isn't going to stop people from copying it all around the 'net, but I have to say it anyway. If you are interested in doing a translation of this article, please email me to let me know. I don't foresee denying anyone permission to do a translation, I would just like to be aware of the translation so I can post a link to it here.

The demo code that accompanies this article is released to the public domain. I release it this way so that the code can benefit everyone. (I don't make the article itself public domain because having the article available only on CodeProject helps both my own visibility and the CodeProject site.) If you use the demo code in your own application, an email letting me know would be appreciated (just to satisfy my curiosity about whether folks are benefitting from my code) but is not required. Attribution in your own source code is also appreciated but not required.

Revision History

September 13, 2003: Article first published.
January 13, 2006: Moved options sheet/wizard code into the view class. Updated section on centering the sheet.

Series Navigation: « Part VII (Splitter Windows)

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here