Replacing TabSheets with Frames - by Dan Miser

By: Dan Miser

Abstract: Using TFrames, interfaces and inheritance, you can overcome the limitations of using TTabSheets in your application.

Introduction

Using a TPageControl component helps separate your GUI by putting bits and pieces of related functionality on a single form. While this approach is very nice, putting all of the visual controls and code directly on the TTabsheets of the TPageControl can lead to a few problems, such as:
  • Increased unit size. This makes it harder to focus on the intent of the code.
  • Tabsheets are always created. This means the controls that are on the tabsheets are always created. This, in turn, means that you may encounter compilation problems as the unit gets too large, or you will find the run-time impact of memory, resources, and handles to be too severe.
  • Code is not logically separated. This makes it harder to reuse code that can be found inside the tabsheets.
  • Replacement of tabs. If you have a custom tab that only displays for one customer, you must add the tab and controls all in the same unit.
To solve these problems, this article will focus on a framework that will show you how to use TFrames, inheritance and interfaces to get your application all of the benefits of the TPageControl, without the drawbacks. Along the way, we'll also get rid of a couple of bugs that you would encounter if you created the framework from scratch.

Download the sample code for this article, and customize it for your project.

TFrame giveth and TFrame taketh away

According to the Delphi help file, "TFrame is a container for components; it can be nested within forms or other frames.". Our goal is to have a TPageControl with many TTabsheets, and let each TTabsheet host a TFrame. Using this one bit of indirection, we address all of the drawbacks listed above. This thinking isn't new - it's been with us since Delphi 1 and TTabbedNotebook. People have searched for a "Best Practice" way of doing this, and in my mind, when working with TPageControl, Peter Below described the best approach. After changing it a bit, here is the first bit of code:

{ Dynamic support for frame-based tabsheets }
type
  TFrameClass = class of TFrame;

procedure TSampleForm.FormCreate(Sender: TObject);
begin
  Tabsheet1.Tag := Integer(TFrame1);
  Tabsheet2.Tag := Integer(TFrame2);
end;

procedure TSampleForm.CreateFrame(ATabsheet: TTabSheet);
var
  frame: TFrame;
begin
  if GetFrame(ATabsheet) = nil then
    if ATabsheet.Tag <> 0 then
    begin
      frame := TFrameClass(ATabsheet.Tag).Create(Self);
      frame.Parent := ATabsheet;
      frame.Align := alClient;
    end;
end;

procedure TSampleForm.DestroyFrame(ATabsheet: TTabsheet);
var
  frame: TFrame;
begin
  frame := GetFrame(ATabsheet);
  if frame <> nil then
    frame.Free;
end;

function TSampleForm.GetFrame(ATabSheet: TTabsheet): TFrame;
begin
  if not Assigned(ATabsheet) then
    ATabsheet := PageControl1.ActivePage;
  Result := nil;
  if Assigned(ATabsheet) and (ATabSheet.ControlCount > 0) 
    and (ATabSheet.Controls[0] is TFrame) then
    Result := TFrame(ATabSheet.Controls[0]);
end;

procedure TSampleForm.TabSheetHide(Sender: TObject);
begin
  DestroyFrame(Sender as TTabsheet);
end;

procedure TSampleForm.TabSheetShow(Sender: TObject);
begin
  CreateFrame(Sender as TTabsheet);
end;
As you may have guessed from the names of the methods, TabSheetHide and TabSheetShow are event-handlers that should be hooked up to each TTabsheet's OnHide and OnShow event, respectively. The meat of this approach is that the actual class reference of each frame is linked to the tabsheet in the TTabsheet.Tag property. The other interesting thing to note is that we assign the TFrame.Parent property to the TTabsheet, which allows the frame to be hosted by the TTabsheet.

This approach assumes that you are careful about the code that you put in the PageControl.OnChange and OnChanging events. Be careful that you don't reference objects that have already been freed as a result of TabSheetHide destroying the frame!

One thing to note about TFrames is that even though they are very similar to TForms, they are different enough that they don't have some of the events that you have come to rely on in TForms. Instead of OnCreate, you must use a constructor. Instead of OnDestroy, you use the TFrame destructor. Instead of OnShow, we will use interfaces to simulate the event.

