Introduction

I've written a lot of COM code over the years. One of things I used quite liberally were OLE property pages. It was a handy way to configure an otherwise invisible COM component in a safe, reliable way. I know, most people associate property pages with ActiveX controls, but property pages are based on interfaces, and those interfaces are completely independent of the ActiveX control architecture.


 

One of the best things about OLE property pages is that you don't have to know anything about the object you've just instantiated to use them. You can just query for the supported interface (ISpecifyPropertyPages) and if it exists, call the GetPages() method to retrieve the CLSID's of the property pages for the object. From there, it's just a little jump to OleCreatePropertyFrame() and voilà, you've just given that COM component the ability to configure itself rather than you having to do all the work.

OLE property pages were painfully absent from .NET, and I immediately began trying to figure out how to get them back. What I found was that this was no simple task.

The Problems

The first obvious problem was porting the various interfaces and structs to .NET. There are three primary interfaces required to implement this fully. They are ISpecifyPropertyPages, IPropertyPage, and IPropertyPageSite. When scanning all the methods of those interfaces, I quickly narrowed down what structs I would have to implement. Fortunately for me, .NET already knows about RECT (Rectangle), MSG (Message), SIZE (Size), and POINT (Point) structs and how to marshal them, so all we really needed to implement was the PROPPAGEINFO struct and the CAUUID struct.

Both structures allocate and free COM memory in ways that are counter-intuitive for straight conversion to strings and Guid arrays. I opted for the IntPtr method for the three strings in the PROPPAGEINFO struct and for the Guid array in the CAUUID structure. From there, I just implemented a few helpers within the struct to assign and extract the data out of the IntPtr's.

A Sanity Check

After I had successfully mapped the structs and interfaces, it was time to test this baby out! Quickly, I hashed together a few lines of code:

Type typ;
object obj;
Guid[] g;
typ = Type.GetTypeFromProgID("MSComDlg.CommonDialog");
obj = Activator.CreateInstance(typ);
ActiveXMessageFormatter.InitStreamedObject(obj);
ISpecifyPropertyPages pag = (ISpecifyPropertyPages)obj;
CAUUID cau = new CAUUID(0);
pag.GetPages(ref cau);
g = cau.GetPages();
// The method below was added in a base class mentioned later in
// the article
PropertyPage.CreatePropertyFrame(IntPtr.Zero, 100, 100,
"Hello World", new object[] {obj},
g);

I ran it, and voilà! The Common Dialog control's property pages popped up on the screen. Encouraged, it was time to get down to implementing my own property pages in my existing .NET apps.

Implementing Property Pages in .NET

The first thing I needed was a base class to handle the various aspects of implementing a property page. It would originally derive from UserControl, but for reasons I will explain later, I instead inherited from Form. This base class would implement IPropertyPage and handle the management of the underlying objects whos properties are being exposed along with providing a few virtual methods for a derived class to receive events on. This base class also had to do a little bit of magic to get the UserControl to display inside the requested container (IPropertyPage::Activate passes a parent window inside which this control needs to reside).

The Derived Classes

I created two pages, derived from my PropertyPage base class. I threw a couple controls on them, gave them both [Guid("")] attributes, and made them public. I also opted to register the class library for COM interop because we're writing essentially a COM-callable set of classes. I chose also to give them the [ClassInterfaceType(ClassInterfaceType.None)] attribute.

I then created a class that would expose the ISpecifyPropertyPages interface. The single method, GetPages(), is really simple:

public void GetPages(ref CAUUID pPages)
{
Guid[] g = new Guid[2];
g[0]     = typeof(MyPropertyPage1).GUID;
g[1]     = typeof(MyPropertyPage2).GUID;
pPages.SetPages(g);
}

I didn't think it merited any kind of base class. Okay! So, after giving this class a Guid() attribute and setting its ClassInterfaceType to none, it was time to get cracking!

Interop Blues

I re-ran my test application, replacing the MSComDlg.CommonDialog ProgID with the ProgID of my new class (the one that exposed ISpecifyPropertyPages). To my horror, it failed to cast the resultant object to ISpecifyPropertyPages. It seems that because I had compiled both the class library and the test application using the common source containing the interfaces and structs, each of those declarations became specifically unique to each assembly. Therefore, I couldn't do a straight cast to the interface even though they had the same Guid. This was totally understandable, of course, so I quickly regrouped.

