Title: Fixing the List View Drag and Drop Bug
Question: The drag and drop implementation in the TListView component has a very annoying bug. If you are dragging an item too fast, the drag cursor will remain on the screen sometimes, leaving a noticable garbage on the form. In this article, I will explain the nature of this bug and show you two different ways to fix it.
Answer:
A long time ago, when I first tried to do drag and drop
with C++Builder 1, I noticed that there was a small but
very annoying problem with the TListView class. When I
was dragging items from a list view control, the cursor
sometimes left a small garbage on the screen. I reported
this bug to Inprise, but they didn't fix it, not even
in Delphi/BCB 4. Finally, almost 2 years after recognizing
this bug, I managed to fix it.
Before I cover how to fix this bug, I would like to
show you how to reproduce it and explain the exact reason
why it happens. It's very easy to create a simple test
application and experiment the bug yourself. Start a new
application and drop two TListView components on the form.
Align them one above the other, as shown in Figure 1. Set the
DragMode property for the lower list view to dmAutomatic,
then set ViewStyle to vsReport and add a new column and a
few items to the control.
Figure 1 - A Sample Program to Reproduce the Bug
Build the application and try to drag the items from the
lower list view towards the upper one. If you are doing
it fast enough, the drag cursor will remain on the screen,
leaving a noticable garbage on your form. See Figure 2
for an example. I just did three drag and drops between
the two list views, and the screen is full of garbage.
Figure 2 - A Screen Shot of the Effect of the Bug
To understand the reason of this bug, you need to have a
look at the source code for the VCL. In the comctrls.pas
file, search for the TCustomListView.DoStartDrag method.
I will not show the full code due to copyright reasons,
but here is the relevant part of the procedure:
procedure TCustomListView.DoStartDrag(var DragObject: TDragObject);
begin
{...}
GetCursorPos(P);
P := ScreenToClient(P);
{...}
with P do DragItem := GetItemAt(X, Y);
{...}
ImageHandle := ListView_CreateDragImage(Handle, DragItem.Index, P1);
{...}
end;
As you see, the VCL uses the GetCursorPos API function to
get the current position of the cursor. Using these
coordinates, the function finds out the currently
selected list item and displays the drag image.
The problem is that Windows is a multitasking operating
system, and the GetCursorPos function is asynchronous,
which means it always reports the very latest position
of the cursor -- which might change too fast.
The drag and drop operation is usually initiated by a
click on the left mouse button. Consider the following.
You click on the second item in the list view, because
you want to drag it. Windows sends a WM_LBUTTONDOWN
message to the application, with the current cursor
coordinates. In the meantime, you move the cursor to
a different location. Windows interrupts the running of
your application to update the position of the cursor.
VCL still didn't finish processing your events. Finally,
when the DoStartDrag method starts to be executed, the
cursor is at a completely different position. VCL gets
the current position from Windows, and it believes that
that's the start position for the drag and drop. From
this moment, the behavior of the VCL is unpredictable,
it is working with the wrong coordinates, it is working
with the wrong drag image. From this point, anything
might happen. The thing is that nothing else happens
but the drag cursor remains on the screen.
Now how to fix this bug? The best would be to remember
the coordinates of the last mouse click before the drag
and drop is initiated, and use those values instead
of getting the position with GetCursorPos. It is not
recommended, however, to modify and recompile the VCL.
You can not even do that with C++Builder. So I have
found out an easy solution that doesn't require you
to edit the comctrls.pas file.
You just have to subclass the TListView component and
add a few new lines to it. The source code should
look something like this:
type
TListViewFix = class(TListView)
private
FDragPoint: TPoint;
procedure WMLButtonDown(var Message: TWMMouse); message WM_LBUTTONDOWN;
protected
procedure DoStartDrag(var DragObject: TDragObject); override;
end;
procedure TListViewFix.WMLButtonDown(var Message: TWMMouse);
begin
FDragPoint := ClientToScreen(Point(Message.XPos, Message.YPos));
inherited;
end;
procedure TListViewFix.DoStartDrag(var DragObject: TDragObject);
begin
if ItemFocused nil then
Windows.SetCursorPos(FDragPoint.x, FDragPoint.y);
inherited;
end;
The WMLButtonDown method traps the WM_LBUTTONDOWN message and
memorizes the latest position of the cursor. Note that it
does not use the GetCursorPos function, which might report the
wrong position. The WM_LBUTTONDOWN message contains the exact
coordinates of the cursor at the time of the click. Windows
registers and sends those coordinates with the message, so
there's no need to get it again.
We have to override the DoStartDrag procedure and take action
right before calling the inherited method. It is possible that
the cursor has been moved by this time, but we can easily restore
the position by calling the SetCursorPos API function. This
sets the cursor back to the place where it was when the user
initiated the drag and drop. After that, we immediately call
the original DoStartDrag method, so it can start the drag and
drop operation right away. It is true that still there might
be a small delay between the SetCursorPos and GetCursorPos
functions, but that is just a few instructions. Practically,
that time is so short that you have no chance to move the
cursor, because the computer can perform a few operations
faster than you can move the mouse. This fixes the drag and
drop bug and efficiently eliminates the garbage on screen.
If you already have an existing application and don't want
to create a new component, you can use the same idea to
fix the drag and drop garbage bug right in the unit of
your form. Add an OnMouseDown and an OnStartDrag event to
your TListView with the following code:
procedure TForm1.ListView1MouseDown(Sender: TObject;
Button: TMouseButton; Shift: TShiftState; X, Y: Integer)
begin
FDragPoint := ListView1.ClientToScreen(Point(X, Y));
end;
procedure TForm1.ListView1StartDrag(Sender: TObject;
var DragObject: TDragObject)
begin
if ListView1.ItemFocused nil then
Windows.SetCursorPos(FDragPoint.x, FDragPoint.y);
end;
The code is almost the same as in the component version,
except the FDragPoint and the event handlers are members
of your form, not the list view descendant class.
If you're using C++Builder, the code should look like this:
void __fastcall TForm1::ListView1MouseDown(TObject *Sender,
TMouseButton Button, TShiftState Shift, int X, int Y)
{
FDragPoint = ListView1-ClientToScreen(Point(X, Y));
}
void __fastcall TForm1::ListView1StartDrag(TObject *Sender,
TDragObject *&DragObject)
{
if(ListView1-ItemFocused)
SetCursorPos(FDragPoint.x, FDragPoint.y);
}
DragPoint should be declared in the include file as follows:
private:
TPoint DragPoint;
TTreeView suffers from the same bug, but it can be fixed
the same way as in TListView.