Here is the description from Borland R&D about why these things are different:

"The reason that a frame does not have a OnCreate event is there is no good
time to fire the event. The WM_CREATE is not good enough since it is sent
when the window handle is created not the component. Since we try and delay
the time when the window is create, you might receive the WM_CREATE when the
containing form is visible. Since, during streaming, the window handle might
be thrown away and created over again, it might be sent more than once. If
you feel that WM_CREATE is good enough, then you are certainly welcome to
override it.

Even if you did use WM_CREATE and only fired the OnCreate for the first
WM_CREATE you found you would fire the event at the wrong time in the case a
frame is inherited from an ancestor form. During streaming of a form, the
entire image of the ancestor will be create, possibly creating the window
handle of the frame. If the window handle is create you will call the
OnCreate method the ancestor define. If the descendent changed the OnCreate
of the frame, that method would be ignored.

There is no clear time in the streaming of a frame when the frame is done
streaming. This is due to the way in which the frame is streamed and the
interaction with the frame and form inheritance. If the create events fires
too early, then the wrong method might get fired. If it is fired too late,
then some events will fire before the OnCreate. In any case, even if I came
up with a solution, the description of the solution would be so complicated,
since the problem is complicated, that it would be unclear to the user when
the event fires.

None of these problems are true of the constructor so you should override
the constructor."

Interfaces to the Rescue

The biggest drawback to putting code into a constructor instead of an OnCreate event-handler, is that not all code that you write in a constructor will work that early in the call sequence. In some cases, you need to have code execute a bit later on, like in the OnShow event. To simulate the OnShow event, I decided to create an interface that each TFrame would implement, and then call those methods as appropriate. The interface looks like this:

type
  IFrame = interface
  ['{81EE8DDD-475E-4FAB-B1F5-AAD36D0118E3}']
    procedure OnShow;
    procedure OnHide;
  end;
So the code in the form now changes to look like this:

procedure TSampleForm.TabSheetShow(Sender: TObject);
var
  frame: TFrame;
  intf: IFrame;
begin
  // Ensure that the frame is created. 
  // Helpful when we are calling TabsheetShow(tsXXX) directly
  CreateFrame(Sender as TTabsheet);  
  frame := GetFrame(Sender as TTabsheet);
  if Supports(frame, IFrame, intf) then
    intf.OnShow;
end;

procedure TSampleForm.TabSheetHide(Sender: TObject);
var
  frame: TFrame;
  intf: IFrame;
begin
  frame := GetFrame(Sender as TTabsheet);
  if Supports(frame, IFrame, intf) then
    intf.OnHide;
end;
This code queries each frame to see if it supports the IFrame interface. If it does, then it calls out to the appropriate method at the right time.

Lastly, each frame you create will implement the IFrame interface, which means that the frame will have an OnShow and OnHide method where you can take action.

Use in an ActiveForm

Everything was working wonderfully, and then came the dreaded bug report. Our applications are written in such a way as to be either a client EXE, an ActiveX ActiveForm, or a Netscape plugin. Running as an ActiveForm caused problems on some of the tabsheets by popping up an error "Frame1 has no parent window". It turns out that the streaming problems Chuck mentioned above affect more than just constructors and OnCreate. The problem was that components that had (e.g.) a TStrings property were failing due to the fact that the construction had not finished yet and Application.Handle was zero due to this project being an ActiveX library. This was a bad combination as the streaming got to TWinControl.CreateWnd for these components.

To solve this problem, we make some modifications in the frame:


type
  // we need access to the new ctor. 
  // TBaseFrame is covered in the next section
  TFrameClass = class of TBaseFrame; 

// BaseFrame code modifications
constructor TBaseFrame.Create(AOwner: TComponent; AParentWnd: HWND); // reintroduce, overload
begin
  FParentWnd := AParentWnd;
  Create(AOwner); // Let this call out to the child ctor, if overridden
end;