Because I was using the Activator class to activate the object, what would happen if I instead used CoCreateInstance() directly to make a COM object? Would the .NET marshaler realize I was creating a .NET object in spite of my using the API directly? Turns out the answer to that question is YES. On the successful return of CoCreateInstance, .NET helpfully unwrapped my class and handed me a .NET object back.

It turns out that, no matter how hard I tried, or how indirectly I attempted to marshal this object, .NET would always give me back the base .NET class object. I tried getting an IntPtr of the object's IUnknown, calling Marshal.QueryInterface on it to get the interface and putting it back into object form. I tried every conceivable method I could think of, and none of them worked. This was a major stumbling block.

There was no way I could guarantee that any arbitrary .NET class would use a common assembly that implemented a "common" version of the interfaces I wanted. Furthermore, there was no way I could guarantee that one person's implementation of the interfaces would be identical to mine. I simply had to use .NET's marshaling code to work with these objects through COM—COM was the only commonality I had, so I had to use it regardless of what language the actual object was written in.

Because .NET would insist on giving me a .NET object no matter how I cast, cajoled, or CoCreateInstance'd the .NET class, I had to resort to drastic measures. I'd have to do something unmanaged.

Reborn in Managed C++

C++ is the only place I can mix managed and unmanaged code, so this is the only place where my unorthodox requirements could be met. I quickly threw the PropertyPage base class, interfaces, and structs into C++ (when I say "quickly," I mean I hacked at it endlessly until I managed to get the clean C# code into clunky C++ managed format), and created an unmanaged global function to retrieve the ISpecifyPropertyPages interface given a CLSID and retrieve the pages from it.

After all that, I revisited my original C# class library. A quick reference to my C++ managed DLL, a recompile later, and my test application was working perfectly! I'm home free! Wait! Uh oh ...

UserControls, Events, & Hosting Environments

Once my property page showed up on the screen, I was convinced my work was done. However, as soon as I began typing keys, I realized I was far from finished. The tab key didn't work properly, the UserControl on the page wasn't receiving events properly, and things were Not At All Right.

I thought the tab key was a quick fix. I ran the SPY++ program and analyzed the windows of the property pages. I noticed that the UserControl upon which my PropertyPage base class was derived wasn't given the WS_EX_CONTROLPARENT extended window style. I modified the base class to give the property page this style when I show the page.

After making that change, the tab key started working, albeit badly. I noticed that none of the controls received an OnFocus event, and the tab key seemed to randomly go to the controls on the page, ignoring my tab order completely. It wasn't so random, actually. It was using the Z-Order of the controls on the page rather than the tab order. It seemed to me that Windows was cycling through the controls rather than using the form's inner logic.

I started looking for ways to properly apply the TranslateAccelerator() messages I was receiving from the page's site. In the end, I had to rip out the code that set WS_EX_CONTROLPARENT (it was interfering with .NET's tabbing internals) and write my own tabbing logic in the base class.

From UserControl to Form

Once I got tabbing right, I again thought that I was home free! It wasn't until I started adding mnemonics to controls that things once again went south. In spite of my improved TranslateAccelerator() handling code, I was still unable to use mnemonics. In desperation, I decided to change my inheritance from UserControl to Form. To transition to Form, I had to radically alter the window styles using the API before showing the window, set the form's parent, and pray.

I wasn't confident this would work. I'm delving deep into the "unsupported" section of Windows.Form, so who knows what kind of mess I was creating? Fortunately for me, however, things started looking up. I recompiled the class library and re-ran my test application. To my delight, all was working beautifully! The tab key was tabbing properly, mnemonics were working, and I sat down to start writing this article.

COM and .NET Interop Gotchas

Because OLE property pages are a COM animal, I decided to see just how well native COM could deal with these property pages. I wrote a UserControl in C# for use as an ActiveX control to see what would happen if I added property page support to it. ActiveX controls in .NET aren't officially supported, and you're about to find out one of the reasons why!

