Site menuContactSitemapProduktenInfoWie zijn wijWelkomStartDownload



Magnetizing TForm

Marco Wobben 18 sep 2003
[download] [compiled app] [codecentral] [email]

Introduction
This article describes the WinAmp behaviour which lets several windows of the application snap to each other as if attracted by magnets. This article describes the different aspects there are to snapping, moving, sizing and clustering of magnetized forms. Above all it shows full Delphi sources on how to implement this.

Magnetizing TForms

Requirements
The TMagnet should have the ability to snap to other magnetized forms and once several of these forms are snapped and moved again they should be able to behave like a cluster and move all at once. For easy developing and usability we will create the TMagnet component. This component must be placed on a TCustomForm descendant. The following topics will be covered:

- hook into a TForms message handler;
- required Windows messages and how to respond;
- implement a snapping algoritm;
- snap to other objects;
- clustering several magnets;
- resizing forms while in a cluster.

Activate
We'll start of with some tech-stuff to show you how to hook into the TCustomForm message loop to receive the Windows messages in the component. This hooking and unhooking will be controled by setting the Active property of our TMagnet.
(From now on the TMagnet and the form it's hooked on are symbiotic. The text refers to both the TMagnet and the Form as being one.)

  ...

published
{: Activate the magnet by setting Active to true. }
property Active: boolean read FActive
write SetActive default false;

...

procedure TMagnet.SetActive(const Value: boolean);
begin
if
(Active <> Value) then
begin
if
Value then
begin
// hook into the Form to receive the Windows
// messages
FClientInstance :=
MakeObjectInstance(ClientWndProc);
FPrevClientProc := Pointer(
GetWindowLong(Form.Handle, GWL_WNDPROC));
SetWindowLong(Form.Handle,
GWL_WNDPROC, Integer(FClientInstance));
...
  end
else
begin
 ...

// unhook from the Form to stop reveiving
// the Windows messages
SetWindowLong(Form.Handle, GWL_WNDPROC,
Integer(FPrevClientProc));
FreeObjectInstance(FClientInstance);
...
  end;

FActive := Value;
end;
end;


To have a global list of available active TMagnets we use this routine to add/extract this TMagnet to/from the global list. In the code this list is declared as ActiveMagnets: TList;

Get the message?
There are only a few Windows messages we're interested in:

- WM_ENTERSIZEMOVE
- WM_WINDOWPOSCHANGING
- WM_EXITSIZEMOVE
- WM_DESTROY

The first and third signal the user sizing or moving the form and the second is the bit we use to control the magnetizing. It allows to reposition or resize the form before it actually happens. We even may move other forms which in turn will not receive this message since it will not be the user sizing/moving it. This greatly benefits our need as we will see.

(Note the fourth message is required to turn of the TMagnet just before the TForm is destroyed, otherwise we'll be in big trouble trying to unhook from the TForm.)

Below is the ClientWndProc which handles these few messages. As you can see all unhandled messages (Result=0) are passed on the TForm message handler itself. The WM_WINDOWPOSCHANGING works with the WindowPos record which contains the new position, width and height the user requests. The WM_DESTROY simply turns off the TMagnet.

procedure TMagnet.ClientWndProc(var Message: TMessage);
var
R: TRect;
P: TPoint;
begin
with
Message do
begin
case
Msg of
WM_ENTERSIZEMOVE:
begin
...

end;
WM_EXITSIZEMOVE:
begin
...
  end;
WM_WINDOWPOSCHANGING:
with TWmWindowPosChanging(
Message).WindowPos^ do
begin
...
end;
WM_DESTROY:
Active := False;
end;
if (Result = 0) then
Result := CallWindowProc(FPrevClientProc,
Form.Handle, Msg, wParam, lParam);
end;
end;

WM_ENTERSIZEMOVE

Mainly initialization code for internal states which is used in the WM_WINDOWPOSCHANGING related methods.

          FOldArea := Area;
FDragStart := Area;
FDragging := True;

WM_EXITSIZEMOVE
Finalization code which calls the ClusterSnapList method. This method optionally clusters the magnets which have been snapped during the moving or sizing of the form. (And resets some internal state related variables.)

          ClusterSnapList;
FOldArea := Area;
FDragging := False;

WM_WINDOWPOSCHANGING
This is the main message to be used. First we determine if the Form is resized or repositioned. The resizing will be explained later, first we'll concentrate on the moving of a Form.

        with TWmWindowPosChanging(Message).WindowPos^ do
begin
FSnapList.Clear;
if ... and
(Dragging) then
begin
...
if
(flags and SWP_NOMOVE = 0) then
begin
P := Point(x, y);
WindowPosChanging(P, cx, cy);
...
x := P.x;
y := P.y;
end;
Result := 1;
end
...
end;

The main method being called is WindowPosChanging. It takes the proposed new position and the current width and height of the form. This method will be able to perform all snapping behaviours with these parameters. The new (snapped) position will be returned as the result values for the Windows message.
Note the code is shortened for readability and will be expanded in the following paragraphs as we add more functionality to the TMagnets behaviour.

Watch that thing
The routine WindowPosChanging will iterate over three types of objects.
- First it'll approach other active magnets on the screen.
- Secondly it'll detect the screen edge to snap to.
- Lastly it'll detect any application mainform to snap on.

Active Magnets
It simply walks down the global list of active magnets, ignoring clustered magnets (this will be explained later), asking the snap algoritm if this is a potential to-be-snapped object. Magnets snap on the outside edge of each other. If the other magnet is snapped to the new coordinates are returned in aPos and the magnet snapped to is added in the snaplist.

  if (soMagnet in SnapOptions) then
begin
i := ActiveMagnets.Count - 1;
while (i >= 0) do
begin
// only enable snap to active magnets outside
// the cluster
if (FCluster.IndexOf(ActiveMagnets[i]) < 0) then
begin
SnapNow := SnapToRect(
aPos.x, aPos.y, W, H,
TMagnet(ActiveMagnets[i]).Area,
sbOuter);
if SnapNow then
FSnapList.Add(ActiveMagnets[i]);
end;
Dec(i);
end;
end;

The if statement contains cluster related conditions, but simply it comes down to evaluating only magnets available for snapping. Once you've read the paragraphs for clustering this statement clearify itself.

Application mainform
The snapping to the mainform is somewhat of a special case. Snapping can occur on the outside of the mainform or at the inside (being the clientarea of the mainform). Default the entire area of the mainform is used to pass to the SnapToRect. However if the magnet is partially on the mainform the clientarea is used to pass to the SnapToRect.

  if (soInMainForm in SnapOptions) and
(Form.Active) then
begin
MainFormR := Application.MainForm.BoundsRect;
if RectOverlap(Area, MainFormR) then
MainFormR := Classes.Rect(
Application.MainForm.ClientToScreen(
Application.MainForm.ClientRect.TopLeft),
Application.MainForm.ClientToScreen(
Application.MainForm.ClientRect.BottomRight));
SnapNow := SnapToRect(
aPos.x, aPos.y, W, H,
MainFormR,
sbNear);
end;

The set method for the property SnapOptions prevents the magnet in the mainform to get the soInMainForm option set.

Screen edge
Finally all snaps should be limited to screen edges if applicable so this code is done last in line:

  if (soInScreen in SnapOptions) then
begin
// determine screen snapping
SnapNow := SnapToRect(
aPos.X, aPos.Y, W, H,
ScreenWorkArea,
sbInner);
end;

Note that the SnapToRect is called with the sbInner value to make sure the snapping keeps the magnet inside the screen work area. ScreenWorkArea is a function returning the current work area of the desktop instead of the actual screen coordinates.

Snap
Implementing the snapping algoritm requires a source rectangle to be snapped, a border rectangle to snap to and a border argument specifying how the source responds to the border. The border value can be:

- sbInner: The source can only snap on the inside of the border rectangle;
- sbOuter: The source can only snap on the outside of the border rectangle;
- sbNear:  The source can both snap on the inside as well as on the outside.

The code fragment below shows the outline of if-statements for the aBorder parameter and a few of the snap detecting routines:

function TMagnet.SnapToRect(var aLeft, aTop: integer;
const aWidth, aHeight: integer; aRect: TRect; aBorder:
TSnapBorder): boolean;
var
ISect, RangeRect: TRect;
begin
Result := False;

if (aBorder = sbInner) then
begin
// left edge
if (aLeft < aRect.Left + Range) then
begin
aLeft := aRect.Left;
Result := True;
end;
...
end
else
if
(aBorder in [sbOuter, sbNear]) then
begin
RangeRect := GrowRect(aRect, Range);

// if the source is not in range we can
// exit this routine

IntersectRect(ISect, RangeRect, Rect(
aLeft, aTop, aLeft + aWidth, aTop + aHeight));
if IsRectEmpty(ISect) then Exit;

// if sbOuter is specified the larger part of
// the rectangle must be outside
if (aBorder = sbOuter) then
begin
IntersectRect(ISect, aRect, Rect(
aLeft, aTop, aLeft + aWidth, aTop + aHeight));
if (RectArea(ISect) >=
RectArea(Rect(
aLeft, aTop, aLeft + aWidth, aTop + aHeight))
div 2) then
Exit;
end;

// left to the right border
if (Abs(aLeft - aRect.Right) < Range) then
begin
aLeft := aRect.Right;
Result := True;
end;
// left on the left border
if (Abs(aLeft - aRect.Left) < Range) then
begin
aLeft := aRect.Left;
Result := True;
end;
...
end;
end;

This is the most essential part of the algoritm. The component contains lots more code, but is all directed at magnet and cluster control.

Clusterbot
If two or more magnets are snapped together we call it a cluster if dragging one of the drags all of them.
- To realize this we require a method being called to perform clustering.
- Secondly we need the cluster as a whole to snap once moved/sized again.

Clustering
Once two (or more) magnets are snapped and the moving/sizing of the magnet is ended a cluster can be formed. Snapped magnets merge the clusters they are part of into one new larger supercluster.

procedure TMagnet.ClusterSnapList;
var
j: integer;
begin
try
if
(FSnapList.Count > 0) and (EnableClustering) then
begin
  for j := 0 to FSnapList.Count - 1 do
if
RectAligned(Area, TMagnet(FSnapList[j]).Area) then
AppendCluster(TMagnet(FSnapList[j]).Cluster);
// distribute the assembled Cluster to all magnets
for j := 0 to Cluster.Count - 1 do
TMagnet(Cluster[j]).AppendCluster(Cluster);
end;
finally
FSnapList.Clear;
end;
end;

This code appends clusters from all other magnets being snapped to its own cluster. Once this is done this magnets cluster is complete and the new cluster list should be appended to all magnets now mentioned in this new cluster. This way every magnet has a complete reference list of the cluster it is in.

Note: This code contains a little overhead in appending clusters but this happens very few times and the number of magnets in a cluster will be minimal in a real life application. And is in the end the most readable sollution.

Snapping
Once a cluster is formed the weird looking outline of the actual cluster is no longer a rectangle. Thus adding a simple routine call to SnapToRect for the cluster is not sufficient. Therefore we extend the WindowPosChanging method with the ability to let every magnet in the cluster join the snapping behaviour.

After the snap detection of the dragged magnet to all other available magnet (remember: outside the cluster it is part of) we call every magnet in this cluster:

  // every magnet in the cluster has it's own snap
// and is to be considered if in the cluster
if (InCluster) and (Dragging) and
(ClusterSnapping) then
begin
for
i := 0 to FCluster.Count - 1 do
begin
M := TMagnet(Cluster[i]);
if (M <> Self) then
begin
P := Delta;
// transform to M new position request
P.X := M.Area.Left + P.X;
P.Y := M.Area.Top + P.Y;
// allow M to snap
M.WindowPosChanging(P,
M.Area.Right-M.Area.Left,
M.Area.Bottom-M.Area.Top);

// should merge snaplist with the most
// recent snaplist of M
for j:=0 to M.FSnapList.Count-1 do
if
FSnapList.IndexOf(M.FSnapList[j]) < 0 then
FSnapList.Add(M.FSnapList[j]);

// transform M to the actual Delta
P.X := P.X - M.Area.Left;
P.Y := P.Y - M.Area.Top;
// apply M's suggestion if it is different
// and not a zero-move.
if ((P.X <> Delta.X) or (P.Y <> Delta.Y)) then
begin
// transform the adjusted delta to current
// coordinates
aPos.X := aPos.X - Delta.x + P.X;
aPos.Y := aPos.Y - Delta.y + P.Y;
end;
end;
end;
end;

Asking a magnets in the cluster to suggest a snap by calling their WindowPosChaning automatically moves the magnets, effecively moving the entire cluster.

Break on my mark
Unsnapping is simply done by not releasing the mousebutton while moving the form. However what if you temporarily don't want the form to snap? Or what if the form is already snapped and part of a cluster and you want it to break apart? Upon receiving the WM_WINDOWPOSCHANING we determint the keystate of the keyboard. If the Ctrl key is pressed (and AutoSnap is turned on) we don't call WindowPosChanging but call a method to uncluster automatically. If AutoSnap is turned off pressing the Ctrl key on the keyboard works inverted. Below is an extension on previously mentioned code:

      WM_WINDOWPOSCHANGING:
with TWmWindowPosChanging(Message).WindowPos^ do
begin
FSnapList.Clear;
if ((GetKeyState(VK_CONTROL) and $F0 = 0)
xor (not AutoSnap)) and
(Dragging) then
begin

...
Result := 1;
end
else
begin
if
Dragging and InCluster then
UnCluster;
end;
end;

The UnCluster method is rather complicated. Envision the following example: a cluster contains three magnets aligned neatly from left to right and we unsnap/uncluster the middle one. The two remaining magnets should no longer be related as a single cluster but form themselves two new smaller clusters.

procedure TMagnet.UnCluster;
var
j: integer;
OldNeighbour: TMagnet;
OldNeighbours: TList;
begin
// remove this magnet from all others in the Cluster
for j := Cluster.Count - 1 downto 0 do
begin
if
(Cluster[j] <> Self) then
TMagnet(Cluster[j]).RemoveFromCluster(Self);
end;
// reclusterize old neighbours which were in
// this cluster
OldNeighbours := TList.Create;
try
for
j:=Cluster.Count-1 downto 0 do
if
RectAligned(FOldArea,
TMagnet(Cluster[j]).Area) then
OldNeighbours.Add(Cluster[j]);
while (OldNeighbours.Count > 0) do
begin
OldNeighbour := TMagnet(OldNeighbours[0]);
OldNeighbour.ReCluster(nil);
OldNeighbours.Extract(OldNeighbour);
// make sure other old neighbours which are now
// reclustered are no longer in the list to be
// reclustered again
for j:=0 to OldNeighbour.Cluster.Count-1 do
OldNeighbours.Extract(OldNeighbour.Cluster[j]);
end;
// empty the cluster list
for j:=Cluster.Count-1 downto 0 do
if
Cluster[j] <> Self then
Cluster.Delete(j);
finally
OldNeighbours.Free;
end;
end;
 
If unclustering requires first of all to collect all neighbouring magnets (which were neighbour at the time the dragging started). All direct neighbours will be asked to start reclustering. When they're finished this process we'll remove every magnet from their new cluster from the list of former neighbours.

The trick of course is not in the UnCluster method, but in the method ReCluster. It takes a TList as parameter in which the new cluster will be assembled. The magnet starting this routine will pass in a nil value to mark the starting point. It will create the new cluster list and add itself. Next is to ask every neighbouring magnet, not yet in this new cluster list, in the former cluster to recluster as well and to add their magnets to this new cluster list.

Once we return to the initiator we erase all cluster lists from the magnets in the newly assembled list and than broadcast this new clusterlist to all newly listed magnets. This finishes the step of reclustering (former) neighbours.
 
procedure TMagnet.ReCluster(NewCluster: TList);
var
i, j: integer;
NewC: TList;
begin
// assign the new clusterlist, of none is assigned
// this magnet is the initiator for the new cluster
// to be build.
NewC := NewCluster;
try
if
(NewC = nil) then
NewC := TList.Create;
// since we're asked to recluster we should be
// neighbour and be added
NewC.Add(Self);
// ask all magnets from the old cluster which is
// not yet in the new cluster to check for its
// neighbours.
for j:=0 to Cluster.Count-1 do
begin
if
(NewC.IndexOf(Cluster[j]) = -1) and
(RectAligned(Area,
TMagnet(Cluster[j]).Area)) then
TMagnet(Cluster[j]).ReCluster(NewC);
end;
// if this is the initiator for the new cluster
// broadcast the changes
if (NewCluster = nil) then
begin
// extract all magnets in the new list from the
// old cluster
for j:=Cluster.Count-1 downto 0 do
for
i:=0 to NewC.Count-1 do
TMagnet(Cluster[j]).RemoveFromCluster(
TMagnet(NewC[i]));
// publish the new clusterlist to all magnets
// in this new list (incl self)
for j:=0 to NewC.Count-1 do
TMagnet(NewC[j]).Cluster.Assign(NewC);
end;
finally
if
(NewC <> NewCluster) then
  NewC.Free;
  end;
end;

Stretching out

Although the available code is complete and includes the aspect of resizing forms, this article is long enought by now to go into depth on this item. A brief list of considerations is given:

- sizing a form rightwards should move not only all forms in the cluster on the right but also snap to forms on the right.
- sizing should be limited if any of the magnets hit the screen edge.
- if a cluster is in the form of a capital C, and the sizing of the magnet on the bottomright moves the cluster to the left, the magnets in the cluster on the topright should be moved with the cluster.

Conclusion
Seeing all these code snippets you'll be aware this component is not about snapping but about collection management. The actual snapping itself is concetrated in a single method which gets called every now and than. The bigger part is the management of magnets alltogether or clusters and maintaining correct relationships between them.

The TMagnet component has promoted almost every if-statement in the implementation into an option for the component which can be set by the Object Inspector. This makes the component even more usable and flexible. This way you can have forms snap only to each other but never form clusters, forms containing only toolbars may cluster and be snapped inside the mainform, etc...

The real power in this component comes to live through delegation of snapping and clustering. Advanced looking gadgets may now be included, for instance activating gravity pull on one of the magnets, or pushing all magnets outwards to the screen edge etc...

Imagination is the only limit.

Copyright © 2002 Bommeljé Crompvoets en partners

Het is toegestaan dit artikel in zijn geheel te kopiëren en te verspreiden, mits de tekst woordelijk in tact blijft en deze notitie bevat. Het is toegestaan om deze tekst te citeren, of te wijzigen, mits de oorspronkelijke auteur en houder van het copyright vermeld worden.
You may republish this paper verbatim, including this notation. You may update, correct, or expand the material, provided that you include a notation stating the original author and copyright holder.
 








[English] [welkom] [wie zijn wij] [info] [diensten] [producten] [download] [contact] [verwijzingen]