// Adjust the parent window assignment if this is a library (e.g. ActiveX)
procedure TBaseFrame.CreateParams(var Params: TCreateParams);
begin
  inherited CreateParams(Params);
  if (Parent = nil) and IsLibrary and not (csDestroying in ComponentState) then
    Params.WndParent := FParentWnd;
end;
And make a simple modification to how we are creating the frames:

procedure TSampleForm.CreateFrame(ATabsheet: TTabSheet);
var
  frame: TFrame;
begin
  if GetFrame(ATabsheet) = nil then
    if ATabsheet.Tag <> 0 then
    begin
      // Pass on the desired parent HWND
      frame := TFrameClass(ATabsheet.Tag).Create(Self, ATabsheet.Handle); 
      frame.Parent := ATabsheet;
      frame.Align := alClient;
    end;
end;
By making these changes, the new framework works flawlessly across all 3 of the client options listed above.

Inheritance

Now that we have all of the problems solved, let's focus on how to turn this into an easy-to-reuse solution. Instead of making each and every frame implement the IFrame interface, we can have a TBaseFrame class that will do that for us. If a frame needs special handling, we simply override the OnShow or OnHide events. TBaseFrame looks like this:

type
  TBaseFrame = class(TFrame, IFrame)
  private
    { Private declarations }
    FParentWnd: HWND;
    procedure CMShowingChanged(var Message: TMessage); message CM_SHOWINGCHANGED;
  protected
    { Public declarations }
    procedure OnShow; virtual;
    procedure OnHide; virtual;
    procedure CreateParams(var Params: TCreateParams); override;
  public
    constructor Create(AOwner: TComponent; AParentWnd: HWND); reintroduce; overload;
  end;

type
  TFrameClass = class of TBaseFrame;

implementation

{$R *.dfm}

{ TBaseFrame }
procedure TBaseFrame.CMShowingChanged(var Message: TMessage);
begin
  inherited;
  SetFocus;
  SelectFirst; // Put the focus to the first control in the frame
end;

constructor TBaseFrame.Create(AOwner: TComponent; AParentWnd: HWND);
begin
  FParentWnd := AParentWnd;
  Create(AOwner); // Let this call out to the child consructor, if overridden
end;

procedure TBaseFrame.CreateParams(var Params: TCreateParams);
begin
  inherited CreateParams(Params);
  if (Parent = nil) and IsLibrary and not (csDestroying in ComponentState) then
    Params.WndParent := FParentWnd;
end;

procedure TBaseFrame.OnShow;
begin
  // Empty
end;

procedure TBaseFrame.OnHide;
begin
  // Empty
end;

Creating a base frame (TBaseFrame) that does all of our work means that when we want to plug a new frame into a system, we do 2 things: create a new frame that descends from TBaseFrame and add a line of code to associate the tabsheet with the frame. Since a TFrame can host other TFrames, there's no limit to how involved the framework can become.

General notes

Here are some things that need to be considered when using this framework:
  • Don't use TPageControl.OnChange/OnChanging and TTabsheet.OnEnter/OnExit - or at least be sure that those events don't require access to a frame that is destroyed.
  • Write your code so that frames do not talk to other frames.
  • Frames can talk back to the main form. Either use the main form's global variable, or extend the IFrame interface to have Get/SetParentForm methods.
  • When writing code that needs to access a specific frame, create a custom interface for that frame.
Some ideas for future enhancement of the framework are:
  • Create a custom TTabSheet designer that allows you to work with the Delphi IDE at design-time and automatically save out to a TFrame instead.
  • Create a descendant TPageControl that will automatically take care of the events and hookup of tab sheets.

About the Author

Dan Miser is a long-time Delphi programmer, specializing in multi-tier technologies. He has written many magazine articles, been a contributing author to the "Delphi x Developer's Guide" series, acted as technical reviewer, and worked on many interesting products, both within and outside of Borland. Dan is currently working at TIP Technologies in Brookfield, WI. For a more in-depth look at Dan's life, see this interview by Clay Shannon.

Server Response from: BDN9A

 
© Copyright 2008 Embarcadero Technologies, Inc. All Rights Reserved. Contact Us   Site Map   Legal Notices   Privacy Policy   Report Software Piracy