|
Models, Views, and Frames |
|
This originally appeared as a chapter in High
Performance Delphi 3 Programming
and then as a two part magazine series that started in the October 1997
issue of Visual Developer magazine as the cover article.
One of Delphi's subtler virtues is the way it simplifies many aspects of the Windows API. Things that once required lots of cumbersome code have been reduced to a property assignment. What was so arcane as to be unimaginable is now quite simply trivial. While the Canvas property is probably the most familiar example of this, the Parent property deserves our attention, too. When you set a control's Parent property, you have told Windows to make that control a child window of its new Parent. It is now visible and enabled whenever its parent is visible and enabled. (This is how tabbed notebooks work: Each page is, in effect, a panel. All the components on it are children of the notebook page. When a page is brought to the front, over the other pages, it becomes visible, and so do all the components on it.) If you set the Parent property at runtime, you can get all sorts of special effects, from dynamic creation of notebook-like controls to placing one form on a blank area of another form. Why might you want to embed one form in another form? Consider these four scenarios:
Frames can handle the third scenario rather neatly, too. If you build a tabbed dialog with a frame on each tab, you have a generic tabbed dialog that can hold any views that you need it to hold. The form's unit has been pared down from an unwieldy unit containing all sorts of (at least potentially) unrelated code to a simple container with little more than the code to fill each frame and, perhaps, some OnChanging logic that tells the active frame to ask its view if it's OK to change to another tab.Your large tabbed dialog can now be split into multiple units, so that each member of a team can check in her own updates. No more frustrating losses of updates or reversal of bug fixes due to sloppy merging. Somewhat similarly, a form for editing the class hierarchy in scenario four might contain a few controls for the things that can be done to all members of the hierarchy and a frame where you can place the view specific to each member of the hierarchy. (You could make the object responsible for telling the editing form which view to use.) To switch from one type of object to another, you'd simply put a different view in the frame. Real Live CodeThis chapter might have seemed a bit abstract, so far. Day in, day out, you work with objects, components, forms, and event handlers - not models, views, and frames. But these abstractions are useful and even necessary. The standard model/view abstraction helps us avoid the "RAD trap" of scattering our applications' logic throughout a bunch of event handlers where it's hard to follow, hard to modify, and hard to reuse. Adding the notion of a frame helps us avoid the similar trap of tying a view to a particular container object, like a form, panel, or notebook page. Still, the theory's only as good as the code it helps you write - how do you implement a view so that you can plop it into more than one frame? One approach might be to use the new D3 notion of component templates. These let us bundle up a group of interacting components - complete with names and event handlers - and place them on the component palette for reuse. This is a great idea, but it doesn't quite do what we want, here, because what we end up with is components on a form. When we want to put the same view on two different forms we find that the collection as a whole is not an object that we can give methods to - so how can we tell the view to read or write its model? When we have, say, two different Person views in the same Employee view, we find that placing two instances of a component template on the same form, means that all the controls in the second instance lose their saved names and end up as Label1, Edit1, and so forth. When we want to do team development of complex dialogs, we find both that all the pages end up in the same unit, and also that changing the template doesn't change the instance. A similar but 'stronger' approach is to make a view into a compound component, whose private variable include other visual components. However, if you've ever actually done this, you know that creating and resizing the component is a real nightmare. Instead of simply placing components on a form the way we've grown used to, we have to explicitly create each subcomponent. Instead of dragging components around and setting properties in the Object Inspector until things look right, we have to manually set each and every property. This is doable, of course, but it's very slow, tedious, and error-prone, and we can end up with a lot of code that's hard to read and/or maintain. Worse, because the process is so painful, one tends to try to do as little as possible and ends up with ugly, poorly laid out messes. Views built this way may 'have all the widgets they need', but often aren't very usable. We could avoid all the pain and ugliness of manually building compound components if we could build a a form visually and then turn it into a component. Delphi, does in fact, allow us to do just this, but it's not particularly easy or straightforward. You have to buy or build a special design-time-only component to set the form's undocumented IsControl property; add some code to the form; and manually edit the .dfm file to change the form object's parent class. If you really want to do this, Ray Lischner's excellent book Secrets Of Delphi 2 supplies the special component to tweak IsControl, and tells you step-by-step how to proceed - but while I found this section of his book fascinating reading, I've never actually used the technique and recommend that you don't either. Why? Because you have to go through the same procedure each time you create a new view, or change an existing one. Turning a form into a component in this way seems to make more sense for really generic components - such as bundling a TMemo or a TRichEdit with a toolbar - than for views. Instead, I use form inheritance to derive perfectly ordinary forms from TEmbeddedForm. As you can see in Listing 1, which is an extract from Embedded.pas, embedded forms have a special constructor which lets them be treated as 'lightweight controls' which can be placed on any container control - panel, notebook page, or group box - at runtime. Since forms are themselves objects, you can freely add any methods you need to make them act like views. Since these lightweight controls are normal forms, it's just as easy to change an embedded form as your project evolves as it is to change any other form. Listing 1 - A special constructor for embedded formstype EmbeddedFormMode = (efmZoomed, efmTopLeft, efmCentered); function ALZ(Number: integer): Cardinal; // At Least Zero begin if Number > 0 then Result := Number else Result := 0; end; constructor TEmbeddedForm.CreateEmbedded( _Owner: TComponent; Frame: TWinControl; Mode: EmbeddedFormMode ); begin Inherited Create(_Owner); Parent := Frame; BorderIcons := []; BorderStyle := bsNone; case Mode of efmZoomed: Align := alClient; efmTopLeft: begin Top := 0; Left := 0; end; // efmTopLeft efmCentered: begin Top := ALZ((Frame.Height - Height) div 2); Left := ALZ((Frame.Width - Width) div 2); end; // efmCentered else Assert(False); end; // case Visible := True; end; The most important line in Listing 1 is "Parent := Frame", which makes the Frame control the embedded form's Parent. This is just like what is done 'behind the scenes' for a form's design-time controls when a form is loaded, and it has three consequences. First, a child control is visible whenever its parent is visible. Thus, hiding the frame hides the view, while making the frame visible or bringing it to the front also makes the view it contains visible. Second, child controls are clipped to their parent's client area; large views are automatically clipped to fit within their frame. Third, the child control is positioned relative to its parent's client area; just as with any other control, the embedded form's Top and Left properties are relative to the container it's placed on. This last means that when an embedded form is "zoomed" by setting Align to alClient, it acts just like any other alClient aligned control - it sizes itself to fill its frame, and automatically resizes itself (and calls any OnResize handler) whenever the frame is resized. Conversely, unzoomed views retain their design-time size, and may be centered or placed at the topleft corner of their frame. You can match the view's starting size to its frame's starting size by setting its ClientHeight and ClientWidth properties at design time - or you can size frame windows based on the design size of their embedded forms, as the generic wizard and property sheets I'll talk about in the Model Editors section do. Before we get too far from Listing 1, though, I should point out that the lines "BorderIcons := []" and "BorderStyle := bsNone" mean that what actually appears at runtime is the view form's client area - there's no border or caption to show the frame actually contains a whole independent form. Thus, as you can see in Figures 3 and 4, a view's designtime Caption will have no effect at all at runtime. From Embedded Forms To ViewsThe ability to use a form as a lightweight control is obviously a good start. We can place the form wherever we like. We can make as many copies of the form as we need. We can add methods to the form object so that it 'acts like' a view. How should a view act?
Listing 2 - Model, View, and Frame Behaviortype TModel = TObject; // Both IView and IModelEdit have a ReadOnly property IReadOnly = interface function GetReadOnly: boolean; procedure SetReadOnly(Value: boolean); property ReadOnly: boolean read GetReadOnly write SetReadOnly; end; // Fill a view from a model & write changes back; // frame/view interactions IFrame = interface; IView = interface (IReadOnly) procedure ReadFromModel(Model: TModel); procedure WriteToModel(Model: TModel); function GetValid: boolean; procedure SetValid(Value: boolean); property Valid: boolean read GetValid write SetValid; procedure AddNotifiee( Notify: IFrame); procedure RemoveNotifiee(Notify: IFrame); end; IFrame = interface procedure OnValidChanged( ChangingObject: TObject; View: IView ); end; // Wizards and Property Sheets are "model editors" IModelEdit = interface (IReadOnly) // Low level routines that allow an app to setup // an editor once, and run it several times. procedure Initialize; function RunEditor(Model: TModel): boolean; procedure Finalize; // Initialize/RunEditor/Finalize function EditModel(Model: TModel): boolean; end; It's probably pretty clear that we're working with a simple architecture here. Models are totally passive containers of data that are usually created, loaded, or stored by a data module Views can read and write models on command, and can tell their frames when they become valid or invalid. Model editors - both wizards and property sheets - simply setup and run, and report back on whether they've actually made any changes to the model object because the user hit OK. What we don't have is a full-blown Model-View-Controller architecture, where models can tell views to update themselves because the model has changed, and so on. It certainly wouldn't be all that hard to add this, but it would only further complicate a chapter on embedded forms - and it's not as if this simple Model-View-Frame architecture is weak or simplistic. I've used it with great success in several different projects. Before we move on, you may wondering why I use interfaces, instead of simply defining view objects and frame objects and deriving from them. Am I just using interfaces for the sake of using the new whizbang features in D3? Not really. To start with, views and frames have a circular relationship. Frames need to tell their views when to read or write models; views need to tell frames when their Valid property changes. Obviously, I could have implemented this circularity using forward class declarations instead of a forward interface declaration, but I think using interfaces makes the interactions more obvious than when they're obscured by various unrelated object properties and methods. Also, though I don't take advantage of this in EmbeddedForms.dpr, using interfaces means that a view might be implemented in several different ways; views don't have to be descendants of TEmbeddedForm. The most important reason, though, is that a view may also be a frame. For example, an Employee object might contain references to 'employee' and 'supervisor' People objects, each of which contains name and address information. A view of the Employee object might contain a view of the employee or supervisor field. Delphi doesn't support multiple inheritance, so an object can't be simultaneously a TView and a TFrame, but an object can easily implement both the IView and IFrame interfaces. Before Delphi supported interfaces, the best way to implement something like the INotify protocol was to use procedural types: type TOnValidChanged = procedure(ChangingObject: TObject) of object; procedure TView.AddNotifiee(Callback: TOnValidChanged) This worked, but TOnvalidChanged is basically the same as TNotifyProc, and obviously every Delphi program has a lot of those. You can pass any TNotifyProc to this AddNotifiee, and the compiler has no way to prevent you from passing the wrong one by mistake. With interfaces, the frame's callback has to have the right name and the right signature, and it has to be in an object that has promised to implement the IFrame protocol - so it's much harder to make a careless mistake. Interfaced FormsWhen I began to implement the interfaces in Listing 2, I ran into a snag. Though the resolution was simple, it took me many hours of generating and testing hypotheses before I could stop my system from crashing whenever I called AddNotifiee(Self) from a form that implemented IFrame. To understand what was going on, you're going to need a bit of background. The D3 documentation is quite clear that any object that implements an interface must also implement the IUnknown interface, which supports reference counting and interface querying. If you declare type IFoo = interface procedure Foo; end; TFoo = class (TObject, IFoo) procedure Foo; end; procedure TFoo.Foo; begin end; the compiler will complain about the undeclared identifiers QueryInterface, _AddRef, and _Release. You have to explicitly implement IUnknown, or you have to derive your object from TInterfacedObject instead of from TObject. On the other hand, the compiler won't complain at all about type TFoo = class (TForm, IFoo) procedure Foo; end; procedure TFoo.Foo; begin end; That means that Borland implemented IUnknown somewhere in the VCL, and we're home free, right? Well, no. Whenever you pass a TForm as an interface reference, you get GPF's in the VCL. It turns out that while TComponent does implement IUnknown's methods, it does so in a way which isn't very useful to those of us who want to use interfaces within an application.. IUnknown calls are delegated to FVCLComObject, which is a pointer that is only set when you call GetComObject to get an interface reference for the object. What's more, GetComObject only sets FVCLComObject if you have used VclCom in your project. If you do that, your call to GetComObject generates complaints about class factories not being registered - at which point I stopped caring. This is probably great if you are sharing COM objects with other applications, but if all you wanted was to add interfaces to your forms? It's far simpler to just look at the implementation of TInterfacedObject and add a simple, self-contained IUnknown implementation to TForm, and then just derive from TInterfacedForm instead of from TForm. Listing 3 - InterfacedForms.pasunit InterfacedForms; // Copyright © 1997 by Jon Shemitz, all rights reserved. // Permission is hereby granted to freely use, modify, and // distribute this source code PROVIDED that all six lines of // this copyright and contact notice are included without any // changes. Questions? Comments? Offers of work? // mailto:jon@midnightbeach.com // ------------------------------------------------------------ // Adds a functional IUnknown implementation to TForm. interface uses Classes, Forms; type TInterfacedForm = class (TForm, IUnknown) private fRefCount: integer; protected function QueryInterface( const IID: TGUID; out Obj): Integer; stdcall; function _AddRef: Integer; stdcall; function _Release: Integer; stdcall; public property RefCount: integer read fRefCount write fRefCount; end; implementation uses Windows; // for E_NOINTERFACE // IUnknown code based on the TInterfacedObject source function TInterfacedForm.QueryInterface( const IID: TGUID; out Obj): Integer; begin if GetInterface(IID, Obj) then Result := 0 else Result := E_NOINTERFACE; end; function TInterfacedForm._AddRef: Integer; begin Inc(fRefCount); Result := fRefCount; end; function TInterfacedForm._Release: Integer; begin Dec(fRefCount); Result := fRefCount; if fRefCount = 0 then Destroy; end; end. Pretty simple, actually. In retrospect, I don't know what took me so long, though I suppose I was sort of blindsided by the assumption that adding interfaces to a form wouldn't compile unless it was safe. While I was experimenting, however, I discovered one more problem with D3's implementation of interfaces that I should mention before we move on. Interface references are reference counted, just like huge strings are. Every time you make a copy of an interface reference variable (either by direct assignment, or by passing it to a procedure), the object's _AddRef method gets called to increment the reference count. Every time you remove a reference to the interface (either by explicitly reassigning a variable, or when it goes out of scope), the object's _Release method gets called to decrement the reference count. When the reference count goes to 0, the object frees itself. 'Old-style' object references don't affect the reference count at all. This works perfectly - if you only ever interact with the object through interface references. For example, if you have type IFoo = interface procedure Foo; end; TFoo = class (TForm, IFoo) procedure Foo; end; procedure TFoo.Foo; begin end; procedure Bar(InterfaceReference: IFoo); begin end; begin Bar(TFoo.Create); end. the TFoo created in the call to Bar will be automatically freed when Bar returns. But consider a slightly different scenario, where we mix object reference and interface references: var ObjectReference: TFoo; begin ObjectReference := TFoo.Create; try Bar(ObjectReference); finally ObjectReference.Free; end; end. The problem is that setting ObjectReference to TFoo.Create does not affect the object's reference count. After we set ObjectReference, the RefCount property is still 0, just as it was when the object was created. However, when we call procedure Bar, we do an implicit assignment to its InterfaceReference parameter. This generates a call to _AddRef, which sets RefCount to 1. When Bar returns, InterfaceReference goes out of scope. This in turn generates a call to _Release, which sets RefCount back to 0, and thus frees the object. ObjectReference is now invalid! The next time we refer to it - when we Free it, in this case - we will get a GPF. Obviously, this scenario is a bit contrived, but it serves to illustrate the sort of problems you will face if you start adding interfaces to legacy code. For that matter, even in new code there will be times when you find that the most natural way to work with an object is through a mixture of object and interface references. (In particular, there's nothing like TList that works with interface references.) In these cases, what you will need to do is to artificially set the object's reference count to 1, before you ever use an interface reference to it. For example, the next section's TAbstractView class has the following OnCreate handler: procedure TAbstractView.FormCreate(Sender: TObject); begin inherited; _AddRef; // so can pass Self as an interface reference end; Explicitly calling _AddRef this way means that the first interface reference sets RefCount to 2, and that RefCount will never go to 0. Thus, the object will never destroy itself and invalidate your object references; it will survive until you explicitly free it, in the old-fashioned way. Of course, you only need to call _AddRef explicitly if you will be mixing object and interface references. If you will only interact with the object via interface references, you should be very chary of explicitly calling _AddRef lest you mess up the reference counting so that your object doesn't free itself when it should. Conversely, when you do have such a 'pure interface' object, you should be careful to never create an object reference to it, lest that object reference become invalid when the interface reference count goes to 0 and the object frees itself. One simple precaution you can take is to make all the interfaced methods protected - they will still be publically accessible via the interface, but if you can't access them from an object reference, you're not particularly likely to ever create one. Abstract, Valid, and Fickle ViewsAs you can see from the inheritance tree in Figure 5, the EmbeddedForms project uses interfaced forms to build two basic sorts of views: valid views, whose Valid property is always True, and fickle views, whose Valid property may change. You might use a valid view for something like a memo field, where you don't care about the contents in any way, or perhaps for an initial or final panel of a wizard. You would use a fickle view any time it's possible for users to enter invalid data that they should not be allowed to save - a date like February 31st, proposed expenditures that exceed the budget, and so on. Since both implement the IView interface, both can be handled identically by generic code that knows only that it has a collection of views to deal with. Both types of view descend from Listing 4's TAbstractView: Listing 4 - Views.pasunit Views; // Copyright © 1997 by Jon Shemitz, all rights reserved. // Permission is hereby granted to freely use, modify, and // distribute this source code PROVIDED that all six lines of // this copyright and contact notice are included without any // changes. Questions? Comments? Offers of work? // mailto:jon@midnightbeach.com // ------------------------------------------------------------ // Maps the IView contract onto an embedded form. Typically, // you would derive views from TValidView or TFickleView. interface uses Models, Embedded; type TAbstractView = class(TEmbeddedForm, IView) procedure FormCreate(Sender: TObject); private fReadOnly: boolean; protected function GetValid: boolean; virtual; abstract; procedure SetValid(Value: boolean); virtual; abstract; function GetReadOnly: boolean; virtual; procedure SetReadOnly(Value: boolean); virtual; public procedure ReadFromModel(Model: TModel); virtual; procedure WriteToModel(Model: TModel); virtual; procedure AddNotifiee( Notify: IFrame); virtual; abstract; procedure RemoveNotifiee(Notify: IFrame); virtual; abstract; property Valid: boolean read GetValid write SetValid; property ReadOnly: boolean read fReadOnly write SetReadOnly; end; TViewClass = class of TAbstractView; implementation {$R *.DFM} function TAbstractView.GetReadOnly: boolean; begin Result := fReadOnly; end; // TAbstractView.GetReadOnly procedure TAbstractView.SetReadOnly(Value: boolean); begin fReadOnly := Value; Enabled := not Value; // A read only view will display information but not // let users change it; you can override SetReadOnly // to change the appearance of read only views. end; // TAbstractView.SetReadOnly procedure TAbstractView.ReadFromModel(Model: TModel); begin end; // TAbstractView.ReadFromModel procedure TAbstractView.WriteToModel(Model: TModel); begin end; // TAbstractView.WriteToModel procedure TAbstractView.FormCreate(Sender: TObject); begin inherited; _AddRef; // so can pass Self as an interface reference end; end. TAbstractView effectively splits the IView protocol into three parts - read-only, validation, and model IO - and handles each a bit differently.
As you might imagine from the name, you're not meant to actually use a TAbstractView nor to inherit directly from one. Rather, you would use a TValidView or a TFickleView. Listing 5 - The IValid methods in ValidViews.pasfunction TValidView.GetValid: boolean; begin Result := True; end; // TValidView.GetValid procedure TValidView.SetValid(Value: boolean); begin // A TValidView is always Valid - ignore the Value end; // TValidView.SetValid procedure TValidView.AddNotifiee(Notify: IFrame); begin // A TValidView is always Valid - ignore the Add request end; // TValidView.AddNotifiee procedure TValidView.RemoveNotifiee(Notify: IFrame); begin // A TValidView is always Valid - ignore the Remove request end; Listing 6 - Extracts from FickleViews.pastype TFickleView = class(TAbstractView) private fValid: boolean; fNotify: IFrame; // This implementation of IValid only supports 1 Notifiee public procedure AddNotifiee( Notify: IFrame); override; procedure RemoveNotifiee(Notify: IFrame); override; function GetValid: boolean; override; procedure SetValid(Value: boolean); override; end; procedure TFickleView.AddNotifiee(Notify: IFrame); begin fNotify := Notify; end; // TFickleView.AddNotifiee procedure TFickleView.RemoveNotifiee(Notify: IFrame); begin fNotify := Nil; end; // TFickleView.RemoveNotifiee function TFickleView.GetValid: boolean; begin Result := fValid; end; // TFickleView.GetValid procedure TFickleView.SetValid(Value: boolean); begin if Value <> fValid then begin fValid := Value; if Assigned(fNotify) then fNotify.OnValidChanged(Self, Self); end; // Value <> fValid end; Obviously, I could simply have collapsed all 'abstract', 'valid', and 'fickle' views into a single TView class. Splitting them up as I did has two main benefits: First, because valid views have special code to flat-out ignore the validation parts of the IView protocol, they are a trifle faster and take a bit less memory. More importantly, when you have derived a particular view from TValidView instead of TFickleView, its Valid property is always True, even if you carelessly set it to False. Model EditorsWizards and property sheets are both model editors - you pass them a model object, they run, and then they return control. If the user pressed the OK button and changed the model, they return True; otherwise they return False. The abstract wizard and property sheet in the EmbeddedForms.dpr project let you create actual wizards and property sheets that can share model objects and views. All you have to do is:
The abstract wizard and property sheet code does all the rest: Both automatically size themselves to fit the largest view. The wizard has standard Prev/Next/OK button logic; the property sheet will disable the OK button whenever a page is invalid, unless at least one page was already invalid when you called EditModel. Both call all views' ReadFromModel method on entry, and call all views' WriteToModel method if the user clicks OK. The property sheet has a ReadOnly property so you can let users look at objects without being able to edit them. Both are 'pure interface' objects with no public methods, so that you don't have to bother with Free or try finally. Listing 7, for example, is the code from main.pas that actually creates and runs the sample wizard and property sheet: Listing 7 - Running model editorsprocedure TTestForm.EditModel(Editor: IModelEdit; Model: TModel); begin {$ifdef ReadOnly} Editor.ReadOnly := True; {$endif} // ReadOnly if Editor.EditModel(Model) then ShowMessage('OK!') else ShowMessage('Abort ...'); end; // TTestForm.EditModel procedure TTestForm.RunWizard(Sender: TObject); var Employee: TEmployee; begin Employee := DataModel.NewEmployee; try EditModel(TWizard.Create(Self), Employee); finally Employee.Free; end; end; procedure TTestForm.RunSheet(Sender: TObject); var Employee: TEmployee; begin Employee := DataModel.LoadEmployee(3); try EditModel(TPropertySheet.Create(Self), Employee); finally Employee.Free; end; end; To me, the really amazing part of the wizard and property sheet code is how simple such generic code is, in Delphi. The key is the array of TViewClass argument to InitializeSheet() and InitializeWizard(): Listing 8 - TAbstractPropertySheet.InitializeSheet// From PropertySheets.pas procedure TAbstractPropertySheet.InitializeSheet( Captions: array of string; Views: array of TViewClass ); var MaxSpan: TSpan; Index: integer; Sheet: TTabSheet; ActualView: TAbstractView; begin Assert( fViews.Count = 0, 'Should only call ' + Name + '.InitializeSheet once' ); Assert( High(Captions) >= Low(Captions), // can use Slice() to 'Must have at least one tab' ); // pass empty arrays Assert( High(Captions) = High(Views), 'Must have same number of Captions as of Views' ); MaxSpan := Point(0, 0); for Index := Low(Captions) to High(Captions) do begin Sheet := TTabSheet.Create(Self); with Sheet do begin PageControl := Self.PageControl; Caption := Captions[Index]; end; // with Sheet ActualView := Views[Index].CreateEmbedded( Self, Sheet, efmTopLeft ); fViews.Add(ActualView); ActualView.AddNotifiee(Self); MaxSpan := UnionSpan(MaxSpan, ActualView.Span); end; // for Sheet := PageControl.ActivePage; Width := (Width - Sheet.Width) + MaxSpan.X; Height := (Height - Sheet.Height) + MaxSpan.Y; end; The three Assert statements check that this property sheet hasn't already been setup; that there is at least one caption; and that there are the same number of captions as view classes. (I really love Assert - I never quite realized just how cumbersome {$IfOpt D+} {$endif} was until I didn't have to use it anymore. Assertions are easier to type, as well as being smaller and easier to read.) Spans are defined in Embedded.pas. They're just a width/height pair, the BottomRight of a TRect whose Top and Left are 0: function TEmbeddedForm.Span: TSpan; begin Result.X := Width; Result.Y := Height; end; UnionSpan is very similar to the Windows API's UnionRect except that it works on spans instead of rectangles. Setting MaxSpan to (0, 0) is just setup for calculating the smallest rectangle that will hold all the Views. The real work is done in the loop on the Captions array. For each item in the array, we create a new tab sheet; place it on the page control; and set its caption. Then we use the array of class of TAbstractView argument, Views, to create a new view. We then add the new view to a TList of views; tell it to call its frame whenever Valid changes; and inflate MaxSpan. Once we've added all the views, we calculate how much room to allow 'around' MaxSpan for the frame, caption, buttons, and notebook tabs. We get this by looking at the difference between the form's height/width and the PageControl.ActivePage's height/width. TAbstractWizard is very similar, but is a bit more complicated because instead of notebook pages, we use three panels: An outer panel, a top-aligned caption panel; and a client-aligned frame panel. Activating a particular page is simply a matter of bringing the appropriate outer panel to the front: Listing 9 - TAbstractWizard.SetCurrentPage// From Wizards.pas property CurrentPage: integer read fCurrentPage write SetCurrentPage; procedure TAbstractWizard.SetCurrentPage(Value: integer); var LastPage, PageIsValid: boolean; begin Assert(TObject(fPanels[Value]) is TPanel); Assert(TObject(fViews[Value]) is TAbstractView); // Using Assert(is) with 'blind' casts gives us the debugging // safety of "as" without the (modest) performance hit fCurrentPage := Value; TPanel(fPanels[Value]).BringToFront; LastPage := Value = fPageCount; PageIsValid := TAbstractView(fViews[Value]).Valid; PrevBtn.Enabled := Value > 0; NextBtn.Enabled := PageIsValid and (not LastPage); OkBtn.Enabled := PageIsValid and LastPage; end; As you can see in Listing 9, another nice feature of Assert statements is the way the assert-and-blind-cast pair gives us all the develop-time type checking of as without any deployment-time speed penalty. Otherwise, the code is pretty straightforward: we set fCurrentPage and bring the appropriate panel to the front. Then we check to see if it is the first or last page and if the page is Valid, and then we set the Previous, Next, and OK buttons accordingly. The rest of the code in Wizards.pas and PropertySheets.pas is pretty straightforward. While I'll be pleased and flattered if you think it's worth studying, you don't need to understand it to use it, and it's certainly not worth killing trees to print any more of it. (Of course, it's all available online.) The sample modelWhile the real point of EmbeddedForms.pas is to give an extended example of embedded forms in action, and to give you a usable wizard and property sheet framework, it also contains a simplistic data model and four views, both to show you how to use the framework and to serve as an example of a view containing another view. The Data unit is a skeletal data module that contains methods to create, 'load', and 'save' Employee objects. In a real application, these methods would probably be wrappers around database access routines; here, the load method just retrieves some compiled-in dummy data and the save method does nothing at all. The Employee object contains references to two People objects, which contain personal data about the employee and his or her supervisor. The Employee ID view in Figures 1 and 2 allows the user to select the employee's supervisor from a pulldown list and to edit the employee's name and tax identification. The interesting part of this is that we are displaying the same view of the supervisor field as of the employee field - and using two different copies of the same view object to do so. At design-time, both views are just blank place holders (Figure 6). When we create the form, we create two instances of the Person ID view of Figure 7 and place each on the appropriate panel of the Employee ID view form. Listing 10 - EmployeeIdViews.pasunit EmployeeIdViews; // Copyright © 1997 by Jon Shemitz, all rights reserved. // Permission is hereby granted to freely use, modify, and // distribute this source code PROVIDED that all six lines of // this copyright and contact notice are included without any // changes. Questions? Comments? Offers of work? // mailto:jon@midnightbeach.com // ------------------------------------------------------------ // A reasonably plausible employee id view - select/view the // supervisor; set name and tax id. interface uses Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs, StdCtrls, ExtCtrls, Models, Embedded, FickleViews, PersonIdViews; type TEmployeeIdView = class(TFickleView, IFrame) SupervisorPnl: TPanel; SupervisorCaptionPnl: TPanel; SupervisorFrame: TPanel; SelectSupervisor: TComboBox; SupervisorLbl: TLabel; EmployeeIdFrame: TPanel; procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); procedure SelectSupervisorChange(Sender: TObject); private SupervisorView, EmployeeView: TPersonIdView; protected procedure ReadFromModel(Model: TModel); override; procedure WriteToModel(Model: TModel); override; procedure SetReadOnly(Value: boolean); override; procedure OnValidChanged( ChangingObject: TObject; View: IView ); end; implementation {$R *.DFM} uses Data; // create/destroy procedure TEmployeeIdView.FormCreate(Sender: TObject); var Index: integer; begin inherited; SupervisorView := TPersonIdView.CreateEmbedded( Self, SupervisorFrame, efmCentered ); SupervisorView.ReadOnly := True; SupervisorView.AddNotifiee(Self); EmployeeView := TPersonIdView.CreateEmbedded( Self, EmployeeIdFrame, efmCentered ); EmployeeView.AddNotifiee(Self); with DataModel do for Index := 0 to SupervisorCount - 1 do SelectSupervisor.Items.Add( GetEmployeeName(Supervisor[Index]) ); end; // TEmployeeIdView.FormCreate procedure TEmployeeIdView.FormDestroy(Sender: TObject); begin inherited; SupervisorView.RemoveNotifiee(Self); SupervisorView.Free; EmployeeView.RemoveNotifiee(Self); EmployeeView.Free; end; // TEmployeeIdView.FormDestroy // IView overrides procedure TEmployeeIdView.ReadFromModel(Model: TModel); begin Assert(Model is TEmployee); with TEmployee(Model) do begin SupervisorView.ReadFromModel(Supervisor); EmployeeView.ReadFromModel(Employee); SelectSupervisor.ItemIndex := DataModel.IndexOfSupervisor(Supervisor.ID); end; // with end; // TEmployeeIdView.ReadFromModel procedure TEmployeeIdView.WriteToModel(Model: TModel); begin Assert(Model is TEmployee); with TEmployee(Model) do begin SupervisorView.WriteToModel(Supervisor); EmployeeView.WriteToModel(Employee); end; // with end; // TEmployeeIdView.WriteToModel procedure TEmployeeIdView.SetReadOnly(Value: boolean); begin inherited; EmployeeView.ReadOnly := ReadOnly; SelectSupervisor.Color := ShowReadOnly_EditColors[ReadOnly]; end; // TEmployeeIdView.SetReadOnly // change supervisor procedure TEmployeeIdView.SelectSupervisorChange(Sender: TObject); var ID: TPersonID; Supervisor: TPerson; begin inherited; ID := DataModel.Supervisor[SelectSupervisor.ItemIndex]; Supervisor := DataModel.LoadPerson(ID); try SupervisorView.ReadFromModel(Supervisor); finally Supervisor.Free; end; end; // TEmployeeIdView.SelectSupervisorChange // frame notification procedure TEmployeeIdView.OnValidChanged( ChangingObject: TObject; View: IView ); begin Valid := SupervisorView.Valid and EmployeeView.Valid; end; // TEmployeeIdView.OnValidChanged end. FormCreate creates the two TPersonID views and registers itself as their frame. The supervisor view is read-only; the only way to change it is via the drop-down box. FormDestroy undoes the registration (releases the interface reference) and frees the embedded forms. ReadFromModel and WriteToModel basically just delegate their job to the embedded views. In general, it's a good idea for all model IO functions to do as these do, and Assert that their Model argument is of the type that they expect. This will cause a runtime error during development if you pass the wrong model type to the model editor, or pass the wrong view type to the model editor's setup routine. Other ApplicationsEmbeddedForms.dpr really only illustrates the first two scenarios that I laid out at the beginning of this chapter: Using the same form in a wizard and a property sheet, and using forms as lightweight components. I haven't shown you any actual examples of the second two scenarios: Using embedded forms for team development of a tabbed dialog, or to build a generic editor that can handle any member of an object hierarchy. However, I have illustrated the techniques you would need to handle these less common cases. To build a dialog out of multiple independent forms, just have each derive from TEmbeddedForm. Create a tab for each page, and in the dialog's OnCreate handler, call CreateEmbedded for each page's form. I generally make a point of freeing each page in the OnDestroy handler, honoring the general Free What You Create rule, but this isn't strictly necessary as freeing the dialog will free all its child components. If you need any sort of per page validation - perhaps you won't let users tab off of invalid pages - you can derive from TFickleView instead of TEmbeddedForm. You could model a generic editor on the abstract model editors: A container object with all the standard controls you need, and a blank frame panel where the object-specific controls will go. Each member of the object hierarchy might have a class function which returns a TViewClass. This would let the generic editor fill the frame with the right view for each object it's asked to edit. I've gotten a lot of mileage out of embedded forms over the last year or so. They have great ability to make your code simpler and clearer, more reliable and more flexible. They're yet another example of the sort of thing that's always been possible under Windows, but which was impossibly complex until Delphi made it easy. Jon Shemitz has been programming since he was 12, and has been programming professionally since 1981. After an overly long period making very little money as an independent developer, he's been "crazy busy" doing contract programming for the last few years. He lives in Santa Cruz, California with his partner and their two homeschooled children, and fills the odd free moment with reading, gardening, cooking, and exercise. He can be reached at jon@midnightbeach.com or via http://www.midnightbeach.com/jon. This chapter first appeared in High Performance Delphi 3 Programming Copyright © 1997 Jon Shemitz - jon@midnightbeach.com - May 18, 1996..July 29, 1997 |
||
Created on May 18, 1996, last updated December 27, 2002
Contact jon@midnightbeach.com
|