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.

No comments: