 |
 |
 |
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.
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.
|