The ActiveX Control Test Container and .NET

After plugging in all the code I'd need, it was time to break open the test container and see what happens. I successfully added my control to the container, and it did show properly. I right-clicked the control, and lo and behold, a Properties menu option was there and enabled! I clicked it, and was quite pleased to find my property pages flashing up on the screen before me. Success!

Not. Although the test container calls the standard OleCreatePropertyFrame API just like my .NET test application did, the objects it passed in as parameters were to my horror, inherited from __ComObject—which meant that .NET for some reason wasn't able to unwrap my objects to their native .NET form. As a result, when a page tried to access or modify any member of the underlying object, it failed.

It was time to dig out the source for the test container and start scraping. I quickly found out that the test container aggregates the ActiveX control if it can. Meaning, when it creates the ActiveX control, it creates it with an outer unknown. This outer unknown intercepts IUnknown, IDispatch, and the container's own extended interface, but delegates the remaining queries to the inner object.

Because the control container (and many other ActiveX control hosting environments) create the controls as aggregates, it means that .NET has no way of properly marshaling and unwrapping the object before giving it to native .NET code! This meant that none of my property pages could talk to the underlying control properly. I tried a lot of different hacks to make this work, and finally came across an easy fix.

I added a new interface, called IProvideObjectHandle, to my base C++ class library. It has a single property, ObjectHandle, that returns (what else) and ObjectHandle! Because ObjectHandle is a MarshalByRef object that wraps up other objects for remoting, I thought this would be the perfect medium to pass my base .NET object to my property pages.

I found that even though my property page received __ComObject's from the control test container, I could still query for public interfaces and receive pointers (still __ComObject-based, but valid pointers nonetheless). Therefore, by implementing IProvideObjectHandle in my control, my property pages could query for it and then call the property ObjectHandle. Here's how my control implemented it:

public ObjectHandle ObjectHandle
{
get
{
return new ObjectHandle(this);
}
}

If, during the COM->.NET interop, the original aggregated version of the control could not be passed properly, the solution is to pass an object that could be marshaled and translated to a native object properly! ObjectHandle would take care of our interop transition for us, and once we were in all-native land, provide its Unwrap() method to safely provide us with a native object.

There was one other issue, left unresolved regarding the ActiveX Control Test Container—it never released the ActiveX control entirely. There was one open reference on the control itself. This seemed to be the case even if I created an empty control and instantiated it in the test container. The refcount on the control never reached zero, and I assume this is because of the aggregation occurring and the references being passed into and out of the control. It was beyond the scope of this author and this article to try and resolve and is just one of the many reasons authoring ActiveX controls are not officially supported in .NET.

What's Left

There are a few things I left out of this implementation—persistance being a major one. I'm leaving that alone because persisting objects in .NET is a pretty well-covered topic. You can decide for yourself how to persist the objects you instantiate and configure via property pages.

The other thing I left out was implementing custom property page containers. If the standard OleCreatePropertyFrame() function isn't your cup of tea and you want to roll your own, there are a few gotcha's in store for you along the way—all having to do with .NET native objects talking to other .NET native objects and how they pass interfaces around. Most of the hoops I had to jump through had to do with that subject.

The Source Code

The source code associated with this article has three projects:

  • The PropertyPages project, which is a managed C++ project complete with a strong name. It defines the interfaces and the base class the other projects use.
  • The ControlWithPropSheet project is a C# class library that exposes three public classes, registered for COM interop. The MyUserControl control implements the ISpecifyPropertyPages and IProvideObjectHandle interfaces, both of which only have a single method and are trivial to implement yourself. The other two classes are the PropertyPage-derived classes that provide property pages to the control. They are intentionally simple so you can get an understanding of what's been done.
  • The final project is DisplayPropSheets, a C# console application that displays the property sheets of any COM object you give it, provided the COM object implements ISpecifyPropertyPages.

Downloads

  • PropertyPages.zip - VS.NET 2003 Project and Files
  • 转:http://www.codeguru.com/Cpp/Cpp/cpp_managed/nfc/article.php/c8545/#more

    posted on 2007-11-20 10:54  Dragon-China  阅读(1255)  评论(0编辑  收藏  举报