Sunday, July 10, 2011

Generic cross data report with lazreport

In the lazreport repository there's a demo app showing how to create a cross data report. It uses two instances of TfrUserDataset: one for the master (row) data and one for the cross (column) data. At first glance the component does not provide another way to build such reports. A deeper look shows the contrary. Here are the steps to build a cross data report with arbitrary number of rows and columns.

WARNING: to follow this guide is necessary basic lazreport knowledge.

Prepare the report

Nothing special here

  • Create an empty report

  • Add a Master Data band

  • Add a Cross Data band

  • Add a Text Object inside the Cross Data

  • In the Text Object put a variable named value: [value]



Add handler to retrieve the value

Those familiar with lazreport will have no problems:

procedure TForm1.frReport1GetValue(const ParName: String; var ParValue: Variant);
begin
if ParName = 'value' then
ParValue := IntToStr(FRow) + ' - ' + IntToStr(FCol);
end;

Just the column and row indexes for demonstration purpose. Using together with matrix like data structures the retrieve of actual data is straightforward.

Set the number of rows and columns

The most attentive developers will notice that no dataset (even the virtual dataset) was linked to each band. In fact running the report at this stage will lead to a blank page.
If the number of columns and rows are previously know just set the Virtual Dataset option for each band. This is not an optimum solution since not always we have that info. Here's how to set the Virtual Dataset record count at runtime:

procedure TForm1.frReport1BeginDoc;
var
BandView: TfrBandView;
begin
BandView := frReport1.FindObject('MasterData1') as TfrBandView;
BandView.DataSet := '9';
BandView := frReport1.FindObject('CrossData1') as TfrBandView;
BandView.DataSet := '2';
end;

This will create a report with nine rows and two columns. Yep, you read right: lazreport store the number of the records of band's Virtual Dataset in an string field, the same field that store the name of an associated TfrDataset.
WARNING: don't look at lazreport source. It may scare the faint hearted ;-)

Track the row and column positions

The tricky part. The first thing to do is add two integer fields (FRow and FCol) to the Form/Data Module containing the TfrReport instance.

To get the column add an event to OnPrintColumn, and store the ColNo parameter:

procedure TForm1.frReport1PrintColumn(ColNo: Integer; var ColWidth: Integer);
begin
FCol := ColNo;
end;

Notice that the Lazarus IDE will create an event declaration with the second parameter named Width. This will not compile with {$mode ObjFpc}. Renaming it to ColWidth will make the compiler happy.

There's not an event that pass the current row position. The first try is to hook into the OnBeginBand

procedure TForm1.frReport1BeginBand(Band: TfrBand);
begin
Inc(FRow);
end;

Running the report with this will show wrong row indexes because it will increment in all bands not only the Master/Row band. The fix is easy:

procedure TForm1.frReport1BeginBand(Band: TfrBand);
begin
if Band.Typ = btMasterData then
Inc(FRow);
end;

It's done, add Data Header and Cross Header bands, glue with actual data and the generic cross data report is done. The sample project.

Friday, July 01, 2011

Make a generic control behaves like a "DropDown window"

Some controls, like the dropdown list of a combo box, disappears as soon as focus is lost. In web applications / pages this concept is expanded further by allowing form controls inside the drop down window.

To make a generic LCL control works like those web widgets, basically is necessary to hide it when the focus is lost. If the drop down control is a form this can be accomplished as simple as setting the BorderStyle to bsNone and using the Deactivate handler to hide itself:
procedure TMyForm.FormDeactivate(Sender: TObject);
begin
Hide;
end;

For TFrame is possible to put an instance of it in an temp TForm configured as above. For other control classes, created at design time and with a parent already assigned, this may work but is not desired.

The alternative is to detect when the focus has changed and then check if the focused control is outside the "drop down" control. Unfortunately, AFAIK, there's no way in VCL/LCL to detect globally when the focus changed. Well, in fact there's an event that just do that: Screen.OnActiveControlChange. The drawback of using this event each time a "drop down" control is used is that will override a previously set event handler. Fortunately, LCL provides an alternative to set multiple handlers through Screen.AddHandlerActiveControlChanged.

So, for a TPanel descendant we would use something like to add remove the handler when the visible state is changed:

procedure TMyPanel.VisibleChanged;
begin
if Visible then
Screen.AddHandlerActiveControlChanged(@FocusChangeHandler)
else
Screen.RemoveHandlerActiveControlChanged(@FocusChangeHandler);
end;

In the handler code check if the focused control is itself or a child. If not hide:


procedure TMyPanel.FocusChangeHandler(Sender: TObject; LastControl: TControl);
var
AControl: TControl;
begin
AControl := Screen.ActiveControl;
if (AControl <> Self) and not IsParentOf(AControl) then
Visible := False;
end;

Yep! When the focus goes to outside of the "drop down" control it will automatically hide itself.

But...

Sometimes clicking outside of the control will not change the focus so it will not hide like desired. The solution is to detect user inputs (mouse click) globally. Setting OnMouse* events for all form controls is a no-no for obvious reasons. Using Application.OnUserInput event is an idea but has the same drawback of Screen.OnActiveControlChange. Similar problem, similar solution: is possible to set multiple handlers through Application.AddOnUserInputHandler.

The updated VisibleChanged code:


procedure TMyPanel.VisibleChanged;
begin
if Visible then
begin
Screen.AddHandlerActiveControlChanged(@FocusChangeHandler);
Application.AddOnUserInputHandler(@UserInputHandler);
end
else
begin
Screen.RemoveHandlerActiveControlChanged(@FocusChangeHandler);
Application.RemoveOnUserInputHandler(@UserInputHandler);
end;
end;

And the input handler that checks if the control where mouse is over is outside or not:


procedure TMyPanel.UserInputHandler(Sender: TObject; Msg: Cardinal);
var
AControl: TControl;
begin
case Msg of
LM_LBUTTONDOWN, LM_LBUTTONDBLCLK, LM_RBUTTONDOWN, LM_RBUTTONDBLCLK,
LM_MBUTTONDOWN, LM_MBUTTONDBLCLK, LM_XBUTTONDOWN, LM_XBUTTONDBLCLK:
begin
AControl := Application.MouseControl;
if (AControl <> Self) and not IsParentOf(AControl) then
Visible := False;
end;
end;
end;

Relatively simple but doing that for each control would be annoying so i wrote a component that takes care of it (among other few details). Enjoy.